feat(setup): Variant B full-bleed hero on 13.3 panel

Adopts the hero treatment Matt picked from the /tmp/setup-mockups
gallery: a 1200×460 harbour photo banner with the WeVisto wordmark at
200pt overlaid centred, then a 70-px accent band carrying the section
title. Replaces the prior 130-tall single band where the 110×110 logo
card couldn't render the Camogli photo recognisably under the 6-colour
palette.

Implementation notes:
- compose_hero_banner() crops from the hi-res IMG_2524.jpg (so we don't
  upsample the 900-square version), composites the SVG black-fade
  gradient, then Floyd-Steinberg dithers to the Spectra-6 palette so the
  photo reads as continuous tone instead of nearest-neighbour colour
  fields. Wordmark composited after the dither to keep text edges crisp.
- Compact orientation diagrams + smaller manual QR (box_size=5) so the
  AP screen's left column still fits the 4 steps + diagrams + help QR
  inside the 1070-px body left below the taller hero.
- Setup QR cell shrunk 16 → 14 (656 → 574 px) so the setup screen fits
  the QR + MAC chip + progress bar below the hero.
- Redundant two-line "Scan the QR to link this frame / to your
  wevisto.com account." dropped from setup screen — heading + label
  above the QR + MAC chip below it cover the same ground without
  crowding the post-hero body.
- epd_driver.cpp QR overlay coords updated to match: AP 230→590,
  setup (272,490,16) → (313,750,14).

compose_logo() (square card) kept for any future use; not currently
called by gen_ap/gen_setup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-14 23:16:43 -04:00
parent 3420ec56f5
commit b0ea1ce216
8 changed files with 157 additions and 96 deletions
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 240 KiB

