Skip to content

About

Official website for Lightning, a native Matrix desktop client.

Resources

Stars

1 star

Watchers

0 watching

Forks

Latest commit

 

History

65 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

lightning-website

The official website for Lightning, a native Matrix desktop client for Linux, Windows and macOS.

Live at https://www.lightning-matrix.org, hosted on Cloudflare Workers.

It is a single static page. There is no build step, no framework and no JavaScript required to read it — one optional 70-line script keeps a deployed page in step with releases.json.

Layout

public/              <- everything Cloudflare serves (the assets directory)
  index.html         <- the site; hand-editable
  releases.json      <- the release feed; edit this to cut a release
  releases.js        <- optional: refreshes a live page from releases.json,
                        recolours it per theme, and rolls the star field
  themes.json        <- all eleven client palettes; generated, see extract-themes.py
  sky-shapes.json    <- the constellations the star field is built from
  404.html
  robots.txt
  sitemap.xml
  _headers           <- Cloudflare: CSP, security headers, cache policy
  _redirects         <- Cloudflare: same-site path redirects (relative URLs only)
  assets/
    lightning-mark.svg
    shots/*.png        <- every scenario in every theme: <scenario>--<theme>.png
    og-card.png        <- 1200x630 link preview; source in tools/og-card.html
  fonts/*.woff2      <- Manrope and JetBrains Mono (self-hosted subsets)

wrangler.jsonc       <- Cloudflare config: serve public/, 404.html on miss
src/
  worker.js          <- one route, GET /api/latest (newest release from GitHub)
artifact/
  Lightning.html     <- the original Claude artifact this site was built from
tools/
  build-site.py      <- GENERATES public/index.html; edit this, not the HTML
  bump-feed.py       <- points releases.json at what GitHub actually published
  fontfaces.css      <- the self-hosted @font-face block build-site.py inlines
  unbundle.py        <- converted that artifact into public/; history, do not run
  check.py           <- invariant checks; run after editing public/
  check-assets.py    <- the download cards against a REAL release's assets
  og-card.html       <- source for assets/og-card.png; NOT deployed
  extract-themes.py  <- reads the CLIENT's qml/AppTheme.qml into themes.json
  lightbox-test.js   <- jsdom test for the screenshot overlay (needs jsdom)
  cards-test.js      <- jsdom test for the package-card rebuild (needs jsdom)
  sky-test.js        <- jsdom test for the constellation field (needs jsdom)
  theme-test.js      <- jsdom test for the theme wave (needs jsdom)

Nothing outside public/ is deployed.

Cutting a release

Point the feed at what GitHub published, rebuild, check, push:

python3 tools/bump-feed.py            # newest release (or: bump-feed.py v0.9.5)
python3 tools/build-site.py
python3 tools/check.py
python3 tools/check-assets.py

public/releases.json is still the single source — the version, release date and every package card come from it — but do not retype the filenames. The Windows and macOS assets embed the release commit's short SHA, so a find-and-replace of the version number leaves three cards naming files that do not exist, and the page keeps looking perfect because the JavaScript pass quietly repairs it for anyone running JavaScript. The reader without it gets a 404. bump-feed.py takes every name from the GitHub API, never invents one, and keeps the editorial parts (label, badge, the install command's shape, match) exactly as they were.

It also drops a package GitHub does not have. macOS is allow_failure in the release pipeline, deliberately, so one sleeping Mac cannot block a release — and at 0.9.5 it was absent for an unrelated reason. A card for an asset that was never uploaded is a dead download button, so the package leaves the feed and build-site.py omits that platform's block entirely.

Run it after the release publishes. Before that there is nothing to read.

{
  "version":  "0.8.1",
  "released": "2026-09-01",          // ISO date, shown as "Released ..."
  "releases_url": "https://github.com/Mizerd/lightning/releases",
  "donate_url":   "",                // empty hides the Donate button
  "asset_url": "https://github.com/Mizerd/lightning/releases/download/v${version}/${file}",
  "packages": [
    { "os": "linux", "label": "...", "format": ".deb",
      "file": "lightning_0.8.1_amd64.deb",
      "install": "sudo apt install ./lightning_0.8.1_amd64.deb",
      "remove": "sudo apt remove lightning" }
  ]
}

os must be linux, windows or macos. The first two become the two download columns; the macos entry is the package card inside the Gatekeeper block below them. The Windows and macOS filenames embed the build's short commit sha, so they change every release — copy them from the GitHub release page rather than editing by hand:

gh release view v0.8.1 --repo Mizerd/lightning --json assets \
  --jq '.assets[].name'

Which means the feed cannot be filled in before the release exists: the sha is the release commit's. Cut the release first, then bring the site to it, which is also what stops the buttons pointing at assets GitHub has not published yet.

The alpha bar at the top of the page names the version too. It used to read "0.7.x", which nothing kept current; it is now bound like every other version on the page, so it follows a release on its own.

index.html also ships the current values baked in, so the page is correct before releases.js runs and stays correct if it never does. Those baked-in values only refresh when the page is rebuilt (below) — for a normal release, editing releases.json alone is enough and the live page follows.

Download buttons

Every package card has a button pointing straight at its release asset. The URL comes from the asset_url template, with ${version} and ${file} filled in, so no version is written into the HTML.

In practice you may not need to touch anything: GET /api/latest reports whatever GitHub has actually published, and the page prefers it over releases.json. Cut a release on GitHub and this site follows within the route's five-minute cache — the version, the date, every download URL, and the filenames inside the install commands.

Each card is matched to its asset by file extension (data-lg-format), which works because every release publishes exactly one asset per format. Add a second .deb and the first one wins — give the new one a distinct format if that ever happens.

The file values in releases.json are still worth keeping accurate: they are what a visitor with JavaScript disabled sees, and what the buttons fall back to if GitHub is unreachable.

Copy buttons

The Linux install commands each carry a copy button. Windows and macOS get none — their boxes hold GUI actions ("double-click ..."), not commands.

Three things about it are deliberate. The button is a sibling of the command box rather than a child, so textContent yields the command with no button label mixed in. Clicks are handled by delegation on document, not by a listener per button, because packages() rebuilds these cards with cloneNode — which does not copy event listeners, so per-button listeners would die on every rebuild.

And the button is a flex item beside the box, never an overlay. The first version positioned it absolutely over the box and reserved room with padding-right, which does not work: the box scrolls (white-space: pre, overflow-x: auto), so that padding sits at the end of the scrollable content rather than at the right edge of what you can see. The AppImage command — the longest one — ran straight under the button at scroll position zero. The row is flex-wrap: wrap, so on a phone the button drops to its own line instead of squeezing the command.

The buttons ship hidden and releases.js reveals them, so a reader without JavaScript is never shown a button that cannot work.

The Donate button

The markup is in index.html but is hidden, because there is no donation account to point it at yet. Put a real URL in donate_url and it appears by itself — no HTML edit. Leaving it empty keeps it hidden and unclickable.

Editing the page

public/index.html is generated. Edit tools/build-site.py and rebuild:

python3 tools/build-site.py     # rewrites public/index.html
python3 tools/check.py

The generator is the reason the page and the feed cannot disagree: every download card, every filename and every install command is built from public/releases.json, and the screenshot dimensions are read out of the PNG headers rather than restated. Nothing about a release is typed into the HTML.

Its design brief is in its own docstring, and the short version is same build: the page is made from the application's own tokens (Indigo Night surfaces, the Storm bolt as the single accent, Manrope and JetBrains Mono — the faces the client bundles) and shows nothing but the application. If an element does not exist in the app, it does not go on the site. That is why there are no gradients, no glows, no scroll reveals and no animation of any kind: the client has none, and a page that claims the client is restrained should be the first evidence for it.

tools/fontfaces.css is the self-hosted @font-face block the generator inlines. It is also the authority on which weights exist — JetBrains Mono at 400/500/700, Manrope at 400/500/600/700. Asking for a weight with no face does not fall back to a near one: with font-display: swap there is nothing to swap in and the text paints as nothing. check.py compares the two.

tools/unbundle.py is history, and must not be run. It converted the original Claude artifact into the first version of this site and would overwrite the generated page with it. It is kept because the corrections it documents — the GitLab→GitHub rewrite, the JSON-LD, the asset unpacking — are the record of how the site got here. The original artifact was a 2.7 MB self-extracting bundle: a base64 manifest that JavaScript unpacked into blob: URLs at runtime, rendered through React 18 and a proprietary x-dc runtime — ~280 KB of JavaScript to perform 12 string substitutions, rendering nothing at all without JavaScript. The published page needs no framework and never has since.

Checking a change

python3 tools/check.py

Verifies the things that have actually broken: every local reference resolves, every package card has its own button pointing at its own asset, the baked-in version agrees with releases.json, every screenshot declares the size the file actually is and is openable, no orphaned screenshot is left in public/assets/, every screenshot URL in the JSON-LD resolves, the theme strip still has eleven swatches, the canonical and the JSON-LD agree with the page, nothing served mentions GitLab, and releases.js is not cacheable for longer than the HTML it rewrites. Exits non-zero, so it works in a pre-push hook.

Nothing here counts screenshots against a fixed number. It used to say four, and four is what a check goes stale as: the total is taken off the page and the sized set, the zoom triggers and the files on disk are compared to it.

A check that cannot fail is decoration. When adding one, inject the defect it is meant to catch, watch it fail, then restore — all of the above have been put through that.

That last check exists because of a real bug. releases.js was served with max-age=3600 while index.html was must-revalidate, so a visitor could run new HTML against an hour-old script. The old script rebuilt the package cards by cloning card zero and knew nothing about the download buttons, so every clone inherited card zero's href: every Linux button served the .deb and every Windows button the .msi. The links in the HTML were all correct — which is why this could not be found by inspecting the page or the URLs.

The lesson generalises: any script that rewrites the generated DOM must not outlive that DOM in a cache. releases.js is now no-cache, like releases.json.

tools/check-assets.py covers the one JavaScript behaviour that depends on something outside this repository: /api/latest picks a card's asset by suffix, so whether it lands on the right file depends on what the release actually published. It reimplements assetFor() exactly and runs it against the live GitHub release:

python3 tools/check-assets.py            # newest release
python3 tools/check-assets.py v0.8.1     # a specific tag
python3 tools/check-assets.py --feed     # offline, names from releases.json

Run it after a release publishes. Without the data-lg-match tokens it reports the real failure — the macOS card resolving to the Windows portable zip — which is what it was written against.

check.py cannot test the rest of the JavaScript — that needs a DOM. For it:

npm install jsdom          # not a repo dependency; install where convenient

tools/cards-test.js is that test, and it is the one to run after any change to the download section or to releases.js:

node tools/cards-test.js

It loads the real index.html, stubs window.fetch for releases.json and /api/latest, evals the real releases.js, and asserts that every [data-lg-pkg] card ends up with its own install command, format, label and download URL — counted off the page, never written down. It runs both passes and the feed-only path with /api/latest failing, because the two mask each other: the GitHub pass sets every href by suffix and papers over a broken rebuild in the feed pass.

Its /api/latest stub reports a different version and build sha from releases.json. That is the pass's whole job — it is what makes a deployed page follow a release nobody has edited this repository for — and a stub that echoes the feed back proves nothing about it.

What it exists for: releases.js rebuilds the cards by cloning card zero, so a card missing one of the [data-lg-bind] slots silently keeps card zero's value, and the baked HTML looks perfect while it happens. It has shipped twice — first every Linux button serving the .deb, then, in the 2026-09-13 redesign, every Linux card showing the AppImage's chmod line.

The lightbox is worth testing the same way, and the useful assertions are the ones about what must not happen: clicking the image does not close it, the overlay is reused rather than rebuilt on the second open, the scroll lock is released, and focus returns to the screenshot that was clicked.

tools/sky-test.js is the third, and it covers the one part of the page that does not exist until the script runs. The background constellations are baked into index.html for a reader without JavaScript, and releases.js throws that field away and rolls a new one at the layer's real pixel size on every load — so check.py can only ever see the fallback. jsdom lays nothing out, so the test stubs getBoundingClientRect on .lg-sky and asserts the things that fail SILENTLY: stars placed outside the viewBox (clipped away without a word), a dust density that does not follow the area (boulders on a phone, mist on a 4K panel), stars behind the text column, and a field that stops being different on every load.

Anything about layout, and anything about whether text is actually painted, needs a real browser instead — jsdom has none. The Copy buttons shipped as empty rounded rectangles because .lg-copy asked JetBrains Mono for font-weight: 600 and the self-hosted subset carries 400/500/700 only: with font-display: swap there was no face to swap in and the run painted as nothing, while the DOM said Copy throughout. check.py now compares every declared weight against the @font-face rules, but only a render found it. Headless Firefox works, with one trap: --screenshot fires at the load event, so a probe on a timer renders nothing. Measure synchronously in an iframe's onload and write the numbers into the page, one page load per viewport width. Scroll-driven lgRise animations also sit at their from state (opacity: 0) in a screenshot, so probe pages want *{animation:none!important;opacity:1!important}, and loading="lazy" images never arrive at all — strip it before measuring anything below the fold.

GitHub only

The site links to GitHub and names no other host. The project was on a self-hosted GitLab when the artifact was written, so unbundle.py rewrote every link, button and sentence that referred to it (correction 1) and failed the build if any survived. build-site.py names GitHub and nothing else, and check.py repeats the check against what is on disk.

If the repository ever moves again, change it in build-site.py — editing public/index.html alone means the next rebuild reinstates the old URL.

No terminal for Windows or macOS

Linux users get shell commands, because that is how software is installed there. Windows and macOS users get none: they are GUI users, and a command prompt is a wall, not an instruction.

This took three fixes. The .msi card said msiexec /i ... — now "double-click" (keeping the filename, so /api/latest can still rewrite it). The macOS block is click-by-click. And sha256sum -c SHA256SUMS was sitting in the Windows column, which is the one place it cannot run; it moved to the Linux column, where it is native. What replaced it in the Windows column says plainly that Windows cannot check a checksum without a command prompt, so the protection is downloading from the release page and nowhere else — and the SmartScreen box no longer tells people to "check the checksum" it cannot help them check.

macOS

There is a macOS release, from 0.7.5. The block is a real package card ("os": "macos" in releases.json, rebuilt by packages("macos", …)) wrapped in the Gatekeeper walkthrough, which stays static copy in build-site.py because it is instruction, not release data.

It leads with the two limits, because neither is a choice and both rule out a lot of people: Apple Silicon only, and macOS 26 or newer. Both come from the Qt build the app links.

The walkthrough is the Open Anyway route: try to open it and let macOS refuse, then System Settings → Privacy & Security → Security → Open Anyway, then confirm once. Step 1 is not filler — the button only appears after macOS has blocked the app at least once. Control-click → Open is mentioned only as the macOS 14-and-earlier shortcut, since macOS 15 removed it and this build needs macOS 26 anyway.

The block promises the app is unsigned and tells people how to get past Gatekeeper. That is a real security instruction, so it leads with where to get the file: someone clicking Open Anyway is vouching for the download themselves. It also says the two things the client cannot say for itself — that the macOS build does not update itself, and that nobody has clicked through it on a Mac.

Two cards, one file extension

/api/latest matches a card to a GitHub asset by suffix, and 0.7.5 was the release that broke the "exactly one asset per format" assumption: the Windows portable and the macOS bundle are both .zip. Both cards would have taken whichever .zip GitHub listed first — and the page would have looked perfect while doing it, because the hrefs baked into the HTML are all correct and only the JavaScript pass goes wrong. Exactly the shape of the bug that once served the .deb from every Linux button.

So a card may carry data-lg-match, a longer unambiguous suffix (-portable.zip, -macos-arm64.zip), which pass 2 prefers over the format badge a reader sees. It comes from a "match" field in releases.json. check.py enforces both halves: no two cards may share a match token, and a match token must actually be a suffix of the file its own card names.

The brand

The original page pitched "Everything other Matrix clients fake." before it had said what it was — a swipe at competitors standing where the product's own description belonged. The name appeared once, at 18 px in the nav.

The <h1> is now the thing itself: "Lightning writes the interface. The Rust SDK writes the Matrix." It is a claim about how the client is BUILT, it is checkable (the architecture section under it names the four layers and who owns what), and it is the one sentence that separates this project from every other Matrix client — none of which can say it. The second half is set in the muted text colour, so the two clauses read as a pair rather than as one long line.

The mark and wordmark sit in the header at 20 px, the size they are in the application's own title bar. The eyebrow above the <h1> carries the three facts a reader needs before anything else and nothing more: version, alpha, licence.

Screenshots

The six screenshots are the only photographs on the page, and every section that makes a claim about the interface sits beside one.

Refreshing them: the set is the SHOTS table in build-site.py — filenames only; the alt text lives with the row that shows the picture, and the dimensions are read out of the PNG headers. Drop the PNGs into public/assets/, list them there, run build-site.py.

Two rules worth keeping. Give a replaced picture a new filename. Screenshots are not content-hashed and _headers caches /assets/* for a day, so overwriting one shows a returning visitor yesterday's picture under today's caption. And delete what you replaced — check.py fails on a screenshot-*.png in public/assets/ that nothing points at, which is how an orphan is caught rather than quietly deployed.

The artifact's own four pictures are not written out any more. They were a snapshot of the client the day the artifact was made, and keeping them on the same filenames is what produced the 0.7.x state: a file called screenshot-threads.png holding a picture of the theme editor, under a caption describing a GIF picker that was not in the shot.

They carried no width/height and are loading="lazy", so until each file arrived its box was zero pixels tall: the caption sat under nothing and the grid jumped as the images landed. Correction 12 reads the real dimensions out of each PNG's IHDR at build time and writes them onto the tag, so the browser reserves the right shape before a byte is fetched. They are read from the files rather than typed in because a re-exported screenshot would otherwise reserve the wrong shape, which is worse than reserving none — check.py compares the two.

They render about 540 px wide and are shipped at 2,400 px, which is a full screen of lightbox on any ordinary display at roughly a third of the bytes a raw grab costs. Each is wrapped in a <button data-lg-zoom> and releases.js opens it full screen.

A button rather than a click handler on the <img>: that way it is in the tab order, activates on Enter and Space, and is announced as something you can press. All three are free with a button and all three have to be rebuilt by hand without one.

Three things about the overlay are load-bearing:

  • The image is the one thing that does not close it. Everything else does — backdrop, caption, close button, Escape. On a phone that is what lets you pinch and pan a screenshot without the first touch dismissing it.
  • cursor: zoom-out on the backdrop is not decoration. iOS only dispatches click for a plain <div> when it looks interactive, and a cursor is what makes it look interactive. Without it the backdrop swallows the tap and nothing closes.
  • max-height: calc(100dvh - 150px), not 100vh. On a phone the browser chrome counts towards vh, so vh sizes the image to a viewport taller than the one you can see and crops the bottom of it behind the address bar.

The overlay is built once on first use and reused; closing it drops the src so a 2,400 px image is not held decoded for a page you have gone back to scrolling. Focus moves to the close button on open and returns to the screenshot on close.

The theme strip

The page claimed "Eleven themes" in a feature card and "eleven WCAG-AA themes" in its meta description, and then showed none of them — 8,800 px of one slate palette and a single blue accent. Correction 13 puts eleven swatches under the screenshots, each a miniature of the client: rail, two received bubbles, one sent bubble in the accent.

The colours are not decorative. Every value in the THEMES table in build-site.py is copied from qml/AppTheme.qml in the client repo — the _light, _dark, _graphite … palette objects, resolved through their colour literals, in SettingsManager::Theme enum order. This repository cannot see that one at build time, so if a palette changes there it has to be changed here too. check.py cannot prove they are current; it only proves nobody quietly dropped one, which is the failure that would leave the page saying "Eleven themes" above ten swatches.

Eleven is prime, so the grid is set explicitly rather than left to auto-fit: eleven across on desktop, 6 + 5 below 1080 px, 4 + 4 + 3 on a phone. auto-fit gave nine in a row and a stranded pair underneath. The 1080 px rule needs !important for the usual reason — the grid is an inline style on the element.

Search engines

Google had already picked https://github.com/Mizerd/lightning as the canonical page for this content. That is not a mistake on its part: the domain used to redirect there, and a redirect is the strongest duplicate signal there is. The fix is to say the opposite, in every way a crawler reads:

  • a self-referencing <link rel="canonical"> — this URL is the original
  • SoftwareApplication JSON-LD with sameAs pointing at the repository — the same project, not a competing copy of this page
  • og:url, og:site_name, twitter:card, and a real 1200x630 og:image

The JSON-LD is a <script type="application/ld+json"> data block, which is never executed, so script-src 'self' does not have to be loosened to carry it. releases.js keeps its softwareVersion in step with the rest of the page, because data-lg-bind reaches DOM and this is JSON.

og:image was a raw screenshot: 3839x2043 and 545 KB, which is 7.8 megapixels for a card rendered about 500 px wide, and past the size some scrapers will fetch at all. It is now assets/og-card.png, 1200x630 and 154 KB. Its source is tools/og-card.html — screenshotted in Firefox rather than drawn in ImageMagick, so the type is the site's own webfonts laid out by the same engine as the site. That file is a build input and must not be left in public/.

Note that og:image is referenced by content=, not src=/href=, so the generic "local references resolve" check never sees it. It has its own check — a social card that 404s is invisible until someone shares a link and gets a blank box.

Those two external pointers are FIXED (checked 2026-09-13 against the GitHub API): the repository's homepage is https://www.lightning-matrix.org and its description now leads with the site before mentioning the mirror. They were the reason Google treated the GitHub page as canonical. Nothing here can verify them on an ongoing basis — curl -s https://api.github.com/repos/Mizerd/lightning is the check, and it is worth repeating if search results ever drift back.

The sky

The faint constellations behind the page are built from set shapes, not from noise: public/sky-shapes.json holds thirteen figures — a plough, a Cassiopeia W, a northern cross, a crown, a hunter, and a lightning bolt for the house — each a list of points normalised into a 0..1 box plus the edges to draw between them. The generator scales, rotates, squashes and drops a handful of them into the margins and fills the rest with unconnected dust. Adding a shape to that file is the whole procedure; both generators read it.

There are two generators and they do the same thing in two languages, which is deliberate. build-site.py bakes one field into index.html from a fixed seed, so a rebuild is not a noisy diff of meaningless coordinates and a deliberate change is visible in one; that field is what a reader without JavaScript gets. releases.js throws it away on load and rolls a fresh one — a different sky every visit, which is the point, and more importantly one generated at the layer's real pixel size. The baked field is a fixed viewBox scaled to fit, so on a phone it is a handful of boulders and on a 4K panel a fine mist; rolled live, the density and the star sizes are the same everywhere.

The layer is position: absolute over the whole document rather than fixed, so the stars move exactly as far as the page does. A fixed layer dragged along by a scroll handler always lags or leads by a few pixels and reads as the background being out of sync rather than as depth — that was tried, and the report it earned was "the constellations seem out of sync with the scroll".

Its width and height are stated explicitly, and that is not tidiness. An <svg> is a replaced element: absolutely positioned with width: auto and height: auto, CSS sizes it from its viewBox's intrinsic ratio and ignores right and bottom as over-constrained. Stretching it with top: 0; bottom: 0 therefore did not stretch it at all — at a 2560px-wide window the layer computed some twelve thousand pixels tall against a page of about six and a half, and the document scrolled to nearly twice its own content with nothing in the bottom half but stars. check.py asserts both declarations, because nothing in that file can see a layout.

Per theme the layer's opacity is 0.16 on the eight dark palettes and 0.46 on Moss Light, Lightning Light and Warm. A dark dot at 16% on a cream ground is a smudge; the stars were being swallowed exactly where the ground is brightest.

Motion

There is almost none, and what there is is a state change. The 2026-09-13 rebuild deleted the whole motion layer — eight keyframe animations, 35 scroll reveals, a marquee, a drifting hero glow, a light strike across the header and a scroll progress bar — along with motion.js, the 70-line script that drove the parts CSS could not work out for itself.

They went because the application has none of them. The page's argument is that Lightning is a restrained native client; a page that arrives in a cascade of reveals is arguing the opposite in the only language a visitor can check before downloading anything. Five check.py invariants went with them — the progress bar shipping empty, the scroll sentinel, the hero "arriving as a ladder", the swatch cascade, panels tagged for a spotlight. Every one tested a decoration.

Three behaviours survive, and each one is a thing HAPPENING rather than a thing announcing itself: the screenshot lightbox, the copy buttons, and the theme wave.

The theme wave

Picking a theme repaints the whole page, and the question is only whether the reader can follow it. The version this replaced could not be followed: a 260ms fade on six selectors, so the grounds and the cards eased while every paragraph, heading and rule on top of them snapped, and the seven screenshots blinked. Half a page moving and half of it cutting reads as a glitch.

A light palette and a dark one are opposite at both ends, and that is the whole problem. The ground has to travel from pale to near-black while the text travels the other way, so any continuous interpolation of both has an instant where a half-dark letter sits on a half-light ground. Measured on this page, three ways of timing it bottomed out at:

how the colours were timed worst contrast
one curve for ground and ink 1.13:1
ink slower than the ground (620ms / 880ms) 1.07:1
ink crossing fast on its own late lag 1.02:1

That is not dim, it is unreadable, and there is no pair of curves that avoids it — the crossing is the problem, not the speed.

So the page is not interpolated at all. document.startViewTransition holds a picture of the old page, the new one is built underneath it complete, and an expanding circle from the reader's own click wipes one to the other over 840ms. Every pixel is either fully the old theme or fully the new one, text included; the only thing that moves is the edge, and the edge is the wave. A soft ring in the new theme's accent rides it. The screenshots come along for free — they are in the same picture — which is why they are decoded before the transition starts: an undecoded image would be snapshotted blank and the wave would wipe in seven empty boxes.

Measured, software-rendered (no GPU), 2200×1240, warm cache:

frames median p90 max over 33ms
Firefox 153, idle 79 17.0ms 17.2ms 17.2ms 0
Firefox 153, during a switch 97 17.0ms 17.2ms 17.2ms 0
Chromium, during a switch 74 16.7ms 33.4ms 49.9ms 21

Chromium's long frames are all exactly two vsyncs — a mixed 60/30 cadence rather than stutter — and this is the pessimistic case: rasterizing two full-viewport layers in software. GUI-window measurements on this machine were discarded: the control animation varied between 5 and 85 frames per 1.4s across runs, so the window's own state, not the page, was the variable.

Browsers without view transitions (Firefox before 144, Safari before 18) get the same idea built out of what they do have: one colour transition over everything, delayed per block by its distance from the click, with the pictures cross-faded through overlay <img>s on the same delays. It has the crossing problem above and cannot not have it, so the ink is given a fast 260ms cross and the bad instant is an instant. Measured at a flat 60fps with no dropped frames.

Four things about it are less obvious than they look:

  • The fallback's per-block delay is a custom property (--wave-d) and not a transition written per element by script, because custom properties INHERIT: a heading moves with the section that holds it, and nothing has to be enumerated.
  • It is armed only while .lg-wave is on <html>. A permanent universal colour transition would put 720ms of lag on every hover on the page.
  • The ring is a fixed 360px element scaled up, never one sized to the screen. It has to reach the far corner — three or four thousand pixels on a 4K panel — and a gradient element that big is tens of megabytes to rasterize before it can be composited once. Upscaling a soft radial glow produces a soft radial glow.
  • A hidden tab runs no animation frames at all: every transition on it is frozen at its START value, and Chromium does not even run a view transition's update callback, so the theme would not change until the reader came back. document.hidden switches outright instead of waving, and the fallback's cross-fade cleanup is on a TIMER with the animation frame as the optimisation — not the other way round. That one shipped broken for an hour: cleanup hung off requestAnimationFrame alone, and a tab hidden mid-switch kept the old screenshot at full opacity on top of the new one until the reader came back, with another overlay stacked on every switch.

tools/theme-test.js is the jsdom suite for it — both paths, because jsdom has neither view transitions nor Element.animate and would otherwise test only the fallback. prefers-reduced-motion switches the theme with no wave at all.

Mobile

The generated page is one column below 860 px, and the breakpoint does four things: the hero and the alternating feature rows collapse to a single column; the screenshot moves ABOVE the headline in the hero, so the product arrives before the words; the in-page nav links are hidden (the wordmark stays, and the hero's own Download button is the affordance); and the vertical rhythm tightens.

The install-command boxes are the one element that cannot wrap — a shell command broken across lines is a shell command you cannot paste — so they scroll horizontally inside their own box, with scrollbar-width: thin plus the ::-webkit-scrollbar equivalents. Without those the bar renders in the default light theme: a pure-white rectangle across a dark card, which is the loudest thing on the page at 390 px. That regressed once in the rebuild and was caught by rendering rather than by any check.

Measure, do not eyeball. document.documentElement.scrollWidth must equal clientWidth at 320 px, and nothing should be wider than the viewport — the glow and the marquee that used to be the two deliberate exceptions are both gone. Measured 2026-09-13 in an iframe at 320 px: scrollWidth=308 clientWidth=308.

The artifact-derived page needed a much longer version of this section, and its causes are worth keeping as a list of what to check when a new element is added: a nowrap flex row of nav links (the widest element on the page, and the reason the viewport blew out at all); repeat(auto-fit, minmax(420px, 1fr)) grids inside a 326 px container; a three-column row grid that wrapped prose to about one word per line; white-space: pre propagating its max-content width up through every ancestor, because grid and flex children default to min-width: auto; and desktop type sizes at phone width.

html, body { overflow-x: clip } is a deliberate backstop, not the fix. It is there so a future edit degrades into one clipped element instead of shoving the whole layout sideways.

Local preview

cd public && python3 -m http.server 8899

Then open http://localhost:8899. Serve from public/, not the repository root — every path in the page is absolute (/assets/...).

_headers and _redirects are Cloudflare features and do nothing locally. Workers static assets supports both natively, the same as Pages did.

python3 -m http.server does not run the Worker, so /api/latest 404s and the page falls back to releases.json — which is exactly the no-JS path, worth seeing. To exercise the real route:

npx wrangler dev

If that reports a TLS error on the upstream fetch, the sandbox is missing a CA bundle rather than the Worker being broken: SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt npx wrangler dev.

Deploying

Cloudflare Workers (static assets), connected to this GitHub repository. Every push to main deploys.

wrangler.jsonc is the whole configuration: it points Cloudflare at public/, asks for 404.html on unknown paths, and names src/worker.js as the script behind the one route that is not a file. run_worker_first is deliberately not set, so assets are served straight from the asset layer and a normal page load never enters the Worker; only an unmatched path reaches it, which is why it ends by deferring back to the ASSETS binding.

One-time setup — Cloudflare dashboard → Workers & Pages → Create → Import a repository → lightning-website:

Field Value
Build command (leave empty)
Deploy command npx wrangler deploy
Version-preview command (leave empty)
Root directory /

There is no build step: the repository already contains the finished site. wrangler deploy reads wrangler.jsonc and uploads public/.

Then under the Worker's Domains tab, add both:

  • www.lightning-matrix.org — the canonical hostname
  • lightning-matrix.org — the apex

www is canonical: it is what <link rel="canonical">, the Open Graph tags and sitemap.xml all point at.

If DNS for lightning-matrix.org is already on Cloudflare, adding the domains creates the records for you. Otherwise point the nameservers at Cloudflare first. A custom domain will not attach while the hostname already has A / AAAA / CNAME records — delete those first (but never the MX or TXT records, which carry email).

The apex → www redirect

This is not in _redirects: Workers rejects cross-hostname redirects there ("Only relative URLs are allowed", code 100324), and the rejection fails the entire deploy.

It is a zone Redirect Rule instead — dashboard → lightning-matrix.org → Rules → Redirect Rules:

Match URI Full, wildcard http*://lightning-matrix.org/*
Target https://www.lightning-matrix.org/${2}
Status 301, preserve query string

Redirect Rules run before Workers, so anything matching here never reaches the site. Watch for leftover parking rules pointing at GitHub — they will silently swallow the whole domain.

Deploying by hand, if you ever need to:

npx wrangler deploy          # or: npx wrangler versions upload

Response headers

public/_headers sets a strict Content-Security-Policy — the page loads nothing from any third party, so everything is 'self'. The one loosening is style-src 'unsafe-inline', which the inline style="..." attributes require.

connect-src stays 'self' even though the page needs GitHub's release data: src/worker.js fetches it server-side and the browser only ever talks to this origin. That is the reason the lookup is proxied rather than called from the page — no visitor's IP is handed to GitHub just for loading the site, and one cached edge response serves everyone instead of each visitor spending their own GitHub rate limit.

Caching is deliberately split: fonts are immutable (family + unicode subset fully identify the file), screenshots get a day because their filenames are not content-hashed, and releases.json is no-cache so a release is visible immediately.

Fonts sit at /fonts/, not /assets/fonts/, because Cloudflare applies every matching rule in _headers and merges the results — a nested rule emitted two Cache-Control values in one header and the browser honoured the first, capping fonts at a day. Keep header prefixes disjoint.

If you add a third-party embed later, the CSP will block it until you add the origin to the matching directive.

Licence

GPL-3.0-or-later, matching the client. See LICENSE.

Screenshots come from Lightning's demo mode, which uses invented *.example accounts and local test media — no real conversations.

About

Official website for Lightning, a native Matrix desktop client.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages