Files
harbour-aira/scripts/render_wireframes.py
kaneda 667ff63907 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/.
2026-10-10 09:31:39 +02:00

320 lines
12 KiB
Python
Raw Permalink 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.
#!/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)")