Marketing / project website for AetherSDR — the cross-platform, open-source client for FlexRadio Systems transceivers. Lives at www.aethersdr.com (Cloudflare-hosted).
Design inspired by armbian.com: dark, technical, and feature-forward, using AetherSDR's own palette (deep navy + electric cyan→teal).
index.html The landing page (single page, no build step required)
blog.html The blog — card index + every post, routed client-side
lineage.html The 2003→now amateur-SDR timeline
supporters.html The supporter panadapter — everyone who has funded the project
styles.css All styling (design tokens at the top)
assets/img/ Optimized screenshots, logo, and 3D-spectrum visuals
assets/js/blog.js Blog card-index <-> post routing
assets/js/webmcp.js WebMCP tools — the site's actions, exposed to agents
assets/js/supporters.js The supporter spectrum: live ledger, S-meter scaling, canvas
functions/ Cloudflare Pages Function: Accept: text/markdown handling
api/, .well-known/ Machine-readable surface (generated — see docs/)
serve.py Tiny local static server (python3 serve.py → :4321)
scripts/build.py Bundles everything into dist/index.html (self-contained)
scripts/gen-blog-art.py Regenerates the generated SVG post art
scripts/gen-agent-discovery.py Regenerates sitemap, /api, /.well-known, *.md
scripts/gen-supporters.py Bakes the supporter roll from Open Collective
scripts/check-agent-discovery.sh Smoke-tests that surface on a deployed site
Post art that isn't a screenshot is generated SVG — drawn in the site palette and committed alongside the source that produces it:
python3 scripts/gen-blog-art.py # rewrites assets/img/first-contribution-*.svgIt's deterministic (fixed seed), so re-running produces byte-identical files;
tweak the constants at the top rather than editing thousands of <rect>s by
hand. Each post needs two crops — a wide hero and a near-square for the card
column, which crops to roughly 1.1:1.
Everything lives in blog.html — no build step, no separate post files. A post
is two blocks that share a slug:
- A card in the
.blog-grid, linking to#your-slug. The description is clamped to four lines, so write 3–4 lines and don't worry about matching neighbouring card heights. Cards are ordered newest-first by hand — insert yours below the pinned card and above the newest release post. - An
<article class="blog-post" data-post="your-slug" ... hidden>further down, holding the hero image and full body. Keep thehiddenattribute — it is what keeps the post off the index until it's routed to.
assets/js/blog.js matches the two by slug: clicking a card sets the hash, which
swaps the grid out for that post. Post URLs (/blog.html#your-slug) are
shareable, and Back/Forward work. Body copy goes inside .blog-body, which
styles headings, lists, links, quotes, and code blocks for you.
Note: don't add a
displayvalue to.blog-postin CSS — a class rule out-specifies the browser's[hidden] { display: none }and every post would render on the index at once.
The first card carries is-pinned and a Pinned badge, holding it at the top
of the index whatever its date. To pin a different post, move the class, the
badge markup, and the <!-- PINNED --> comment to that card.
Editing styles.css? Bump the ?v= on the three <link rel="stylesheet"> tags
(index.html, blog.html, 404.html). The file is served with max-age=14400,
so without a new URL a browser can pair a stale stylesheet with fresh markup for
up to four hours.
Cloudflare Pages serves the page extensionless at /blog and 308-redirects
/blog.html to it, so canonical and the og:/twitter: URLs point at /blog.
Internal links keep the blog.html form on purpose — it resolves under
serve.py locally and just costs one redirect hop in production.
/supporters draws everyone who has funded the project as a signal on a
panadapter — the more someone has given, the stronger their signal, spotted
with their callsign the way SpotHub flags a station.
Three layers, in this order:
- The roll of names is real markup, baked into
supporters.htmlbetween theSUPPORTERSmarkers byscripts/gen-supporters.py. That is what a reader without JavaScript gets, and what agents and search engines read. assets/js/supporters.jsreads that markup and draws it.- It then fetches the live ledger from Open Collective and redraws. If that is slow, rate-limited (their limit is 10 requests per window) or refused, the baked roll stays on screen with a quiet note — the page never empties.
python3 scripts/gen-supporters.py # refresh the baked roll
python3 scripts/gen-supporters.py --check # report staleness, write nothingRun it when someone new appears. It is not wired into the deploy: a transient Open Collective outage should not be able to fail a deploy, and the live fetch means the page is current regardless.
Two things worth knowing before editing it:
- Amounts never reach the page. Each supporter carries a
data-strengthbetween 0 and 1 — their log-scaled position between the smallest and largest contribution. The figures stay on the public ledger, where people chose to put them. The scale is logarithmic because the range runs from $3 to $200, and a linear one would render the smallest gift invisible. - A
guest-*slug does not mean anonymous. It means the contribution came through guest checkout without an Open Collective account, which is true of most of this collective — including people who gave their full name and callsign. Only a name of "Guest" means anonymous. Keying on the slug credits 15 real supporters as "Anonymous".
The site publishes a sitemap, an RFC 9727 API catalog over a small read-only
content API, an Agent Skills index, RFC 8288 Link headers, markdown versions
of every page (Accept: text/markdown), and WebMCP tools. All of it is derived
from the pages themselves:
python3 scripts/gen-agent-discovery.py # rewrite the generated files
python3 scripts/gen-agent-discovery.py --check # report drift, write nothingCI regenerates on every deploy, so publishing a page or a post cannot ship a
stale sitemap or post index. Adding a page means adding it to PAGES in that
script and to the Stage site step in the deploy workflow; adding a post
needs nothing — it's read out of blog.html.
After a deploy, scripts/check-agent-discovery.sh [base-url] smoke-tests the
whole surface (status codes, content types, negotiation, and that the security
headers survive the Pages Function). CI runs it against the fresh deployment.
One piece lives in DNS rather than in the deployment: the DNS-AID entrypoint
record. scripts/apply-dns-aid.sh plans it, applies it with a Cloudflare token,
and verifies it — CI can't, because it's a zone record, not a file. The zone
also still needs DNSSEC enabling, which matters more than the record itself.
Both are covered in docs/agent-readiness.md.
Read docs/agent-readiness.md before changing any
of it — in particular, _headers is not applied to responses that a Pages
Function produces, so functions/site-headers.js is generated from it and must
be regenerated whenever _headers changes.
python3 serve.py # http://127.0.0.1:4321
# or any static server:
# npx serve .serve.py is a plain static server: no extensionless /blog, no _headers,
and no Accept: text/markdown negotiation. To exercise the Pages Function and
the headers the way production does:
npx wrangler pages dev . # http://127.0.0.1:8788
curl -sI -H 'Accept: text/markdown' http://127.0.0.1:8788/python3 scripts/build.py # writes dist/index.html with CSS + images inlinedDeploys run from GitHub Actions via Wrangler (Direct Upload). Every push to
main stages the site files into _site/ and publishes them to the Pages
project aetherweb. See .github/workflows/deploy.yml.
- Create a Cloudflare API token — Cloudflare dashboard → My Profile → API Tokens → Create Token → Custom token. Permission: Account → Cloudflare Pages → Edit. Scope it to the AetherSDR account.
- Grab the Account ID — Workers & Pages overview (right sidebar).
- Add two repo secrets — GitHub → Settings → Secrets and variables →
Actions:
CLOUDFLARE_API_TOKENCLOUDFLARE_ACCOUNT_ID
- Push to
main(or run the workflow manually). The first run creates theaetherwebPages project and publishes it tohttps://aetherweb.pages.dev.
The zone is already on Cloudflare, so DNS and TLS are automatic:
- Pages project → Custom domains → Set up a custom domain →
www.aethersdr.com. Cloudflare auto-creates the proxied CNAME and provisions the TLS cert. - Redirect the apex — Rules → Redirect Rules: when hostname equals
aethersdr.com, 301 tohttps://www.aethersdr.com(preserving path/query). This replaces the current apex → GitHub redirect. (If you still want a GitHub shortcut, pointcode.aethersdr.comat it instead.)
npx wrangler pages deploy . --project-name=aetherweb --branch=mainAetherSDR is an independent project and is not affiliated with or endorsed by FlexRadio Systems. Content licensed alongside the AetherSDR project (GPL v3).