Files
ImpTune/.planning/phases/11-ui-enhancements/11-03-PLAN.md
T
kawaandClaude Sonnet 4.6 0badba20d1 docs(11): create phase plan
Plan 4 plans across 3 waves covering UIE-01..05: form separation
(Plan 01), printer edit modal (Plan 02), theme+i18n toggles (Plan 03),
and client detail page (Plan 04). Wave 0 test scaffolds included in Plan 01.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 10:47:19 +02:00

20 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
11-ui-enhancements 03 execute 2
11-01
imptune/templates/base.html
tests/test_static.py
tests/e2e/test_theme_toggle.py
tests/e2e/test_i18n_toggle.py
true
UIE-04
UIE-05
truths artifacts key_links
A theme toggle button is visible on every page in the top-right area
Clicking the theme button cycles data-theme on <html> through light -> dark -> auto
The chosen theme persists across page reloads (stored in localStorage)
A FR/EN toggle is visible in the top-right area alongside the theme button
Clicking the language toggle switches all static UI labels (nav items, buttons, headings) between French and English
The chosen language persists across page reloads (stored in localStorage)
path provides contains
imptune/templates/base.html Top-right controls with theme + language toggles, Alpine.js stores Alpine.store
path provides min_lines
tests/e2e/test_theme_toggle.py E2E: theme button cycles data-theme, localStorage persists 20
path provides min_lines
tests/e2e/test_i18n_toggle.py E2E: lang toggle switches nav labels, localStorage persists 20
from to via pattern
base.html alpine:init script Alpine.store('theme') + Alpine.store('i18n') document.addEventListener('alpine:init', ...) before Alpine defer load alpine:init
from to via pattern
Alpine.store('theme').cycle() document.documentElement.setAttribute('data-theme', ...) Alpine store method called on button click data-theme
from to via pattern
nav links in base.html Alpine.store('i18n').t('key') x-text binding on each nav link and button $store.i18n.t
Add theme toggle (Light/Dark/System) and FR/EN language toggle to the global layout, entirely in base.html using Alpine.js stores.

Purpose: UIE-04 + UIE-05 — Users need persistent theme preference and bilingual support. Both features live in base.html with Alpine.js $store — zero new backend routes, zero new dependencies.

Output: Updated base.html with top-right controls, Alpine.js theme + i18n stores, E2E tests for both toggles.

<execution_context> @C:/Users/SebastienQUEROL/.claude/get-shit-done/workflows/execute-plan.md @C:/Users/SebastienQUEROL/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/phases/11-ui-enhancements/11-CONTEXT.md @.planning/phases/11-ui-enhancements/11-RESEARCH.md

Current base.html structure (full file):

<!DOCTYPE html>
<html lang="en" data-theme="auto">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>ImpTune</title>
  <link rel="stylesheet" href="/static/pico.min.css">
  <link rel="stylesheet" href="/static/app.css">
  <script defer src="/static/alpine.min.js"></script>
  <script src="/static/htmx.min.js"></script>
</head>
<body>
  <div class="layout">
    <nav class="sidebar">
      <div class="sidebar-brand">
        <strong>ImpTune</strong>
      </div>
      <ul class="sidebar-nav">
        <li><a href="/" ...>Dashboard</a></li>
        <li><a href="/drivers" ...>Drivers</a></li>
        <li><a href="/printers" ...>Printers</a></li>
        <li><a href="/clients" ...>Clients</a></li>
        <li><a href="/packages" ...>Packages</a></li>
      </ul>
    </nav>
    <main class="main-content">
      {% block content %}{% endblock %}
    </main>
  </div>
</body>
</html>

Alpine.js store + alpine:init pattern (from RESEARCH.md):

document.addEventListener('alpine:init', () => {
  Alpine.store('theme', { ... });
  Alpine.store('i18n', { ... });
});

