From 78816dfe8c73237574ecdb0013d481df90d2953f Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Tue, 28 Jul 2026 21:41:16 +0000 Subject: [PATCH] fix(ui): una cornice sola, e smette di spostarsi sotto le dita MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Punto 14. La segnalazione dell'operatore del 28/07 non era stata circoscritta sul codice: «le dimensioni delle viste cambiano a seconda del menu». Sono quattro meccanismi distinti, tutti leggibili senza avere il tablet in mano. Ogni vista si dichiarava la propria larghezza: sette valori diversi su diciassette template, e nessuno coincideva con quello della navbar, che sta a max-w-7xl su tutte. Il contenuto era quindi disallineato dalla barra sopra di sé, e il disallineamento cambiava a ogni pagina. Il percorso dell'operatore faceva 1280 → 1024 → tutto schermo → 1280 in quattro passaggi. Il padding verticale mescolava py-8 e py-6 fra viste consecutive, così la prima card cambiava altezza a ogni schermata. La barra di scorrimento è classica da 8px e occupa spazio: una pagina lunga la mostrava, una corta no, e la schermata di misura la toglie sempre. A ogni navigazione tutto il contenuto centrato — navbar compresa — si spostava di 8px. La schermata di misura era alta 100vh, che su Android e iOS è l'altezza con la barra dell'indirizzo nascosta: su un tablet il piede, dove stanno «Fine ciclo misura» e il tastierino, finiva sotto il bordo. Ora: .tmf-page e .tmf-page-narrow in themes.css, con i valori esatti della navbar; scrollbar-gutter: stable; 100dvh dove serve. Due larghezze in tutto il prodotto al posto di sette, e il criterio è il tipo di pagina — liste, tabelle e tele stanno larghe, i moduli stanno stretti. Login e schermata di misura tengono la loro geometria, per i motivi scritti in docs/architecture/LAYOUT.md e ripetuti in EXEMPT dentro il test: la prima non ha navbar a cui allinearsi, la seconda non deve scorrere mentre si misura. Il test è statico e guarda i sorgenti: la vista scritta domani copierà la cornice dalla vicina, ed è lì che la deriva ricomincia. Resta da chiedere all'operatore su quale schermo l'ha vista: in verticale su un tablet quasi tutte quelle larghezze collassano e il difetto non si nota. Co-Authored-By: Claude Opus 5 (1M context) --- docs/architecture/LAYOUT.md | 98 +++++++++++++++++++ src/frontend/flask_app/static/css/themes.css | 56 +++++++++++ .../flask_app/templates/admin/settings.html | 2 +- .../flask_app/templates/admin/stations.html | 2 +- .../flask_app/templates/admin/users.html | 2 +- .../flask_app/templates/auth/profile.html | 2 +- .../errors/station_not_configured.html | 2 +- .../templates/maker/recipe_editor.html | 2 +- .../templates/maker/recipe_list.html | 2 +- .../templates/maker/recipe_preview.html | 2 +- .../templates/maker/task_drawing.html | 2 +- .../templates/maker/task_editor.html | 2 +- .../templates/maker/version_history.html | 2 +- .../templates/measure/select_recipe.html | 2 +- .../templates/measure/task_complete.html | 2 +- .../templates/measure/task_list.html | 2 +- .../templates/statistics/dashboard.html | 2 +- .../flask_app/tests/test_layout_shell.py | 70 +++++++++++++ 18 files changed, 239 insertions(+), 15 deletions(-) create mode 100644 docs/architecture/LAYOUT.md create mode 100644 src/frontend/flask_app/tests/test_layout_shell.py diff --git a/docs/architecture/LAYOUT.md b/docs/architecture/LAYOUT.md new file mode 100644 index 0000000..7c4d013 --- /dev/null +++ b/docs/architecture/LAYOUT.md @@ -0,0 +1,98 @@ +# Cornice delle pagine + +Nasce dal punto 14 del documento del 28/07: *«le dimensioni delle viste cambiano a +seconda del menu; passando da una schermata all'altra la finestra non mantiene +proporzioni stabili»*. La segnalazione era di un operatore e non era ancora stata +circoscritta sul codice. Questo è l'esito della circoscrizione e la regola che ne +è uscita. + +## Cosa succedeva + +Tre meccanismi distinti, tutti verificabili sul codice senza avere il tablet in mano. + +**1. Ogni vista si dichiarava la propria larghezza.** Sette valori diversi su +diciassette template, e nessuno coincideva con quello della navbar, che sta a +`max-w-7xl` su tutte le pagine. Il contenuto risultava quindi disallineato dalla +barra sopra di sé, e il disallineamento cambiava da pagina a pagina. + +| vista | prima | dopo | +|---|---|---| +| `measure/select_recipe.html` | `max-w-7xl` `py-8` | `tmf-page` | +| `measure/task_list.html` | `max-w-5xl` `py-8` | `tmf-page` | +| `measure/task_complete.html` | `max-w-7xl` `py-6` | `tmf-page` | +| `maker/recipe_list.html` | `max-w-6xl` `py-8` | `tmf-page` | +| `maker/task_editor.html` | `max-w-5xl` `py-8` | `tmf-page` | +| `maker/task_drawing.html` | `max-w-5xl` `py-8` | `tmf-page` | +| `maker/recipe_preview.html` | `max-w-5xl` `py-8` | `tmf-page` | +| `admin/stations.html` | `max-w-7xl` `py-6` | `tmf-page` | +| `admin/users.html` | `max-w-7xl` `py-6` | `tmf-page` | +| `statistics/dashboard.html` | `max-w-7xl` `py-6` | `tmf-page` | +| `admin/settings.html` | `max-w-3xl` `py-6` | `tmf-page tmf-page-narrow` | +| `auth/profile.html` | `max-w-4xl` `py-8` | `tmf-page tmf-page-narrow` | +| `maker/recipe_editor.html` | `max-w-4xl` `py-8` | `tmf-page tmf-page-narrow` | +| `maker/version_history.html` | `max-w-4xl` `py-8` | `tmf-page tmf-page-narrow` | +| `errors/station_not_configured.html` | `max-w-2xl` `py-8` | `tmf-page tmf-page-narrow` | + +Il percorso dell'operatore — quello da cui è arrivata la segnalazione — faceva +`1280px → 1024px → tutto schermo → 1280px` in quattro passaggi. + +**2. Il padding verticale non era lo stesso.** `py-8` e `py-6` mescolati fra viste +consecutive: la prima card si trovava a un'altezza diversa a ogni cambio di +schermata. + +**3. La barra di scorrimento appariva e spariva.** È una barra classica da 8px +(`themes.css`, `::-webkit-scrollbar`), quindi occupa spazio nel layout. Una pagina +lunga la mostrava, una corta no, e la schermata di misura la toglie sempre +(`body { overflow: hidden }`): a ogni navigazione tutto il contenuto centrato — +navbar compresa — si spostava di 8px in orizzontale. + +## La regola + +Due classi in `static/css/themes.css`, nessuna larghezza nei template. + +- **`.tmf-page`** — la cornice: `max-width: 80rem`, padding `1rem / 1.5rem / 2rem` + ai tre breakpoint, `padding-block: 1.5rem`. Sono esattamente i valori della + navbar (`max-w-7xl px-4 sm:px-6 lg:px-8`), così il contenuto ci si allinea. +- **`.tmf-page-narrow`** — si aggiunge alla prima e stringe a `56rem`. È per i + moduli: un campo di testo largo 1280px non si compila meglio. +- **`html { scrollbar-gutter: stable }`** — lo spazio della barra è riservato + sempre, anche dove la pagina non scorre. +- **`body.h-screen { height: 100dvh }`** — la schermata di misura è alta quanto la + finestra vera. `100vh` su Android e iOS è l'altezza che la pagina avrebbe con la + barra dell'indirizzo nascosta: su un tablet il piede della schermata — dove + stanno «Fine ciclo misura» e il tastierino — finiva sotto il bordo, e ricompariva + quando la barra si ritraeva. + +Due larghezze in tutto il prodotto al posto di sette, e il criterio è il tipo di +pagina: liste, tabelle e tele di disegno stanno larghe, i moduli stanno stretti. + +## Le due eccezioni + +Hanno geometria propria per un motivo, e sono elencate in +`tests/test_layout_shell.py` perché non sembrino dimenticate. + +- **`auth/login.html`** — schermata piena senza navbar: non c'è niente a cui + allinearsi. +- **`measure/task_execute.html`** — la schermata di misura è un pannello a tutta + altezza che non deve scorrere mentre un operatore sta misurando + (`h-screen overflow-hidden`, footer nascosto). È l'unica vista in cui il cambio + di forma è voluto, ed è anche l'unica in cui l'operatore si ferma a lavorare: + entrarci e uscirne resta un salto, ma ora è l'unico. + +## Cosa resta da verificare sul campo + +La segnalazione non è mai stata riprodotta su un dispositivo: quanto sopra viene +dalla lettura del codice, non da una prova. Quello che è dimostrato è che il +layout *poteva* muoversi per quattro motivi distinti e che ora non può più per +nessuno dei quattro. + +Se l'operatore vedesse ancora spostamenti, il sospetto successivo è la **tastiera +virtuale**: quando compare, il browser ridimensiona la finestra, e con `100dvh` la +schermata di misura si ridimensiona con lei invece di scorrere sotto. È il +comportamento giusto per il tastierino a schermo, ma va guardato con un calibro +USB collegato, dove la tastiera di sistema non dovrebbe comparire affatto. + +Da chiedere all'operatore che ha aperto la segnalazione: **su quale schermo** l'ha +vista. Su un tablet in verticale la maggior parte delle larghezze qui sopra +collassa allo stesso valore e il difetto non si vede; su un monitor da scrivania +si vede tutto. diff --git a/src/frontend/flask_app/static/css/themes.css b/src/frontend/flask_app/static/css/themes.css index 920d39c..f982ab8 100644 --- a/src/frontend/flask_app/static/css/themes.css +++ b/src/frontend/flask_app/static/css/themes.css @@ -124,10 +124,66 @@ textarea:focus-visible { } +/* ============================================================ + Page shell — one frame for every view + + Punto 14. Ogni schermata si disegnava la propria larghezza: sette valori + diversi fra le viste, nessuno allineato alla navbar, che sta a 80rem su + tutte. Passando da un menu all'altro il blocco di contenuto cambiava + larghezza e si spostava rispetto alla barra sopra di sé. + + Qui la cornice è una sola e coincide con quella della navbar. Le pagine + che hanno bisogno di una colonna stretta - i moduli - la stringono al loro + interno: il bordo della pagina resta dov'è, cambia solo il contenuto. + ============================================================ */ + +.tmf-page { + width: 100%; + max-width: 80rem; /* == max-w-7xl, la stessa della navbar */ + margin-inline: auto; + padding-inline: 1rem; /* == px-4 */ + padding-block: 1.5rem; /* == py-6 */ +} + +@media (min-width: 640px) { + .tmf-page { padding-inline: 1.5rem; } /* == sm:px-6 */ +} + +@media (min-width: 1024px) { + .tmf-page { padding-inline: 2rem; } /* == lg:px-8 */ +} + +/* I moduli restano su una colonna leggibile: un campo di testo largo 1280px non + si compila meglio, si compila peggio. Due larghezze in tutto il prodotto - + questa e quella sopra - al posto delle sette di prima. */ +.tmf-page.tmf-page-narrow { + max-width: 56rem; /* == max-w-4xl */ +} + + /* ============================================================ Custom Scrollbar ============================================================ */ +/* Lo spazio della barra di scorrimento è riservato sempre, anche quando la + pagina non scorre. Senza, ogni passaggio fra una vista lunga e una corta - + e ogni ingresso nella schermata di misura, che blocca lo scorrimento - + spostava di 8px tutto il contenuto centrato, navbar compresa. */ +html { + scrollbar-gutter: stable; +} + +/* La schermata di misura è alta quanto la finestra vera, non quanto quella + teorica: su Android e iOS `100vh` è l'altezza che la pagina avrebbe con la + barra dell'indirizzo nascosta, quindi su un tablet il piede - dove stanno + «Fine ciclo misura» e il tastierino - finisce sotto il bordo dello schermo, e + ricompare quando la barra si ritrae. `100dvh` segue la finestra reale. */ +@supports (height: 100dvh) { + body.h-screen { + height: 100dvh; + } +} + /* Webkit (Chrome, Safari, Edge) */ ::-webkit-scrollbar { width: 8px; diff --git a/src/frontend/flask_app/templates/admin/settings.html b/src/frontend/flask_app/templates/admin/settings.html index 1aaa79e..4b94aee 100644 --- a/src/frontend/flask_app/templates/admin/settings.html +++ b/src/frontend/flask_app/templates/admin/settings.html @@ -3,7 +3,7 @@ {% block title %}{{ _('Impostazioni') }} - TieMeasureFlow{% endblock %} {% block content %} -
diff --git a/src/frontend/flask_app/templates/admin/stations.html b/src/frontend/flask_app/templates/admin/stations.html index 4d22975..744c220 100644 --- a/src/frontend/flask_app/templates/admin/stations.html +++ b/src/frontend/flask_app/templates/admin/stations.html @@ -8,7 +8,7 @@ window.__allRecipes = {{ all_recipes|tojson }}; -
diff --git a/src/frontend/flask_app/templates/admin/users.html b/src/frontend/flask_app/templates/admin/users.html index ede89df..65eee6d 100644 --- a/src/frontend/flask_app/templates/admin/users.html +++ b/src/frontend/flask_app/templates/admin/users.html @@ -5,7 +5,7 @@ {% block content %} -
diff --git a/src/frontend/flask_app/templates/auth/profile.html b/src/frontend/flask_app/templates/auth/profile.html index 9eca2aa..645a2c3 100644 --- a/src/frontend/flask_app/templates/auth/profile.html +++ b/src/frontend/flask_app/templates/auth/profile.html @@ -2,7 +2,7 @@ {% block title %}{{ _('Profilo') }} — TieMeasureFlow{% endblock %} {% block content %} -
+
diff --git a/src/frontend/flask_app/templates/errors/station_not_configured.html b/src/frontend/flask_app/templates/errors/station_not_configured.html index d19e6d8..8f42b03 100644 --- a/src/frontend/flask_app/templates/errors/station_not_configured.html +++ b/src/frontend/flask_app/templates/errors/station_not_configured.html @@ -2,7 +2,7 @@ {% block title %}{{ _('Stazione non configurata') }} — TieMeasureFlow{% endblock %} {% block content %} -
+
diff --git a/src/frontend/flask_app/templates/maker/recipe_editor.html b/src/frontend/flask_app/templates/maker/recipe_editor.html index babb2ac..3588685 100644 --- a/src/frontend/flask_app/templates/maker/recipe_editor.html +++ b/src/frontend/flask_app/templates/maker/recipe_editor.html @@ -32,7 +32,7 @@ {% set current_version = recipe.current_version if recipe and recipe.current_version else None %} {% set versions = recipe.versions if recipe and recipe.versions else [] %} -
diff --git a/src/frontend/flask_app/templates/maker/recipe_list.html b/src/frontend/flask_app/templates/maker/recipe_list.html index 940c8bb..35fa604 100644 --- a/src/frontend/flask_app/templates/maker/recipe_list.html +++ b/src/frontend/flask_app/templates/maker/recipe_list.html @@ -3,7 +3,7 @@ {% block content %} -
diff --git a/src/frontend/flask_app/templates/maker/task_drawing.html b/src/frontend/flask_app/templates/maker/task_drawing.html index db13889..2d76adb 100644 --- a/src/frontend/flask_app/templates/maker/task_drawing.html +++ b/src/frontend/flask_app/templates/maker/task_drawing.html @@ -65,7 +65,7 @@ connectionError: {{ _("Errore di connessione al server")|tojson }} }; -
diff --git a/src/frontend/flask_app/templates/maker/task_editor.html b/src/frontend/flask_app/templates/maker/task_editor.html index a96caba..19ffeb0 100644 --- a/src/frontend/flask_app/templates/maker/task_editor.html +++ b/src/frontend/flask_app/templates/maker/task_editor.html @@ -91,7 +91,7 @@ {% endblock %} {% block content %} -
diff --git a/src/frontend/flask_app/templates/maker/version_history.html b/src/frontend/flask_app/templates/maker/version_history.html index 3bca0ac..ae89911 100644 --- a/src/frontend/flask_app/templates/maker/version_history.html +++ b/src/frontend/flask_app/templates/maker/version_history.html @@ -53,7 +53,7 @@ {% endblock %} {% block content %} -
diff --git a/src/frontend/flask_app/templates/measure/select_recipe.html b/src/frontend/flask_app/templates/measure/select_recipe.html index c80dad4..20d382d 100644 --- a/src/frontend/flask_app/templates/measure/select_recipe.html +++ b/src/frontend/flask_app/templates/measure/select_recipe.html @@ -3,7 +3,7 @@ {% block content %} -
+
{# The summary is where an operator lingers: the clock has to be here as well. #} {% include "components/production_clock.html" %} diff --git a/src/frontend/flask_app/templates/measure/task_list.html b/src/frontend/flask_app/templates/measure/task_list.html index e7367a0..bce2dc1 100644 --- a/src/frontend/flask_app/templates/measure/task_list.html +++ b/src/frontend/flask_app/templates/measure/task_list.html @@ -2,7 +2,7 @@ {% block title %}{{ recipe.name }} — {{ _('Task') }} — TieMeasureFlow{% endblock %} {% block content %} -
+
{# The clock follows the operator here too, and brings them back when it expires. #} {% include "components/production_clock.html" %} diff --git a/src/frontend/flask_app/templates/statistics/dashboard.html b/src/frontend/flask_app/templates/statistics/dashboard.html index 6b25177..888d919 100644 --- a/src/frontend/flask_app/templates/statistics/dashboard.html +++ b/src/frontend/flask_app/templates/statistics/dashboard.html @@ -8,7 +8,7 @@ {% endblock %} {% block content %} -
diff --git a/src/frontend/flask_app/tests/test_layout_shell.py b/src/frontend/flask_app/tests/test_layout_shell.py new file mode 100644 index 0000000..85440ef --- /dev/null +++ b/src/frontend/flask_app/tests/test_layout_shell.py @@ -0,0 +1,70 @@ +"""Point 14: the frame must not move when the operator changes view. + +The report was «le dimensioni delle viste cambiano a seconda del menu». On the +code it was seven different page widths across seventeen templates, none of them +the width of the navbar sitting above them, plus a scrollbar that came and went. + +This test guards the part that is easy to undo: the next page someone writes will +copy the shell from a neighbour, and if a neighbour has gone back to declaring its +own `container mx-auto ... max-w-5xl` the drift starts again. +""" +from pathlib import Path + +import pytest + +TEMPLATES = Path(__file__).resolve().parents[1] / "templates" + +# Two views own their geometry on purpose, and say why here so that a reader does +# not have to guess whether they were forgotten: +# login — a full-viewport splash, no navbar to line up with; +# task_execute — the measurement screen, a fixed full-height instrument panel +# that must not scroll while an operator is measuring. +EXEMPT = {"auth/login.html", "measure/task_execute.html"} + +# What a page must not go back to doing: setting its own width and padding. +FORBIDDEN = ("container mx-auto", "max-w-2xl mx-auto", "max-w-3xl mx-auto", + "max-w-4xl mx-auto", "max-w-5xl mx-auto", "max-w-6xl mx-auto", + "max-w-7xl mx-auto") + + +def _views(): + for path in sorted(TEMPLATES.rglob("*.html")): + rel = path.relative_to(TEMPLATES).as_posix() + if rel.startswith("components/") or rel == "base.html": + continue + yield rel, path + + +def test_every_view_uses_the_shared_shell(): + """One frame, declared in one place, so it cannot drift per page.""" + missing = [ + rel for rel, path in _views() + if rel not in EXEMPT and "tmf-page" not in path.read_text(encoding="utf-8") + ] + assert not missing, f"viste senza la cornice condivisa: {missing}" + + +@pytest.mark.parametrize("rel,path", list(_views())) +def test_no_view_declares_its_own_width(rel, path): + if rel in EXEMPT: + pytest.skip("geometria propria, documentata in EXEMPT") + html = path.read_text(encoding="utf-8") + found = [f for f in FORBIDDEN if f in html] + assert not found, ( + f"{rel} torna a dichiarare la propria larghezza ({found}): " + "usare .tmf-page / .tmf-page-narrow" + ) + + +def test_the_shell_matches_the_navbar(): + """The content lines up with the bar above it, or the page looks like it has + two widths at once - which is what the operator was seeing.""" + navbar = (TEMPLATES / "components/navbar.html").read_text(encoding="utf-8") + assert "max-w-7xl mx-auto px-4 sm:px-6 lg:px-8" in navbar, ( + "la navbar ha cambiato cornice: allineare .tmf-page in themes.css" + ) + css = ( + TEMPLATES.parent / "static/css/themes.css" + ).read_text(encoding="utf-8") + assert "max-width: 80rem;" in css # 80rem == max-w-7xl + assert "scrollbar-gutter: stable;" in css