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>
504 lines
20 KiB
Markdown
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 `<script defer src="/static/alpine.min.js">` 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: '☀', dark: '☾', auto: '◑' },
|
|
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">◑</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>
|