This script MUST run BEFORE alpine.min.js defer executes. Place the script tag before the <script defer src="/static/alpine.min.js"> line — inline scripts without defer run synchronously, so they execute before any deferred scripts.

Pico CSS data-theme: already on <html data-theme="auto"> — just toggle the attribute value.

Translation keys needed (full inventory of static UI strings in templates):

  • dashboard, drivers, printers, clients, packages (nav labels)
  • add_printer (button on /printers), add_client (button on clients.html)
  • save_printer (submit button on add/edit forms), edit, delete, cancel, save
  • upload_driver (upload button), no_printers, no_clients
  • printer_name, ip_address, port_name, driver, duplex_mode, color_mode, paper_size, collate, client
  • one_sided, long_edge, short_edge (duplex options)
  • color, color_mode_label (checkbox), collate_label
  • edit_printer (modal heading), close
  • add_client_heading, client_list_heading, add_printer_heading, printer_library_heading
  • theme_light, theme_dark, theme_auto (optional — for aria-labels)

Note: only static chrome strings need translation in this phase. Server-rendered dynamic values (printer names, error messages) stay in English — this is explicitly out of scope per RESEARCH.md open question 3.

Task 1: Alpine.js stores + top-right controls in base.html imptune/templates/base.html, tests/test_static.py **Step 1 — Add Alpine.js store definition script to base.html:**
Add the following script block BEFORE the `&lt;script defer src="/static/alpine.min.js"&gt;` line (inline scripts run before defer scripts):

```html
<script>
document.addEventListener('alpine:init', () => {
  // Theme store: cycles Light -> Dark -> System, persists in localStorage
  Alpine.store('theme', {
    current: localStorage.getItem('imptune_theme') || 'auto',
    icons: { light: '&#9728;', dark: '&#9790;', auto: '&#9681;' },
    init() {
      document.documentElement.setAttribute('data-theme', this.current);
    },
    cycle() {
      const order = ['light', 'dark', 'auto'];
      this.current = order[(order.indexOf(this.current) + 1) % order.length];
      localStorage.setItem('imptune_theme', this.current);
      document.documentElement.setAttribute('data-theme', this.current);
    }
  });

  // i18n store: FR/EN toggle, persists in localStorage
  Alpine.store('i18n', {
    lang: localStorage.getItem('imptune_lang') || 'fr',
    t(key) {
      return (this.translations[this.lang] || {})[key] || key;
    },
    toggle() {
      this.lang = this.lang === 'fr' ? 'en' : 'fr';
      localStorage.setItem('imptune_lang', this.lang);
    },
    translations: {
      fr: {
        dashboard: 'Tableau de bord',
        drivers: 'Pilotes',
        printers: 'Imprimantes',
        clients: 'Clients',
        packages: 'Paquets',
        add_printer: 'Ajouter une imprimante',
        add_client: 'Ajouter un client',
        printer_library: 'Biblioth\u00e8que d\u2019imprimantes',
        edit: 'Modifier',
        delete: 'Supprimer',
        save: 'Enregistrer',
        cancel: 'Annuler',
        upload_driver: 'T\u00e9l\u00e9charger un pilote',
        printer_name: 'Nom de l\u2019imprimante',
        ip_address: 'Adresse IP',
        port_name: 'Nom du port',
        driver: 'Pilote',
        duplex_mode: 'Mode recto-verso',
        one_sided: 'Recto simple',
        long_edge: 'Grand c\u00f4t\u00e9',
        short_edge: 'Petit c\u00f4t\u00e9',
        color_mode: 'Mode couleur',
        paper_size: 'Format papier',
        collate: 'Assembler',
        client: 'Client',
        edit_printer: 'Modifier l\u2019imprimante',
        no_printers: 'Aucune imprimante configur\u00e9e.',
        no_clients: 'Aucun client configur\u00e9.',
        client_list: 'Liste des clients',
        name: 'Nom',
        created: 'Cr\u00e9\u00e9 le',
        back_to_printers: 'Retour aux imprimantes',
        theme_label: 'Th\u00e8me',
        lang_label: 'FR'
      },
      en: {
        dashboard: 'Dashboard',
        drivers: 'Drivers',
        printers: 'Printers',
        clients: 'Clients',
        packages: 'Packages',
        add_printer: 'Add Printer',
        add_client: 'Add Client',
        printer_library: 'Printer Library',
        edit: 'Edit',
        delete: 'Delete',
        save: 'Save',
        cancel: 'Cancel',
        upload_driver: 'Upload Driver',
        printer_name: 'Printer Name',
        ip_address: 'IP Address',
        port_name: 'Port Name',
        driver: 'Driver',
        duplex_mode: 'Duplex Mode',
        one_sided: 'One-Sided',
        long_edge: 'Long Edge',
        short_edge: 'Short Edge',
        color_mode: 'Color Mode',
        paper_size: 'Paper Size',
        collate: 'Collate',
        client: 'Client',
        edit_printer: 'Edit Printer',
        no_printers: 'No printers configured yet.',
        no_clients: 'No clients configured yet.',
        client_list: 'Client List',
        name: 'Name',
        created: 'Created',
        back_to_printers: 'Back to Printers',
        theme_label: 'Theme',
        lang_label: 'EN'
      }
    }
  });
});
</script>
```

**Step 2 — Add top-right controls area to the layout in base.html:**

Inside the `<div class="layout">`, add a top-right controls bar above the main content area. Modify the layout to include a controls area:

```html
<div class="layout">
  <nav class="sidebar">
    <!-- existing sidebar content — update nav link labels to use x-text -->
    <div class="sidebar-brand">
      <strong>ImpTune</strong>
    </div>
    <ul class="sidebar-nav">
      <li><a href="/" {% if request.url.path == "/" %}class="active"{% endif %}
             x-text="$store.i18n.t('dashboard')">Dashboard</a></li>
      <li><a href="/drivers" {% if request.url.path == "/drivers" %}class="active"{% endif %}
             x-text="$store.i18n.t('drivers')">Drivers</a></li>
      <li><a href="/printers" {% if request.url.path == "/printers" %}class="active"{% endif %}
             x-text="$store.i18n.t('printers')">Printers</a></li>
      <li><a href="/clients" {% if request.url.path == "/clients" %}class="active"{% endif %}
             x-text="$store.i18n.t('clients')">Clients</a></li>
      <li><a href="/packages" {% if request.url.path == "/packages" %}class="active"{% endif %}
             x-text="$store.i18n.t('packages')">Packages</a></li>
    </ul>
  </nav>
  <div class="main-wrapper">
    <header class="topbar">
      <div class="topbar-controls">
        <!-- Theme toggle button: cycles Light -> Dark -> System -->
        <button class="secondary outline"
                x-data
                x-html="$store.theme.icons[$store.theme.current]"
                :aria-label="$store.theme.current"
                @click="$store.theme.cycle()"
                title="Toggle theme">&#9681;</button>
        <!-- Language toggle button -->
        <button class="secondary outline"
                x-data
                x-text="$store.i18n.t('lang_label')"
                @click="$store.i18n.toggle()"
                title="Toggle language">FR</button>
      </div>
    </header>
    <main class="main-content">
      {% block content %}{% endblock %}
    </main>
  </div>
</div>
```

Note on x-data: Since the buttons use $store (global), they need Alpine to be active. Each button element gets a minimal `x-data` attribute (empty string is fine) to be scoped into Alpine. Alternatively wrap the .topbar-controls div with x-data.

**Step 3 — Add minimal CSS for topbar to imptune/static/app.css (if needed):**

The topbar does not need app.css changes for basic functionality — Pico CSS handles button styles. BUT if the layout currently uses CSS grid/flex that doesn't accommodate the new .main-wrapper and .topbar, add minimal styles. Check existing app.css first. If .layout is a CSS grid with sidebar + main-content columns, wrap main-content in main-wrapper and update the grid to target .main-wrapper. Keep app.css changes minimal.

