Files
ImpTune/.planning/phases/11-ui-enhancements/11-RESEARCH.md
T
2026-04-15 10:39:48 +02:00

569 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 11: UI Enhancements - Research
**Researched:** 2026-04-15
**Domain:** FastAPI + Jinja2 + HTMX + Alpine.js + Pico CSS — frontend UX improvements
**Confidence:** HIGH (all findings grounded in live codebase inspection)
---
<user_constraints>
## User Constraints (from CONTEXT.md)
### Locked Decisions
**UIE-01 — Printer Edit Modal:**
- Edit button added to each row in the Actions column, next to the existing Delete button (small button, labelled "Edit")
- Clicking Edit opens a native HTML `<dialog>` modal pre-filled with that printer's fields
- Modal contains printer fields only (name, IP, port, driver, duplex, color, paper, collate, client) — no embedded driver upload form
- Submitting the edit form sends HTMX PATCH to `/printers/{id}`, closes the modal, and refreshes the printer list in-place (no page reload)
**UIE-02 — Add-Printer Form Separation:**
- `/printers` becomes the Printer Library only — the Add Printer form is removed from this page
- A dedicated `/printers/new` page holds the Add Printer form
- An "Add Printer" button (or link) on `/printers` navigates to `/printers/new`
- After submitting the add form, auto-redirect back to `/printers`
- The driver upload form embedded in the printer form stays on `/printers/new`
**UIE-03 — Client-Filtered Navigation:**
- Client names become clickable links everywhere they appear (printer list group headers, client list table)
- Links navigate to `/clients/{id}` — a dedicated page per client
- `/clients/{id}` shows: client name as page title + filtered printer list for that client only
- Edit and Delete actions on `/clients/{id}` work identically to `/printers` (same modal, same HTMX routes)
**UIE-04 — Theme Toggle:**
- Icon button in the top-right corner of the page (in the main layout, visible on all pages)
- Cycles through: Light → Dark → System on each click; shows current mode icon (sun / moon / auto)
- Alpine.js sets `data-theme` attribute on `<html>` element + persists choice in localStorage
- Pico CSS already supports `data-theme="light"`, `data-theme="dark"`, `data-theme="auto"` natively — no CSS changes needed for basic theming
**UIE-05 — Language Toggle:**
- FR/EN toggle in the top-right corner alongside the theme icon
- Implemented with Alpine.js translation object: all UI strings stored in a JS translation dictionary keyed by `fr` / `en`
- Switching language updates instantly with no page reload; choice persists in localStorage
- Alpine.js `$store` or top-level x-data used to make the language reactive across all components
- All labels, buttons, headings, and messages must be translated (no hardcoded English strings left in templates)
### Claude's Discretion
- Exact icon choices for the theme cycle button (emoji vs SVG vs Unicode symbols)
- Exact layout/styling of the top-right controls area (spacing, grouping of theme + language)
- Translation string file organisation (inline in base.html JS block vs separate translations.js file)
- How `<dialog>` close is triggered (close button, backdrop click, or both)
### Deferred Ideas (OUT OF SCOPE)
None — discussion stayed within phase scope.
</user_constraints>
---
<phase_requirements>
## Phase Requirements
| ID | Description | Research Support |
|----|-------------|-----------------|
| UIE-01 | Printer edit modal via native `<dialog>`, pre-filled, HTMX PATCH `/printers/{id}`, in-place list refresh | PATCH route pattern, dialog open/close, reuse printer_form partial minus upload form |
| UIE-02 | Separate `/printers/new` page for add form; `/printers` shows library + "Add Printer" link; POST redirects back | New GET route `/printers/new` in pages.py, redirect on POST, `/printers` strips form |
| UIE-03 | Client names as links to `/clients/{id}`; per-client page with filtered printer list | New GET route `/clients/{id}` in pages.py, filtered Printer query, client_id lookup |
| UIE-04 | Theme toggle (Light/Dark/System) in top-right; Alpine.js sets `data-theme` on `<html>`; persisted in localStorage | Pico CSS 2.1.1 native data-theme support confirmed, Alpine.js store pattern |
| UIE-05 | FR/EN language toggle; Alpine.js `$store` with translation dictionary; instant update, localStorage persist | Alpine.js `$store` global reactive store, x-text binding pattern |
</phase_requirements>
---
## Summary
Phase 11 is a pure frontend sprint on top of a fully operational v1.0 backend. No database schema changes, no new backend business logic. The stack is locked: **FastAPI 0.115, Jinja2 3.1, HTMX 2.0.8, Alpine.js 3.15.11, Pico CSS 2.1.1** — all bundled statically, no CDN dependency, no npm build step.
The five requirements decompose into three backend touches (one new PATCH route, two new GET page routes) and two purely frontend changes (theme toggle + i18n store, both live entirely in `base.html`). The heaviest integration work is UIE-01 (edit modal): it reuses the existing `printer_form.html` partial with a changed form action and HTMX method, wrapped in a `<dialog>` element that Pico CSS already styles natively.
The test infrastructure is pytest (unit/integration via `TestClient`) plus pytest-playwright for E2E. All new routes have clear HTTP-level contracts testable without a browser. Alpine.js-driven behaviours (theme persistence, language switching) require either E2E or visual-manual verification; unit tests cannot observe DOM state mutations from `x-data`.
**Primary recommendation:** Implement in 3 plans: (1) backend routes + modal PATCH + form separation, (2) client detail page, (3) base.html theme + i18n controls.
---
## Standard Stack
### Core (all versions confirmed from static files and requirements.txt)
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| FastAPI | 0.115.x | HTTP routing, form parsing, template rendering | Already the app framework |
| Jinja2 | 3.1.x | Server-side HTML templating with `{% include %}` | Already used for all pages and partials |
| HTMX | 2.0.8 | Declarative AJAX — POST/DELETE/PATCH with HTML swap | Already used for all list mutations |
| Alpine.js | 3.15.11 | Client-side reactivity, `x-data`, `$store` | Already used for port auto-fill; `defer` loaded |
| Pico CSS | 2.1.1 | Semantic CSS framework with `<dialog>` + `data-theme` support | Already loaded; native dialog + theme support confirmed |
| Peewee | 3.17.x | ORM for SQLite — Printer/Client/Driver models | Already the ORM; no schema changes needed |
| pytest + TestClient | 8.x / httpx 0.27 | HTTP-level integration tests | Existing test harness used across 14 plans |
| pytest-playwright | current | E2E Alpine.js verification | Established in Phase 9 UX-02 |
### No New Dependencies
This phase requires zero new pip installs and zero new JS libraries. Everything needed is already bundled in `imptune/static/`.
---
## Architecture Patterns
### Recommended Project Structure Changes
```
imptune/
├── api/
│ ├── printers.py # ADD: PATCH /printers/{id} handler
│ └── clients.py # (no changes needed — POST /clients unchanged)
├── templates/
│ ├── base.html # ADD: top-right controls (theme + lang toggle)
│ ├── printers.html # CHANGE: remove form include, add "Add Printer" link
│ ├── printers_new.html # NEW: /printers/new page
│ ├── client_detail.html # NEW: /clients/{id} page
│ └── partials/
│ ├── printer_list.html # CHANGE: add Edit button + client links
│ ├── printer_edit_modal.html # NEW: <dialog> with edit form
│ └── client_list.html # CHANGE: wrap client name in <a href="/clients/{id}">
└── api/pages.py # ADD: GET /printers/new, GET /clients/{id}
```
### Pattern 1: HTMX PATCH Route (UIE-01)
**What:** Add `PATCH /printers/{id}` to `api/printers.py`. Accepts same form fields as POST. Updates the DB record. Returns `_render_printer_list(request)` — identical to DELETE response.
**When to use:** Any in-place mutation that follows the existing delete pattern.
**Key implementation note:** `printer_form.html` has Alpine.js `x-data` with `ip` and `port` bound to `x-model`. Inside a `<dialog>`, Alpine.js initialises normally because `<dialog>` is a standard DOM element — no special wiring needed. The `portEdited` flag should be initialised to `true` in the edit modal (unlike the add form where it starts `false`) so that editing IP does not clobber a manually-set port.
```python
# In api/printers.py — add alongside existing POST and DELETE
@router.patch("/{printer_id}", response_class=HTMLResponse)
def update_printer(
request: Request,
printer_id: int,
name: str = Form(...),
ip_address: str = Form(...),
port_name: str = Form(...),
duplex_mode: str = Form("OneSided"),
color_mode: str = Form(""),
paper_size: str = Form("A4"),
collate: str = Form(""),
client_id: str = Form(""),
driver_id: str = Form(""),
) -> HTMLResponse:
printer = Printer.get_or_none(Printer.id == printer_id)
if printer is None:
return _error_response(f"Printer {printer_id} not found.", status_code=404)
# ... validate + update fields ...
printer.updated_at = _utcnow() # models.py already has updated_at field
printer.save()
return _render_printer_list(request)
```
**HTMX PATCH caveat:** HTMX 2.x sends PATCH natively via `hx-patch`. FastAPI 0.115 registers `@router.patch(...)` without issue. The form inside the `<dialog>` uses:
```html
hx-patch="/printers/{{ p.id }}"
hx-target="#printer-list"
hx-swap="outerHTML"
```
No HTMX method override (`X-HTTP-Method-Override`) is needed — HTMX sends the real HTTP method.
### Pattern 2: Native `<dialog>` Modal (UIE-01)
**What:** Pico CSS 2.1.1 styles `<dialog>` natively. Open with `dialog.showModal()`, close with `dialog.close()` or a `<form method="dialog">` cancel button.
**When to use:** Any lightweight overlay that does not need a third-party modal library.
```html
<!-- partials/printer_edit_modal.html — included once per printer row -->
<dialog id="edit-modal-{{ p.id }}">
<article>
<header>
<button aria-label="Close" rel="prev"
onclick="document.getElementById('edit-modal-{{ p.id }}').close()"></button>
<h3>Edit Printer</h3>
</header>
<div x-data="{ ip: '{{ p.ip_address }}', port: '{{ p.port_name }}', portEdited: true }">
<form hx-patch="/printers/{{ p.id }}"
hx-target="#printer-list"
hx-swap="outerHTML"
hx-on::after-request="document.getElementById('edit-modal-{{ p.id }}').close()">
<!-- same fields as printer_form.html, pre-filled via Jinja2 p.* values -->
</form>
</div>
</article>
</dialog>
<!-- Trigger button in the Actions column -->
<button class="secondary"
onclick="document.getElementById('edit-modal-{{ p.id }}').showModal()">
Edit
</button>
```
**Pico CSS dialog header close button:** Pico CSS 2.x styles `<button rel="prev">` inside `<header>` as a close `×` icon automatically. This is the idiomatic Pico close pattern.
**HTMX after-request close:** `hx-on::after-request` fires after a successful HTMX request. Use it to close the modal programmatically. For error cases the modal stays open, which is correct — the error fragment replaces `#printer-list` but the modal remains for correction.
### Pattern 3: POST → Redirect for `/printers/new` (UIE-02)
**What:** After the add form is submitted, the server should redirect to `/printers` instead of returning a partial. This requires a change to the existing `POST /printers` handler or detecting the request origin.
**How to implement cleanly:** The simplest approach is to detect the `HX-Request` header. If it is present (HTMX request from the library page or any HTMX consumer), return the partial as today. If absent (full-page form submit from `/printers/new`), return a `RedirectResponse` to `/printers`.
```python
from fastapi.responses import RedirectResponse
@router.post("", response_class=HTMLResponse)
def create_printer(request: Request, ...) -> HTMLResponse:
# ... validate + create ...
if request.headers.get("HX-Request"):
return _render_printer_list(request)
return RedirectResponse(url="/printers", status_code=303)
```
**Why 303 not 302:** RFC 7231 requires 303 See Other for POST→GET redirect to ensure the browser does a GET on the redirect target.
### Pattern 4: Alpine.js `$store` for Global Reactive State (UIE-04 + UIE-05)
**What:** Alpine.js 3.x `Alpine.store(name, initialState)` creates a globally accessible reactive object. Any element with `x-data` can read it via `$store.name`.
**Placement:** Define stores in a `<script>` block in `base.html` using the `alpine:init` event, which fires before Alpine initialises the DOM:
```html
<!-- In base.html <head> or just before </body> -->
<script>
document.addEventListener('alpine:init', () => {
Alpine.store('theme', {
current: localStorage.getItem('theme') || 'auto',
cycle() {
const order = ['light', 'dark', 'auto'];
const next = order[(order.indexOf(this.current) + 1) % 3];
this.current = next;
localStorage.setItem('theme', next);
document.documentElement.setAttribute('data-theme', next);
},
init() {
document.documentElement.setAttribute('data-theme', this.current);
}
});
Alpine.store('i18n', {
lang: localStorage.getItem('lang') || 'fr',
translations: {
fr: {
printers: 'Imprimantes',
clients: 'Clients',
drivers: 'Pilotes',
packages: 'Paquets',
dashboard: 'Tableau de bord',
add_printer: 'Ajouter une imprimante',
edit: 'Modifier',
delete: 'Supprimer',
save: 'Enregistrer',
cancel: 'Annuler',
// ... all UI strings
},
en: {
printers: 'Printers',
clients: 'Clients',
drivers: 'Drivers',
packages: 'Packages',
dashboard: 'Dashboard',
add_printer: 'Add Printer',
edit: 'Edit',
delete: 'Delete',
save: 'Save',
cancel: 'Cancel',
// ... all UI strings
}
},
t(key) {
return this.translations[this.lang][key] || key;
},
toggle() {
this.lang = this.lang === 'fr' ? 'en' : 'fr';
localStorage.setItem('lang', this.lang);
}
});
});
</script>
```
**Usage in templates:** Any element can bind text via `x-text="$store.i18n.t('edit')"` or use it inline with `:value`. Buttons and labels need `x-text`; `placeholder` and `aria-label` attributes need `:placeholder` and `:aria-label`.
**`defer` script tag ordering:** `alpine.min.js` is loaded with `defer`. The `alpine:init` event fires when Alpine is ready but before it scans the DOM. The store definition script MUST either (a) be placed before the Alpine `defer` load and listen to `alpine:init`, or (b) be a `defer` script placed after the Alpine script tag. Option (a) is the safe pattern used by Alpine.js documentation.
### Pattern 5: `GET /clients/{id}` Client Detail Page (UIE-03)
**What:** New page route in `pages.py`. Queries printers filtered by `client_id`. Renders new `client_detail.html` template that reuses `partials/printer_list.html`.
```python
# In api/pages.py
@router.get("/clients/{client_id}", response_class=HTMLResponse)
def client_detail(request: Request, client_id: int):
from imptune.db.models import Client, Driver, Printer
client = Client.get_or_none(Client.id == client_id)
if client is None:
return HTMLResponse(content="<h1>404</h1>", status_code=404)
query = (
Printer.select(Printer, Client)
.join(Client, JOIN.LEFT_OUTER)
.where(Printer.client == client_id)
.order_by(Printer.name)
)
grouped = {client.name: list(query)} # single-client grouped dict
all_drivers = list(Driver.select().order_by(Driver.uploaded_at.desc()))
driver_data = [
{"driver": d, "names": json.loads(d.driver_desc) if d.driver_desc else []}
for d in all_drivers
]
return templates.TemplateResponse(
request=request,
name="client_detail.html",
context={"client": client, "grouped": grouped, "driver_data": driver_data},
)
```
**Reuse:** `client_detail.html` includes `partials/printer_list.html` directly. Since `printer_list.html` already renders from a `grouped` dict, the same partial works for both the full printer library and the per-client view without modification — as long as the modal partial is also included.
### Anti-Patterns to Avoid
- **Separate Alpine component per printer row for the modal:** Do not use `x-data` on each `<tr>` to manage modal open state. Use `document.getElementById(...).showModal()` directly on the button `onclick`. Alpine is only needed inside the dialog for the IP/port reactive fields.
- **Putting `<dialog>` outside `printer_list.html`:** The modal must be co-located with the printer row data (inside the Jinja2 `{% for p in printers %}` loop) so it can be pre-filled with `p.*` values. Do not try to fill it via JavaScript after open — it will not work with HTMX-swapped content.
- **Using Alpine `$store` for modal open/close state:** Native `<dialog>` `.showModal()` / `.close()` is simpler and does not require Alpine state. Reserve `$store` for cross-page state (theme, language).
- **Translating Jinja2 server-rendered strings with Alpine i18n:** Server-rendered strings (e.g., dynamic data like printer names, error messages from the server) cannot be translated by Alpine. Only static UI chrome (labels, buttons, headings, nav items) should use `x-text="$store.i18n.t()"`. Server error messages must be translated server-side if needed — but since they are out of scope for this phase, leave them in English.
- **Using `hx-method="PATCH"` instead of `hx-patch`:** HTMX 2.x uses `hx-patch` directly. `hx-method` is not a real HTMX attribute. Also do NOT add a hidden `_method` field — that is a Rails/Laravel pattern HTMX does not use.
---
## Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Modal overlay | Custom CSS + JS show/hide | Native `<dialog>` via Pico CSS | Browser-native, accessible, keyboard-focustrapped, Pico already styles it |
| Theme persistence | Custom CSS class toggling | `data-theme` attr on `<html>` + Pico CSS | Pico 2.x natively supports `light`/`dark`/`auto` on this attribute |
| Global reactive state | Custom event bus or window globals | Alpine.js `Alpine.store()` | Built into Alpine 3.x, reactive, no extra libraries |
| i18n library | Vue-i18n, i18next | Alpine `$store` with translation dict | Stack is Alpine; no build step; total string count is ~30 keys |
| HTTP method override | Hidden `_method` field | `hx-patch` attribute (HTMX native) | HTMX 2.x sends real PATCH method natively |
| Client-side routing | SPA router | Standard `<a href="/clients/{id}">` links | Server-rendered pages; no SPA needed; simpler |
**Key insight:** Every custom solution adds maintenance burden without solving problems the existing stack doesn't already handle. The entire phase is achievable with zero new dependencies.
---
## Common Pitfalls
### Pitfall 1: Alpine.js store not available when templates render
**What goes wrong:** `$store.i18n.t('edit')` in a template returns undefined or throws because the store was not defined before Alpine scanned that element.
**Why it happens:** If the store-definition `<script>` runs after Alpine has already initialised the DOM (e.g., wrong script tag placement or missing `alpine:init` listener), stores are undefined.
**How to avoid:** Always define stores inside a `document.addEventListener('alpine:init', ...)` handler. The `alpine:init` event is emitted by Alpine before it walks the DOM, ensuring stores exist when directives are evaluated.
**Warning signs:** Console error "Alpine: Cannot read properties of undefined (reading 't')" on page load.
### Pitfall 2: Modal pre-fill not updating when HTMX swaps the list
**What goes wrong:** After editing a printer, the HTMX swap replaces `#printer-list`. The new HTML includes fresh modals with correct Jinja2-rendered values. BUT if the user opens a modal again for the same printer, they may see stale values if the modal is opened before the swap completes.
**Why it happens:** The `hx-on::after-request` closes the modal after the swap. If the swap and close happen in the wrong order, the old modal DOM is gone before close() is called.
**How to avoid:** Use `hx-on::after-request` on the form (fires after response is processed and swap is done). The dialog referenced by ID will be in the new DOM by then — close it via `document.getElementById(...)` which re-queries the DOM at call time.
### Pitfall 3: HTMX PATCH returns partial but POST redirect was changed
**What goes wrong:** After UIE-02 adds the HX-Request detection, a future refactor or test sends a POST without HX-Request header and unexpectedly gets a 303 redirect instead of an HTML partial.
**Why it happens:** The `HX-Request` detection branch changes the POST contract. Tests that use `TestClient.post(...)` without setting the HX-Request header will now get 303.
**How to avoid:** Update `test_printer_crud.py` tests that POST to `/printers` to add `headers={"HX-Request": "true"}` when they expect the partial response. Or — alternative — use the redirect for ALL POST /printers responses and have `/printers` page rebuild the list from scratch on GET (simpler for the new page flow, slightly less seamless if accessed from the library page via HTMX).
**Decision needed for planner:** The CONTEXT.md says the add form on `/printers/new` redirects after submit. But the existing HTMX tests on `/printers` POST expect a partial response. The safest approach is: keep HTMX POST returning partial (for future HTMX consumers), and on `/printers/new` use a plain `<form>` (no HTMX) so the browser follows the redirect. This avoids branching logic in the handler.
### Pitfall 4: Pico CSS `<dialog>` close button visual
**What goes wrong:** The `<button rel="prev">` close button only renders as an `×` if it is inside a `<header>` element within the `<article>` wrapper inside `<dialog>`. Wrong nesting produces an unstyled button.
**Why it happens:** Pico CSS 2.x `<dialog>` styling relies on the `article > header > button[rel="prev"]` selector pattern.
**How to avoid:** Always nest: `<dialog> > <article> > <header> > <button rel="prev">`.
### Pitfall 5: `x-text` vs server-rendered text in translated templates
**What goes wrong:** A `<button>Delete</button>` with `x-text="$store.i18n.t('delete')"` will show "Delete" initially (server-rendered text node), then flicker to the translated value when Alpine initialises.
**Why it happens:** Alpine.js initialises asynchronously after DOM parse. The initial text content is visible briefly before Alpine overwrites it.
**How to avoid:** Use empty text content in translated elements: `<button x-text="$store.i18n.t('delete')"></button>`. Alpine fills it on init. For SSR fallback, this is acceptable for a tool used by technicians (no progressive enhancement requirement stated).
### Pitfall 6: `Printer.updated_at` field exists but is never set on edit
**What goes wrong:** PATCH updates printer fields but `updated_at` stays at creation time.
**Why it happens:** Peewee `.save()` without specifying fields updates all columns, but `updated_at` has a `default=_utcnow` which only fires on `.create()`, not `.save()`.
**How to avoid:** Explicitly set `printer.updated_at = _utcnow()` before `printer.save()` in the PATCH handler.
---
## Code Examples
Verified patterns from live codebase inspection:
### Existing Delete button pattern (printer_list.html — lines 34-40)
```html
<button
hx-delete="/printers/{{ p.id }}"
hx-target="#printer-list"
hx-swap="outerHTML"
hx-confirm="Delete '{{ p.name }}'?">
Delete
</button>
```
Edit button slots in beside this — same column, same pattern, different HTTP method and trigger mechanism.
### Existing Alpine.js x-data + x-model pattern (printer_form.html — line 1)
```html
<div x-data="{ ip: '{{ printer.ip_address if printer else '' }}',
port: '{{ printer.port_name if printer else '' }}',
portEdited: {{ 'true' if printer else 'false' }} }">
```
Edit modal pre-fill follows the exact same pattern, with `portEdited: true` hardcoded (edit mode always treats port as user-set).
### Existing grouped-by-client query (api/printers.py — lines 33-48)
```python
query = (
Printer.select(Printer, Client)
.join(Client, JOIN.LEFT_OUTER)
.order_by(Client.name, Printer.name)
)
grouped: dict[str, list[Printer]] = defaultdict(list)
for p in query:
client_name = p.client.name if p.client_id else "Unassigned"
grouped[client_name].append(p)
```
Client detail page uses the same pattern filtered by `Printer.client == client_id`.
### Pico CSS data-theme toggle (already wired in base.html — line 2)
```html
<html lang="en" data-theme="auto">
```
Alpine.js theme store only needs to call `document.documentElement.setAttribute('data-theme', value)`. No CSS changes required.
---
## State of the Art
| Old Approach | Current Approach | Impact |
|--------------|------------------|--------|
| Custom modal JS libraries | Native `<dialog>` + Pico CSS | Zero extra JS, accessible by default |
| `hx-method` attribute override | `hx-patch` / `hx-delete` native HTMX 2.x | Cleaner, no hidden fields |
| Alpine.js component-scoped data only | Alpine.js `$store` (v3.x) | True global reactive state across partials |
| Page-level `x-data` for global state | `Alpine.store()` + `alpine:init` event | Reliable init order, accessible anywhere |
---
## Open Questions
1. **POST /printers response strategy for UIE-02**
- What we know: Current tests expect a partial HTML response from `POST /printers`. CONTEXT.md says `/printers/new` should redirect after submit.
- What's unclear: Should the PATCH handler detect HX-Request, or should `/printers/new` use a plain form (non-HTMX) so the server always redirects?
- Recommendation: Use a plain `<form>` (no `hx-post`) on `/printers/new`. The server always redirects on `POST /printers`. Update existing tests to use `follow_redirects=False` and assert 303, or update them to follow the redirect and check the resulting page. This avoids branching logic in the handler and is consistent with the "redirect after form submit" web convention.
2. **Where to include `printer_edit_modal.html`**
- What we know: The modal must be inside the `{% for p in printers %}` loop to access `p.*` values.
- What's unclear: Should the modal be inside `printer_list.html` or a separate include per page that uses the list?
- Recommendation: Include the modal directly inside `printer_list.html`'s loop, as a Jinja2 `{% include %}` or inline block. This keeps it co-located with the trigger button and avoids duplication.
3. **Translation string completeness for UIE-05**
- What we know: All static UI strings must be translated. Exact count unknown until templates are audited.
- What's unclear: Are error messages from the server (e.g., "Printer name is required.") in scope?
- Recommendation: Server-side error messages are out of scope for this phase (they appear as HTMX-swapped fragments). Only translate static template strings (nav labels, buttons, headings, form labels).
---
## Validation Architecture
### Test Framework
| Property | Value |
|----------|-------|
| Framework | pytest 8.x + httpx 0.27 + pytest-playwright |
| Config file | none — pytest.ini or pyproject.toml not detected; pytest auto-discovers `tests/` |
| Quick run command | `pytest tests/ -x -q --ignore=tests/e2e` |
| Full suite command | `pytest tests/ -q` |
| E2E run command | `pytest tests/e2e/ -q` (requires live server + playwright browsers) |
### Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| UIE-01 | PATCH `/printers/{id}` updates DB and returns printer list partial | unit/integration | `pytest tests/test_printer_crud.py -x -q -k "patch or edit"` | ❌ Wave 0 |
| UIE-01 | Edit modal triggers `showModal()` and pre-fills values | E2E (Alpine.js) | `pytest tests/e2e/test_printer_edit.py -x -q` | ❌ Wave 0 |
| UIE-02 | GET `/printers/new` returns 200 with add form | integration | `pytest tests/test_printer_crud.py -x -q -k "printers_new"` | ❌ Wave 0 |
| UIE-02 | POST `/printers` returns 303 redirect to `/printers` | integration | `pytest tests/test_printer_crud.py -x -q -k "redirect"` | ❌ Wave 0 |
| UIE-02 | GET `/printers` no longer contains the add form | integration | `pytest tests/test_printer_crud.py -x -q -k "library_no_form"` | ❌ Wave 0 |
| UIE-03 | GET `/clients/{id}` returns 200 with filtered printer list | integration | `pytest tests/test_printer_crud.py -x -q -k "client_detail"` | ❌ Wave 0 |
| UIE-03 | GET `/clients/9999` returns 404 | integration | `pytest tests/test_printer_crud.py -x -q -k "client_not_found"` | ❌ Wave 0 |
| UIE-03 | Client names appear as `<a href="/clients/{id}">` in printer list | integration | `pytest tests/test_printer_crud.py -x -q -k "client_links"` | ❌ Wave 0 |
| UIE-04 | Theme toggle button present in base layout | integration | `pytest tests/test_static.py -x -q -k "theme_toggle"` | ❌ Wave 0 |
| UIE-04 | Alpine.js theme store persists to localStorage and sets data-theme | E2E | `pytest tests/e2e/test_theme_toggle.py -x -q` | ❌ Wave 0 |
| UIE-05 | Alpine.js i18n store switches all labels between FR/EN | E2E | `pytest tests/e2e/test_i18n_toggle.py -x -q` | ❌ Wave 0 |
### Sampling Rate
- **Per task commit:** `pytest tests/ -x -q --ignore=tests/e2e`
- **Per wave merge:** `pytest tests/ -q`
- **Phase gate:** Full suite green (including E2E) before `/gsd:verify-work`
### Wave 0 Gaps
- [ ] `tests/test_printer_crud.py` — add test functions for UIE-01 (PATCH), UIE-02 (redirect, new page), UIE-03 (client detail, 404, links)
- [ ] `tests/e2e/test_printer_edit.py` — E2E: open modal, check pre-fill, submit, confirm list update
- [ ] `tests/e2e/test_theme_toggle.py` — E2E: click theme button, verify `data-theme` attribute cycles, verify localStorage
- [ ] `tests/e2e/test_i18n_toggle.py` — E2E: click lang toggle, verify nav label text changes, verify localStorage
Existing test files (`test_printer_crud.py`, `conftest.py`, `tests/e2e/conftest.py`) are already in place and working — new test functions are added to existing files, no new infrastructure needed.
---
## Sources
### Primary (HIGH confidence — live codebase inspection)
- `imptune/static/pico.min.css` v2.1.1 — confirmed `data-theme` light/dark/auto support, `<dialog>` native styling, `button[rel="prev"]` close pattern
- `imptune/static/alpine.min.js` v3.15.11 — confirmed `Alpine.store()` API, `alpine:init` event, `$store` magic property
- `imptune/static/htmx.min.js` v2.0.8 — confirmed `hx-patch` attribute support (no method override needed)
- `imptune/api/printers.py` — confirmed `_render_printer_list()` helper, form field names, `_error_response()` pattern
- `imptune/api/pages.py` — confirmed route structure, template context patterns, grouped query pattern
- `imptune/templates/partials/printer_list.html` — confirmed Actions column, grouped dict rendering, `#printer-list` target ID
- `imptune/templates/partials/printer_form.html` — confirmed Alpine.js x-data fields, form field names, driver upload sub-form location
- `imptune/db/models.py` — confirmed `Printer.updated_at` field exists, all field names for PATCH form
### Secondary (MEDIUM confidence — Alpine.js 3.x documentation patterns)
- Alpine.js `$store` and `alpine:init` pattern: confirmed in Alpine.js v3 source code (version string "3.15.11" found in static file; `Jr` function = `Alpine.store`, `alpine:init` dispatch confirmed in `Or` = `Alpine.start`)
### Tertiary (LOW confidence)
- None — all critical claims verified from live files.
---
## Metadata
**Confidence breakdown:**
- Standard stack: HIGH — all versions confirmed from bundled static files and requirements.txt
- Architecture: HIGH — all patterns grounded in existing code; no speculation
- Pitfalls: HIGH — derived from direct code inspection (e.g., `portEdited` flag, `updated_at` default, Alpine init order)
- Test map: HIGH — existing test infrastructure fully inspected; gaps identified precisely
**Research date:** 2026-04-15
**Valid until:** 2026-05-15 (stable stack; only changes if Alpine/HTMX/Pico are upgraded)