studio UX/UI: analisi delle schermate attuali e proposta per la 0.6.0

docs/UX-STUDIO.md: 10 problemi rilevati (esito dei comandi fuori vista,
12 pulsanti con coppie acceso/spento, doppia navigazione, dati vecchi
mostrati come attuali), la proposta (Home di sola consultazione, pagina
Regolazioni, interruttori, feedback ancorato), microcopy, piano di
verifica e decisioni da prendere. Schemi in docs/ux/.
This commit is contained in:
2026-10-10 09:31:39 +02:00
parent 6eea29f99d
commit 667ff63907
8 changed files with 652 additions and 0 deletions
+319
View File
@@ -0,0 +1,319 @@
#!/usr/bin/env python3
"""Wireframe dello studio UX/UI di harbour-aira (proposta).
Sono SCHEMI, non anteprime rifinite: mostrano disposizione, gerarchia e
comportamento dei componenti. Le anteprime curate delle schermate attuali
stanno in docs/anteprima/; qui si confrontano le proposte dello studio.
python3 scripts/render_wireframes.py -> docs/ux/*.png
"""
from __future__ import annotations
import pathlib
from PIL import Image, ImageDraw
from render_mockups import (W, H, M, font, background, status_bar,
PRIMARY, SECONDARY, ACCENT, BTN_BG, BTN_EDGE, OK)
OUT = pathlib.Path("docs/ux")
WARN = (232, 178, 92) # ambra: attenzione
BAD = (226, 106, 106) # rosso: problema
# ---------------------------------------------------------------- primitive
def badge(d, x, y, n):
"""Numero di richiamo: si spiega nelle note dello studio."""
d.ellipse([x, y, x + 34, y + 34], fill=ACCENT)
t = str(n)
tw = d.textlength(t, font=font(22, bold=True))
d.text((x + 17 - tw / 2, y + 5), t, font=font(22, bold=True), fill=(10, 12, 16))
def box(d, x, y, w, h, fill=None, outline=BTN_EDGE, radius=14, width=2):
d.rounded_rectangle([x, y, x + w, y + h], radius=radius,
fill=fill, outline=outline, width=width)
def btn(d, x, y, w, text, h=66, primary=False):
box(d, x, y, w, h, fill=BTN_BG if not primary else (30, 62, 78),
outline=ACCENT if primary else BTN_EDGE)
f = font(24)
tw = d.textlength(text, font=f)
d.text((x + (w - tw) / 2, y + (h - 26) / 2), text, font=f,
fill=PRIMARY)
def text_line(d, x, y, text, size=24, color=SECONDARY, bold=False, width=None,
align="left"):
f = font(size, bold)
if width is None:
d.text((x, y), text, font=f, fill=color)
return y + size + 8
words, lines, cur = text.split(), [], ""
for wtxt in words:
probe = (cur + " " + wtxt).strip()
if d.textlength(probe, font=f) <= width or not cur:
cur = probe
else:
lines.append(cur)
cur = wtxt
lines.append(cur)
for ln in lines:
tx = x if align == "left" else (x + (width - d.textlength(ln, font=f)) / 2)
d.text((tx, y), ln, font=f, fill=color)
y += size + 10
return y
def pulley_hint(d, y, items):
d.text((M, y), "▾", font=font(28, bold=True), fill=ACCENT)
x = M + 40
for i, it in enumerate(items):
if i:
d.text((x, y + 2), "·", font=font(22), fill=BTN_EDGE)
x += 22
d.text((x, y + 2), it, font=font(22), fill=SECONDARY)
x += d.textlength(it, font=font(22)) + 16
return y + 40
def pushup_hint(d, y, items):
d.text((M, y), "▴", font=font(28, bold=True), fill=ACCENT)
x = M + 40
for i, it in enumerate(items):
if i:
d.text((x, y + 2), "·", font=font(22), fill=BTN_EDGE)
x += 22
d.text((x, y + 2), it, font=font(22), fill=SECONDARY)
x += d.textlength(it, font=font(22)) + 16
return y + 40
def cell(d, x, y, w, h, title, value, sub, frac=None, chevron=True):
box(d, x, y, w, h, fill=BTN_BG)
d.text((x + 24, y + 22), title, font=font(24), fill=ACCENT)
d.text((x + 24, y + 58), value, font=font(54, bold=True), fill=PRIMARY)
d.text((x + 24, y + 124), sub, font=font(22), fill=SECONDARY)
if frac is not None:
d.rounded_rectangle([x + 24, y + h - 34, x + w - 24, y + h - 24],
radius=5, fill=(56, 64, 74))
d.rounded_rectangle([x + 24, y + h - 34,
x + 24 + (w - 48) * frac, y + h - 24],
radius=5, fill=ACCENT)
if chevron:
d.text((x + w - 40, y + h / 2 - 22), "›", font=font(38, bold=True),
fill=SECONDARY)
def slider(d, x, y, w, frac, left, right):
cy = y + 18
d.text((x, cy - 14), left, font=font(22), fill=SECONDARY)
rw = d.textlength(right, font=font(22))
d.text((x + w - rw, cy - 14), right, font=font(22), fill=SECONDARY)
tx0, tx1 = x + 60, x + w - 60
d.rounded_rectangle([tx0, cy - 3, tx1, cy + 3], radius=3, fill=(56, 64, 74))
kx = tx0 + (tx1 - tx0) * frac
d.rounded_rectangle([tx0, cy - 3, kx, cy + 3], radius=3, fill=ACCENT)
d.ellipse([kx - 22, cy - 22, kx + 22, cy + 22], fill=PRIMARY)
return y + 62
def switch_row(d, y, label, desc, on, x=M, w=W - 2 * M):
d.text((x, y), label, font=font(26), fill=PRIMARY)
d.text((x, y + 34), desc, font=font(20), fill=SECONDARY)
sw, sh = 104, 56
sx, sy = x + w - sw, y + 4
d.rounded_rectangle([sx, sy, sx + sw, sy + sh], radius=sh / 2,
fill=(30, 62, 78) if on else (44, 50, 58),
outline=ACCENT if on else BTN_EDGE, width=2)
kx = sx + sw - 28 if on else sx + 28
d.ellipse([kx - 22, sy + sh / 2 - 22, kx + 22, sy + sh / 2 + 22],
fill=PRIMARY)
return y + 96
def presets(d, y, values):
n = len(values)
gap = 10
bw = (W - 2 * M - gap * (n - 1)) / n
for i, v in enumerate(values):
x = M + i * (bw + gap)
box(d, x, y, bw, 58, fill=BTN_BG)
tw = d.textlength(v, font=font(22))
d.text((x + (bw - tw) / 2, y + 16), v, font=font(22), fill=PRIMARY)
return y + 74
# ------------------------------------------------------------------ schermate
def casa_screen():
img = background()
d = ImageDraw.Draw(img)
status_bar(d)
d.text((M, 84), "Aira", font=font(44, bold=True), fill=PRIMARY)
y = pulley_hint(d, 142, ["Aggiorna", "Dati", "Impianto", "Impostazioni"])
badge(d, W - M - 34, 136, 1)
# stato in una riga sola
d.ellipse([M, y + 10, M + 22, y + 32], fill=OK)
d.text((M + 38, y + 6), "Tutto regolare · Automatico", font=font(24),
fill=PRIMARY)
d.text((W - M - 120, y + 8), "21:18", font=font(22), fill=SECONDARY)
badge(d, W - M - 34, y + 2, 2)
y += 74
# due letture grandi: sono il motivo per cui si apre l'app
cw = (W - 2 * M - 20) / 2
cell(d, M, y, cw, 214, "Casa", "22,8 °C", "obiettivo 19,0 °C")
cell(d, M + cw + 20, y, cw, 214, "Acqua calda", "50,7 °C",
"obiettivo 55,0 °C", frac=0.78)
badge(d, W - M - 34, y + 180, 3)
y += 230
y = text_line(d, M, y, "Tocca un valore per regolarlo ›", 22, SECONDARY)
badge(d, W - M - 34, y - 44, 4)
y += 18
y = text_line(d, M, y, "Azioni rapide", 28, ACCENT, bold=True)
badge(d, W - M - 34, y - 40, 5)
bw = (W - 2 * M - 2 * 16) / 3
for i, t in enumerate(("Boost ACS", "Assenza", "Altri comandi")):
btn(d, M + i * (bw + 16), y, bw, t, h=72)
y += 92
# letture di contorno, in fondo: servono di rado
y = text_line(d, M, y, "Esterna 18,0 °C · Termostato 22,8 °C · 1 zona",
22, SECONDARY)
badge(d, W - M - 34, y - 44, 6)
d.text((M, H - 76), "Aggiornato alle 21:18", font=font(22), fill=SECONDARY)
badge(d, W - M - 34, H - 88, 7)
return img
def regolazioni_screen():
img = background()
d = ImageDraw.Draw(img)
status_bar(d)
d.text((M, 84), "Regolazioni", font=font(44, bold=True), fill=PRIMARY)
y = pulley_hint(d, 142, ["Aggiorna", "Dati", "Impianto", "Impostazioni"]) + 10
yh = y
y = text_line(d, M, y, "Riscaldamento · casa", 28, ACCENT, bold=True)
badge(d, W - M - 34, yh - 4, 1)
fv = font(38, bold=True)
vw = d.textlength("19,0 °C", font=fv)
d.text((W - M - vw, y), "19,0 °C", font=fv, fill=PRIMARY)
y += 54
y = slider(d, M, y, W - 2 * M, (19.0 - 5) / 25.0, "5 °C", "30 °C")
y = presets(d, y, ["16°", "17°", "18°", "19°", "20°", "21°"])
btn(d, M, y, W - 2 * M, "Imposta 19,0 °C", h=70, primary=True)
y += 104
yh = y
y = text_line(d, M, y, "Acqua calda", 28, ACCENT, bold=True)
badge(d, W - M - 34, yh - 4, 2)
vw = d.textlength("55,0 °C", font=fv)
d.text((W - M - vw, y), "55,0 °C", font=fv, fill=PRIMARY)
y += 54
y = slider(d, M, y, W - 2 * M, (55.0 - 45) / 15.0, "45 °C", "60 °C")
y = presets(d, y, ["45°", "47°", "50°", "55°", "60°"])
btn(d, M, y, W - 2 * M, "Imposta 55,0 °C", h=70, primary=True)
y += 104
yh = y
y = text_line(d, M, y, "Boost acqua calda", 28, ACCENT, bold=True)
badge(d, W - M - 34, yh - 4, 3)
bw = (W - 2 * M - 16) / 2
btn(d, M, y, bw, "Avvia boost", h=70)
btn(d, M + bw + 16, y, bw, "Ferma boost", h=70)
y += 108
text_line(d, M, y, "Il comando passa dal bridge e può richiedere qualche "
"secondo: lo stato si aggiorna da solo.", 20, SECONDARY,
width=W - 2 * M)
badge(d, W - M - 34, y + 34, 4)
return img
def comandi_screen():
img = background()
d = ImageDraw.Draw(img)
status_bar(d)
d.text((M, 84), "Comandi impianto", font=font(44, bold=True), fill=PRIMARY)
y = pulley_hint(d, 142, ["Aggiorna", "Dati", "Impianto", "Impostazioni"]) + 6
yh = y
y = text_line(d, M, y, "Modalità", 28, ACCENT, bold=True)
badge(d, W - M - 34, yh - 4, 1)
y = switch_row(d, y, "Assenza", "Abbassa la casa e spegne l'acqua calda", False)
y = switch_row(d, y, "Riscaldamento forzato", "Scalda finché non lo fermi", False)
y = switch_row(d, y, "Funzione riscaldamento", "Il riscaldamento può accendersi", True)
y += 12
yh = y
y = text_line(d, M, y, "Acqua calda", 28, ACCENT, bold=True)
badge(d, W - M - 34, yh - 4, 2)
y = switch_row(d, y, "Riscaldamento ACS", "Manterrà l'acqua in temperatura", True)
btn(d, M, y, W - 2 * M, "Ciclo legionella", h=70)
y += 96
y = text_line(d, M, y, "Rumore", 28, ACCENT, bold=True)
btn(d, M, y, W - 2 * M, "Modalità notte per un'ora", h=70)
y += 96
yh = y
y = text_line(d, M, y, "Diagnostica", 28, ACCENT, bold=True)
badge(d, W - M - 34, yh - 4, 3)
btn(d, M, y, W - 2 * M, "Verifica impianto", h=70)
y += 100
text_line(d, M, y, "Gli interruttori mostrano lo stato attuale: la sezione "
"\"Stato attuale\" non serve più.", 20, SECONDARY,
width=W - 2 * M)
badge(d, W - M - 34, y + 34, 4)
return img
def confronto():
"""Prima/dopo affiancati, con l'altezza del contenuto a colpo d'occhio."""
left = Image.open("docs/anteprima/it/dashboard.png")
right = casa_screen()
s = 0.52
lw, lh = int(left.width * s), int(left.height * s)
rw, rh = int(right.width * s), int(right.height * s)
gap, top, capt = 28, 92, 158
canvas = Image.new("RGB", (gap * 3 + lw + rw, top + max(lh, rh) + capt),
(18, 22, 28))
d = ImageDraw.Draw(canvas)
canvas.paste(left.resize((lw, lh)), (gap, top))
canvas.paste(right.resize((rw, rh)), (gap * 2 + lw, top))
# la home attuale scorre oltre lo schermo
ov = Image.new("RGB", (lw, 74), BAD)
sh = Image.blend(canvas.crop((gap, top + lh - 74, gap + lw, top + lh)), ov, 0.35)
canvas.paste(sh, (gap, top + lh - 74))
d.text((gap + 16, top + lh - 52), "↓ il contenuto continua sotto",
font=font(22, bold=True), fill=(20, 20, 20))
# didascalie: vanno a capo dentro la larghezza della schermata
text_line(d, gap, 34, "OGGI: si scorre (≈1,7 schermate)", 26, BAD,
bold=True, width=lw)
text_line(d, gap * 2 + lw, 34, "PROPOSTA: sta in una schermata", 26, OK,
bold=True, width=rw)
text_line(d, gap, top + lh + 22,
"Due slider con pulsante di conferma e tre pulsanti di "
"navigazione ridondanti col menù.", 20, SECONDARY, width=lw)
text_line(d, gap * 2 + lw, top + rh + 22,
"Stato, due letture grandi, tre azioni rapide: le regolazioni "
"hanno una pagina loro.", 20, SECONDARY, width=rw)
return canvas
if __name__ == "__main__":
OUT.mkdir(parents=True, exist_ok=True)
for name, fn in (("casa", casa_screen), ("regolazioni", regolazioni_screen),
("comandi", comandi_screen), ("confronto", confronto)):
p = OUT / f"{name}.png"
fn().save(p)
print(f"scritto {p} ({p.stat().st_size} byte)")