NOTE: Do not modify app.css if it would break existing tests. The test_no_cdn_urls_in_templates test only checks HTML, not CSS.

**Step 4 — Add integration test to tests/test_static.py:**

Add function:
```python
def test_theme_toggle_present(client):
    """GET / contains a theme toggle button (data-theme cycling control)."""
    response = client.get("/")
    assert response.status_code == 200
    # The button's @click should reference $store.theme.cycle
    assert "theme" in response.text
    assert "cycle" in response.text or "store.theme" in response.text
```
cd C:/Users/SebastienQUEROL/Documents/projets/ImpTune && pytest tests/test_static.py -x -q -k "theme_toggle_present" 2>&1 | tail -10 Also: pytest tests/ -x -q --ignore=tests/e2e (full non-E2E suite GREEN) - base.html contains Alpine.js store definitions (theme + i18n) - Theme toggle button and FR/EN button visible on layout - test_theme_toggle_present passes - test_no_cdn_urls_in_templates still passes (no external URLs added) - All non-E2E tests GREEN Task 2: E2E tests for theme toggle and language toggle tests/e2e/test_theme_toggle.py, tests/e2e/test_i18n_toggle.py **Step 1 — Create tests/e2e/test_theme_toggle.py:**
```python
"""UIE-04: E2E tests for theme toggle — data-theme cycling and localStorage persistence."""
from __future__ import annotations
import pytest


def test_theme_cycles_on_click(page, live_server: str) -> None:
    """Clicking theme button cycles data-theme attribute: auto -> light -> dark -> auto."""
    page.goto(f"{live_server}/", wait_until="domcontentloaded")

    # Initial state: auto (default from base.html)
    initial_theme = page.evaluate("document.documentElement.getAttribute('data-theme')")
    assert initial_theme == "auto"

    # Click once -> light
    page.click("button[aria-label='auto']")
    page.wait_for_function(
        "document.documentElement.getAttribute('data-theme') === 'light'",
        timeout=2000,
    )
    assert page.evaluate("document.documentElement.getAttribute('data-theme')") == "light"

    # Click again -> dark
    page.click("button[aria-label='light']")
    page.wait_for_function(
        "document.documentElement.getAttribute('data-theme') === 'dark'",
        timeout=2000,
    )
    assert page.evaluate("document.documentElement.getAttribute('data-theme')") == "dark"


def test_theme_persists_across_reload(page, live_server: str) -> None:
    """After clicking theme toggle, the chosen theme is restored on reload."""
    page.goto(f"{live_server}/", wait_until="domcontentloaded")

    # Switch to light mode
    page.click("button[aria-label='auto']")
    page.wait_for_function(
        "document.documentElement.getAttribute('data-theme') === 'light'",
        timeout=2000,
    )

    # Reload the page
    page.reload(wait_until="domcontentloaded")

    # Theme should still be light (from localStorage)
    theme_after_reload = page.evaluate("document.documentElement.getAttribute('data-theme')")
    assert theme_after_reload == "light"

    # Cleanup: reset to auto
    page.evaluate("localStorage.setItem('imptune_theme', 'auto')")
```

**Step 2 — Create tests/e2e/test_i18n_toggle.py:**