+154 -93
View File
@@ -80,8 +80,17 @@ F_URL = ttf("DejaVuSans.ttf", 22) # URL bar mono
# ── Layout constants ─────────────────────────────────────────────────────────
HEADER_H = 130 # header band height
BODY_Y = HEADER_H
# Hero treatment: full-bleed harbour photo banner (460 tall) + slim accent
# band (70 tall) carrying the section title. Replaces the prior 130-tall
# single header band — at 110×110 the harbour photo couldn't render
# recognisably under the panel's 6-colour palette, so the brand moment now
# takes the whole top of the screen. 320×320 logo card variant is kept in
# compose_logo() for web/email use.
HERO_BANNER_H = 460
HERO_BAND_H = 70
HERO_H = HERO_BANNER_H + HERO_BAND_H # 530
HEADER_H = HERO_H # alias kept for grep
BODY_Y = HERO_H
DIV_X = 600 # vertical divider between left + right columns
LEFT_X = 0; LEFT_W = 600
RIGHT_X = 602; RIGHT_W = W - RIGHT_X # 598
@@ -91,17 +100,22 @@ RIGHT_PAD = 36
# ── QR overlay regions — MUST match the panel driver ─────────────────────────
# Dynamic QRs (left as WHITE rectangles in the .bin so firmware can overlay).
# Coords shifted to accommodate the taller HERO_H=530 (was HEADER_H=130);
# epd_driver.cpp epd_draw_*_screen calls must update to match.
AP_QR_MODS = 37 # version 5, ECC_LOW
AP_QR_CELL = 14 # 37 × 14 = 518 px
AP_QR_PX = AP_QR_MODS * AP_QR_CELL
AP_QR_X = RIGHT_X + (RIGHT_W - AP_QR_PX) // 2 # 642
AP_QR_Y = BODY_Y + 100 # 230
AP_QR_Y = BODY_Y + 60 # 590
# Setup QR shrunk from 16 to 14 cells (656 → 574 px) so the post-hero body
# can still fit the QR + MAC chip + caption + progress bar within the
# remaining 1070 px below the hero.
SETUP_QR_MODS = 41 # version 6, ECC_LOW
SETUP_QR_CELL = 16 # 41 × 16 = 656 px
SETUP_QR_CELL = 14 # 41 × 14 = 574 px
SETUP_QR_PX = SETUP_QR_MODS * SETUP_QR_CELL
SETUP_QR_X = (W - SETUP_QR_PX) // 2 # 272
SETUP_QR_Y = BODY_Y + 360 # 490
SETUP_QR_X = (W - SETUP_QR_PX) // 2 # 313
SETUP_QR_Y = BODY_Y + 220 # 750
def text_center(draw, cx, y, text, font, fill):
@@ -139,70 +153,109 @@ def draw_qr_frame(draw, qx, qy, qp, accent):
draw.rectangle([qx - 4, qy - 4, qx + qp + 3, qy + qp + 3], outline=BK, width=4)
def draw_header(draw, accent, header_text):
"""Coloured band along the top with section title on the left. Logo
placeholder is rendered separately by draw_logo_placeholder() so the
brand mark sits in the top right of every setup screen."""
draw.rectangle([0, 0, W - 1, HEADER_H - 1], fill=accent)
draw.text((LEFT_PAD, 40), header_text, font=F_BAR, fill=BK)
def draw_hero(img, accent, header_text):
"""Full-bleed photo banner + wordmark + slim accent band carrying the
section title. The brand moment occupies the top 530 px of the panel."""
banner = compose_hero_banner(W, HERO_BANNER_H)
img.paste(banner, (0, 0))
draw = ImageDraw.Draw(img)
band_y = HERO_BANNER_H
draw.rectangle([0, band_y, W - 1, HERO_H - 1], fill=accent)
# F_BAR is 34pt, ~38 px tall — vertically centred in the band.
text_y = band_y + (HERO_BAND_H - 38) // 2
draw.text((LEFT_PAD, text_y), header_text, font=F_BAR, fill=BK)
# ── Logo ─────────────────────────────────────────────────────────────────────
# Composed mirror of webApp/frontend/public/logo.svg: harbour photo +
# black-fade gradient + "WeVisto" wordmark (V in yellow). Rendered in
# pure PIL because cairosvg / rsvg / imagemagick aren't installed on the
# build host. Source photo lives in webApp/brand/.
LOGO_SIDE = 110 # square; fits within HEADER_H=130 with padding
LOGO_X = W - LOGO_SIDE - LEFT_PAD # 1054
LOGO_Y = (HEADER_H - LOGO_SIDE) // 2 # 10
LOGO_SRC = os.path.join(os.path.dirname(os.path.abspath(__file__)),
"..", "..", "webApp", "brand",
"IMG_2524-square900.jpg")
# ── Brand assets ─────────────────────────────────────────────────────────────
# Source photo (5712×4284 original — gives us latitude for landscape crops
# without having to upscale the 900-square version). The square JPEG is
# used by compose_logo() for the web/email logo card; the hires original
# is used by compose_hero_banner() for the panel hero banner.
LOGO_SRC_SQUARE = os.path.join(os.path.dirname(os.path.abspath(__file__)),
"..", "..", "webApp", "brand",
"IMG_2524-square900.jpg")
LOGO_SRC_HIRES = os.path.join(os.path.dirname(os.path.abspath(__file__)),
"..", "..", "webApp", "brand",
"IMG_2524.jpg")
def compose_logo(size):
"""Returns a size×size RGB PIL image of the WeVisto wordmark over the
Camogli harbour photo. Mirrors webApp/frontend/public/logo.svg (320×320
viewBox).
The composition is done at SVG-native 320×320 then LANCZOS-downsampled
to `size` — gets the same anti-aliasing the browser would produce, much
cleaner text than rendering directly at the small target size.
stroke_width=2 fakes font-weight 900 (DejaVuSans-Bold is only weight
700; no Black face is installed on the build host)."""
SRC = 320
bg = Image.open(LOGO_SRC).convert("RGB").resize((SRC, SRC), Image.LANCZOS)
# Black overlay band: 45% opacity peak at 42-58% height, fading at edges.
overlay = Image.new("RGBA", (SRC, SRC), (0, 0, 0, 0))
od = ImageDraw.Draw(overlay)
for y in range(SRC):
t = y / (SRC - 1)
def _gradient_overlay(w, h):
"""Black-fade overlay matching webApp/frontend/public/logo.svg: 0%
opaque at top/bottom, 45% in the middle 42-58% band. Used to anchor
the wordmark legibly against varying photo content underneath."""
layer = Image.new("RGBA", (w, h), (0, 0, 0, 0))
od = ImageDraw.Draw(layer)
for y in range(h):
t = y / max(1, h - 1)
if t < 0.42:
a = 0.45 * (t / 0.42)
elif t < 0.58:
a = 0.45
else:
a = 0.45 * ((1 - t) / 0.42)
od.line([(0, y), (SRC, y)], fill=(0, 0, 0, int(a * 255)))
bg = Image.alpha_composite(bg.convert("RGBA"), overlay).convert("RGB")
od.line([(0, y), (w, y)], fill=(0, 0, 0, int(a * 255)))
return layer
draw = ImageDraw.Draw(bg)
font = ttf("DejaVuSans-Bold.ttf", 62)
def _render_wordmark(canvas, cx, cy, font_size, stroke=2):
"""We[white] V[yellow] isto[white] centred at (cx, cy). stroke_width
fakes font-weight 900 — DejaVuSans-Bold is only weight 700."""
draw = ImageDraw.Draw(canvas)
font = ttf("DejaVuSans-Bold.ttf", font_size)
parts = [("We", WH), ("V", YL), ("isto", WH)]
widths = [draw.textbbox((0, 0), t, font=font, stroke_width=2)[2] for t, _ in parts]
widths = [draw.textbbox((0, 0), t, font=font, stroke_width=stroke)[2] for t, _ in parts]
total_w = sum(widths)
x = (SRC - total_w) // 2
y = 175 - 62 // 2 - 16 # matches SVG translate(160 175) baseline
x = cx - total_w // 2
y = cy - font_size // 2 - max(2, font_size // 16)
for (t, fill), w in zip(parts, widths):
draw.text((x, y), t, font=font, fill=fill,
stroke_width=2, stroke_fill=fill)
stroke_width=stroke, stroke_fill=fill)
x += w
return bg.resize((size, size), Image.LANCZOS)
def compose_logo(size):
"""Square logo card (photo + gradient + wordmark) at `size`×`size`.
Composes at SVG-native 320×320 then LANCZOS-downsamples for clean
anti-aliasing. Kept for any future use that wants the square mark on
the panel; the active hero uses compose_hero_banner() instead."""
SRC = 320
bg = Image.open(LOGO_SRC_SQUARE).convert("RGB").resize((SRC, SRC), Image.LANCZOS)
bg = Image.alpha_composite(bg.convert("RGBA"), _gradient_overlay(SRC, SRC)).convert("RGB")
_render_wordmark(bg, SRC // 2, int(0.547 * SRC), 62, stroke=2)
return bg.resize((size, size), Image.LANCZOS) if size != SRC else bg
def draw_logo_placeholder(img):
"""Paste the composed WeVisto logo in the top-right of the header."""
img.paste(compose_logo(LOGO_SIDE), (LOGO_X, LOGO_Y))
def compose_hero_banner(banner_w, banner_h):
"""Wide hero banner: harbour photo cropped from the hi-res original to
the target aspect, gradient overlay for legibility, Floyd-Steinberg
dithered to the Spectra-6 palette (so the photo reads as continuous
tone instead of nearest-neighbour colour blocks), then a big WeVisto
wordmark composited on top in crisp palette-exact colours."""
src = Image.open(LOGO_SRC_HIRES).convert("RGB")
sw, sh = src.size
src_ar = sw / sh
tgt_ar = banner_w / banner_h
if src_ar > tgt_ar:
new_w = int(sh * tgt_ar)
x0 = (sw - new_w) // 2
src = src.crop((x0, 0, x0 + new_w, sh))
else:
new_h = int(sw / tgt_ar)
y0 = int(sh * 0.42 - new_h // 2)
y0 = max(0, min(y0, sh - new_h))
src = src.crop((0, y0, sw, y0 + new_h))
banner = src.resize((banner_w, banner_h), Image.LANCZOS)
banner = Image.alpha_composite(banner.convert("RGBA"),
_gradient_overlay(banner_w, banner_h)).convert("RGB")
# FS-dither the photo + gradient against the Spectra-6 palette. Without
# this, the harbour collapses to blocky nearest-neighbour colour fields
# when pack()'s nearest() runs over the final panel image.
pal = Image.new("P", (1, 1))
pal.putpalette([*BK, *WH, *YL, *RD, *BL, *GR, *([0, 0, 0] * 250)])
banner = banner.quantize(palette=pal, dither=Image.FLOYDSTEINBERG).convert("RGB")
# Wordmark rendered AFTER the dither so text edges stay crisp instead
# of getting stippled. Colours used (WH, YL) are already palette-exact.
_render_wordmark(banner, banner_w // 2, int(0.55 * banner_h), 200, stroke=4)
return banner
def draw_divider(draw):
@@ -244,7 +297,7 @@ def paste_rotated_text(img, text, font, fill, anchor_xy, ccw_degrees=90):
img.paste(rotated, anchor_xy, rotated)
def orientation_diagrams(img, cx, top_y, label_color=None):
def orientation_diagrams(img, cx, top_y, label_color=None, compact=False):
"""
Side-by-side PORTRAIT / LANDSCAPE diagrams illustrating the two ways
the user can hang the frame. Drawn in current portrait-view coords:
@@ -273,12 +326,19 @@ def orientation_diagrams(img, cx, top_y, label_color=None):
# Same external dimensions for both diagrams (the LANDSCAPE is a 90°-CCW
# rotation of an upright wide rect — its bounding box is square'd to
# match portrait so the pair sits in a clean two-up grid).
diag_w, diag_h = 130, 200
ribbon_thick = 14
pair_gap = 100 # extra room so the rotated label doesn't crowd the divider
# match portrait so the pair sits in a clean two-up grid). `compact`
# shrinks the diagrams ~35% so they fit alongside the manual QR in the
# AP screen's tighter post-hero body.
if compact:
diag_w, diag_h = 90, 140
ribbon_thick = 10
pair_gap = 70
else:
diag_w, diag_h = 130, 200
ribbon_thick = 14
pair_gap = 100
pair_w = diag_w * 2 + pair_gap
base_y = top_y + 100
base_y = top_y + 60 if compact else top_y + 100
pt_x = cx - pair_w // 2
ls_x = pt_x + diag_w + pair_gap
@@ -328,30 +388,31 @@ def gen_ap(accent=YL,
img = Image.new("RGB", (W, H), WH)
draw = ImageDraw.Draw(img)
draw_header(draw, accent, header_text)
draw_logo_placeholder(img)
draw_hero(img, accent, header_text)
draw_divider(draw)
# ── LEFT COLUMN ──────────────────────────────────────────────────────────
# Big "Connect to WiFi" heading with accent underline.
# Single-line "Connect to WiFi" heading — was a two-line block under the
# old shallow header; the new 530-tall hero swallowed the vertical budget
# so the body collapses to one line at a smaller F_TITLE size.
head_y = BODY_Y + 30
draw.text((LEFT_PAD, head_y), "Connect to", font=F_HEAD, fill=BK)
draw.text((LEFT_PAD, head_y + 90), "WiFi", font=F_HEAD, fill=BK)
bb = draw.textbbox((0, 0), "WiFi", font=F_HEAD)
underline_y = head_y + 90 + bb[3] + 6
f_head = ttf("DejaVuSans-Bold.ttf", 60)
draw.text((LEFT_PAD, head_y), "Connect to WiFi", font=f_head, fill=BK)
bb = draw.textbbox((0, 0), "Connect to WiFi", font=f_head)
underline_y = head_y + bb[3] + 6
draw.rectangle([LEFT_PAD, underline_y,
LEFT_PAD + bb[2] + 4, underline_y + 6], fill=accent)
# 5 numbered steps — same instructions as the 7.3" but with the larger
# type that fits comfortably on a 13.3" portrait body.
# 4 numbered steps. step_pitch tightened from 112 → 108 so the steps +
# orientation diagram + manual QR all fit in the post-hero 1070-px body.
steps = [
("Turn on your WeVisto", ""),
("Unlock your phone", ""),
("Scan QR 1", "This will connect your phone to the WeVisto"),
("Scan QR 2", "This will open the WeVisto setup page"),
]
step_y0 = head_y + 240
step_pitch = 112
step_y0 = underline_y + 60
step_pitch = 108
box = 50 # numbered black box size
text_x = LEFT_PAD + box + 22
text_max_w = DIV_X - text_x - 18 # don't cross the column divider
@@ -366,16 +427,19 @@ def gen_ap(accent=YL,
for j, line in enumerate(wrap_to_width(draw, l2, F_STEP, text_max_w)):
draw.text((text_x, by + 32 + j * line_h), line, font=F_STEP, fill=BK)
# Orientation diagrams — tucked between the steps and the manual QR so
# the user sees both possible hanging positions before they commit.
orientation_diagrams(img, LEFT_X + LEFT_W // 2, step_y0 + len(steps) * step_pitch + 60)
# Orientation diagrams — between the steps and the manual QR. Compact
# variant (diag_h=130) so the LEFT column fits inside the smaller body.
orientation_diagrams(img, LEFT_X + LEFT_W // 2,
step_y0 + len(steps) * step_pitch + 30,
compact=True)
# Manual QR bottom-left — covers "captive portal didn't open" + general
# troubleshooting. Same intent as the 7.3" but bigger box, more room.
# troubleshooting. Shrunk to box_size=5 (~205 px) to share the column
# with the orientation diagrams above it.
manual_qr = qrcode.QRCode(
version=None,
error_correction=qrcode.constants.ERROR_CORRECT_L,
box_size=7,
box_size=5,
border=2,
)
manual_qr.add_data(MANUAL_URL)
@@ -383,15 +447,14 @@ def gen_ap(accent=YL,
manual_img = manual_qr.make_image(fill_color=BK, back_color=WH).convert("RGB")
mw, mh = manual_img.size
manual_x = LEFT_PAD
manual_y = H - mh - 60
manual_y = H - mh - 30
img.paste(manual_img, (manual_x, manual_y))
# Side label aligned with manual QR.
label_x = manual_x + mw + 28
label_y = manual_y + (mh - 80) // 2
label_x = manual_x + mw + 24
label_y = manual_y + (mh - 64) // 2
draw.text((label_x, label_y), "Need help?", font=F_STEP_B, fill=BK)
draw.text((label_x, label_y + 38), "Scan for setup", font=F_STEP, fill=BK)
draw.text((label_x, label_y + 70), "& troubleshoot", font=F_STEP, fill=BK)
draw.text((label_x, label_y + 32), "Scan for setup", font=F_STEP, fill=BK)
draw.text((label_x, label_y + 58), "& troubleshoot", font=F_STEP, fill=BK)
# ── RIGHT COLUMN ─────────────────────────────────────────────────────────
rcx = RIGHT_X + RIGHT_W // 2
@@ -448,29 +511,27 @@ def gen_setup():
img = Image.new("RGB", (W, H), WH)
draw = ImageDraw.Draw(img)
draw_header(draw, GR, "WIFI CONNECTED — STEP 2 OF 2")
draw_logo_placeholder(img)
draw_hero(img, GR, "WIFI CONNECTED — STEP 2 OF 2")
# Centered heading
text_center(draw, W // 2, BODY_Y + 30, "Almost ready", F_HEAD, BK)
# Centered heading. Two-line instructions that used to sit beneath the
# underline were dropped in the redesign — the hero already establishes
# WeVisto, the "SCAN TO FINISH" label sits right above the QR, and the
# MAC chip below identifies the device. Three labels for one QR was
# redundant under the tighter post-hero body height.
text_center(draw, W // 2, BODY_Y + 30, "Almost ready", F_HEAD, BK)
bb = draw.textbbox((0, 0), "Almost ready", font=F_HEAD)
underline_w = bb[2] + 4
text_w = bb[2] - bb[0]
underline_x = (W - text_w) // 2
underline_y = BODY_Y + 30 + bb[3] + 6
draw.rectangle([underline_x, underline_y,
underline_x + text_w, underline_y + 6], fill=GR)
text_center(draw, W // 2, BODY_Y + 170, "Scan the QR to link this frame", F_STEP_B, BK)
text_center(draw, W // 2, BODY_Y + 210, "to your wevisto.com account.", F_STEP, BK)
# Setup QR with decorative border + green/black double frame
draw_qr_frame(draw, SETUP_QR_X, SETUP_QR_Y, SETUP_QR_PX, GR)
leave_qr_white(draw, SETUP_QR_X, SETUP_QR_Y, SETUP_QR_PX)
text_center(draw, W // 2,
SETUP_QR_Y - 56,
"SCAN TO FINISH",
F_LABEL, BK)
text_center(draw, W // 2, SETUP_QR_Y - 56,
"Scan the QR to link your wevisto.com account",
F_STEP_B, BK)
# MAC chip below QR — frame's identifier so the user knows which device
# they're claiming. Static placeholder until firmware writes text here.
+3 -3
View File
@@ -428,13 +428,13 @@ static void draw_from_lfs(const char* path, uint8_t fallback_color,
// firmware paints the live QR into it. Mismatch = the QR draws over
// decorative borders or the QR placeholder shows through.
void epd_draw_ap_screen(QRCode* qr) {
draw_from_lfs("/ap_bg.bin", COLOR_YELLOW, qr, 642, 230, 14);
draw_from_lfs("/ap_bg.bin", COLOR_YELLOW, qr, 642, 590, 14);
}
void epd_draw_ap_screen_retry(QRCode* qr) {
draw_from_lfs("/ap_bg_retry.bin", COLOR_RED, qr, 642, 230, 14);
draw_from_lfs("/ap_bg_retry.bin", COLOR_RED, qr, 642, 590, 14);
}
void epd_draw_setup_screen(QRCode* qr) {
draw_from_lfs("/setup_bg.bin", COLOR_GREEN, qr, 272, 490, 16);
draw_from_lfs("/setup_bg.bin", COLOR_GREEN, qr, 313, 750, 14);
}