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.
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.
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.pypublic/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.
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.
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.
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 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.
public/index.html is generated. Edit tools/build-site.py and rebuild:
python3 tools/build-site.py # rewrites public/index.html
python3 tools/check.pyThe 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.
python3 tools/check.pyVerifies 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.jsonRun 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 convenienttools/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.jsIt 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.
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.
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.
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.
/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 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.
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
SHOTStable inbuild-site.py— filenames only; thealttext lives with the row that shows the picture, and the dimensions are read out of the PNG headers. Drop the PNGs intopublic/assets/, list them there, runbuild-site.py.Two rules worth keeping. Give a replaced picture a new filename. Screenshots are not content-hashed and
_headerscaches/assets/*for a day, so overwriting one shows a returning visitor yesterday's picture under today's caption. And delete what you replaced —check.pyfails on ascreenshot-*.pnginpublic/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.pngholding 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-outon the backdrop is not decoration. iOS only dispatchesclickfor 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), not100vh. On a phone the browser chrome counts towardsvh, sovhsizes 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 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.
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 SoftwareApplicationJSON-LD withsameAspointing at the repository — the same project, not a competing copy of this pageog:url,og:site_name,twitter:card, and a real 1200x630og: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 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.
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.
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-waveis 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.hiddenswitches 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 offrequestAnimationFramealone, 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.
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.
cd public && python3 -m http.server 8899Then 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 devIf 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.
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 hostnamelightning-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).
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 uploadpublic/_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.
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.
{ "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" } ] }