```python
"""UIE-05: E2E tests for language toggle — FR/EN switching and localStorage persistence."""
from __future__ import annotations
import pytest


def test_language_toggle_switches_nav_label(page, live_server: str) -> None:
    """Clicking FR/EN button switches nav label from French to English."""
    page.goto(f"{live_server}/", wait_until="domcontentloaded")

    # Default lang is 'fr' — nav should show French labels
    # Wait for Alpine to hydrate
    page.wait_for_function(
        "document.querySelector('nav a[href=\"/printers\"]').textContent.trim() !== ''",
        timeout=3000,
    )

    # In French, printers nav label = 'Imprimantes'
    printers_label_fr = page.text_content("nav a[href='/printers']").strip()
    assert printers_label_fr == "Imprimantes", f"Expected 'Imprimantes', got '{printers_label_fr}'"

    # Click the language toggle button
    page.click("button[title='Toggle language']")

    # Wait for label to update
    page.wait_for_function(
        "document.querySelector('nav a[href=\"/printers\"]').textContent.trim() === 'Printers'",
        timeout=2000,
    )

    printers_label_en = page.text_content("nav a[href='/printers']").strip()
    assert printers_label_en == "Printers"


def test_language_persists_across_reload(page, live_server: str) -> None:
    """After switching to EN, language is preserved on page reload."""
    page.goto(f"{live_server}/", wait_until="domcontentloaded")

    # Switch to English
    page.click("button[title='Toggle language']")
    page.wait_for_function(
        "document.querySelector('nav a[href=\"/printers\"]').textContent.trim() === 'Printers'",
        timeout=2000,
    )

    # Reload
    page.reload(wait_until="domcontentloaded")
    page.wait_for_function(
        "document.querySelector('nav a[href=\"/printers\"]').textContent.trim() !== ''",
        timeout=3000,
    )

    label_after_reload = page.text_content("nav a[href='/printers']").strip()
    assert label_after_reload == "Printers"

    # Cleanup: reset to fr
    page.evaluate("localStorage.setItem('imptune_lang', 'fr')")
```

Note on E2E test selectors: these tests use `button[aria-label='auto']` for theme and `button[title='Toggle language']` for i18n. These selectors must match what Task 1 renders in base.html. Verify the button attributes in the template match the test selectors. If different approaches were chosen in Task 1 (e.g., different aria-label strategy), update the selectors to match.
cd C:/Users/SebastienQUEROL/Documents/projets/ImpTune && pytest tests/e2e/test_theme_toggle.py tests/e2e/test_i18n_toggle.py -x -q 2>&1 | tail -15 - test_theme_cycles_on_click: data-theme cycles auto -> light -> dark on button clicks - test_theme_persists_across_reload: theme persists after page reload - test_language_toggle_switches_nav_label: nav label switches from Imprimantes to Printers on toggle - test_language_persists_across_reload: language choice persists after reload All 4 E2E tests GREEN. Run full non-E2E suite: ``` cd C:/Users/SebastienQUEROL/Documents/projets/ImpTune && pytest tests/ -x -q --ignore=tests/e2e ``` Expected: all GREEN (including test_no_cdn_urls_in_templates — no external URLs in base.html).

Run E2E for this plan:

cd C:/Users/SebastienQUEROL/Documents/projets/ImpTune && pytest tests/e2e/test_theme_toggle.py tests/e2e/test_i18n_toggle.py -q

Expected: 4 tests GREEN.

Manual spot-check (checkpoint:human-verify handled by /gsd:verify-work):

  • Open any page — theme button and FR/EN button visible in top-right area
  • Click theme button — dark mode activates (background goes dark)
  • Reload — dark mode persists
  • Click FR/EN — nav labels switch language
  • Reload — language persists

<success_criteria>

  • Theme toggle button visible on all pages; cycles data-theme: auto -> light -> dark -> auto
  • Chosen theme persists across page reloads (localStorage key: imptune_theme)
  • FR/EN toggle button visible alongside theme button; all nav labels, heading labels switch language
  • Chosen language persists across page reloads (localStorage key: imptune_lang)
  • test_theme_toggle_present (integration) GREEN
  • All 4 E2E tests GREEN (theme cycles, theme persists, lang switches, lang persists)
  • test_no_cdn_urls_in_templates still GREEN (no CDN URLs added) </success_criteria>
After completion, create `.planning/phases/11-ui-enhancements/11-03-SUMMARY.md`