]> danix's work - publisher.git/commitdiff
chore: add CLAUDE.md, HANDOFF.md, design spec, implementation plan, frontmatter fixes
authorDanilo M. <redacted>
Sun, 3 May 2026 08:58:46 +0000 (10:58 +0200)
committerDanilo M. <redacted>
Sun, 3 May 2026 08:58:46 +0000 (10:58 +0200)
CLAUDE.md [new file with mode: 0644]
HANDOFF.md [new file with mode: 0644]
core/frontmatter.py
docs/superpowers/plans/2026-05-01-my-publisher.md [new file with mode: 0644]
docs/superpowers/specs/2026-05-01-my-publisher-design.md [new file with mode: 0644]
tests/test_frontmatter.py

diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644 (file)
index 0000000..952aac6
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,73 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## What This Is
+
+PyQt6 desktop GUI for Linux that centralizes the danix.xyz Hugo blog publishing workflow. Centralizes: article management, translations (IT↔EN via RunPod), git operations, hugo server, media uploads, and taxonomy management.
+
+Blog repo: `/home/danix/Programming/GIT/danix.xyz-hacker-theme`  
+Translation script: `/home/danix/bin/transart.py` (TranslateGemma 27b on RunPod)  
+Config file: `~/.config/my-publisher/config.toml`
+
+## Commands
+
+```bash
+# Run app
+python main.py
+
+# Run all tests
+pytest tests/ -v
+
+# Run single test file
+pytest tests/test_config.py -v
+
+# Run single test
+pytest tests/test_config.py::test_config_round_trips -v
+
+# Install deps
+pip install -r requirements.txt
+```
+
+## Architecture
+
+Single Python process. Three layers:
+
+**`core/`** — UI-agnostic business logic:
+- `config.py` — load/save `~/.config/my-publisher/config.toml` via tomlkit
+- `models.py` — `Article` dataclass + `ARTICLE_TYPES = ["Life", "Photo", "Link", "Quote", "Tech"]`
+- `article_scanner.py` — scans `content/it/` and `content/en/`, builds `list[Article]`, detects translation pairs by matching slugs across languages
+- `frontmatter.py` — parse/write Hugo TOML frontmatter (delimited by `+++`, not YAML `---`)
+- `taxonomy.py` — load/save `docs/tags-it.txt` + `docs/tags-en.txt` (line-aligned pairs)
+
+**`workers/`** — async operations via Qt signals:
+- `git_worker.py` — `QThread` for pull, push master/production, `git rm`, restore deleted
+- `hugo_worker.py` — `QProcess` for `hugo server -D`, streams stdout line by line
+- `translation_worker.py` — `QProcess` for `transart.py`, streams stdout for progress display
+
+Workers emit `output(str)`, `error(str)`, `finished(bool)` signals. UI connects to these — never blocks the main thread.
+
+**`ui/`** — PyQt6 widgets:
+- `main_window.py` — `QMainWindow` with sidebar + `QStackedWidget`. Sidebar has groups: CONTENUTO / WORKFLOW / DEPLOY. Uses `QFileSystemWatcher` on `content/it/` and `content/en/` to auto-refresh on external changes (e.g. Typora edits).
+- Each sidebar section maps to a widget in the stack.
+
+## Key Conventions
+
+**Hugo frontmatter is TOML, not YAML.** Delimited by `+++`. Always use `tomlkit` (not `tomllib`) to preserve key ordering and comments when writing back.
+
+**Translation pairs are matched by slug.** An IT article at `content/it/articles/my-post/index.md` and EN at `content/en/articles/my-post/index.md` share slug `my-post`. `article_scanner.py` builds the pairing.
+
+**Git branches:** `master` = staging (hugo server testing), `production` = live deploy (post-receive hook on server).
+
+**Media path convention:** Files upload to `static/uppies/YYYY/MM/filename`. Web path served as `/uppies/YYYY/MM/filename`.
+
+**Tests are UI-free.** `core/` and `workers/` are tested with pytest + tmp_path fixtures. Qt UI components are not unit-tested — verify manually by running the app.
+
+**Config path override in tests:** Pass `path` explicitly to `Config.load(path)` / `Config.save(path)` rather than monkeypatching the module-level constant.
+
+## Spec and Plan
+
+Full design spec: `docs/superpowers/specs/2026-05-01-my-publisher-design.md`  
+Implementation plan (20 tasks): `docs/superpowers/plans/2026-05-01-my-publisher.md`
+
+Implementation is in progress — tasks 1–4 complete (scaffold, config, models, frontmatter parser). Tasks 5–20 pending.
diff --git a/HANDOFF.md b/HANDOFF.md
new file mode 100644 (file)
index 0000000..07b13a6
--- /dev/null
@@ -0,0 +1,18 @@
+Who this is for:
+  Danix is a developer and blogger running a bilingual (IT/EN) Hugo site at danix.xyz. He is building a PyQt6 desktop GUI called my-publisher to centralize his blog publishing workflow.
+
+What we covered:
+  We went through the full brainstorming and design process for my-publisher, including visual mockups of the app layout (sidebar with grouped sections, article list with IT/EN tabs, article detail panel). We settled on a PyQt6 single-process architecture with QThread/QProcess for
+  async ops. We defined all features: article management with translation status indicators, a dedicated "Senza Traduzione" sidebar entry as a worklist, article detail panel with frontmatter display + markdown preview + action buttons, frontmatter editor with type dropdown (5 fixed
+  types: Life/Photo/Link/Quote/Tech), translation view with streaming log from transart.py, git ops panel, hugo server panel, taxonomy manager (IT/EN tag pairs), media upload to static/uppies/YYYY/MM/, and git-based soft-delete with restore. We wrote the full design spec and a 20-task implementation plan, then began subagent-driven implementation.
+
+What was confirmed:
+  App layout: sidebar with groups CONTENUTO / WORKFLOW / DEPLOY + QStackedWidget content area. Blog repo is at /home/danix/Programming/GIT/danix.xyz-hacker-theme, translation script at /home/danix/bin/transart.py, config stored at ~/.config/my-publisher/config.toml. Git branches: master = staging, production = live via post-receive hook. Hugo frontmatter uses TOML delimited by +++ (not YAML). Translation pairs matched by slug across content/it/ and content/en/. Media served from /uppies/YYYY/MM/. ARTICLE_TYPES = ["Life", "Photo", "Link", "Quote", "Tech"].
+  Design spec at docs/superpowers/specs/2026-05-01-my-publisher-design.md. Implementation plan at docs/superpowers/plans/2026-05-01-my-publisher.md.
+
+Still in progress:
+  Implementation is underway via subagent-driven development. Tasks 1-4 are complete and committed (scaffold, config module, data models, frontmatter parser). Tasks 5-20 are pending. The /init command was just run and produced CLAUDE.md. The code quality review for Task 4 (frontmatter parser) was interrupted before it completed — that review was in flight when the handoff was triggered.
+
+Next steps:
+  Resume subagent-driven development from Task 5 (taxonomy module). Before starting, optionally run the interrupted Task 4 code quality review (git range ba5438a..b4d3b52). Then proceed in order: Task 5 taxonomy, Task 6 article scanner, Task 7-9 workers, Task 10 main window, Tasks
+  11-20 UI components. Use the plan at docs/superpowers/plans/2026-05-01-my-publisher.md and follow the subagent-driven-development skill for each task (implementer subagent, then spec review, then code quality review before marking complete).
\ No newline at end of file
index 4e9eff35026d4e32c73d1aa0890fb1b6951b4018..06c36d11202f977794aafd39c846f7353be724db 100644 (file)
@@ -24,5 +24,5 @@ def write_frontmatter(path: Path, frontmatter: dict, body: str) -> None:
     doc = tomlkit.loads(original_toml)
     for k, v in frontmatter.items():
         doc[k] = v
-    new_text = f"+++\n{tomlkit.dumps(doc)}+++\n\n{body}"
+    new_text = f"+++\n{tomlkit.dumps(doc).strip()}\n+++\n\n{body}"
     path.write_text(new_text, encoding="utf-8")
diff --git a/docs/superpowers/plans/2026-05-01-my-publisher.md b/docs/superpowers/plans/2026-05-01-my-publisher.md
new file mode 100644 (file)
index 0000000..ff60e3f
--- /dev/null
@@ -0,0 +1,2655 @@
+# my-publisher Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Build a PyQt6 desktop GUI for Linux that centralizes the danix.xyz blog publishing workflow — articles, translations, git, hugo server, media, and taxonomy management.
+
+**Architecture:** Single Python process, PyQt6 for UI, QThread/QProcess for all async operations so the UI never blocks. Core layer (data models, config, file scanning) is UI-agnostic. Workers wrap subprocess operations and emit Qt signals. UI components consume workers via signals/slots.
+
+**Tech Stack:** Python 3.11+, PyQt6, tomlkit, mistune. Blog repo at `/home/danix/Programming/GIT/danix.xyz-hacker-theme`, translation script at `/home/danix/bin/transart.py`.
+
+---
+
+## File Map
+
+```
+my-publisher/
+├── main.py                         # QApplication entry point, loads config, shows main window
+├── requirements.txt
+├── core/
+│   ├── __init__.py
+│   ├── config.py                   # load/save ~/.config/my-publisher/config.toml
+│   ├── models.py                   # Article, TaxonomyModel dataclasses + ARTICLE_TYPES
+│   ├── article_scanner.py          # scan content/it/ + content/en/, build list[Article]
+│   ├── frontmatter.py              # parse/write TOML frontmatter with tomlkit
+│   └── taxonomy.py                 # load/save tags-it.txt + tags-en.txt, TaxonomyModel
+├── workers/
+│   ├── __init__.py
+│   ├── git_worker.py               # QThread: pull, push master, push production, rm, restore
+│   ├── hugo_worker.py              # QProcess wrapper: hugo server start/stop + log streaming
+│   └── translation_worker.py      # QProcess wrapper: transart.py + stdout streaming
+├── ui/
+│   ├── __init__.py
+│   ├── main_window.py              # QMainWindow: sidebar + QStackedWidget content area
+│   ├── setup_dialog.py             # first-run config dialog
+│   ├── articles_view.py            # tab IT/EN list + "Senza Traduzione" mode
+│   ├── article_detail.py           # header + frontmatter column + markdown preview column
+│   ├── frontmatter_editor.py       # modal dialog: dynamic TOML form
+│   ├── translation_view.py         # QProcess log streaming + post-translation preview
+│   ├── git_view.py                 # repo status + pull/push buttons + deleted articles
+│   ├── taxonomy_view.py            # tab Tags/Categorie, IT|EN columns, edit + translate
+│   ├── media_view.py               # drag-drop upload to static/uppies/YYYY/MM/
+│   └── hugo_panel.py               # hugo server start/stop + log + URL link
+└── tests/
+    ├── test_config.py
+    ├── test_models.py
+    ├── test_article_scanner.py
+    ├── test_frontmatter.py
+    └── test_taxonomy.py
+```
+
+---
+
+## Task 1: Project scaffold + dependencies
+
+**Files:**
+- Create: `requirements.txt`
+- Create: `main.py`
+- Create: `core/__init__.py`, `workers/__init__.py`, `ui/__init__.py`
+
+- [ ] **Step 1: Create requirements.txt**
+
+```
+PyQt6>=6.6.0
+tomlkit>=0.12.0
+mistune>=3.0.0
+```
+
+- [ ] **Step 2: Install dependencies**
+
+```bash
+cd /home/danix/Programming/GIT/my-publisher
+pip install -r requirements.txt
+```
+
+Expected: all packages install without errors.
+
+- [ ] **Step 3: Create package init files**
+
+```bash
+mkdir -p core workers ui tests
+touch core/__init__.py workers/__init__.py ui/__init__.py tests/__init__.py
+```
+
+- [ ] **Step 4: Create minimal main.py**
+
+```python
+import sys
+from PyQt6.QtWidgets import QApplication
+
+def main():
+    app = QApplication(sys.argv)
+    app.setApplicationName("my-publisher")
+    app.setOrganizationName("danix")
+    sys.exit(app.exec())
+
+if __name__ == "__main__":
+    main()
+```
+
+- [ ] **Step 5: Verify app launches and exits**
+
+```bash
+python main.py &
+sleep 1
+kill %1
+```
+
+Expected: no import errors.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git init
+git add requirements.txt main.py core/__init__.py workers/__init__.py ui/__init__.py tests/__init__.py
+git commit -m "chore: initial project scaffold"
+```
+
+---
+
+## Task 2: Config module
+
+**Files:**
+- Create: `core/config.py`
+- Create: `tests/test_config.py`
+
+- [ ] **Step 1: Write failing tests**
+
+```python
+# tests/test_config.py
+import pytest
+from pathlib import Path
+import tempfile, os
+from core.config import Config, DEFAULT_CONFIG_PATH
+
+def test_config_loads_defaults_when_missing(tmp_path, monkeypatch):
+    monkeypatch.setattr("core.config.DEFAULT_CONFIG_PATH", tmp_path / "config.toml")
+    cfg = Config.load()
+    assert cfg.blog_repo == ""
+    assert cfg.transart_script == "/home/danix/bin/transart.py"
+    assert cfg.typora_bin == "typora"
+
+def test_config_round_trips(tmp_path, monkeypatch):
+    path = tmp_path / "config.toml"
+    monkeypatch.setattr("core.config.DEFAULT_CONFIG_PATH", path)
+    cfg = Config(blog_repo="/some/path", transart_script="/bin/x", typora_bin="typora")
+    cfg.save()
+    loaded = Config.load()
+    assert loaded.blog_repo == "/some/path"
+
+def test_config_is_complete_false_when_blog_repo_empty(tmp_path, monkeypatch):
+    monkeypatch.setattr("core.config.DEFAULT_CONFIG_PATH", tmp_path / "config.toml")
+    cfg = Config.load()
+    assert cfg.is_complete() is False
+
+def test_config_is_complete_true_when_all_set(tmp_path, monkeypatch):
+    monkeypatch.setattr("core.config.DEFAULT_CONFIG_PATH", tmp_path / "config.toml")
+    cfg = Config(blog_repo="/some/repo", transart_script="/bin/x", typora_bin="typora")
+    assert cfg.is_complete() is True
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+```bash
+pytest tests/test_config.py -v
+```
+
+Expected: ImportError or AttributeError — `core.config` not defined.
+
+- [ ] **Step 3: Implement core/config.py**
+
+```python
+from __future__ import annotations
+from dataclasses import dataclass, field
+from pathlib import Path
+import tomlkit
+
+DEFAULT_CONFIG_PATH = Path.home() / ".config" / "my-publisher" / "config.toml"
+
+@dataclass
+class Config:
+    blog_repo: str = ""
+    transart_script: str = "/home/danix/bin/transart.py"
+    typora_bin: str = "typora"
+
+    def is_complete(self) -> bool:
+        return bool(self.blog_repo)
+
+    def save(self, path: Path = DEFAULT_CONFIG_PATH) -> None:
+        path.parent.mkdir(parents=True, exist_ok=True)
+        doc = tomlkit.document()
+        doc.add("blog_repo", self.blog_repo)
+        doc.add("transart_script", self.transart_script)
+        doc.add("typora_bin", self.typora_bin)
+        path.write_text(tomlkit.dumps(doc))
+
+    @classmethod
+    def load(cls, path: Path = DEFAULT_CONFIG_PATH) -> "Config":
+        if not path.exists():
+            return cls()
+        data = tomlkit.loads(path.read_text())
+        return cls(
+            blog_repo=str(data.get("blog_repo", "")),
+            transart_script=str(data.get("transart_script", "/home/danix/bin/transart.py")),
+            typora_bin=str(data.get("typora_bin", "typora")),
+        )
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+```bash
+pytest tests/test_config.py -v
+```
+
+Expected: 4 passed.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add core/config.py tests/test_config.py
+git commit -m "feat: config load/save with tomlkit"
+```
+
+---
+
+## Task 3: Data models
+
+**Files:**
+- Create: `core/models.py`
+- Create: `tests/test_models.py`
+
+- [ ] **Step 1: Write failing tests**
+
+```python
+# tests/test_models.py
+from pathlib import Path
+from core.models import Article, ARTICLE_TYPES
+
+def test_article_types_contains_five():
+    assert len(ARTICLE_TYPES) == 5
+    assert "Tech" in ARTICLE_TYPES
+    assert "Life" in ARTICLE_TYPES
+
+def test_article_fields():
+    a = Article(
+        slug="test-post",
+        lang="it",
+        path=Path("/blog/content/it/articles/test-post/index.md"),
+        frontmatter={"title": "Test", "type": "Tech"},
+        has_translation=False,
+        translation_path=None,
+    )
+    assert a.slug == "test-post"
+    assert a.lang == "it"
+    assert a.has_translation is False
+    assert a.translation_path is None
+
+def test_article_with_translation():
+    a = Article(
+        slug="test-post",
+        lang="it",
+        path=Path("/blog/content/it/articles/test-post/index.md"),
+        frontmatter={},
+        has_translation=True,
+        translation_path=Path("/blog/content/en/articles/test-post/index.md"),
+    )
+    assert a.has_translation is True
+    assert a.translation_path is not None
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+```bash
+pytest tests/test_models.py -v
+```
+
+Expected: ImportError.
+
+- [ ] **Step 3: Implement core/models.py**
+
+```python
+from __future__ import annotations
+from dataclasses import dataclass
+from pathlib import Path
+
+ARTICLE_TYPES = ["Life", "Photo", "Link", "Quote", "Tech"]
+
+@dataclass
+class Article:
+    slug: str
+    lang: str          # "it" | "en"
+    path: Path
+    frontmatter: dict
+    has_translation: bool
+    translation_path: Path | None
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+```bash
+pytest tests/test_models.py -v
+```
+
+Expected: 3 passed.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add core/models.py tests/test_models.py
+git commit -m "feat: Article dataclass and ARTICLE_TYPES"
+```
+
+---
+
+## Task 4: Frontmatter parser
+
+**Files:**
+- Create: `core/frontmatter.py`
+- Create: `tests/test_frontmatter.py`
+
+- [ ] **Step 1: Write failing tests**
+
+```python
+# tests/test_frontmatter.py
+import pytest
+from pathlib import Path
+import tempfile
+from core.frontmatter import parse_frontmatter, write_frontmatter
+
+SAMPLE_MD = """\
++++
+title = "Test Article"
+type = "Tech"
+draft = false
+tags = ["linux", "python"]
++++
+
+# Body
+
+Some content here.
+"""
+
+def test_parse_returns_frontmatter_dict(tmp_path):
+    f = tmp_path / "index.md"
+    f.write_text(SAMPLE_MD)
+    fm, body = parse_frontmatter(f)
+    assert fm["title"] == "Test Article"
+    assert fm["type"] == "Tech"
+    assert fm["draft"] is False
+    assert "linux" in fm["tags"]
+
+def test_parse_returns_body(tmp_path):
+    f = tmp_path / "index.md"
+    f.write_text(SAMPLE_MD)
+    fm, body = parse_frontmatter(f)
+    assert "# Body" in body
+    assert "Some content here." in body
+
+def test_write_preserves_format(tmp_path):
+    f = tmp_path / "index.md"
+    f.write_text(SAMPLE_MD)
+    fm, body = parse_frontmatter(f)
+    fm["title"] = "Updated Title"
+    write_frontmatter(f, fm, body)
+    fm2, _ = parse_frontmatter(f)
+    assert fm2["title"] == "Updated Title"
+    assert fm2["type"] == "Tech"
+
+def test_parse_raises_on_missing_delimiters(tmp_path):
+    f = tmp_path / "index.md"
+    f.write_text("# No frontmatter\n\nJust body.")
+    with pytest.raises(ValueError, match="frontmatter"):
+        parse_frontmatter(f)
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+```bash
+pytest tests/test_frontmatter.py -v
+```
+
+Expected: ImportError.
+
+- [ ] **Step 3: Implement core/frontmatter.py**
+
+```python
+from __future__ import annotations
+from pathlib import Path
+import tomlkit
+
+def parse_frontmatter(path: Path) -> tuple[dict, str]:
+    """Parse TOML frontmatter delimited by +++. Returns (frontmatter_dict, body_str)."""
+    text = path.read_text(encoding="utf-8")
+    if not text.startswith("+++"):
+        raise ValueError(f"No TOML frontmatter found in {path}")
+    parts = text.split("+++", 2)
+    if len(parts) < 3:
+        raise ValueError(f"Malformed frontmatter in {path}")
+    _, toml_block, body = parts
+    fm = dict(tomlkit.loads(toml_block))
+    return fm, body.lstrip("\n")
+
+def write_frontmatter(path: Path, frontmatter: dict, body: str) -> None:
+    """Write frontmatter + body back to file, preserving TOML format."""
+    text = path.read_text(encoding="utf-8")
+    parts = text.split("+++", 2)
+    if len(parts) < 3:
+        raise ValueError(f"Malformed frontmatter in {path}")
+    _, original_toml, _ = parts
+    doc = tomlkit.loads(original_toml)
+    for k, v in frontmatter.items():
+        doc[k] = v
+    new_text = f"+++\n{tomlkit.dumps(doc).strip()}\n+++\n\n{body}"
+    path.write_text(new_text, encoding="utf-8")
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+```bash
+pytest tests/test_frontmatter.py -v
+```
+
+Expected: 4 passed.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add core/frontmatter.py tests/test_frontmatter.py
+git commit -m "feat: TOML frontmatter parse/write with tomlkit"
+```
+
+---
+
+## Task 5: Taxonomy module
+
+**Files:**
+- Create: `core/taxonomy.py`
+- Create: `tests/test_taxonomy.py`
+
+- [ ] **Step 1: Write failing tests**
+
+```python
+# tests/test_taxonomy.py
+import pytest
+from pathlib import Path
+from core.taxonomy import TaxonomyModel, load_taxonomy, save_taxonomy
+
+def _write_pair(tmp_path, it_lines, en_lines):
+    it_file = tmp_path / "tags-it.txt"
+    en_file = tmp_path / "tags-en.txt"
+    it_file.write_text("\n".join(it_lines) + "\n")
+    en_file.write_text("\n".join(en_lines) + "\n")
+    return it_file, en_file
+
+def test_load_taxonomy_basic(tmp_path):
+    it_f, en_f = _write_pair(tmp_path, ["linux", "vita"], ["linux", "life"])
+    model = load_taxonomy(it_f, en_f)
+    assert model.it_to_en["linux"] == "linux"
+    assert model.it_to_en["vita"] == "life"
+
+def test_load_taxonomy_orphan_it(tmp_path):
+    it_f, en_f = _write_pair(tmp_path, ["linux", "vita", "extra"], ["linux", "life"])
+    model = load_taxonomy(it_f, en_f)
+    assert "extra" in model.orphans_it
+
+def test_load_taxonomy_orphan_en(tmp_path):
+    it_f, en_f = _write_pair(tmp_path, ["linux"], ["linux", "extra-en"])
+    model = load_taxonomy(it_f, en_f)
+    assert "extra-en" in model.orphans_en
+
+def test_save_taxonomy_round_trips(tmp_path):
+    it_f, en_f = _write_pair(tmp_path, ["linux"], ["linux"])
+    model = load_taxonomy(it_f, en_f)
+    model.it_to_en["vita"] = "life"
+    save_taxonomy(model, it_f, en_f)
+    model2 = load_taxonomy(it_f, en_f)
+    assert model2.it_to_en["vita"] == "life"
+
+def test_save_taxonomy_sorted(tmp_path):
+    it_f, en_f = _write_pair(tmp_path, [], [])
+    from core.taxonomy import TaxonomyModel
+    model = TaxonomyModel(it_to_en={"zzz": "zzz", "aaa": "aaa"}, orphans_it=[], orphans_en=[])
+    save_taxonomy(model, it_f, en_f)
+    lines = it_f.read_text().splitlines()
+    assert lines == sorted(lines)
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+```bash
+pytest tests/test_taxonomy.py -v
+```
+
+Expected: ImportError.
+
+- [ ] **Step 3: Implement core/taxonomy.py**
+
+```python
+from __future__ import annotations
+from dataclasses import dataclass, field
+from pathlib import Path
+
+@dataclass
+class TaxonomyModel:
+    it_to_en: dict[str, str]
+    orphans_it: list[str]   # IT terms with no EN pair
+    orphans_en: list[str]   # EN terms with no IT pair
+
+def load_taxonomy(it_path: Path, en_path: Path) -> TaxonomyModel:
+    it_terms = [l.strip() for l in it_path.read_text().splitlines() if l.strip()] if it_path.exists() else []
+    en_terms = [l.strip() for l in en_path.read_text().splitlines() if l.strip()] if en_path.exists() else []
+    min_len = min(len(it_terms), len(en_terms))
+    it_to_en = {it_terms[i]: en_terms[i] for i in range(min_len)}
+    orphans_it = it_terms[min_len:]
+    orphans_en = en_terms[min_len:]
+    return TaxonomyModel(it_to_en=it_to_en, orphans_it=orphans_it, orphans_en=orphans_en)
+
+def save_taxonomy(model: TaxonomyModel, it_path: Path, en_path: Path) -> None:
+    pairs = sorted(model.it_to_en.items(), key=lambda x: x[0])
+    it_path.write_text("\n".join(k for k, _ in pairs) + "\n")
+    en_path.write_text("\n".join(v for _, v in pairs) + "\n")
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+```bash
+pytest tests/test_taxonomy.py -v
+```
+
+Expected: 5 passed.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add core/taxonomy.py tests/test_taxonomy.py
+git commit -m "feat: taxonomy load/save with IT/EN pair alignment"
+```
+
+---
+
+## Task 6: Article scanner
+
+**Files:**
+- Create: `core/article_scanner.py`
+- Create: `tests/test_article_scanner.py`
+
+- [ ] **Step 1: Write failing tests**
+
+```python
+# tests/test_article_scanner.py
+from pathlib import Path
+from core.article_scanner import scan_articles
+
+def _make_article(base: Path, lang: str, slug: str, fm_extra: str = "") -> Path:
+    p = base / "content" / lang / "articles" / slug
+    p.mkdir(parents=True)
+    (p / "index.md").write_text(f"+++\ntitle = \"{slug}\"\ntype = \"Tech\"\n{fm_extra}+++\n\nBody.\n")
+    return p / "index.md"
+
+def test_scan_finds_articles(tmp_path):
+    _make_article(tmp_path, "it", "primo-post")
+    _make_article(tmp_path, "en", "first-post")
+    articles = scan_articles(tmp_path)
+    slugs = [a.slug for a in articles]
+    assert "primo-post" in slugs
+    assert "first-post" in slugs
+
+def test_scan_detects_translation_pair(tmp_path):
+    _make_article(tmp_path, "it", "shared-slug")
+    _make_article(tmp_path, "en", "shared-slug")
+    articles = scan_articles(tmp_path)
+    it_art = next(a for a in articles if a.lang == "it" and a.slug == "shared-slug")
+    en_art = next(a for a in articles if a.lang == "en" and a.slug == "shared-slug")
+    assert it_art.has_translation is True
+    assert it_art.translation_path == en_art.path
+    assert en_art.has_translation is True
+
+def test_scan_detects_missing_translation(tmp_path):
+    _make_article(tmp_path, "it", "solo-italiano")
+    articles = scan_articles(tmp_path)
+    art = next(a for a in articles if a.slug == "solo-italiano")
+    assert art.has_translation is False
+    assert art.translation_path is None
+
+def test_scan_parses_frontmatter(tmp_path):
+    _make_article(tmp_path, "it", "con-fm", 'tags = ["linux"]\n')
+    articles = scan_articles(tmp_path)
+    art = next(a for a in articles if a.slug == "con-fm")
+    assert art.frontmatter["title"] == "con-fm"
+    assert "linux" in art.frontmatter["tags"]
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+```bash
+pytest tests/test_article_scanner.py -v
+```
+
+Expected: ImportError.
+
+- [ ] **Step 3: Implement core/article_scanner.py**
+
+```python
+from __future__ import annotations
+from pathlib import Path
+from core.models import Article
+from core.frontmatter import parse_frontmatter
+
+def scan_articles(blog_root: Path) -> list[Article]:
+    articles: list[Article] = []
+    by_slug: dict[tuple[str, str], Path] = {}
+
+    for lang in ("it", "en"):
+        content_dir = blog_root / "content" / lang / "articles"
+        if not content_dir.exists():
+            continue
+        for index_md in sorted(content_dir.glob("*/index.md")):
+            slug = index_md.parent.name
+            by_slug[(lang, slug)] = index_md
+
+    for (lang, slug), path in by_slug.items():
+        other_lang = "en" if lang == "it" else "it"
+        translation_path = by_slug.get((other_lang, slug))
+        try:
+            fm, _ = parse_frontmatter(path)
+        except (ValueError, Exception):
+            fm = {}
+        articles.append(Article(
+            slug=slug,
+            lang=lang,
+            path=path,
+            frontmatter=fm,
+            has_translation=translation_path is not None,
+            translation_path=translation_path,
+        ))
+
+    return articles
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+```bash
+pytest tests/test_article_scanner.py -v
+```
+
+Expected: 4 passed.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add core/article_scanner.py tests/test_article_scanner.py
+git commit -m "feat: article scanner with translation pair detection"
+```
+
+---
+
+## Task 7: Git worker
+
+**Files:**
+- Create: `workers/git_worker.py`
+
+- [ ] **Step 1: Implement workers/git_worker.py**
+
+```python
+from __future__ import annotations
+from PyQt6.QtCore import QThread, pyqtSignal
+import subprocess
+from pathlib import Path
+
+class GitWorker(QThread):
+    output = pyqtSignal(str)
+    error = pyqtSignal(str)
+    finished = pyqtSignal(bool)  # True = success
+
+    def __init__(self, repo_path: Path, command: list[str], parent=None):
+        super().__init__(parent)
+        self._repo_path = repo_path
+        self._command = command
+
+    def run(self):
+        try:
+            result = subprocess.run(
+                self._command,
+                cwd=self._repo_path,
+                capture_output=True,
+                text=True,
+            )
+            if result.stdout:
+                self.output.emit(result.stdout)
+            if result.returncode != 0:
+                self.error.emit(result.stderr or f"Exit code {result.returncode}")
+                self.finished.emit(False)
+            else:
+                self.finished.emit(True)
+        except Exception as e:
+            self.error.emit(str(e))
+            self.finished.emit(False)
+
+    @staticmethod
+    def pull(repo_path: Path, parent=None) -> "GitWorker":
+        return GitWorker(repo_path, ["git", "pull"], parent)
+
+    @staticmethod
+    def push_master(repo_path: Path, parent=None) -> "GitWorker":
+        return GitWorker(repo_path, ["git", "push", "origin", "master"], parent)
+
+    @staticmethod
+    def push_production(repo_path: Path, parent=None) -> "GitWorker":
+        return GitWorker(repo_path, ["git", "push", "origin", "production"], parent)
+
+    @staticmethod
+    def remove_article(repo_path: Path, rel_path: str, slug: str, lang: str, parent=None) -> "GitWorker":
+        # Two-step: git rm then git commit
+        class _RmWorker(QThread):
+            output = pyqtSignal(str)
+            error = pyqtSignal(str)
+            finished = pyqtSignal(bool)
+
+            def run(self_inner):
+                for cmd in [
+                    ["git", "rm", "-r", rel_path],
+                    ["git", "commit", "-m", f"remove: {slug} ({lang})"],
+                ]:
+                    result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True)
+                    if result.stdout:
+                        self_inner.output.emit(result.stdout)
+                    if result.returncode != 0:
+                        self_inner.error.emit(result.stderr or f"Exit code {result.returncode}")
+                        self_inner.finished.emit(False)
+                        return
+                self_inner.finished.emit(True)
+
+        return _RmWorker(parent)
+
+    @staticmethod
+    def restore_article(repo_path: Path, commit_hash: str, rel_path: str, parent=None) -> "GitWorker":
+        return GitWorker(repo_path, ["git", "checkout", commit_hash, "--", rel_path], parent)
+```
+
+- [ ] **Step 2: Verify import works**
+
+```bash
+python -c "from workers.git_worker import GitWorker; print('ok')"
+```
+
+Expected: `ok`
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add workers/git_worker.py
+git commit -m "feat: GitWorker QThread for pull/push/rm/restore"
+```
+
+---
+
+## Task 8: Hugo worker
+
+**Files:**
+- Create: `workers/hugo_worker.py`
+
+- [ ] **Step 1: Implement workers/hugo_worker.py**
+
+```python
+from __future__ import annotations
+from PyQt6.QtCore import QObject, QProcess, pyqtSignal
+from pathlib import Path
+
+class HugoWorker(QObject):
+    log_line = pyqtSignal(str)
+    started = pyqtSignal()
+    stopped = pyqtSignal()
+    error = pyqtSignal(str)
+
+    def __init__(self, repo_path: Path, parent=None):
+        super().__init__(parent)
+        self._repo_path = repo_path
+        self._process = QProcess(self)
+        self._process.readyReadStandardOutput.connect(self._on_stdout)
+        self._process.readyReadStandardError.connect(self._on_stderr)
+        self._process.finished.connect(self._on_finished)
+
+    @property
+    def is_running(self) -> bool:
+        return self._process.state() == QProcess.ProcessState.Running
+
+    def start(self):
+        if self.is_running:
+            return
+        self._process.setWorkingDirectory(str(self._repo_path))
+        self._process.start("hugo", ["server", "-D"])
+        self.started.emit()
+
+    def stop(self):
+        if self.is_running:
+            self._process.terminate()
+            self._process.waitForFinished(3000)
+            self.stopped.emit()
+
+    def _on_stdout(self):
+        data = self._process.readAllStandardOutput().data().decode("utf-8", errors="replace")
+        for line in data.splitlines():
+            self.log_line.emit(line)
+
+    def _on_stderr(self):
+        data = self._process.readAllStandardError().data().decode("utf-8", errors="replace")
+        for line in data.splitlines():
+            self.log_line.emit(line)
+
+    def _on_finished(self, exit_code: int, _):
+        if exit_code != 0:
+            self.error.emit(f"hugo server exited with code {exit_code}")
+        self.stopped.emit()
+```
+
+- [ ] **Step 2: Verify import**
+
+```bash
+python -c "from workers.hugo_worker import HugoWorker; print('ok')"
+```
+
+Expected: `ok`
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add workers/hugo_worker.py
+git commit -m "feat: HugoWorker QProcess with log streaming"
+```
+
+---
+
+## Task 9: Translation worker
+
+**Files:**
+- Create: `workers/translation_worker.py`
+
+- [ ] **Step 1: Implement workers/translation_worker.py**
+
+```python
+from __future__ import annotations
+from PyQt6.QtCore import QObject, QProcess, pyqtSignal
+from pathlib import Path
+
+class TranslationWorker(QObject):
+    log_line = pyqtSignal(str)
+    finished = pyqtSignal(bool, str)  # success, output_path
+    error = pyqtSignal(str)
+
+    def __init__(self, transart_script: Path, article_path: Path, direction: str, parent=None):
+        super().__init__(parent)
+        self._script = transart_script
+        self._article_path = article_path
+        self._direction = direction  # e.g. "it-en" or "en-it"
+        self._process = QProcess(self)
+        self._process.readyReadStandardOutput.connect(self._on_stdout)
+        self._process.readyReadStandardError.connect(self._on_stderr)
+        self._process.finished.connect(self._on_finished)
+
+    def start(self):
+        self._process.start("python3", [
+            str(self._script),
+            "--direction", self._direction,
+            str(self._article_path),
+        ])
+
+    def _on_stdout(self):
+        data = self._process.readAllStandardOutput().data().decode("utf-8", errors="replace")
+        for line in data.splitlines():
+            self.log_line.emit(line)
+
+    def _on_stderr(self):
+        data = self._process.readAllStandardError().data().decode("utf-8", errors="replace")
+        for line in data.splitlines():
+            self.log_line.emit(f"[stderr] {line}")
+
+    def _on_finished(self, exit_code: int, _):
+        success = exit_code == 0
+        # Derive expected output path: content/it/... → content/en/...
+        path_str = str(self._article_path)
+        if "/it/" in path_str:
+            out = path_str.replace("/it/", "/en/", 1)
+        elif "/en/" in path_str:
+            out = path_str.replace("/en/", "/it/", 1)
+        else:
+            out = ""
+        if not success:
+            self.error.emit(f"transart.py exited with code {exit_code}")
+        self.finished.emit(success, out)
+```
+
+- [ ] **Step 2: Verify import**
+
+```bash
+python -c "from workers.translation_worker import TranslationWorker; print('ok')"
+```
+
+Expected: `ok`
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add workers/translation_worker.py
+git commit -m "feat: TranslationWorker QProcess for transart.py streaming"
+```
+
+---
+
+## Task 10: Main window + setup dialog
+
+**Files:**
+- Create: `ui/setup_dialog.py`
+- Create: `ui/main_window.py`
+- Modify: `main.py`
+
+- [ ] **Step 1: Implement ui/setup_dialog.py**
+
+```python
+from __future__ import annotations
+from PyQt6.QtWidgets import (
+    QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
+    QPushButton, QFileDialog, QFormLayout,
+)
+from core.config import Config
+
+class SetupDialog(QDialog):
+    def __init__(self, config: Config, parent=None):
+        super().__init__(parent)
+        self.setWindowTitle("my-publisher — Setup")
+        self.setMinimumWidth(500)
+        self._config = config
+        self._build_ui()
+
+    def _build_ui(self):
+        layout = QVBoxLayout(self)
+        layout.addWidget(QLabel("Configura my-publisher prima di iniziare."))
+
+        form = QFormLayout()
+
+        self._blog_repo = QLineEdit(self._config.blog_repo)
+        browse_btn = QPushButton("Sfoglia...")
+        browse_btn.clicked.connect(self._browse_repo)
+        row = QHBoxLayout()
+        row.addWidget(self._blog_repo)
+        row.addWidget(browse_btn)
+        form.addRow("Blog repo:", row)
+
+        self._transart = QLineEdit(self._config.transart_script)
+        form.addRow("transart.py:", self._transart)
+
+        self._typora = QLineEdit(self._config.typora_bin)
+        form.addRow("Typora bin:", self._typora)
+
+        layout.addLayout(form)
+
+        btns = QHBoxLayout()
+        save_btn = QPushButton("Salva")
+        save_btn.clicked.connect(self._save)
+        btns.addStretch()
+        btns.addWidget(save_btn)
+        layout.addLayout(btns)
+
+    def _browse_repo(self):
+        path = QFileDialog.getExistingDirectory(self, "Seleziona blog repo")
+        if path:
+            self._blog_repo.setText(path)
+
+    def _save(self):
+        self._config.blog_repo = self._blog_repo.text().strip()
+        self._config.transart_script = self._transart.text().strip()
+        self._config.typora_bin = self._typora.text().strip()
+        self._config.save()
+        self.accept()
+```
+
+- [ ] **Step 2: Implement ui/main_window.py**
+
+```python
+from __future__ import annotations
+from PyQt6.QtWidgets import (
+    QMainWindow, QWidget, QHBoxLayout, QVBoxLayout,
+    QLabel, QPushButton, QStackedWidget, QFrame, QSizePolicy,
+)
+from PyQt6.QtCore import Qt, QFileSystemWatcher
+from PyQt6.QtGui import QFont
+from pathlib import Path
+from core.config import Config
+from core.article_scanner import scan_articles
+from core.models import Article
+
+class SidebarButton(QPushButton):
+    def __init__(self, text: str, parent=None):
+        super().__init__(text, parent)
+        self.setFlat(True)
+        self.setCheckable(True)
+        self.setStyleSheet("""
+            QPushButton { text-align: left; padding: 5px 10px; border-radius: 4px; color: #888; }
+            QPushButton:checked { background: #2a2a4e; color: #a855f7; }
+            QPushButton:hover:!checked { background: #1a1a2e; color: #ccc; }
+        """)
+
+class MainWindow(QMainWindow):
+    def __init__(self, config: Config, parent=None):
+        super().__init__(parent)
+        self.config = config
+        self._articles: list[Article] = []
+        self._watcher = QFileSystemWatcher(self)
+        self._watcher.directoryChanged.connect(self._on_fs_change)
+        self.setWindowTitle("my-publisher")
+        self.setMinimumSize(1100, 700)
+        self._build_ui()
+        self._refresh_articles()
+        self._setup_watcher()
+
+    def _build_ui(self):
+        central = QWidget()
+        self.setCentralWidget(central)
+        root = QHBoxLayout(central)
+        root.setContentsMargins(0, 0, 0, 0)
+        root.setSpacing(0)
+
+        # Sidebar
+        sidebar = self._build_sidebar()
+        root.addWidget(sidebar)
+
+        # Divider
+        line = QFrame()
+        line.setFrameShape(QFrame.Shape.VLine)
+        line.setStyleSheet("color: #2a2a4e;")
+        root.addWidget(line)
+
+        # Content stack
+        self._stack = QStackedWidget()
+        root.addWidget(self._stack, stretch=1)
+
+        # Placeholder pages (replaced in later tasks)
+        for name in ["articles", "no_translation", "new_article", "taxonomy",
+                     "media", "translations", "git", "hugo"]:
+            w = QLabel(f"[{name}]")
+            w.setAlignment(Qt.AlignmentFlag.AlignCenter)
+            self._stack.addWidget(w)
+            setattr(self, f"_page_{name}", self._stack.count() - 1)
+
+    def _build_sidebar(self) -> QWidget:
+        w = QWidget()
+        w.setFixedWidth(210)
+        w.setStyleSheet("background: #1a1a2e;")
+        layout = QVBoxLayout(w)
+        layout.setContentsMargins(8, 12, 8, 12)
+        layout.setSpacing(2)
+
+        header = QHBoxLayout()
+        title = QLabel("📰 my-publisher")
+        title.setStyleSheet("color: #fff; font-weight: bold; font-size: 13px; padding: 4px 8px;")
+        header.addWidget(title)
+        self._refresh_btn = QPushButton("🔄")
+        self._refresh_btn.setFlat(True)
+        self._refresh_btn.setFixedSize(28, 28)
+        self._refresh_btn.setStyleSheet("color: #555; border: none;")
+        self._refresh_btn.clicked.connect(self._refresh_articles)
+        header.addWidget(self._refresh_btn)
+        layout.addLayout(header)
+
+        def section(text):
+            lbl = QLabel(text)
+            lbl.setStyleSheet("color: #a855f7; font-size: 9px; text-transform: uppercase; letter-spacing: 1px; padding: 8px 8px 2px;")
+            layout.addWidget(lbl)
+
+        self._btn_group: list[SidebarButton] = []
+
+        def btn(icon_text: str, page_attr: str) -> SidebarButton:
+            b = SidebarButton(icon_text)
+            b.clicked.connect(lambda: self._show_page(page_attr, b))
+            layout.addWidget(b)
+            self._btn_group.append(b)
+            return b
+
+        section("CONTENUTO")
+        btn("📋 Articoli", "_page_articles")
+        self._btn_no_trans = btn("⚠️ Senza Traduzione", "_page_no_translation")
+        self._badge_no_trans = QLabel("0")
+        self._badge_no_trans.setStyleSheet("background:#3a1a1a;color:#ff6b6b;border-radius:8px;padding:1px 6px;font-size:10px;")
+        btn("➕ Nuovo articolo", "_page_new_article")
+        btn("🏷 Tassonomia", "_page_taxonomy")
+        btn("🖼 Media", "_page_media")
+
+        section("WORKFLOW")
+        btn("🌍 Traduzioni", "_page_translations")
+        btn("🔧 Git ops", "_page_git")
+        btn("🚀 Hugo server", "_page_hugo")
+
+        section("DEPLOY")
+        btn("🧪 Test (master)", "_page_git")
+        btn("🚢 Production", "_page_git")
+
+        layout.addStretch()
+        return w
+
+    def _show_page(self, page_attr: str, active_btn: SidebarButton):
+        for b in self._btn_group:
+            b.setChecked(False)
+        active_btn.setChecked(True)
+        self._stack.setCurrentIndex(getattr(self, page_attr))
+
+    def _refresh_articles(self):
+        if not self.config.blog_repo:
+            return
+        self._articles = scan_articles(Path(self.config.blog_repo))
+        missing = [a for a in self._articles if not a.has_translation]
+        self._badge_no_trans.setText(str(len(missing)))
+
+    def _setup_watcher(self):
+        if not self.config.blog_repo:
+            return
+        root = Path(self.config.blog_repo)
+        for lang in ("it", "en"):
+            d = root / "content" / lang / "articles"
+            if d.exists():
+                self._watcher.addPath(str(d))
+
+    def _on_fs_change(self, _path: str):
+        self._refresh_articles()
+```
+
+- [ ] **Step 3: Update main.py to show window**
+
+```python
+import sys
+from PyQt6.QtWidgets import QApplication
+from core.config import Config
+from ui.main_window import MainWindow
+from ui.setup_dialog import SetupDialog
+
+def main():
+    app = QApplication(sys.argv)
+    app.setApplicationName("my-publisher")
+    app.setOrganizationName("danix")
+
+    config = Config.load()
+    if not config.is_complete():
+        dlg = SetupDialog(config)
+        if dlg.exec() == 0:
+            sys.exit(0)
+        config = Config.load()
+
+    window = MainWindow(config)
+    window.show()
+    sys.exit(app.exec())
+
+if __name__ == "__main__":
+    main()
+```
+
+- [ ] **Step 4: Launch app and verify sidebar renders**
+
+```bash
+python main.py
+```
+
+Expected: window opens with sidebar, all section buttons visible, no errors in terminal.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add ui/setup_dialog.py ui/main_window.py main.py
+git commit -m "feat: main window with sidebar navigation and file watcher"
+```
+
+---
+
+## Task 11: Articles view
+
+**Files:**
+- Create: `ui/articles_view.py`
+- Modify: `ui/main_window.py` (replace placeholder page)
+
+- [ ] **Step 1: Implement ui/articles_view.py**
+
+```python
+from __future__ import annotations
+from PyQt6.QtWidgets import (
+    QWidget, QVBoxLayout, QHBoxLayout, QTabWidget,
+    QListWidget, QListWidgetItem, QLabel, QPushButton,
+)
+from PyQt6.QtCore import Qt, pyqtSignal
+from PyQt6.QtGui import QColor
+from core.models import Article
+
+class ArticleItem(QListWidgetItem):
+    def __init__(self, article: Article):
+        super().__init__()
+        self.article = article
+        lang_other = "EN" if article.lang == "it" else "IT"
+        if article.has_translation:
+            badge = f"🇬🇧 ✓" if article.lang == "it" else "🇮🇹 ✓"
+            status = f"{article.slug}  [{badge}]"
+        else:
+            badge = f"🇬🇧 ✗" if article.lang == "it" else "🇮🇹 ✗"
+            status = f"{article.slug}  [{badge}]"
+        self.setText(status)
+        if not article.has_translation:
+            self.setForeground(QColor("#ff6b6b"))
+
+class ArticlesView(QWidget):
+    article_selected = pyqtSignal(object)  # Article
+    translate_requested = pyqtSignal(object)  # Article
+
+    def __init__(self, parent=None):
+        super().__init__(parent)
+        self._articles: list[Article] = []
+        self._build_ui()
+
+    def _build_ui(self):
+        layout = QVBoxLayout(self)
+        layout.setContentsMargins(0, 0, 0, 0)
+        self._tabs = QTabWidget()
+        self._tabs.setStyleSheet("QTabBar::tab { padding: 6px 16px; }")
+        self._list_it = QListWidget()
+        self._list_en = QListWidget()
+        self._tabs.addTab(self._list_it, "🇮🇹 Italiano")
+        self._tabs.addTab(self._list_en, "🇬🇧 English")
+        layout.addWidget(self._tabs)
+        self._list_it.itemClicked.connect(lambda item: self.article_selected.emit(item.article))
+        self._list_en.itemClicked.connect(lambda item: self.article_selected.emit(item.article))
+
+    def set_articles(self, articles: list[Article]):
+        self._articles = articles
+        self._list_it.clear()
+        self._list_en.clear()
+        for a in articles:
+            item = ArticleItem(a)
+            if a.lang == "it":
+                self._list_it.addItem(item)
+            else:
+                self._list_en.addItem(item)
+
+class MissingTranslationView(QWidget):
+    article_selected = pyqtSignal(object)  # Article
+    translate_requested = pyqtSignal(object)  # Article
+
+    def __init__(self, parent=None):
+        super().__init__(parent)
+        self._build_ui()
+
+    def _build_ui(self):
+        layout = QVBoxLayout(self)
+        header = QLabel("⚠️ Articoli senza traduzione")
+        header.setStyleSheet("color: #ff6b6b; font-weight: bold; font-size: 13px; padding: 10px;")
+        layout.addWidget(header)
+        self._list = QListWidget()
+        layout.addWidget(self._list)
+        self._list.itemClicked.connect(lambda item: self.article_selected.emit(item.article))
+
+    def set_articles(self, articles: list[Article]):
+        self._list.clear()
+        missing = [a for a in articles if not a.has_translation]
+        for a in missing:
+            lang_other = "🇬🇧" if a.lang == "it" else "🇮🇹"
+            item = ArticleItem(a)
+            item.setText(f"{a.slug}  [manca {lang_other}]  ({a.lang.upper()})")
+            self._list.addItem(item)
+```
+
+- [ ] **Step 2: Wire into main_window.py**
+
+In `MainWindow._build_ui`, after creating `self._stack`, replace placeholder article pages:
+
+```python
+from ui.articles_view import ArticlesView, MissingTranslationView
+
+# replace placeholder creation for "articles" and "no_translation":
+self._articles_view = ArticlesView()
+self._articles_view.article_selected.connect(self._on_article_selected)
+self._stack.addWidget(self._articles_view)
+self._page_articles = self._stack.count() - 1
+
+self._missing_view = MissingTranslationView()
+self._missing_view.article_selected.connect(self._on_article_selected)
+self._stack.addWidget(self._missing_view)
+self._page_no_translation = self._stack.count() - 1
+```
+
+Add `_refresh_articles` update and `_on_article_selected` stub to `MainWindow`:
+
+```python
+def _refresh_articles(self):
+    if not self.config.blog_repo:
+        return
+    self._articles = scan_articles(Path(self.config.blog_repo))
+    self._articles_view.set_articles(self._articles)
+    self._missing_view.set_articles(self._articles)
+    missing = [a for a in self._articles if not a.has_translation]
+    self._badge_no_trans.setText(str(len(missing)))
+
+def _on_article_selected(self, article: Article):
+    # placeholder — wired in Task 12
+    pass
+```
+
+- [ ] **Step 3: Launch and verify**
+
+```bash
+python main.py
+```
+
+Expected: click "Articoli" → tab IT/EN with real articles from blog repo. Untranslated articles shown in red.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add ui/articles_view.py ui/main_window.py
+git commit -m "feat: articles view with IT/EN tabs and missing translation highlight"
+```
+
+---
+
+## Task 12: Article detail panel
+
+**Files:**
+- Create: `ui/article_detail.py`
+- Modify: `ui/main_window.py`
+
+- [ ] **Step 1: Implement ui/article_detail.py**
+
+```python
+from __future__ import annotations
+import subprocess
+import re
+from PyQt6.QtWidgets import (
+    QWidget, QVBoxLayout, QHBoxLayout, QLabel,
+    QPushButton, QTextBrowser, QScrollArea, QSizePolicy, QFrame,
+)
+from PyQt6.QtCore import pyqtSignal, Qt
+from core.models import Article
+from core.frontmatter import parse_frontmatter
+
+def _strip_shortcodes(text: str) -> str:
+    return re.sub(r'\{\{[^}]+\}\}', '[shortcode]', text)
+
+class ArticleDetailView(QWidget):
+    open_typora = pyqtSignal(object)       # Article
+    open_frontmatter = pyqtSignal(object)  # Article
+    translate = pyqtSignal(object)         # Article
+    push_master = pyqtSignal(object)       # Article
+    publish = pyqtSignal(object)           # Article
+    delete_article = pyqtSignal(object)    # Article
+
+    def __init__(self, parent=None):
+        super().__init__(parent)
+        self._article: Article | None = None
+        self._build_ui()
+
+    def _build_ui(self):
+        layout = QVBoxLayout(self)
+        layout.setContentsMargins(0, 0, 0, 0)
+
+        # Header
+        header = QWidget()
+        header.setStyleSheet("background: #0f0f1a; border-bottom: 1px solid #2a2a4e;")
+        h_layout = QHBoxLayout(header)
+        h_layout.setContentsMargins(12, 8, 12, 8)
+
+        self._title_label = QLabel("")
+        self._title_label.setStyleSheet("color: #fff; font-weight: bold; font-size: 13px;")
+        self._path_label = QLabel("")
+        self._path_label.setStyleSheet("color: #555; font-size: 10px;")
+        info = QVBoxLayout()
+        info.addWidget(self._title_label)
+        info.addWidget(self._path_label)
+        h_layout.addLayout(info, stretch=1)
+
+        for (icon, signal_name, style) in [
+            ("✏️ Typora", "open_typora", "color:#a855f7;border:1px solid #a855f7;"),
+            ("🔧 Frontmatter", "open_frontmatter", "color:#888;border:1px solid #444;"),
+            ("🌍 Traduci", "translate", "color:#f59e0b;border:1px solid #f59e0b;"),
+            ("🧪 Push master", "push_master", "color:#60a5fa;border:1px solid #60a5fa;"),
+            ("🚢 Pubblica", "publish", "color:#00ff88;border:1px solid #00ff88;"),
+            ("🗑 Elimina", "delete_article", "color:#ff6b6b;border:1px solid #ff6b6b;"),
+        ]:
+            btn = QPushButton(icon)
+            btn.setStyleSheet(f"background:#1a1a2e;{style}padding:4px 10px;border-radius:4px;font-size:10px;")
+            btn.clicked.connect(lambda _, s=signal_name: getattr(self, s).emit(self._article))
+            h_layout.addWidget(btn)
+
+        layout.addWidget(header)
+
+        # Body: split columns
+        body = QHBoxLayout()
+        body.setContentsMargins(0, 0, 0, 0)
+        body.setSpacing(0)
+
+        # Frontmatter column
+        self._fm_widget = QWidget()
+        self._fm_widget.setFixedWidth(230)
+        self._fm_widget.setStyleSheet("background:#0a0a12;")
+        self._fm_layout = QVBoxLayout(self._fm_widget)
+        self._fm_layout.setContentsMargins(10, 10, 10, 10)
+        fm_title = QLabel("Frontmatter")
+        fm_title.setStyleSheet("color:#a855f7;font-size:9px;text-transform:uppercase;letter-spacing:1px;")
+        self._fm_layout.addWidget(fm_title)
+        self._fm_content = QVBoxLayout()
+        self._fm_layout.addLayout(self._fm_content)
+        self._fm_layout.addStretch()
+        body.addWidget(self._fm_widget)
+
+        divider = QFrame()
+        divider.setFrameShape(QFrame.Shape.VLine)
+        divider.setStyleSheet("color:#1a1a2e;")
+        body.addWidget(divider)
+
+        # Preview column
+        preview_container = QWidget()
+        preview_layout = QVBoxLayout(preview_container)
+        preview_layout.setContentsMargins(12, 10, 12, 10)
+        preview_title = QLabel("Preview markdown")
+        preview_title.setStyleSheet("color:#a855f7;font-size:9px;text-transform:uppercase;letter-spacing:1px;")
+        preview_layout.addWidget(preview_title)
+        self._preview = QTextBrowser()
+        self._preview.setStyleSheet("background:#0d0d1a;color:#ccc;border:none;")
+        self._preview.setOpenExternalLinks(True)
+        preview_layout.addWidget(self._preview)
+        body.addWidget(preview_container, stretch=1)
+
+        layout.addLayout(body, stretch=1)
+
+    def set_article(self, article: Article):
+        self._article = article
+        self._title_label.setText(article.slug)
+        rel = str(article.path).replace(str(article.path.parent.parent.parent.parent), "")
+        self._path_label.setText(rel)
+
+        # Populate frontmatter
+        while self._fm_content.count():
+            item = self._fm_content.takeAt(0)
+            if item.widget():
+                item.widget().deleteLater()
+
+        for key, val in article.frontmatter.items():
+            row = QVBoxLayout()
+            key_lbl = QLabel(key)
+            key_lbl.setStyleSheet("color:#555;font-size:9px;")
+            val_lbl = QLabel(str(val) if not isinstance(val, list) else ", ".join(str(v) for v in val))
+            val_lbl.setStyleSheet("color:#e2e8f0;font-size:11px;")
+            val_lbl.setWordWrap(True)
+            row.addWidget(key_lbl)
+            row.addWidget(val_lbl)
+            container = QWidget()
+            container.setLayout(row)
+            self._fm_content.addWidget(container)
+
+        # Load and render markdown preview
+        try:
+            _, body = parse_frontmatter(article.path)
+            clean = _strip_shortcodes(body)
+            self._preview.setMarkdown(clean)
+        except Exception as e:
+            self._preview.setPlainText(f"[Errore caricamento: {e}]")
+```
+
+- [ ] **Step 2: Wire into main_window.py**
+
+Add to `MainWindow._build_ui` after the missing_view block:
+
+```python
+from ui.article_detail import ArticleDetailView
+
+self._detail_view = ArticleDetailView()
+self._detail_view.open_typora.connect(self._do_open_typora)
+self._detail_view.open_frontmatter.connect(self._do_open_frontmatter)
+self._detail_view.translate.connect(self._do_translate)
+self._detail_view.push_master.connect(lambda a: self._do_git_push("master"))
+self._detail_view.publish.connect(lambda a: self._do_git_push("production"))
+self._detail_view.delete_article.connect(self._do_delete_article)
+self._stack.addWidget(self._detail_view)
+self._page_detail = self._stack.count() - 1
+```
+
+Add action stubs to `MainWindow`:
+
+```python
+def _on_article_selected(self, article: Article):
+    self._detail_view.set_article(article)
+    self._stack.setCurrentIndex(self._page_detail)
+
+def _do_open_typora(self, article: Article):
+    subprocess.Popen([self.config.typora_bin, str(article.path)])
+
+def _do_open_frontmatter(self, article: Article):
+    pass  # implemented in Task 13
+
+def _do_translate(self, article: Article):
+    pass  # implemented in Task 15
+
+def _do_git_push(self, branch: str):
+    pass  # implemented in Task 16
+
+def _do_delete_article(self, article: Article):
+    pass  # implemented in Task 16
+```
+
+- [ ] **Step 3: Launch and verify**
+
+```bash
+python main.py
+```
+
+Expected: click article → detail panel shows frontmatter fields + markdown preview. "✏️ Typora" opens file in Typora.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add ui/article_detail.py ui/main_window.py
+git commit -m "feat: article detail panel with frontmatter display and markdown preview"
+```
+
+---
+
+## Task 13: Frontmatter editor dialog
+
+**Files:**
+- Create: `ui/frontmatter_editor.py`
+- Modify: `ui/main_window.py` (wire `_do_open_frontmatter`)
+
+- [ ] **Step 1: Implement ui/frontmatter_editor.py**
+
+```python
+from __future__ import annotations
+from PyQt6.QtWidgets import (
+    QDialog, QVBoxLayout, QHBoxLayout, QFormLayout,
+    QLabel, QLineEdit, QComboBox, QPushButton,
+    QCompleter, QWidget, QScrollArea,
+)
+from PyQt6.QtCore import Qt
+from pathlib import Path
+from core.models import Article, ARTICLE_TYPES
+from core.frontmatter import parse_frontmatter, write_frontmatter
+from core.taxonomy import load_taxonomy
+
+class FrontmatterEditor(QDialog):
+    def __init__(self, article: Article, blog_root: Path, parent=None):
+        super().__init__(parent)
+        self.setWindowTitle(f"Frontmatter — {article.slug}")
+        self.setMinimumWidth(520)
+        self._article = article
+        self._blog_root = blog_root
+        self._fields: dict[str, QWidget] = {}
+        self._build_ui()
+
+    def _build_ui(self):
+        layout = QVBoxLayout(self)
+
+        scroll = QScrollArea()
+        scroll.setWidgetResizable(True)
+        inner = QWidget()
+        form = QFormLayout(inner)
+        scroll.setWidget(inner)
+        layout.addWidget(scroll)
+
+        lang = self._article.lang
+        tags_it = self._blog_root / "docs" / "tags-it.txt"
+        tags_en = self._blog_root / "docs" / "tags-en.txt"
+        taxonomy = load_taxonomy(tags_it, tags_en)
+        known_tags = list(taxonomy.it_to_en.keys()) if lang == "it" else list(taxonomy.it_to_en.values())
+
+        for key, val in self._article.frontmatter.items():
+            if key == "type":
+                widget = QComboBox()
+                widget.addItems(ARTICLE_TYPES)
+                if str(val) in ARTICLE_TYPES:
+                    widget.setCurrentText(str(val))
+                self._fields[key] = widget
+            elif key == "tags":
+                widget = QLineEdit(", ".join(str(v) for v in val) if isinstance(val, list) else str(val))
+                completer = QCompleter(known_tags)
+                completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
+                widget.setCompleter(completer)
+                self._fields[key] = widget
+            else:
+                widget = QLineEdit(str(val))
+                self._fields[key] = widget
+            form.addRow(QLabel(key), widget)
+
+        btns = QHBoxLayout()
+        save_btn = QPushButton("Salva")
+        save_btn.clicked.connect(self._save)
+        cancel_btn = QPushButton("Annulla")
+        cancel_btn.clicked.connect(self.reject)
+        btns.addStretch()
+        btns.addWidget(cancel_btn)
+        btns.addWidget(save_btn)
+        layout.addLayout(btns)
+
+    def _save(self):
+        updated = dict(self._article.frontmatter)
+        for key, widget in self._fields.items():
+            if isinstance(widget, QComboBox):
+                updated[key] = widget.currentText()
+            elif isinstance(widget, QLineEdit):
+                val = widget.text().strip()
+                if key == "tags":
+                    updated[key] = [t.strip() for t in val.split(",") if t.strip()]
+                else:
+                    updated[key] = val
+        try:
+            _, body = parse_frontmatter(self._article.path)
+            write_frontmatter(self._article.path, updated, body)
+            self._article.frontmatter.update(updated)
+            self.accept()
+        except Exception as e:
+            from PyQt6.QtWidgets import QMessageBox
+            QMessageBox.critical(self, "Errore", str(e))
+```
+
+- [ ] **Step 2: Wire into main_window.py**
+
+Replace `_do_open_frontmatter` stub:
+
+```python
+def _do_open_frontmatter(self, article: Article):
+    from ui.frontmatter_editor import FrontmatterEditor
+    dlg = FrontmatterEditor(article, Path(self.config.blog_repo), self)
+    if dlg.exec():
+        self._detail_view.set_article(article)
+```
+
+- [ ] **Step 3: Launch and verify**
+
+```bash
+python main.py
+```
+
+Expected: click "🔧 Frontmatter" → dialog with all fields. `type` field is dropdown with 5 options. Save writes to disk. Reopen article → updated values visible.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add ui/frontmatter_editor.py ui/main_window.py
+git commit -m "feat: frontmatter editor dialog with type dropdown and tag autocomplete"
+```
+
+---
+
+## Task 14: New article stub creation
+
+**Files:**
+- Create: `ui/new_article_dialog.py`
+- Modify: `ui/main_window.py`
+
+- [ ] **Step 1: Implement ui/new_article_dialog.py**
+
+```python
+from __future__ import annotations
+from datetime import date
+from pathlib import Path
+from PyQt6.QtWidgets import (
+    QDialog, QVBoxLayout, QFormLayout, QHBoxLayout,
+    QLineEdit, QComboBox, QLabel, QPushButton, QMessageBox,
+)
+from core.models import ARTICLE_TYPES
+
+STUB_TEMPLATE = """\
++++
+title = "{title}"
+date = {date}
+type = "{type}"
+draft = true
+excerpt = ""
+tags = []
+categories = []
++++
+
+TODO: scrivi il contenuto qui.
+"""
+
+class NewArticleDialog(QDialog):
+    def __init__(self, blog_root: Path, parent=None):
+        super().__init__(parent)
+        self.setWindowTitle("Nuovo articolo")
+        self.setMinimumWidth(400)
+        self._blog_root = blog_root
+        self._created_path: Path | None = None
+        self._build_ui()
+
+    @property
+    def created_path(self) -> Path | None:
+        return self._created_path
+
+    def _build_ui(self):
+        layout = QVBoxLayout(self)
+        form = QFormLayout()
+
+        self._slug = QLineEdit()
+        self._slug.setPlaceholderText("mio-articolo-2026")
+        form.addRow("Slug:", self._slug)
+
+        self._title = QLineEdit()
+        self._title.setPlaceholderText("Il mio articolo")
+        form.addRow("Titolo:", self._title)
+
+        self._lang = QComboBox()
+        self._lang.addItems(["it", "en"])
+        form.addRow("Lingua:", self._lang)
+
+        self._type = QComboBox()
+        self._type.addItems(ARTICLE_TYPES)
+        form.addRow("Tipo:", self._type)
+
+        layout.addLayout(form)
+
+        btns = QHBoxLayout()
+        create_btn = QPushButton("Crea")
+        create_btn.clicked.connect(self._create)
+        cancel_btn = QPushButton("Annulla")
+        cancel_btn.clicked.connect(self.reject)
+        btns.addStretch()
+        btns.addWidget(cancel_btn)
+        btns.addWidget(create_btn)
+        layout.addLayout(btns)
+
+    def _create(self):
+        slug = self._slug.text().strip()
+        title = self._title.text().strip()
+        lang = self._lang.currentText()
+        article_type = self._type.currentText()
+
+        if not slug or not title:
+            QMessageBox.warning(self, "Errore", "Slug e titolo sono obbligatori.")
+            return
+
+        target = self._blog_root / "content" / lang / "articles" / slug
+        if target.exists():
+            QMessageBox.warning(self, "Errore", f"Esiste già: {target}")
+            return
+
+        target.mkdir(parents=True)
+        index_md = target / "index.md"
+        index_md.write_text(STUB_TEMPLATE.format(
+            title=title,
+            date=date.today().isoformat(),
+            type=article_type,
+        ), encoding="utf-8")
+
+        self._created_path = index_md
+        self.accept()
+```
+
+- [ ] **Step 2: Wire into main_window.py**
+
+Replace the placeholder for `_page_new_article`. In `_build_ui`, instead of adding a label placeholder, connect the sidebar button to open the dialog:
+
+```python
+# In _build_sidebar, change the "Nuovo articolo" button click handler:
+# Find the btn("➕ Nuovo articolo", ...) call and replace with:
+b_new = SidebarButton("➕ Nuovo articolo")
+b_new.clicked.connect(self._do_new_article)
+layout.addWidget(b_new)
+self._btn_group.append(b_new)
+```
+
+Add to `MainWindow`:
+
+```python
+def _do_new_article(self):
+    from ui.new_article_dialog import NewArticleDialog
+    import subprocess
+    dlg = NewArticleDialog(Path(self.config.blog_repo), self)
+    if dlg.exec() and dlg.created_path:
+        self._refresh_articles()
+        subprocess.Popen([self.config.typora_bin, str(dlg.created_path)])
+```
+
+- [ ] **Step 3: Launch and verify**
+
+```bash
+python main.py
+```
+
+Expected: click "➕ Nuovo articolo" → dialog → fill slug/title/type → create → file appears in blog repo → Typora opens it.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add ui/new_article_dialog.py ui/main_window.py
+git commit -m "feat: new article stub creation with Typora open"
+```
+
+---
+
+## Task 15: Translation view
+
+**Files:**
+- Create: `ui/translation_view.py`
+- Modify: `ui/main_window.py` (wire `_do_translate`)
+
+- [ ] **Step 1: Implement ui/translation_view.py**
+
+```python
+from __future__ import annotations
+import subprocess
+from pathlib import Path
+from PyQt6.QtWidgets import (
+    QWidget, QVBoxLayout, QHBoxLayout, QLabel,
+    QPushButton, QPlainTextEdit, QTextBrowser,
+)
+from PyQt6.QtCore import pyqtSignal
+from core.models import Article
+from core.frontmatter import parse_frontmatter
+from workers.translation_worker import TranslationWorker
+import re
+
+def _strip_shortcodes(text: str) -> str:
+    return re.sub(r'\{\{[^}]+\}\}', '[shortcode]', text)
+
+class TranslationView(QWidget):
+    push_master = pyqtSignal()
+    publish = pyqtSignal()
+
+    def __init__(self, transart_script: str, typora_bin: str, parent=None):
+        super().__init__(parent)
+        self._transart_script = transart_script
+        self._typora_bin = typora_bin
+        self._article: Article | None = None
+        self._worker: TranslationWorker | None = None
+        self._output_path: str = ""
+        self._build_ui()
+
+    def _build_ui(self):
+        layout = QVBoxLayout(self)
+
+        # Header
+        self._header = QLabel("")
+        self._header.setStyleSheet("color:#fff;font-weight:bold;font-size:13px;padding:10px;")
+        layout.addWidget(self._header)
+
+        # Log
+        log_label = QLabel("Output traduzione:")
+        log_label.setStyleSheet("color:#a855f7;font-size:9px;text-transform:uppercase;letter-spacing:1px;padding:0 10px;")
+        layout.addWidget(log_label)
+        self._log = QPlainTextEdit()
+        self._log.setReadOnly(True)
+        self._log.setStyleSheet("background:#0a0a12;color:#00ff88;font-family:monospace;font-size:11px;")
+        self._log.setMaximumHeight(180)
+        layout.addWidget(self._log)
+
+        # Preview
+        preview_label = QLabel("Preview traduzione:")
+        preview_label.setStyleSheet("color:#a855f7;font-size:9px;text-transform:uppercase;letter-spacing:1px;padding:0 10px;")
+        layout.addWidget(preview_label)
+        self._preview = QTextBrowser()
+        self._preview.setStyleSheet("background:#0d0d1a;color:#ccc;border:none;")
+        layout.addWidget(self._preview, stretch=1)
+
+        # Action buttons (hidden until translation done)
+        self._action_bar = QWidget()
+        btns = QHBoxLayout(self._action_bar)
+        btns.setContentsMargins(10, 6, 10, 6)
+
+        self._btn_typora = QPushButton("✏️ Apri in Typora")
+        self._btn_typora.clicked.connect(self._open_typora)
+        self._btn_retry = QPushButton("🔄 Rigenera")
+        self._btn_retry.clicked.connect(self._retry)
+        self._btn_master = QPushButton("🧪 Push master")
+        self._btn_master.clicked.connect(self.push_master.emit)
+        self._btn_publish = QPushButton("🚢 Pubblica")
+        self._btn_publish.clicked.connect(self.publish.emit)
+
+        for btn in (self._btn_typora, self._btn_retry, self._btn_master, self._btn_publish):
+            btns.addWidget(btn)
+        btns.addStretch()
+
+        self._action_bar.setVisible(False)
+        layout.addWidget(self._action_bar)
+
+    def start_translation(self, article: Article):
+        self._article = article
+        self._log.clear()
+        self._preview.clear()
+        self._action_bar.setVisible(False)
+
+        direction = "it-en" if article.lang == "it" else "en-it"
+        self._header.setText(f"Traduzione: {article.slug} ({article.lang.upper()} → {('EN' if article.lang == 'it' else 'IT')})")
+
+        self._worker = TranslationWorker(
+            Path(self._transart_script),
+            article.path,
+            direction,
+            parent=self,
+        )
+        self._worker.log_line.connect(self._log.appendPlainText)
+        self._worker.finished.connect(self._on_finished)
+        self._worker.error.connect(lambda e: self._log.appendPlainText(f"[ERRORE] {e}"))
+        self._worker.start()
+
+    def _on_finished(self, success: bool, output_path: str):
+        self._output_path = output_path
+        if success and Path(output_path).exists():
+            try:
+                _, body = parse_frontmatter(Path(output_path))
+                self._preview.setMarkdown(_strip_shortcodes(body))
+            except Exception as e:
+                self._preview.setPlainText(f"[Errore preview: {e}]")
+        self._action_bar.setVisible(True)
+
+    def _open_typora(self):
+        if self._output_path:
+            subprocess.Popen([self._typora_bin, self._output_path])
+
+    def _retry(self):
+        if self._article:
+            self.start_translation(self._article)
+```
+
+- [ ] **Step 2: Wire into main_window.py**
+
+Add to `MainWindow._build_ui`:
+
+```python
+from ui.translation_view import TranslationView
+
+self._translation_view = TranslationView(
+    self.config.transart_script,
+    self.config.typora_bin,
+    parent=self,
+)
+self._translation_view.push_master.connect(lambda: self._do_git_push("master"))
+self._translation_view.publish.connect(lambda: self._do_git_push("production"))
+self._stack.addWidget(self._translation_view)
+self._page_translation = self._stack.count() - 1
+```
+
+Replace `_do_translate` stub:
+
+```python
+def _do_translate(self, article: Article):
+    self._translation_view.start_translation(article)
+    self._stack.setCurrentIndex(self._page_translation)
+```
+
+- [ ] **Step 3: Launch and verify**
+
+```bash
+python main.py
+```
+
+Expected: click "🌍 Traduci" on an article → translation view appears with streaming log. When done: preview shows translated markdown + action buttons appear.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add ui/translation_view.py ui/main_window.py
+git commit -m "feat: translation view with QProcess streaming and post-translation preview"
+```
+
+---
+
+## Task 16: Git view
+
+**Files:**
+- Create: `ui/git_view.py`
+- Modify: `ui/main_window.py`
+
+- [ ] **Step 1: Implement ui/git_view.py**
+
+```python
+from __future__ import annotations
+from pathlib import Path
+from PyQt6.QtWidgets import (
+    QWidget, QVBoxLayout, QHBoxLayout, QLabel,
+    QPushButton, QPlainTextEdit, QTabWidget, QListWidget, QListWidgetItem,
+)
+from PyQt6.QtCore import pyqtSignal
+from workers.git_worker import GitWorker
+import subprocess
+
+class GitView(QWidget):
+    refresh_needed = pyqtSignal()
+
+    def __init__(self, repo_path: Path, parent=None):
+        super().__init__(parent)
+        self._repo_path = repo_path
+        self._worker: GitWorker | None = None
+        self._build_ui()
+
+    def _build_ui(self):
+        layout = QVBoxLayout(self)
+
+        # Status bar
+        status_row = QHBoxLayout()
+        self._branch_label = QLabel("Branch: —")
+        self._branch_label.setStyleSheet("color:#a855f7;font-weight:bold;")
+        status_row.addWidget(self._branch_label)
+        status_row.addStretch()
+        refresh_btn = QPushButton("🔄 Refresh")
+        refresh_btn.clicked.connect(self.load_status)
+        status_row.addWidget(refresh_btn)
+        layout.addLayout(status_row)
+
+        # Tabs
+        tabs = QTabWidget()
+
+        # Status tab
+        status_tab = QWidget()
+        st_layout = QVBoxLayout(status_tab)
+        self._status_log = QPlainTextEdit()
+        self._status_log.setReadOnly(True)
+        self._status_log.setStyleSheet("background:#0a0a12;color:#ccc;font-family:monospace;font-size:11px;")
+        st_layout.addWidget(self._status_log)
+
+        btns = QHBoxLayout()
+        for label, slot in [
+            ("⬇ Pull", self._do_pull),
+            ("🧪 Push master", self._do_push_master),
+            ("🚢 Push production", self._do_push_production),
+        ]:
+            btn = QPushButton(label)
+            btn.clicked.connect(slot)
+            btns.addWidget(btn)
+        btns.addStretch()
+        st_layout.addLayout(btns)
+        tabs.addTab(status_tab, "Stato repo")
+
+        # Deleted articles tab
+        deleted_tab = QWidget()
+        del_layout = QVBoxLayout(deleted_tab)
+        del_layout.addWidget(QLabel("Articoli eliminati (da git log):"))
+        self._deleted_list = QListWidget()
+        del_layout.addWidget(self._deleted_list)
+        restore_btn = QPushButton("♻️ Ripristina selezionato")
+        restore_btn.clicked.connect(self._do_restore)
+        del_layout.addWidget(restore_btn)
+        tabs.addTab(deleted_tab, "Articoli eliminati")
+
+        layout.addWidget(tabs)
+
+        # Output log
+        self._output = QPlainTextEdit()
+        self._output.setReadOnly(True)
+        self._output.setStyleSheet("background:#0a0a12;color:#00ff88;font-family:monospace;font-size:11px;")
+        self._output.setMaximumHeight(150)
+        layout.addWidget(self._output)
+
+    def load_status(self):
+        try:
+            branch = subprocess.check_output(
+                ["git", "rev-parse", "--abbrev-ref", "HEAD"],
+                cwd=self._repo_path, text=True,
+            ).strip()
+            self._branch_label.setText(f"Branch: {branch}")
+
+            status = subprocess.check_output(
+                ["git", "status", "--short"],
+                cwd=self._repo_path, text=True,
+            )
+            log = subprocess.check_output(
+                ["git", "log", "--oneline", "-5"],
+                cwd=self._repo_path, text=True,
+            )
+            self._status_log.setPlainText(f"--- git status ---\n{status}\n--- ultimi commit ---\n{log}")
+
+            # Load deleted articles
+            self._deleted_list.clear()
+            deleted = subprocess.check_output(
+                ["git", "log", "--diff-filter=D", "--name-only", "--pretty=format:%H %s", "--", "content/"],
+                cwd=self._repo_path, text=True,
+            )
+            current_hash = None
+            for line in deleted.splitlines():
+                if line.strip() == "":
+                    continue
+                if line.startswith("content/"):
+                    if current_hash:
+                        item = QListWidgetItem(f"{line}  [{current_hash[:8]}]")
+                        item.setData(256, (current_hash, line))
+                        self._deleted_list.addItem(item)
+                else:
+                    parts = line.split(" ", 1)
+                    current_hash = parts[0] if parts else None
+        except Exception as e:
+            self._output.appendPlainText(f"[Errore] {e}")
+
+    def _run_worker(self, worker: GitWorker):
+        self._worker = worker
+        worker.output.connect(self._output.appendPlainText)
+        worker.error.connect(lambda e: self._output.appendPlainText(f"[ERRORE] {e}"))
+        worker.finished.connect(lambda ok: self.refresh_needed.emit() if ok else None)
+        worker.start()
+
+    def _do_pull(self):
+        self._run_worker(GitWorker.pull(self._repo_path, self))
+
+    def _do_push_master(self):
+        self._run_worker(GitWorker.push_master(self._repo_path, self))
+
+    def _do_push_production(self):
+        self._run_worker(GitWorker.push_production(self._repo_path, self))
+
+    def _do_restore(self):
+        item = self._deleted_list.currentItem()
+        if not item:
+            return
+        commit_hash, rel_path = item.data(256)
+        worker = GitWorker.restore_article(self._repo_path, commit_hash, rel_path, self)
+        self._run_worker(worker)
+        self.load_status()
+```
+
+- [ ] **Step 2: Wire into main_window.py**
+
+Replace git placeholder in `_build_ui`:
+
+```python
+from ui.git_view import GitView
+
+self._git_view = GitView(Path(self.config.blog_repo), parent=self)
+self._git_view.refresh_needed.connect(self._refresh_articles)
+self._stack.addWidget(self._git_view)
+self._page_git = self._stack.count() - 1
+```
+
+Replace `_do_git_push` stub:
+
+```python
+def _do_git_push(self, branch: str):
+    from workers.git_worker import GitWorker
+    repo = Path(self.config.blog_repo)
+    if branch == "master":
+        worker = GitWorker.push_master(repo, self)
+    else:
+        worker = GitWorker.push_production(repo, self)
+    worker.error.connect(lambda e: self.statusBar().showMessage(f"Git error: {e}", 5000))
+    worker.finished.connect(lambda ok: self.statusBar().showMessage("Push completato." if ok else "Push fallito.", 4000))
+    worker.start()
+
+def _do_delete_article(self, article: Article):
+    from PyQt6.QtWidgets import QMessageBox
+    from workers.git_worker import GitWorker
+    reply = QMessageBox.question(
+        self, "Elimina articolo",
+        f"Eliminare '{article.slug}' ({article.lang})? L'operazione sarà tracciata in git.",
+    )
+    if reply == QMessageBox.StandardButton.Yes:
+        rel = str(article.path.parent.relative_to(Path(self.config.blog_repo)))
+        worker = GitWorker.remove_article(
+            Path(self.config.blog_repo), rel, article.slug, article.lang, self
+        )
+        worker.error.connect(lambda e: self.statusBar().showMessage(f"Errore: {e}", 5000))
+        worker.finished.connect(lambda ok: self._refresh_articles() if ok else None)
+        worker.start()
+```
+
+- [ ] **Step 3: Launch and verify**
+
+```bash
+python main.py
+```
+
+Expected: click "🔧 Git ops" → branch shown, status + last 5 commits visible. Pull/push buttons run git ops with output in log.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add ui/git_view.py ui/main_window.py
+git commit -m "feat: git view with status, pull/push, and deleted article restore"
+```
+
+---
+
+## Task 17: Hugo server panel
+
+**Files:**
+- Create: `ui/hugo_panel.py`
+- Modify: `ui/main_window.py`
+
+- [ ] **Step 1: Implement ui/hugo_panel.py**
+
+```python
+from __future__ import annotations
+from pathlib import Path
+from PyQt6.QtWidgets import (
+    QWidget, QVBoxLayout, QHBoxLayout,
+    QLabel, QPushButton, QPlainTextEdit,
+)
+from PyQt6.QtCore import pyqtSignal, QUrl
+from PyQt6.QtGui import QDesktopServices
+from workers.hugo_worker import HugoWorker
+
+class HugoPanel(QWidget):
+    def __init__(self, repo_path: Path, parent=None):
+        super().__init__(parent)
+        self._worker = HugoWorker(repo_path, parent=self)
+        self._worker.log_line.connect(self._on_log)
+        self._worker.started.connect(self._on_started)
+        self._worker.stopped.connect(self._on_stopped)
+        self._worker.error.connect(lambda e: self._log.appendPlainText(f"[ERRORE] {e}"))
+        self._build_ui()
+
+    def _build_ui(self):
+        layout = QVBoxLayout(self)
+
+        top = QHBoxLayout()
+        self._status_label = QLabel("🔴 Hugo server: fermo")
+        self._status_label.setStyleSheet("color:#ff6b6b;font-weight:bold;")
+        top.addWidget(self._status_label)
+        top.addStretch()
+
+        self._start_btn = QPushButton("▶ Avvia")
+        self._start_btn.clicked.connect(self._worker.start)
+        self._stop_btn = QPushButton("⏹ Ferma")
+        self._stop_btn.clicked.connect(self._worker.stop)
+        self._stop_btn.setEnabled(False)
+        top.addWidget(self._start_btn)
+        top.addWidget(self._stop_btn)
+
+        self._url_btn = QPushButton("🌐 http://localhost:1313")
+        self._url_btn.setFlat(True)
+        self._url_btn.setStyleSheet("color:#60a5fa;text-decoration:underline;")
+        self._url_btn.clicked.connect(lambda: QDesktopServices.openUrl(QUrl("http://localhost:1313")))
+        self._url_btn.setVisible(False)
+        top.addWidget(self._url_btn)
+
+        layout.addLayout(top)
+
+        self._log = QPlainTextEdit()
+        self._log.setReadOnly(True)
+        self._log.setStyleSheet("background:#0a0a12;color:#00ff88;font-family:monospace;font-size:11px;")
+        layout.addWidget(self._log)
+
+    def _on_log(self, line: str):
+        self._log.appendPlainText(line)
+
+    def _on_started(self):
+        self._status_label.setText("🟢 Hugo server: in esecuzione")
+        self._status_label.setStyleSheet("color:#00ff88;font-weight:bold;")
+        self._start_btn.setEnabled(False)
+        self._stop_btn.setEnabled(True)
+        self._url_btn.setVisible(True)
+
+    def _on_stopped(self):
+        self._status_label.setText("🔴 Hugo server: fermo")
+        self._status_label.setStyleSheet("color:#ff6b6b;font-weight:bold;")
+        self._start_btn.setEnabled(True)
+        self._stop_btn.setEnabled(False)
+        self._url_btn.setVisible(False)
+
+    def closeEvent(self, event):
+        self._worker.stop()
+        super().closeEvent(event)
+```
+
+- [ ] **Step 2: Wire into main_window.py**
+
+Replace hugo placeholder in `_build_ui`:
+
+```python
+from ui.hugo_panel import HugoPanel
+
+self._hugo_panel = HugoPanel(Path(self.config.blog_repo), parent=self)
+self._stack.addWidget(self._hugo_panel)
+self._page_hugo = self._stack.count() - 1
+```
+
+Also stop hugo server on app close — override `closeEvent` in `MainWindow`:
+
+```python
+def closeEvent(self, event):
+    self._hugo_panel._worker.stop()
+    super().closeEvent(event)
+```
+
+- [ ] **Step 3: Launch and verify**
+
+```bash
+python main.py
+```
+
+Expected: click "🚀 Hugo server" → panel with start button. Click "▶ Avvia" → log streams hugo output → link `http://localhost:1313` appears clickable. "⏹ Ferma" kills server.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add ui/hugo_panel.py ui/main_window.py
+git commit -m "feat: hugo server panel with start/stop and log streaming"
+```
+
+---
+
+## Task 18: Taxonomy view
+
+**Files:**
+- Create: `ui/taxonomy_view.py`
+- Modify: `ui/main_window.py`
+
+- [ ] **Step 1: Implement ui/taxonomy_view.py**
+
+```python
+from __future__ import annotations
+from pathlib import Path
+from PyQt6.QtWidgets import (
+    QWidget, QVBoxLayout, QHBoxLayout, QTabWidget,
+    QTableWidget, QTableWidgetItem, QPushButton,
+    QLabel, QDialog, QFormLayout, QLineEdit, QMessageBox,
+)
+from PyQt6.QtCore import Qt
+from PyQt6.QtGui import QColor
+from core.taxonomy import TaxonomyModel, load_taxonomy, save_taxonomy
+
+class AddTermDialog(QDialog):
+    def __init__(self, parent=None):
+        super().__init__(parent)
+        self.setWindowTitle("Aggiungi termine")
+        layout = QVBoxLayout(self)
+        form = QFormLayout()
+        self._it = QLineEdit()
+        self._en = QLineEdit()
+        form.addRow("IT:", self._it)
+        form.addRow("EN:", self._en)
+        layout.addLayout(form)
+        btns = QHBoxLayout()
+        ok = QPushButton("Aggiungi")
+        ok.clicked.connect(self.accept)
+        cancel = QPushButton("Annulla")
+        cancel.clicked.connect(self.reject)
+        btns.addStretch()
+        btns.addWidget(cancel)
+        btns.addWidget(ok)
+        layout.addLayout(btns)
+
+    @property
+    def it_term(self) -> str:
+        return self._it.text().strip()
+
+    @property
+    def en_term(self) -> str:
+        return self._en.text().strip()
+
+class TaxonomyView(QWidget):
+    def __init__(self, blog_root: Path, parent=None):
+        super().__init__(parent)
+        self._blog_root = blog_root
+        self._tags_it = blog_root / "docs" / "tags-it.txt"
+        self._tags_en = blog_root / "docs" / "tags-en.txt"
+        self._cats_it = blog_root / "docs" / "categories.txt"
+        self._model: TaxonomyModel | None = None
+        self._build_ui()
+        self.load()
+
+    def _build_ui(self):
+        layout = QVBoxLayout(self)
+
+        tabs = QTabWidget()
+
+        # Tags tab
+        tags_tab = QWidget()
+        tl = QVBoxLayout(tags_tab)
+        self._tags_table = QTableWidget(0, 2)
+        self._tags_table.setHorizontalHeaderLabels(["🇮🇹 Italiano", "🇬🇧 English"])
+        self._tags_table.horizontalHeader().setStretchLastSection(True)
+        tl.addWidget(self._tags_table)
+        tag_btns = QHBoxLayout()
+        add_tag_btn = QPushButton("➕ Aggiungi termine")
+        add_tag_btn.clicked.connect(self._add_term)
+        save_tags_btn = QPushButton("💾 Salva")
+        save_tags_btn.clicked.connect(self._save)
+        tag_btns.addWidget(add_tag_btn)
+        tag_btns.addStretch()
+        tag_btns.addWidget(save_tags_btn)
+        tl.addLayout(tag_btns)
+        tabs.addTab(tags_tab, "🏷 Tags")
+
+        layout.addWidget(tabs)
+
+        self._status = QLabel("")
+        self._status.setStyleSheet("color:#888;font-size:10px;padding:4px;")
+        layout.addWidget(self._status)
+
+    def load(self):
+        if not self._tags_it.exists():
+            self._status.setText("tags-it.txt non trovato.")
+            return
+        self._model = load_taxonomy(self._tags_it, self._tags_en)
+        self._populate_table()
+
+    def _populate_table(self):
+        if not self._model:
+            return
+        self._tags_table.setRowCount(0)
+        for it_term, en_term in sorted(self._model.it_to_en.items()):
+            row = self._tags_table.rowCount()
+            self._tags_table.insertRow(row)
+            self._tags_table.setItem(row, 0, QTableWidgetItem(it_term))
+            self._tags_table.setItem(row, 1, QTableWidgetItem(en_term))
+
+        for orphan in self._model.orphans_it:
+            row = self._tags_table.rowCount()
+            self._tags_table.insertRow(row)
+            it_item = QTableWidgetItem(orphan)
+            it_item.setForeground(QColor("#ff6b6b"))
+            en_item = QTableWidgetItem("⚠ mancante")
+            en_item.setForeground(QColor("#ff6b6b"))
+            self._tags_table.setItem(row, 0, it_item)
+            self._tags_table.setItem(row, 1, en_item)
+
+        self._status.setText(
+            f"{len(self._model.it_to_en)} coppie · {len(self._model.orphans_it)} IT orfani · {len(self._model.orphans_en)} EN orfani"
+        )
+
+    def _add_term(self):
+        dlg = AddTermDialog(self)
+        if dlg.exec() and dlg.it_term and dlg.en_term:
+            if self._model:
+                self._model.it_to_en[dlg.it_term] = dlg.en_term
+                self._populate_table()
+
+    def _save(self):
+        if not self._model:
+            return
+        updated: dict[str, str] = {}
+        for row in range(self._tags_table.rowCount()):
+            it_item = self._tags_table.item(row, 0)
+            en_item = self._tags_table.item(row, 1)
+            if it_item and en_item:
+                it_val = it_item.text().strip()
+                en_val = en_item.text().strip()
+                if it_val and en_val and en_val != "⚠ mancante":
+                    updated[it_val] = en_val
+        self._model.it_to_en = updated
+        save_taxonomy(self._model, self._tags_it, self._tags_en)
+        self._status.setText("Salvato.")
+```
+
+- [ ] **Step 2: Wire into main_window.py**
+
+Replace taxonomy placeholder in `_build_ui`:
+
+```python
+from ui.taxonomy_view import TaxonomyView
+
+self._taxonomy_view = TaxonomyView(Path(self.config.blog_repo), parent=self)
+self._stack.addWidget(self._taxonomy_view)
+self._page_taxonomy = self._stack.count() - 1
+```
+
+- [ ] **Step 3: Launch and verify**
+
+```bash
+python main.py
+```
+
+Expected: click "🏷 Tassonomia" → table with IT|EN tag pairs. Orphans shown in red. Add term dialog works. Save updates `docs/tags-it.txt` and `docs/tags-en.txt`.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add ui/taxonomy_view.py ui/main_window.py
+git commit -m "feat: taxonomy view with IT/EN pair table and orphan detection"
+```
+
+---
+
+## Task 19: Media view
+
+**Files:**
+- Create: `ui/media_view.py`
+- Modify: `ui/main_window.py`
+
+- [ ] **Step 1: Implement ui/media_view.py**
+
+```python
+from __future__ import annotations
+import shutil
+from datetime import date
+from pathlib import Path
+from PyQt6.QtWidgets import (
+    QWidget, QVBoxLayout, QHBoxLayout,
+    QLabel, QPushButton, QListWidget, QListWidgetItem, QFileDialog,
+)
+from PyQt6.QtCore import Qt, pyqtSignal
+from PyQt6.QtGui import QDragEnterEvent, QDropEvent
+
+class MediaView(QWidget):
+    def __init__(self, blog_root: Path, parent=None):
+        super().__init__(parent)
+        self._blog_root = blog_root
+        self.setAcceptDrops(True)
+        self._build_ui()
+        self._refresh_list()
+
+    def _target_dir(self) -> Path:
+        today = date.today()
+        return self._blog_root / "static" / "uppies" / str(today.year) / f"{today.month:02d}"
+
+    def _build_ui(self):
+        layout = QVBoxLayout(self)
+
+        header = QLabel("🖼 Media — static/uppies/YYYY/MM/")
+        header.setStyleSheet("color:#fff;font-weight:bold;font-size:13px;padding:10px;")
+        layout.addWidget(header)
+
+        drop_zone = QLabel("Trascina file qui, oppure usa il pulsante sotto.")
+        drop_zone.setAlignment(Qt.AlignmentFlag.AlignCenter)
+        drop_zone.setStyleSheet("border:2px dashed #2a2a4e;color:#555;padding:20px;border-radius:6px;margin:10px;")
+        drop_zone.setMinimumHeight(80)
+        layout.addWidget(drop_zone)
+
+        btns = QHBoxLayout()
+        add_btn = QPushButton("📂 Aggiungi file...")
+        add_btn.clicked.connect(self._pick_files)
+        btns.addWidget(add_btn)
+        btns.addStretch()
+        self._path_label = QLabel("")
+        self._path_label.setStyleSheet("color:#a855f7;font-size:10px;")
+        btns.addWidget(self._path_label)
+        layout.addLayout(btns)
+
+        self._list = QListWidget()
+        layout.addWidget(self._list, stretch=1)
+
+        self._status = QLabel("")
+        self._status.setStyleSheet("color:#888;font-size:10px;padding:4px;")
+        layout.addWidget(self._status)
+
+    def _copy_files(self, paths: list[str]):
+        target = self._target_dir()
+        target.mkdir(parents=True, exist_ok=True)
+        last_web_path = ""
+        for src in paths:
+            dest = target / Path(src).name
+            shutil.copy2(src, dest)
+            today = date.today()
+            last_web_path = f"/uppies/{today.year}/{today.month:02d}/{Path(src).name}"
+
+        if last_web_path:
+            from PyQt6.QtWidgets import QApplication
+            QApplication.clipboard().setText(last_web_path)
+            self._status.setText(f"Copiato in clipboard: {last_web_path}")
+
+        self._refresh_list()
+
+    def _pick_files(self):
+        files, _ = QFileDialog.getOpenFileNames(self, "Seleziona file media")
+        if files:
+            self._copy_files(files)
+
+    def _refresh_list(self):
+        self._list.clear()
+        target = self._target_dir()
+        today = date.today()
+        self._path_label.setText(f"Cartella: static/uppies/{today.year}/{today.month:02d}/")
+        if not target.exists():
+            return
+        for f in sorted(target.iterdir()):
+            if f.is_file():
+                self._list.addItem(QListWidgetItem(f.name))
+
+    def dragEnterEvent(self, event: QDragEnterEvent):
+        if event.mimeData().hasUrls():
+            event.acceptProposedAction()
+
+    def dropEvent(self, event: QDropEvent):
+        paths = [u.toLocalFile() for u in event.mimeData().urls() if u.isLocalFile()]
+        if paths:
+            self._copy_files(paths)
+```
+
+- [ ] **Step 2: Wire into main_window.py**
+
+Replace media placeholder in `_build_ui`:
+
+```python
+from ui.media_view import MediaView
+
+self._media_view = MediaView(Path(self.config.blog_repo), parent=self)
+self._stack.addWidget(self._media_view)
+self._page_media = self._stack.count() - 1
+```
+
+- [ ] **Step 3: Launch and verify**
+
+```bash
+python main.py
+```
+
+Expected: click "🖼 Media" → drag a file onto the window → file copied to `static/uppies/YYYY/MM/` → web path copied to clipboard → file appears in list.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add ui/media_view.py ui/main_window.py
+git commit -m "feat: media view with drag-drop upload and clipboard path copy"
+```
+
+---
+
+## Task 20: Final wiring + polish
+
+**Files:**
+- Modify: `ui/main_window.py`
+- Modify: `main.py`
+
+- [ ] **Step 1: Add statusbar to MainWindow**
+
+In `MainWindow.__init__`, after `self._build_ui()`:
+
+```python
+self.statusBar().showMessage("my-publisher pronto.")
+self.statusBar().setStyleSheet("background:#0f0f1a;color:#555;font-size:10px;")
+```
+
+- [ ] **Step 2: Default to articles view on startup**
+
+At end of `MainWindow.__init__`, after `_refresh_articles()`:
+
+```python
+# Select articles button and show articles view by default
+if self._btn_group:
+    self._btn_group[0].setChecked(True)
+    self._stack.setCurrentIndex(self._page_articles)
+```
+
+- [ ] **Step 3: Add .gitignore**
+
+```bash
+cat > /home/danix/Programming/GIT/my-publisher/.gitignore << 'EOF'
+__pycache__/
+*.pyc
+.superpowers/
+EOF
+```
+
+- [ ] **Step 4: Run all tests**
+
+```bash
+pytest tests/ -v
+```
+
+Expected: all tests pass.
+
+- [ ] **Step 5: End-to-end verification**
+
+Run through the full checklist from the spec:
+
+1. `python main.py` → sidebar with articles from real blog repo
+2. Click article → frontmatter + markdown preview visible
+3. Click "🔧 Frontmatter" → edit `type` via dropdown → save → reopen → value persisted
+4. Open Typora → edit file → return to app → `QFileSystemWatcher` triggers refresh
+5. Click "🌍 Traduci" → log streams → preview appears after completion
+6. Click "🔧 Git ops" → branch and status visible → pull works
+7. Click "🖼 Media" → drop a file → appears in list → path in clipboard
+8. Delete article → confirm dialog → scompare dalla lista → ripristino da Git ops funziona
+
+- [ ] **Step 6: Final commit**
+
+```bash
+git add .
+git commit -m "feat: complete my-publisher app — all views wired and working"
+```
diff --git a/docs/superpowers/specs/2026-05-01-my-publisher-design.md b/docs/superpowers/specs/2026-05-01-my-publisher-design.md
new file mode 100644 (file)
index 0000000..8b831f0
--- /dev/null
@@ -0,0 +1,223 @@
+# my-publisher — Design Spec
+
+**Date:** 2026-05-01  
+**Status:** Approved
+
+---
+
+## Context
+
+Workflow attuale per pubblicare articoli sul blog danix.xyz è frammentato: git manuale da terminale, hugo server lanciato a mano, traduzione via script CLI, Typora aperto separatamente, frontmatter editato a mano. L'obiettivo è centralizzare tutto in una GUI desktop Linux che automatizzi le operazioni ripetitive e riduca il friction nel publishing workflow.
+
+Blog: Hugo bilingual IT/EN, repo `danix.xyz-hacker-theme`, due branch (`master` = staging, `production` = live via post-receive hook). Script traduzione: `/home/danix/bin/transart.py` (RunPod + TranslateGemma 27b).
+
+---
+
+## Architettura
+
+**Stack:** Python 3 + PyQt6. Processo singolo con `QThread`/`QProcess` per operazioni async — UI non blocca mai.
+
+```
+my-publisher/
+├── main.py
+├── ui/
+│   ├── main_window.py        # finestra principale, sidebar + QStackedWidget
+│   ├── articles_view.py      # lista articoli tab IT/EN + voce "Senza Traduzione"
+│   ├── article_detail.py     # pannello dettaglio (frontmatter + preview + azioni)
+│   ├── frontmatter_editor.py # dialog editing TOML frontmatter
+│   ├── translation_view.py   # progress log + preview + azioni post-traduzione
+│   ├── git_view.py           # stato repo + operazioni git
+│   ├── media_view.py         # upload file in static/uppies/YYYY/MM/
+│   └── hugo_panel.py         # start/stop hugo server + log
+├── workers/
+│   ├── git_worker.py         # QThread: pull, push master, push production, git rm, restore
+│   ├── hugo_worker.py        # QProcess: hugo server start/stop, streaming log
+│   └── translation_worker.py # QProcess: transart.py, streaming stdout
+├── core/
+│   ├── article_scanner.py    # scansiona content/it/ e content/en/, costruisce ArticleModel
+│   ├── frontmatter.py        # parse/write TOML frontmatter (tomlkit)
+│   └── config.py             # percorsi configurabili
+└── docs/superpowers/specs/
+```
+
+**Configurazione:** `~/.config/my-publisher/config.toml`
+```toml
+blog_repo = "/home/danix/Programming/GIT/danix.xyz-hacker-theme"
+transart_script = "/home/danix/bin/transart.py"
+typora_bin = "typora"
+```
+Prima apertura → dialog setup se config mancante.
+
+---
+
+## Layout UI
+
+**Struttura:** sidebar sinistra con gruppi + area contenuto destra (QStackedWidget).
+
+### Sidebar
+
+```
+📰 my-publisher         [🔄]
+
+CONTENUTO
+  📋 Articoli
+  ⚠️ Senza Traduzione   [4]
+  ➕ Nuovo articolo
+  🏷 Tassonomia
+  🖼 Media
+
+WORKFLOW
+  🌍 Traduzioni
+  🔧 Git ops
+  🚀 Hugo server
+
+DEPLOY
+  🧪 Test (master)
+  🚢 Production
+```
+
+Icona 🔄 in cima → refresh manuale `article_scanner`. `QFileSystemWatcher` su `content/it/` e `content/en/` → re-scan silenzioso automatico su modifiche esterne (Typora, terminale).
+
+---
+
+## Componenti
+
+### Lista Articoli (`articles_view`)
+
+Tab IT / EN. In ogni tab: lista articoli con badge stato traduzione.
+- Articolo con traduzione: `🇬🇧 ✓` verde
+- Articolo senza traduzione: `🇬🇧 ✗` rosso + bordo sinistro rosso
+
+Voce sidebar "Senza Traduzione" → lista cross-lingua di tutti gli articoli privi di traduzione, con badge "manca 🇬🇧" / "manca 🇮🇹" + pulsante "Traduci" diretto.
+
+Click articolo → emette `article_selected(Article)` → carica `article_detail`.
+
+### Pannello Dettaglio (`article_detail`)
+
+Layout: header + split colonne.
+
+**Header:** path articolo + stato traduzione + 5 pulsanti:
+| Pulsante | Azione |
+|---|---|
+| ✏️ Typora | `subprocess.Popen(["typora", path])` |
+| 🔧 Frontmatter | apre `frontmatter_editor` dialog |
+| 🌍 Traduci | apre `translation_view` |
+| 🧪 Push master | `git_worker` → push master |
+| 🚢 Pubblica | `git_worker` → push production |
+
+**Colonna sinistra:** frontmatter leggibile (campi + valori) + bottone "Modifica frontmatter".
+
+**Colonna destra:** preview markdown (`QTextBrowser`, renderer Qt built-in). Shortcodes Hugo strippati via regex prima del render.
+
+### Editor Frontmatter (`frontmatter_editor`)
+
+Dialog modale. Form generato dinamicamente dai campi TOML dell'articolo. Salva con `tomlkit` per preservare commenti e formato originale.
+
+Il campo `type` è un `QComboBox` con i 5 tipi predefiniti del sito (non editabile liberamente):
+
+```python
+ARTICLE_TYPES = ["Life", "Photo", "Link", "Quote", "Tech"]
+```
+
+Tutti gli altri campi sono field testuali liberi. Tags e categorie mostrano i valori esistenti come chip con possibilità di aggiungere/rimuovere — solo termini già presenti nella tassonomia sono accettati (autocompletamento dal file `tags-{lang}.txt`).
+
+### Vista Traduzione (`translation_view`)
+
+Sostituisce `article_detail` durante e dopo la traduzione.
+
+1. **In corso:** `QProcess` lancia `transart.py`, ogni riga stdout → append in `QPlainTextEdit` (log streaming in tempo reale).
+2. **Completata:** preview markdown della traduzione + 4 pulsanti:
+   - ✏️ Apri in Typora
+   - 🔄 Rigenera traduzione
+   - 🧪 Push master
+   - 🚢 Pubblica
+
+### Git ops (`git_view`)
+
+Stato repo: branch corrente, `git status` (file modificati), `git log --oneline -5`.
+
+Pulsanti: Pull, Push master, Push production. Output in `QPlainTextEdit`.
+
+**Eliminazione articoli:** pulsante "Elimina" in `article_detail` → `git rm` + commit automatico `"remove: <slug> (<lang>)"`. Sezione "Articoli eliminati" in `git_view` → `git log --diff-filter=D --pretty=...` → pulsante "Ripristina" → `git checkout <hash> -- <path>`.
+
+Refresh automatico all'apertura della scheda.
+
+Le voci sidebar "🧪 Test (master)" e "🚢 Production" nel gruppo DEPLOY aprono `git_view` con focus sul branch corrispondente e pulsante azione primario evidenziato. Equivalenti ai pulsanti "Push master" / "Pubblica" nell'`article_detail` — shortcut rapidi senza dover aprire un articolo.
+
+### Tassonomia (`taxonomy_view`)
+
+Scheda dedicata nella sidebar sotto CONTENUTO. Gestisce tag e categorie in modo bilingue.
+
+**Visualizzazione:** due tab — Tags / Categorie. Ogni tab mostra lista di termini con colonne IT | EN. Evidenziati in rosso i termini privi di corrispondenza nell'altra lingua (non presenti in `docs/tags-it.txt` / `docs/tags-en.txt`).
+
+**Operazioni:**
+- Aggiungere nuovo termine (IT + EN insieme)
+- Modificare termine esistente
+- Proposta traduzione automatica: pulsante "Traduci mancanti" → lancia `transart.py` o chiamata diretta all'API Ollama per tradurre i termini orfani in batch
+- Salva → aggiorna `docs/tags-it.txt` e `docs/tags-en.txt` mantenendo ordine alfabetico e allineamento 1:1 tra i due file
+
+**Integrità:** `article_scanner` segnala nel dettaglio articolo se un tag usato non è presente nel file tassonomia.
+
+### Media (`media_view`)
+
+Upload file in `static/uppies/YYYY/MM/` (anno/mese corrente automatico).
+- Drag & drop nella finestra
+- Pulsante "Aggiungi file" → file dialog
+- Post-copia: path relativo copiato in clipboard automaticamente (`/uppies/YYYY/MM/file.ext`)
+- Lista file presenti nella cartella del mese corrente
+
+### Hugo Server (`hugo_panel`)
+
+`QProcess` per `hugo server -D`. Start/stop dalla sidebar con icona status (🟢 running / 🔴 stopped). Log accessibile nel pannello. URL `http://localhost:1313` cliccabile.
+
+---
+
+## Modello Dati
+
+```python
+@dataclass
+class Article:
+    slug: str
+    lang: str          # "it" | "en"
+    path: Path         # path assoluto a index.md
+    frontmatter: dict
+    has_translation: bool
+    translation_path: Path | None
+```
+
+`ArticleModel` = lista di `Article`. `article_scanner` scansiona `content/{it,en}/articles/*/index.md`, costruisce pairs per slug, determina `has_translation`.
+
+```python
+ARTICLE_TYPES = ["Life", "Photo", "Link", "Quote", "Tech"]
+```
+
+`TaxonomyModel`: dizionario `{term_it: term_en}` caricato da `docs/tags-it.txt` e `docs/tags-en.txt` (file allineati riga per riga). Stesso schema per categorie.
+
+---
+
+## Gestione Errori
+
+Ogni worker emette `error(str)` → statusbar + dialog non-bloccante. Git push fallito → stderr visibile. Traduzione fallita → log completo + pulsante "Riprova".
+
+---
+
+## Dipendenze Python
+
+```
+PyQt6
+tomlkit       # parse/write TOML preservando formato
+mistune       # markdown → HTML per preview (fallback a QTextBrowser nativo)
+```
+
+---
+
+## Verifica (test end-to-end)
+
+1. Avviare app → sidebar popolata con articoli del blog reale
+2. Click articolo → frontmatter corretto + preview markdown visibile
+3. Modificare frontmatter → salvare → rileggere file su disco con `tomlkit`
+4. Aprire Typora → modificare articolo → tornare in app → refresh automatico aggiorna preview
+5. Tradurre articolo → log streaming visibile → preview traduzione al termine
+6. Push master → `git log` sul repo mostra commit
+7. Upload media → file in `static/uppies/YYYY/MM/` → path in clipboard
+8. Eliminare articolo → scompare dalla lista → ripristino da Git ops funziona
index 2257a8aaa4d44bd7ec9b6d84d731ca970f5b1ee6..6cd4b4462dd0e386a3e212da6bb93ac610dfbb41 100644 (file)
@@ -1,6 +1,5 @@
 import pytest
 from pathlib import Path
-import tempfile
 from core.frontmatter import parse_frontmatter, write_frontmatter
 
 SAMPLE_MD = """\
@@ -42,6 +41,13 @@ def test_write_preserves_format(tmp_path):
     assert fm2["title"] == "Updated Title"
     assert fm2["type"] == "Tech"
 
+def test_write_is_idempotent(tmp_path):
+    f = tmp_path / "index.md"
+    f.write_text(SAMPLE_MD)
+    fm, body = parse_frontmatter(f)
+    write_frontmatter(f, fm, body)
+    assert f.read_text() == SAMPLE_MD
+
 def test_parse_raises_on_missing_delimiters(tmp_path):
     f = tmp_path / "index.md"
     f.write_text("# No frontmatter\n\nJust body.")