| version | alpha | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| name | Droidspaces | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| description | The visual language of droidspaces.org. Material 3 Expressive, derived from the Droidspaces Android app, for a static site on GitHub Pages. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| colors |
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| typography |
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| rounded |
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| spacing |
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| components |
|
The visual language of droidspaces.org. It is derived from the Droidspaces Android app, whose own
DESIGN.md was read out of
Android/app/src/main/java/com/droidspaces/app/. The website is the app's design language taken
outdoors: same palette family, same flat surfaces, same restraint, plus the shapes and springs of
Material 3 Expressive that a marketing page has room for and a container manager does not.
Every number in this file has a source. Colour roles were generated from the app's dark primary
(#7DC4E4, Catppuccin Sky, ui/theme/Color.kt) with Google's material-color-utilities 0.4.0
on the 2025 colour spec. Radii, spacing, type sizes and the button press morph come from the token
files Google ships in @material/web 2.5.0 (labs/gb/styles/). Spring values come from the
Compose Material 3 ExpressiveMotionTokens.kt. Nothing here was picked by eye.
The front matter is the normative part. The prose says when each value applies.
Droidspaces runs full Linux distributions on Android with a real init system, directly on the phone's kernel. No emulation, no virtual machine, no Termux. The site exists to make that claim in ten seconds to two audiences at once: a self-hoster with an old phone in a drawer, and a kernel developer who will read the namespace list before believing anything.
The home page opens on the reason the project exists, not on the technology: an old phone already has a battery, mobile data and a Linux kernel, so it can be a homelab that keeps running when the power cuts. The kernel developer's claim (systemd as PID 1, no emulation) is the line under it.
The second beat is the philosophy, and it is the project's reason to exist: the phone boots, Droidspaces starts the containers while the phone is still locked and encrypted, and the init system inside starts every service. Gitea, Jellyfin, a website: set up once, then forgotten, the way a Linux server is. Every later section supports that claim.
Numbers on the page (release, stars, contributors) are stamped by the build from the GitHub API, never typed.
The site has to feel like the Android app. Someone who installs the app after reading the site
should recognise it. That decides most of what follows: the same slate-blue palette, surfaces
that step up in tone rather than float on shadows, borders that carry state, and a companion app
that is flat by decision (55 sites set tonalElevation = 0.dp, zero card elevations in the tree).
Where the site differs from the app is where Material 3 Expressive gives it permission to. A marketing page can afford one large shape, one spring, one moment of motion per section. The app cannot, because it is a control surface. So the site is the app with the volume up one notch, not a different product.
Personality, in three words: native, calm, precise. Not playful. Not enterprise. The tone of someone who wrote the container runtime and is showing you it works.
The site is dark by default, because the app is, because terminals are, and because the phone
screenshots that carry every page were captured in dark mode. Light mode exists and is complete,
and it follows prefers-color-scheme unless the visitor overrides it with the toggle.
Roles come from Material 3. The site uses the --md-sys-color-* custom property names exactly,
so the token file can be regenerated from the source colour without touching a stylesheet. The
only file that may contain a hex literal is assets/css/tokens.css.
The source colour is #7DC4E4, the app's dark primary. The scheme is SchemeTonalSpot on the
2025 spec, contrast level 0. Regenerate with the script in scripts/tokens.mjs, never by hand.
| Role | Value | Where it goes |
|---|---|---|
primary |
#a2cde2 |
Filled buttons, the one accent per section, focus rings, active nav item |
on-primary |
#174557 |
Text and icons on primary |
primary-container |
#2d586a |
Tinted surfaces that mean "running" or "active" |
on-primary-container |
#bfe9ff |
Text on primary-container |
secondary |
#b4cad6 |
Restart, secondary actions, quiet emphasis |
secondary-container |
#2a3e48 |
Tonal buttons, selected chips |
tertiary |
#d1dcff |
In-progress, downloading, the second decorative shape on a page |
tertiary-container |
#becefa |
Decorative shapes only, never a button |
error |
#fa746f |
Stop, failed, destructive. Nothing else. |
surface |
#0b0f11 |
Page background |
surface-container-lowest |
#000000 |
Code blocks and terminal panes |
surface-container-low |
#0f1417 |
Footer, the docs sidebar |
surface-container |
#141a1e |
Cards sitting on the page |
surface-container-high |
#1a2124 |
Cards nested in cards, chips, table header row |
surface-container-highest |
#1f272b |
Hover state of a surface-container card |
on-surface |
#dee7ec |
Body text, headings |
on-surface-variant |
#a4acb2 |
Supporting text, metadata, captions |
outline |
#6e777c |
Generated but unused. Outlined buttons take outline-variant so one border colour serves the site |
outline-variant |
#41494e |
Card borders, dividers, table rules |
primary-fixed |
#b5e0f6 |
The phone mockups' accent. A fixed role barely moves between themes, so screenshots captured in dark mode still match in light mode |
| Role | Value |
|---|---|
primary |
#28667e |
on-primary |
#f3faff |
primary-container |
#a8e2fe |
on-primary-container |
#0c536a |
surface |
#f7fafc |
surface-container-lowest |
#ffffff |
surface-container-low |
#eff4f8 |
surface-container |
#e9eff3 |
surface-container-high |
#e2e9ee |
surface-container-highest |
#dbe4e9 |
on-surface |
#2b3438 |
on-surface-variant |
#586065 |
outline-variant |
#abb3b9 |
The full light set, including secondary, tertiary and error, lives in tokens.css and is
generated by the same script. It is not repeated here because nobody should be copying it by hand.
Borders and tint carry state. Fills stay quiet. This is the app's rule and it is the site's.
A "running" container in a screenshot caption is not a green box. It is a normal card with an
accent border. The site never fills a card with primary.
One accent per section. Each section of a page gets one thing in primary: a button, or an
active tab, or a highlighted table cell. If a section has two things in primary, one of them is
wrong. Decorative shapes use tertiary-container or secondary-container, never primary, so
the eye still finds the button first.
Terminals are black. Code blocks and terminal panes sit on surface-container-lowest, which
is #000000 in dark mode. This is the one place the site goes fully black, and it matches the
app's terminal, which does not follow the app theme either.
Text alpha. Secondary text is on-surface-variant at full opacity, not on-surface at 70%.
The M3 role already encodes the reduced contrast, and stacking alpha on it fails WCAG on
surface-container-high. Disabled text is on-surface at 38%, disabled fills at 12%, both from
the M3 state spec.
One family for interface text, one for machine output. Nothing else.
IBM Plex Sans for everything a person wrote. It is the face of the project's own artwork, it is released under the SIL Open Font License, and its plain, even strokes read as a technical document rather than a consumer app. Self-host it from Fontsource as a single latin-subset variable WOFF2 carrying the weight axis. Never load it from Google Fonts at runtime, because the site should render with no third-party request.
JetBrains Mono for everything the machine wrote: commands, output, paths, unit names, kernel
version strings. This matches the app, where machine output is JetBrains Mono from
ui/theme/Type.kt. Self-hosted the same way.
The scale is the Material 3 type scale. The 2021 sizes are unchanged in Expressive; what changed
is the addition of emphasized styles, which take the same size and line height and bump the
weight. Here that step is 400 to 700 for display and headline styles and 400 to 600 for titles
and labels. Body copy stays at 400. The contrast between heavy headings and light body text is
the whole expressive signature, so nothing else is bold. The one exception is the product name
in the hero's sentence, set in <strong> so the eye finds what the sentence is about.
| Element | Style | Notes |
|---|---|---|
| Hero headline | display-lg-emphasized |
57/64, weight 700. Drops to display-md-emphasized under 600px |
| Hero headline, 840 to 1199 | display-md-emphasized |
The 5/12 column is too narrow for 57px there |
| Section headline | headline-lg-emphasized |
32/40, weight 700. A claim of three to six words |
| Card title | title-md-emphasized |
16/24, weight 600 |
| Section intro paragraph | title-lg |
22/28, weight 400, on-surface-variant |
| Body copy | body-lg |
16/24 |
| Supporting text, captions | body-md |
14/20, on-surface-variant |
| Button and chip label | label-lg-emphasized |
14/20, weight 600 |
| Table header, metadata | label-md |
12/16, weight 500 |
| Code, terminal, commands | code |
JetBrains Mono 14/22 |
Line length is capped at 68 characters for body
copy (max-width: 68ch), which is where a 16px sans stays comfortable. Headlines do not track
tighter than 0; letter-spacing of −2px on a display headline is a web-template habit, not a
Material one.
Every section opens the same way: a short claim as the headline, one title-lg sentence under it,
and chips for any list of facts. A second paragraph is the exception, not the pattern; if the
section needs one, the claim is probably too vague.
Never accent a single word of a headline in colour or italic. The headline is one sentence in one colour. Emphasis, when needed, is the sentence break.
The grid unit is 8px. The spacing scale is the Material 3 space token set, of which the site uses nine steps: 4, 8, 12, 16, 24, 32, 48, 64, 96. If a gap wants 40, it wants 32 or 48.
| Gap | Value |
|---|---|
| Icon to its label, chip to chip | 8 |
| Between rows inside a card | 12 |
| Card inner padding | 24 |
| Between cards in a grid | 16 |
| Section inner padding, top and bottom | 96 on desktop, 64 under 840px |
| Page side gutter | 24 on desktop, 16 under 600px |
| Content max width | 1200px |
| Prose max width | 68ch |
Breakpoints are the Material window size classes and nothing else: 600, 840, 1200, 1600. Compact is under 600, medium to 839, expanded to 1199, large to 1599. Layouts collapse at those lines, not at 768 or 1024.
The hero is a two-column split at expanded and above: copy on the left at 5/12, phone mockup on
the right at 7/12, vertically centred. Under 840 it stacks, copy first, and the phone takes 72% of the column
(at most 380px) and the shape behind it the full column (at most 520px), so the shape frames the
phone on both sides and never runs past the edge of the screen. At 840 and up a
shape is never wider than its own column. It sits at z-index: -1 inside a phone stage that sets
isolation: isolate, so it stays behind the phone and never over copy. The section itself is
not isolated: a phone shadow has to cross into the next section, and a section that isolates
cuts it off in a hard line; main clips
horizontal overflow so it can never scroll the page either.
The home page has seven sections: the hero, why it matters (set it up once, forget it), a real Linux rather than an emulation, the network, the app carousel, the comparison, and requirements with the download.
Feature sections alternate the side the phone sits on. Text, then phone; phone, then text. This replaces the row of six identical cards.
The page background is one unbroken surface. Sections carry the 1200px measure in their side
padding and have no fill of their own. Alternating tonal bands were tried and removed: at every
band edge the eye reads a separator line, and the page stops feeling like one surface.
Cards appear in one place: the comparison against PRoot, chroot and QEMU, where a table is the honest format and cards would hide the comparison. Everywhere else, content sits directly on the page background with a headline, a paragraph and a screenshot.
The docs pages are a three-column wiki, full width, no centred box: a 280px sidebar flush to the
left edge on surface-container-low, sticky under the nav at full viewport height with its own
scroll; the page at a 760px measure; and an "On this page" list of the page's h2 and h3 at 240px,
from 1200 up. Between 840 and 1199 the right column goes; under 840 the sidebar becomes a top
drawer. Sidebar groups are <details>, so they collapse without a script. The current section in
"On this page" is primary where the browser supports scroll-target-group; there is no
scroll-spy script. Code is highlighted at build time by Pygments, and its token colours are M3
roles only (keyword primary, string tertiary, name secondary, comment and punctuation
on-surface-variant, number on-primary-container, deletion error), so both themes work. A code
block has a 40px header row holding the language and the copy button, so neither covers the first
line. Search is a native <dialog> on surface-container at radius 28, opened from the sidebar,
Ctrl K or /.
There are no shadows on this site, with one exception.
Depth is tonal. A card on the page is surface-container. A card inside it is
surface-container-high. Hovering a card steps it to surface-container-highest. That is the
whole elevation system, and it is the app's: depth is expressed by stepping the surface colour up
one level and drawing a 1px border.
Every card and every nested surface has a 1px border in outline-variant. On the app the alpha
of that border drifted across 40 sites; on the site it is one value, full opacity, because
outline-variant in M3 is already the low-contrast outline.
The exception is the phone mockup. It carries 0 40px 90px rgba(0,0,0,.55) (--shadow-phone), because it is a
photograph of an object, not a surface of the page, and an object sitting on a page has a shadow.
No other element may have one. If a card looks like it needs a shadow, it needs a higher surface
tier.
Nothing is glassmorphic and nothing is translucent. There is no backdrop-filter on the site.
The navigation bar is an opaque surface of the page like every other surface, and it shows that
content has scrolled under it the tonal way, by stepping a tier. A blurred shade was tried and
removed: it is the one pattern every template ships, and the app has no such bar.
The radius scale is Google's, from md-shape-tokens.css, and the three larger values are the
ones Expressive added: 4, 8, 12, 16, 20, 28, 32, 48, full.
| Element | Radius |
|---|---|
| Card on the page | 28 (xl) |
| Card nested in a card, code block | 16 (lg) |
| Chip, badge | full |
| Button at rest | 28 (xl), which is a pill at 56px tall |
| Button pressed | 12 (md) |
| Button, selected or toggled | 16 (lg) |
| Text input | 16 |
| Phone frame outer | 7.8% of the frame width |
| Phone screen | 6.2% of the frame width |
| Decorative shapes | see below |
Buttons morph when pressed. A resting button is a pill, drawn with 28px corners rather than
full: a 9999px radius animating to 12 on an overshooting curve dips below zero and clamps, which
shows as square, jagged corners for a frame. On :active it snaps to 12px corners
and springs back on release, over 350ms cubic-bezier(0.42, 1.67, 0.21, 0.9). That curve and
those numbers are Google's own, from the expressive button in @material/web 2.5.0, and the
overshoot in the bezier is the point. Do not replace it with ease-out.
Decorative shapes are the one expressive flourish per page. Material 3 Expressive ships 35
named shapes. The site uses five: cookie-12, cookie-9, clover-4, sunny and pill. Each appears at
most twice on a page, as a mask-image over a flat tertiary-container or secondary-container
fill, sitting behind a phone mockup. Every single-phone stage on the home page has one, each shape
used once; the carousel has none, because three overlapping phones leave only slivers of it,
which read as a rendering fault, tertiary-container and secondary-container alternating. They are large (400 to 1000px), slow to move (see
Motion), and never carry text or icons. They are never primary. The SVG masks live in
assets/shapes/ and come from Beer CSS 5 (MIT), which traced them from Google's Figma kit.
The phone frame is its own shape and it is fixed: bezel at 1.75% of the frame width, outer radius
7.8%, screen radius 6.2%, a centred punch-hole camera at 5.2% of the screen width, in the bezel
colour, with nothing drawn inside it. Frame colour #0a0f12 (--phone-frame). The screenshots stop at
the app's title bar, so the mockup draws a status bar (time, camera, signal icons) and a gesture
bar around them in the app's own surface colours (--phone-app-surface, --phone-app-bar,
--phone-app-on-surface). This is a Galaxy S25 Ultra
silhouette, thin and square, and it is the same on every page at every size. Screenshots inside
it are object-fit: cover; object-position: top.
Where Chromium supports it, cards, chips and code blocks take corner-shape: squircle inside an @supports
block, which is the true Material shape. Browsers without it get the round corner. Nothing
depends on the difference. Buttons stay round: their radius animates on press, and an animating
squircle renders with rough edges.
Components are plain HTML with classes. No web component library, no framework. The Lit-based
@material/web components are in maintenance mode and predate Expressive, so they are not used;
their token stylesheets are.
Height 56px (M3 md size). Three variants and no more:
| Variant | Fill | Text | Border |
|---|---|---|---|
| Filled | primary |
on-primary |
none |
| Tonal | secondary-container |
on-secondary-container |
none |
| Outlined | transparent | on-surface-variant |
1px outline-variant |
A section has one filled button. "Download" is filled. "Read the docs" beside it is outlined. Two filled buttons next to each other is the tell of a template.
Labels are label-lg-emphasized, one line, sentence case. "Download the APK", not "DOWNLOAD
NOW". No arrow glyph appended to the label; the button is already the arrow.
The one icon-only button is back to top: tonal, 56px square, a 24px arrow_upward, pinned 16px
from the bottom right corner of the viewport on every page. It is tonal so the filled button in
whatever section is on screen stays the one primary. It appears once a viewport of content has
scrolled past, by a scroll timeline, so a browser without scroll timelines just always has it.
Icons inside buttons are Material Symbols Rounded, 20px, FILL 0, and they stay outlined on
hover: a glyph that fills while the button morphs was two things moving at once. The icon leads
the label.
Height 40px, full radius, surface-container-high fill, 1px outline-variant border,
label-lg-emphasized. Used for lists of facts: init systems, supported architectures, what a
container can run, the hardware the app hands over. Every chip names something documented in the
main repository's README or Documentation/Features.md; a chip that could describe any product
("Docker inside", "Fast") is not a fact. A selected chip, and any chip under the pointer, takes secondary-container with
on-secondary-container text. Chips wrap; they do not
scroll horizontally.
surface-container, radius 28, 1px outline-variant, padding 24. Title in title-md-emphasized,
body in body-md. A card never contains a button unless the card is the download card. Hover
steps the surface to surface-container-highest over the effects spring, with no transform, no
lift, no shadow.
surface-container-lowest, radius 16, padding 20, JetBrains Mono. A copy button sits top-right
as an icon button. Where a terminal transcript appears it is a real <pre>, not an image, with
prompt in primary, output in on-surface, and the cursor block static. It does not type itself
out. The hero has no terminal pane: the phone screenshot already shows the runtime working, and a
boot transcript beside it repeated the next section.
The M3 top app bar. Sticky, 64px tall, opaque surface at rest. Once the page has scrolled it
steps to surface-container, the M3 on-scroll container colour, across the first 64px of scroll;
that tier step is the only sign that content is passing under it. No bottom rule, no blur, no
translucency. Logo mark left, links right, theme toggle last. The active link is primary; the
rest are on-surface-variant, with the M3 state layer on hover.
Under 840px the links collapse into the M3 modal navigation drawer, from the right: 360px wide
on surface-container-low, radius 16 on its open edge, no border, the scrim behind it does the
separating. Items are 56px tall at full radius, and the current page sits on secondary-container
with on-secondary-container text, which is the drawer's active indicator.
The docs page tree is the M3 standard navigation drawer: 280px, on surface like the bar and the
page, no divider. The section labels and the active pill are its only structure, so the bar, the
tree and the article read as one plane. Under 840px it becomes a modal drawer from the left, the
mirror of the menu drawer, opened from the breadcrumb row that stays under the bar.
One component, one silhouette, described under Shapes. It takes a screenshot. Phones do not float: an idle loop on a phone beside a turning shape drifts out of step with it and reads as a glitch, so the shape is the only thing that keeps moving.
Three phones in a group form the app carousel, one <figure> each with a one-line body-md
caption saying what the screen does. Exactly one phone is in front and only its caption shows.
- On a wide pointer screen (
hover: hover, 840 and up) the phones overlap, the side ones at 80% scale. Hovering or keyboard-focusing a phone brings it to the front; at rest the middle one is. Hover wins over focus, so two phones are never in front at once. - Everywhere else the group is a horizontal scroll-snap carousel, edge to edge under 840, opening
on the first screen. Not the middle one:
scroll-initial-targetalso scrolls the page itself to the carousel on every load, so the visitor never sees the hero. A scroll-drivenview(inline)timeline makes the centred phone full size and its neighbours 85%, and shows only the centred caption.
No JavaScript runs either mode.
The one sequence on the home page: phone boots, Droidspaces starts the containers, the init
system starts the services. An <ol> of three steps, each an h3 in title-md-emphasized and
one body-md line. Steps are joined by a 1px outline-variant rule through 12px ring markers in
outline-variant on surface. No numerals and no primary: it is a chain of events, not a
numbered feature list. It fills with scroll (see Motion).
The comparison table is surface-container with a surface-container-high header row, 1px
outline-variant rules, label-md headers, body-md cells. The Droidspaces column is tinted
primary-container at 20%, which is the only fill of primary on the page and is why the
comparison section has no filled button.
surface-container-low, three columns on desktop (project, community, legal), the logo mark and
a one-line description, GPLv3, and the maintainer's name. No newsletter box.
Material 3 Expressive replaced duration-and-easing with springs. Two families: spatial springs
move, resize and reshape things, and may overshoot; effects springs change colour and opacity
and never overshoot. The site uses the Expressive spatial set and the shared effects set, encoded
as CSS linear() easings generated from the Compose damping and stiffness values. No JavaScript
animation library.
| Token | Damping / stiffness | Settles in | Use |
|---|---|---|---|
--motion-spatial-fast |
0.6 / 800 | 480ms | Button press, chip select, icon fill |
--motion-spatial-default |
0.8 / 380 | 470ms | Cards and phones entering the viewport, drawer open |
--motion-spatial-slow |
0.8 / 200 | 640ms | The hero phone's arrival, section shapes |
--motion-effects-fast |
1.0 / 3800 | 180ms | Hover colour, focus ring |
--motion-effects-default |
1.0 / 1600 | 270ms | Surface tier step on hover, theme change |
The linear() point lists are in tokens.css, along with --motion-press for the button morph
and --motion-float, which is generated but unused since phones stopped floating. They were produced from the spring equation, not
drawn, so --motion-spatial-fast really does overshoot by 9.5% and settle, the way the Android
button does.
Six kinds of motion exist on the site, and nothing else moves:
- Arrival. When a phone mockup or decorative shape reaches the screen, it translates up 24px
and fades in over
--motion-spatial-slow, once. It is timed, not scroll-driven: a scroll-linked entrance ran at the scroll's speed, so a fast flick popped objects in and every scroll frame repainted them.site.jswatches with an IntersectionObserver and starts the CSS animation; the motion itself is still CSS. Objects already on screen at load are left alone. Text does not animate in. Cards do not animate in. Only the objects. The section's headline is already there when you get to it. - Press and hover. Buttons morph on press. Surfaces step a tier on hover. Chips take
secondary-containeron hover. Icons do not change. All use the springs above. - Carousel. A phone promoted by hover or focus scales to 100% over
--motion-spatial-default; its caption fades over--motion-effects-default. In the swipe carousel the scale and caption follow the scroll position, not a timer. When the overlapping group arrives, the side phones fan out from behind the front one, on the same timed trigger as arrival. - Shape turn. Decorative shapes turn once every 120 seconds, linear, forever, the way the
M3 Expressive loading indicator they come from turns.
secondary-containershapes turn the other way, so neighbours never look like one pattern. The turn runs on a timer, not on scroll, so the page keeps moving while the visitor reads. A shape arrives by fading in from 90% scale rather than by translating, because its translate is what centres it. - Boot chain fill. As the boot chain moves up the screen, each ring turns
primaryin order and the rule between rings fills, on the chain's ownview()timeline. It is scroll-linked on purpose: it pauses when the reader stops and runs back when they scroll back. The page never locks scrolling to play it; a page that stops scrolling reads as broken and fights keyboard and assistive scrolling. The fill is the section's oneprimary. - Shape morph. The loading indicator on the downloads page cycles through
cookie-12,clover-4andsunnyby morphing the mask, which is the M3 Expressive loading indicator. Nothing else morphs.
Only site.js ever hides an object before its arrival, and only one that is below the screen, so
nothing is hidden without JavaScript: the page renders
complete with JavaScript disabled and with prefers-reduced-motion: reduce, in which case the
shape turn stops, arrival becomes an instant fade, the button morph is disabled, the
carousel loses its fan-out and scale (it still swipes and still hovers, instantly), and the swipe
carousel shows every caption, and the boot chain stays drawn without filling. The reduced-motion
branch is not optional.
There is no float, no parallax, no marquee, no typing effect, no counter that counts up, no pulsing dot, no cursor that blinks, no hover transform that scales or rotates an image, and no page-load sequence that stagger-reveals every element in turn. Each of those is a template tell and each has been seen on a hundred landing pages this year.
Do use primary on exactly one element per section.
Do give every card a 1px outline-variant border and no shadow.
Do let the phone mockups carry the page. They are real screenshots of a real app.
Do write headlines as one plain sentence in one colour.
Do keep body copy at body-lg and under 68 characters a line.
Do run the site with JavaScript off before every release and confirm nothing is missing.
Do test prefers-reduced-motion and prefers-color-scheme on every page.
Do keep a 48px minimum on anything tappable. This is the one rule design does not override.
Don't use a gradient anywhere: not on a background, not on text, not as a glow behind a phone.
Don't use a colour that is not in tokens.css.
Don't put an uppercase, letter-spaced label above a heading.
Don't number sections 01, 02, 03. Nothing on the site is a sequence except the install steps and the boot chain.
Don't join metadata with middle dots. Use a comma or a separate line.
Don't append an arrow to a link or button label.
Don't use an em dash. Use a comma, a full stop, or rewrite the sentence.
Don't write "Unlock", "Supercharge", "Seamless", "Blazing fast", "Next-generation" or "Reimagine".
Don't build a row of three or six identical icon-title-text cards.
Don't animate text, and don't animate anything on scroll except the swipe carousel and the boot chain fill.
Don't add a second shadow, a glow, a frosted card, or a dot grid background.
Don't put backdrop-filter on anything. The navigation bar is opaque; the blurred shade was tried and removed.
Don't load a font, icon or script from a third-party origin at runtime.
Looked at, deliberately left alone. Do not re-open these without a reason the original one misses.
- The phone mockup has a shadow. It is the only shadow on the site. It is an object, not a surface, and the same silhouette is used in the LinkedIn and README artwork, so the two must match.
- Terminal panes go to
#000000in dark mode rather than a tonal surface. The app's terminal does the same, and a slate terminal reads as a disabled one. primary-fixedis used for accents inside the phone screenshots' frames, so the mockups look the same in light mode. A screenshot captured in dark mode cannot change with the theme, and a fixed role is what M3 provides for that case.- The docs pages keep their generated structure from
.github/scripts/build-docs.pyand only restyle it. The Markdown source is in the main repository, and the site must not fork it. - Light mode was generated, not designed. It is the same source colour through the same scheme. If a light-mode surface looks wrong, fix the generator input, not the CSS.