Files
ImpTune/.planning/phases/11-ui-enhancements/11-03-PLAN.md
T
2026-04-15 17:57:12 +02:00

504 lines
20 KiB
Markdown

---
phase: 11-ui-enhancements
plan: "03"
type: execute
wave: 2
depends_on:
- "11-01"
files_modified:
- imptune/templates/base.html
- tests/test_static.py
- tests/e2e/test_theme_toggle.py
- tests/e2e/test_i18n_toggle.py
autonomous: true
requirements:
- UIE-04
- UIE-05
must_haves:
truths:
- "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)"
artifacts:
- path: "imptune/templates/base.html"
provides: "Top-right controls with theme + language toggles, Alpine.js stores"
contains: "Alpine.store"
- path: "tests/e2e/test_theme_toggle.py"
provides: "E2E: theme button cycles data-theme, localStorage persists"
min_lines: 20
- path: "tests/e2e/test_i18n_toggle.py"
provides: "E2E: lang toggle switches nav labels, localStorage persists"
min_lines: 20
key_links:
- from: "base.html alpine:init script"
to: "Alpine.store('theme') + Alpine.store('i18n')"
via: "document.addEventListener('alpine:init', ...) before Alpine defer load"
pattern: "alpine:init"
- from: "Alpine.store('theme').cycle()"
to: "document.documentElement.setAttribute('data-theme', ...)"
via: "Alpine store method called on button click"
pattern: "data-theme"
- from: "nav links in base.html"
to: "Alpine.store('i18n').t('key')"
via: "x-text binding on each nav link and button"
pattern: "\\$store\\.i18n\\.t"
---
<objective>
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.
</objective>
<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>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/phases/11-ui-enhancements/11-CONTEXT.md
@.planning/phases/11-ui-enhancements/11-RESEARCH.md
<interfaces>
<!-- Key patterns from live codebase and RESEARCH.md. -->
Current base.html structure (full file):
```html
<!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):
```javascript
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.
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Alpine.js stores + top-right controls in base.html</name>
<files>imptune/templates/base.html, tests/test_static.py</files>
<action>
**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
```
</action>
<verify>
<automated>cd C:/Users/SebastienQUEROL/Documents/projets/ImpTune && pytest tests/test_static.py -x -q -k "theme_toggle_present" 2>&1 | tail -10</automated>
Also: pytest tests/ -x -q --ignore=tests/e2e (full non-E2E suite GREEN)
</verify>
<done>
- 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
</done>
</task>
<task type="auto">
<name>Task 2: E2E tests for theme toggle and language toggle</name>
<files>tests/e2e/test_theme_toggle.py, tests/e2e/test_i18n_toggle.py</files>
<action>
**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.
</action>
<verify>
<automated>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</automated>
</verify>
<done>
- 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.
</done>
</task>
</tasks>
<verification>
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
</verification>
<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>
<output>
After completion, create `.planning/phases/11-ui-enhancements/11-03-SUMMARY.md`
</output>