Public Access
Site: roro9stack.net, phase 1 (the home page, Install with a browser flasher, Downloads)
A Zola site in site/, from the design: both themes on the device's palette, notched shapes, self-hosted fonts, the wordmark and icons, two generated hero drawings, nine App cards (the mesh messenger marked planned), real screenshots, the updates and build sections. The Install page builds ESP Web Tools' manifest in the browser from the Gitea API (it needs Caddy to allow the origin), refuses any download that isn't on the project's server, and falls back to the esptool steps. Downloads lists the releases. The focus ring shows on notched controls. A page checker fails the build if a page loads from another origin. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
This commit is contained in:
Executable
+90
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Checks a built site (docs/milestones/W1.md): what the pages promise, kept.
|
||||
|
||||
Usage: site/tools/check_site.py <built site folder>
|
||||
|
||||
- No page loads anything from another origin: no script, stylesheet, image, font, frame or preload
|
||||
from elsewhere (links a visitor follows are fine).
|
||||
- Every page has a title, a description and a language, and every image has an alt attribute.
|
||||
- Every file a page references on this site exists.
|
||||
"""
|
||||
import html.parser
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import urllib.parse
|
||||
|
||||
ROOT = sys.argv[1] if len(sys.argv) > 1 else "public"
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
BASE = re.search(r'base_url\s*=\s*"([^"]+)"', open(os.path.join(HERE, "..", "config.toml")).read()).group(1)
|
||||
HOST = urllib.parse.urlparse(BASE).netloc
|
||||
|
||||
|
||||
class Page(html.parser.HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.title = False
|
||||
self.has_title = False
|
||||
self.has_description = False
|
||||
self.lang = None
|
||||
self.loads = [] # (tag, url) of everything the page fetches by itself
|
||||
self.images_without_alt = 0
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
a = dict(attrs)
|
||||
if tag == "html":
|
||||
self.lang = a.get("lang")
|
||||
if tag == "title":
|
||||
self.title = True
|
||||
if tag == "meta" and a.get("name") == "description" and a.get("content"):
|
||||
self.has_description = True
|
||||
if tag in ("script", "img", "iframe", "frame", "embed", "source", "video", "audio") and a.get("src"):
|
||||
self.loads.append((tag, a["src"]))
|
||||
if tag == "link" and a.get("href") and a.get("rel", "") in ("stylesheet", "preload", "icon", "modulepreload", "prefetch"):
|
||||
self.loads.append(("link " + a["rel"], a["href"]))
|
||||
if tag == "img" and "alt" not in a:
|
||||
self.images_without_alt += 1
|
||||
|
||||
def handle_data(self, data):
|
||||
if self.title and data.strip():
|
||||
self.has_title = True
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
if tag == "title":
|
||||
self.title = False
|
||||
|
||||
|
||||
problems = []
|
||||
pages = 0
|
||||
for folder, _, files in os.walk(ROOT):
|
||||
for name in files:
|
||||
if not name.endswith(".html"):
|
||||
continue
|
||||
path = os.path.join(folder, name)
|
||||
shown = os.path.relpath(path, ROOT)
|
||||
p = Page()
|
||||
p.feed(open(path, encoding="utf-8").read())
|
||||
pages += 1
|
||||
if not p.has_title: problems.append(f"{shown}: no title")
|
||||
if not p.has_description: problems.append(f"{shown}: no description")
|
||||
if not p.lang: problems.append(f"{shown}: no language")
|
||||
if p.images_without_alt: problems.append(f"{shown}: {p.images_without_alt} image(s) without alt")
|
||||
for tag, url in p.loads:
|
||||
u = urllib.parse.urlparse(url)
|
||||
if u.netloc and u.netloc != HOST:
|
||||
problems.append(f"{shown}: {tag} loads {url} from another origin")
|
||||
elif not u.netloc and not u.scheme:
|
||||
target = os.path.join(ROOT, u.path.lstrip("/")) if u.path.startswith("/") else os.path.join(folder, u.path)
|
||||
if u.path and not os.path.exists(target):
|
||||
problems.append(f"{shown}: {tag} {url} does not exist")
|
||||
elif u.netloc == HOST:
|
||||
target = os.path.join(ROOT, u.path.lstrip("/"))
|
||||
if not os.path.exists(target):
|
||||
problems.append(f"{shown}: {tag} {url} does not exist")
|
||||
|
||||
if not pages:
|
||||
problems.append(f"no pages found in {ROOT}")
|
||||
for p in problems:
|
||||
print("site check:", p)
|
||||
print(f"site check: {pages} pages, {len(problems)} problem(s)")
|
||||
sys.exit(1 if problems else 0)
|
||||
Executable
+151
@@ -0,0 +1,151 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Writes the two illustrations on the home page (templates/illustrations/sweep.html and sky.html, inline SVG for Zola to include).
|
||||
|
||||
They are drawings of the LoRa Scanner's Sweep view and the GNSS App's sky view on the Cardputer,
|
||||
not screenshots, and the page says so. Every colour is one of the device's own (RGB332). The shapes
|
||||
come from a deterministic generator, so running this again gives the same files:
|
||||
|
||||
python3 site/tools/make_illustrations.py
|
||||
"""
|
||||
import math
|
||||
import os
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
OUT = os.path.join(HERE, "..", "templates", "illustrations")
|
||||
|
||||
BLACK, SLATE, DIM, CYAN, MAG, WHITE, LABEL, ONFILL = "#000000", "#494955", "#6d6d55", "#00dbff", "#ff00ff", "#ffffff", "#b6dbff", "#000055"
|
||||
ORANGE = "#ff9200"
|
||||
|
||||
|
||||
def notch(x, y, w, h, n):
|
||||
"""A rectangle with its corners cut in two steps of n/2 (the site's .n-sm, .n-md and .n-lg)."""
|
||||
a, b = n, n // 2
|
||||
pts = [(0, a), (b, a), (b, b), (a, b), (a, 0), (w - a, 0), (w - a, b), (w - b, b), (w - b, a), (w, a), (w, h - a),
|
||||
(w - b, h - a), (w - b, h - b), (w - a, h - b), (w - a, h), (a, h), (a, h - b), (b, h - b), (b, h - a), (0, h - a)]
|
||||
return " ".join(f"{x + px:g},{y + py:g}" for px, py in pts)
|
||||
|
||||
|
||||
def rnd(n):
|
||||
x = math.sin(n * 127.1 + 311.7) * 43758.5453
|
||||
return x - math.floor(x)
|
||||
|
||||
|
||||
def jsround(v):
|
||||
return math.floor(v + 0.5)
|
||||
|
||||
|
||||
SOURCES = [(5, 0.72, 0.9), (13.5, 0.58, 1.2), (22, 0.95, 1.0), (27, 0.42, 0.8)]
|
||||
|
||||
|
||||
def strength(i, r):
|
||||
s = 0.05 + 0.1 * rnd(i * 7 + r * 13)
|
||||
for k, (c, a, w) in enumerate(SOURCES):
|
||||
if rnd(k * 10 + r) > 0.2:
|
||||
g = math.exp(-((i - c) ** 2) / (2 * w * w))
|
||||
s = max(s, a * g * (0.6 + 0.4 * rnd(i * 3 + r * 5 + k)))
|
||||
return min(s, 1)
|
||||
|
||||
|
||||
def frame(parts, label):
|
||||
keys_y = 306
|
||||
kw = (480 - 13 * 4) / 14
|
||||
keys = []
|
||||
for row in range(4):
|
||||
for col in range(14):
|
||||
x, y = 20 + col * (kw + 4), keys_y + row * 26
|
||||
keys.append(f'<polygon points="{notch(x, y, kw, 22, 4)}" fill="{SLATE}"/>')
|
||||
return (
|
||||
f'<svg xmlns="http://www.w3.org/2000/svg" class="illustration" viewBox="0 0 520 426" role="img" aria-label="{label}" shape-rendering="crispEdges">'
|
||||
f'<polygon points="{notch(0, 0, 520, 426, 16)}" fill="{BLACK}"/>'
|
||||
f'<polygon points="{notch(20, 20, 480, 270, 8)}" fill="{SLATE}"/>'
|
||||
+ "".join(parts) + "".join(keys) + "</svg>\n"
|
||||
)
|
||||
|
||||
|
||||
def label(x, y, text, fill, anchor="start", weight=400, spacing=0.08):
|
||||
return (f'<text x="{x:g}" y="{y:g}" fill="{fill}" text-anchor="{anchor}" font-size="11" font-weight="{weight}" '
|
||||
f'letter-spacing="{spacing}em" class="mono">{text}</text>')
|
||||
|
||||
|
||||
def sweep():
|
||||
p = []
|
||||
# Tabs and Status Bar letters
|
||||
p.append(label(40, 44, "SNIFFER", LABEL, weight=500))
|
||||
p.append(f'<rect x="104" y="32" width="54" height="16" fill="{CYAN}"/>')
|
||||
p.append(label(112, 44, "SWEEP", ONFILL, weight=500))
|
||||
p.append(label(488, 44, "CAP", LABEL, "end", 500))
|
||||
p.append(label(457, 44, "SW", CYAN, "end", 500))
|
||||
p.append(label(434, 44, "L", CYAN, "end", 500))
|
||||
# Bars with peak hold
|
||||
p.append(f'<rect x="32" y="60" width="456" height="124" fill="{BLACK}"/>')
|
||||
colw = (448 - 31 * 2) / 32
|
||||
for i in range(32):
|
||||
s = strength(i, 0)
|
||||
h = max(4, jsround(s * 100 / 4) * 4)
|
||||
gap = jsround(rnd(i * 11 + 3) * 3) * 4
|
||||
gap = min(gap, 112 - h - 4 if 112 - h - 4 > 0 else 0)
|
||||
x = 36 + i * (colw + 2)
|
||||
p.append(f'<rect x="{x:.2f}" y="{180 - h}" width="{colw:.2f}" height="{h}" fill="{MAG if s > 0.6 else CYAN}"/>')
|
||||
p.append(f'<rect x="{x:.2f}" y="{180 - h - gap - 4}" width="{colw:.2f}" height="4" fill="{WHITE}"/>')
|
||||
# Waterfall, newest row on top
|
||||
p.append(f'<rect x="32" y="192" width="456" height="66" fill="{BLACK}"/>')
|
||||
for r in range(6):
|
||||
for i in range(32):
|
||||
s = strength(i, r + 1)
|
||||
if s < 0.3:
|
||||
continue
|
||||
color = CYAN if s < 0.6 else MAG if s < 0.85 else WHITE
|
||||
p.append(f'<rect x="{36 + i * (colw + 2):.2f}" y="{196 + r * 10}" width="{colw:.2f}" height="8" fill="{color}"/>')
|
||||
p.append(label(32, 276, "863", LABEL, spacing=0))
|
||||
p.append(label(260, 276, "866.5", LABEL, "middle", spacing=0))
|
||||
p.append(label(488, 276, "870 MHz", LABEL, "end", spacing=0))
|
||||
return frame(p, "Illustration of the Cardputer showing the LoRa Scanner's Sweep view: bars of signal strength from 863 to 870 MHz with peak marks, and a waterfall under them")
|
||||
|
||||
|
||||
def sky():
|
||||
p = []
|
||||
p.append(label(32, 44, "GNSS", WHITE, weight=500))
|
||||
p.append(f'<rect x="449.5" y="32" width="38.5" height="16" fill="{CYAN}"/>')
|
||||
p.append(label(457.5, 44, "SKY", ONFILL, weight=500))
|
||||
p.append(label(445.5, 44, "POSITION", LABEL, "end", 500))
|
||||
p.append(f'<rect x="32" y="60" width="456" height="218" fill="{BLACK}"/>')
|
||||
ox, oy, CELL, NC, CC = 48, 86, 5, 33, 16
|
||||
for y in range(NC):
|
||||
for x in range(NC):
|
||||
dx, dy = x - CC, y - CC
|
||||
d = math.sqrt(dx * dx + dy * dy)
|
||||
if any(abs(d - R) <= 0.5 for R in (15, 10, 5)):
|
||||
color = DIM
|
||||
elif (dx == 0 or dy == 0) and d < 15 and abs(dx + dy) % 2 == 0 and d > 0:
|
||||
color = SLATE
|
||||
else:
|
||||
continue
|
||||
p.append(f'<rect x="{ox + x * CELL}" y="{oy + y * CELL}" width="{CELL}" height="{CELL}" fill="{color}"/>')
|
||||
cols = [CYAN, MAG, ORANGE, WHITE]
|
||||
sats = [(20, 70, 0.8, 0), (75, 40, 0.6, 1), (130, 25, 0.35, 2), (160, 60, 0.9, 0), (210, 15, 0.25, 3), (250, 50, 0.7, 1),
|
||||
(290, 30, 0.5, 0), (320, 75, 0.85, 2), (345, 35, 0.55, 1)]
|
||||
for az, el, snr, ci in sats:
|
||||
r = (90 - el) / 90 * 15
|
||||
a = az * math.pi / 180
|
||||
cx, cy = jsround(CC + r * math.sin(a)), jsround(CC - r * math.cos(a))
|
||||
p.append(f'<rect x="{ox + cx * CELL - 4}" y="{oy + cy * CELL - 4}" width="14" height="14" fill="{BLACK}"/>')
|
||||
p.append(f'<rect x="{ox + cx * CELL - 2}" y="{oy + cy * CELL - 2}" width="10" height="10" fill="{cols[ci]}"/>')
|
||||
p.append(label(ox + 82.5, 80, "N", MAG, "middle", 500, 0))
|
||||
p.append(label(ox + 82.5, 265, "S", LABEL, "middle", 500, 0))
|
||||
p.append(label(ox - 14, 172, "W", LABEL, "start", 500, 0))
|
||||
p.append(label(ox + 165 + 14, 172, "E", LABEL, "end", 500, 0))
|
||||
# Signal strength of each satellite
|
||||
p.append(label(237, 107, "SNR", LABEL, weight=500))
|
||||
bw = (235 - 8 * 6) / 9
|
||||
for i, (az, el, snr, ci) in enumerate(sats):
|
||||
h = max(8, jsround(snr * 124 / 4) * 4)
|
||||
p.append(f'<rect x="{237 + i * (bw + 6):.2f}" y="{243 - h}" width="{bw:.2f}" height="{h}" fill="{cols[ci]}"/>')
|
||||
return frame(p, "Illustration of the GNSS App's sky view: the satellites' positions in a circle, and the signal strength of each as a bar")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
for name, svg in (("sweep.html", sweep()), ("sky.html", sky())):
|
||||
with open(os.path.join(OUT, name), "w") as f:
|
||||
f.write(svg)
|
||||
print(name, len(svg), "bytes")
|
||||
Reference in New Issue
Block a user