moderne.ai · working draft

Website style rules

What the marketing site settled on over two days of design passes: one flat, token-driven system with no boxes, one heading scale and one body size. Written down so the next change does not have to rediscover it. Token values here are read from tokens.css; where this page and the codebase disagree, the codebase wins.

Do's and don'ts

Most of the work over these two days was removing things. The system is subtractive: rules and type carry the structure, not containers.

Do

  • Use the flat ledger. One top rule over the group, hairline separators between columns. This is the house treatment for stats, cards, rosters, steps and feature lists.
  • Let type and rules do the grouping. A heading plus a hairline is enough; it does not also need a plate.
  • Use supplied assets as they are. Treat an image like a photograph.
  • Keep one content column. --wrap-interior: 1000px on every interior page.
  • Close sections with one plain statement. No button, no flourish.
  • Measure before and after. Every claim in this document came from reading the DOM, not from looking at it.

Don't

  • No boxes. No rounded-rectangle card with fill + border + radius. If it has a plate, a 14px radius and a hover lift, it is the old style.
  • No mono-caps eyebrows above a heading. The heading already says it.
  • No dot separators. 01 · Find became 01 Find.
  • No spectral gradient. Conic rings, animated borders and spectral top-accents are all retired. The keyphrase highlight is reserved for the hero.
  • Don't redraw a supplied asset as SVG, and don't re-author hand-drawn marks. Crop or extend the original instead.
  • Don't invent data. A figure carries the idea; the numbers live in the article.

Typography

Two faces. Geist for everything readable, Geist Mono for technical naming and small labels. There is no third display face on this site — --font-display resolves to Geist.

The scale

--h-display
clamp(38px,6vw,72px)
Hero headline and the closer's key line. Nothing else.
--h1
clamp(34px,3.6vw,52px)
Large section heading.
--h2
clamp(28px,3.4vw,44px)
A display moment, promo banners only. Not the section heading.
--h3
clamp(24px,2.6vw,32px)
The standard section heading sitewide — this is .band-h. If a page invents its own heading size, it is wrong.
--statement
Aliases --h3. Closers, CTA headlines, in-band leads.
--body: 17px
Body copy is always 17px. Several pages were running 14.5–15.5px and have been moved onto the token.
--measure: 770px
Paragraph width. Code blocks in articles sit on the same prose measure — they used to run the full column and jut ~210px past the paragraph explaining them.

Mono is for labels, not for names

Mono, uppercase and wide-tracked reads as a label. That is right for group headings inside the nav panel, figure captions and technical identifiers. It is wrong for anything that is a proper noun or prose:

Company names
Geist, sentence case. "Acrew Capital", not ACREW CAPITAL — a firm is a proper noun, not an eyebrow.
Job titles
Geist. A role is prose.
Recipe names
Show the recipe's display title, not its fully-qualified id. io.moderne.prethink.ExtractCodingConventions became "Extract coding conventions".
Captions and step labels
Mono, but lowercase sentence case — "every repo, at once".

Marks and breaks

® and ™
Wrapped at runtime in .tmk by lib/trademark, sized max(9px,.58em) with line-height:0. The floor matters: a flat .58em put the trademark attribution in an 11.5px legal disclaimer at 6.67px, the one place it has to be legible.
Hyphenated compounds
Wrapped in .nb (nowrap) by lib/nobreak, up to 22 characters. CSS cannot do this: hyphens:none and word-break:keep-all both leave an explicit hyphen breakable. Verified in Chromium.
Orphans
Where a break must be deterministic, set it with <br/>. At any max-width that fits line two, a word creeps up and strands the last one.
Excerpts and sentences
Close them. 14 of 114 blog excerpts ended with no full stop.

Color

Never hardcode. Every colour is a token, and the day/night values are mixed through a single --warm dial, so a raw hex in a stylesheet cannot follow the ground it sits on.

--bg#F2F0EA
--panel#FBFAF6
--surface#F2EFE7
--fig-plate#F4F2EC
--bone#232342
--green#1D5937
--shot-plate#3E7BF6

Green

On light grounds the accent is the forest green #1D5937, not Digital Green #30F284 — the bright green falls to about 1.5:1 on cream and is unreadable. Green is also never the only signal: it is always paired with an underline or an icon.

Rules and hairlines

--line
12% ink. The standard rule — band dividers, the top rule over a ledger.
--hair
55% of --line. For ruling large grids, where a full-strength rule on every cell edge reads as a cage.
--tick
22% ink. Corner registration marks that frame a plate without drawing a border around it.
--logo-filter
Normalises customer and OSS marks of any ink to the current ground.

One divider, not two. Two components that each own a top rule will sit 26–125px apart and read as a doubled line. Only one of them should draw it.

Graphs and figures

Figures are same-origin HTML documents in iframes under /embeds/, auto-sized to their content. They are authored, not drawn, so they can be re-rendered when the copy or the palette moves.

SVG labels do not hold their size

SVG text is in user units and scales with the container. A fixed 13px label against an 860-wide viewBox renders at 13.6px near 900 and 5.8px at an iframe width of 386 — unreadable on a phone.

Step the user-unit size up as the scale falls. The value is 13 ÷ (iframeWidth ÷ viewBoxWidth) at each breakpoint. After fixing: 12.6–13.6px at 1440, 820, 600 and 430 alike.

Plates behind charts
#F7F5F0 — lighter than the cream ground, deliberately not white.
Figure captions
Mono, lowercase. More air above a chart heading than below it (34px / 12px), or it floats between two plates instead of titling the one under it.
Term labels
At least 14.5px at --body colour. 12px at --muted made them the faintest text in the figure, and they are what the table hangs off.
Don't imply numbers
A bar figure with an exponential spike reads as a data claim. If the article does not state the number, the figure should not either.

Measure the body, never documentElement

documentElement.scrollHeight is clamped to the iframe's own viewport, so it can never report less than the height already set. One oversized first measurement locks the frame tall permanently and the ResizeObserver can only re-confirm the bad value — a one-way ratchet. That was the enormous white space under an in-article diagram.

body.scrollHeight is content-driven and shrinks back. Add a 1px tolerance before writing the height, or the write resizes the frame, re-fires the observer, and the two loop on sub-pixel deltas.

Screenshots and images

The standing rule: use the asset that was given to you. Do not re-author it, do not trace it as SVG, do not "improve" a hand-drawn mark. Crop it, extend it, or place it — but it stays the same artwork.

Fixing a bad crop

When a portrait is framed too tight for its plate, extend the image, don't crop it harder. Pad sideways with matched background so the full subject fits the target ratio.

Build the flank from the edge
Average the outer ~34 columns into one column, blur it hard along its length, then tile outward. Any subject pixel touching the edge dissolves into backdrop tone instead of streaking out as a hard band.
Freeze above the shoulder line
Below ~60% the edge is collar and shoulders, not backdrop. Sampling it paints a bright block into the flank. Hold the last backdrop row down to the bottom.
Match the rendered size, not the file
If siblings are cropped 1.33× by the plate and you hand one subject a full frame, it shrinks twice over. Measure headroom as a fraction of the rendered plate.
Keep the original
Derived files sit alongside as -wide / -padded, so framing can be regenerated.

Zoom

Images use initLightbox via .js-zoom. Iframes cannot — a click inside an iframe never reaches the parent document — so diagram embeds get a transparent button laid over the frame. Zoom re-lays the document out at 820px and pans rather than scaling the phone layout up; scaling only enlarges the same small labels.

Carousels

Constant-height track
Stack the slides and give the track a fixed height. A carousel that resizes per slide pushes everything below it around — the "moving Subscribe button" was not a focus bug, it was the carousel above changing height on rotation.
One nav pattern
Round prev / next, a live n / total count, right-aligned dash indicators. The blog featured slider and the newsroom carousel share it.
Show few, then load more
Lists page rather than scroll forever. Newsroom archive shows 5.

Thumbnails

Blog and social cards are authored as HTML and rendered, never drawn in a raster editor. The source lives beside the embeds so a copy or palette change is a re-render, not a redraw.

Source of truth
An HTML file under /embeds/ at the exact thumbnail frame, with a comment saying so: do not hand-edit the raster.
Render
Playwright at 1.5–2× the frame, then downsample. Type stays sharp.
Never repeat the title
The blog card and the post both print the headline directly beneath the image. Carrying it inside says it twice.
Match the in-article figure
If the post contains a diagram, the thumbnail uses the same design, so the card and the article read as one piece.
It is also the social card
og:image and twitter:image point at the same file. Check the share preview before merging.

Shared patterns

The ledger

One top rule over the group, a hairline between columns, no plate and no radius. The trap is the gutter: the rule sits at a column's right edge, so the padding before it needs matching space after it, or the next column's text butts straight against the line. Either a column-gap, or padding-left on non-first columns — not both, or the gutter doubles.

Ruled grids

Internal hairlines only: a cell rules the side facing a neighbour and never an outer edge, so the wall gains structure without gaining a frame. Express it as "top rule from row 2 on, left rule from column 2 on" — then no rule depends on knowing the final row, and a ragged last row stays clean at any item count.

Portrait plates

4:3 (not 16:9 — the headshots are square and a wider plate crops past the chin), greyscaled so unrelated studio backdrops stop fighting, name in Geist, role in mono, an underlined text link. No circular avatars, no cards.

Corner ticks

Four small marks at the corners frame a quote without a box. Drawn as four background layers — pseudo-elements only give you two.

Engineering traps

Each of these cost real time over the two days. They all look correct in the stylesheet.

A class reset cannot out-specify :not(:nth-child(Nn+1))

Restating a ruled grid per breakpoint with a plain class reset silently loses — it renders correctly at one width and draws stray edge rules at the others. Give each column count its own bounded media range instead of overriding the one above it.

Equal specificity resolves by source order, and order differs in production

Two rules at (0,1,0) are decided by which loads last — and the dev server's per-file order is not the production bundle's. A hero paragraph rendered white locally and dark slate on the deployed site. Scope to raise specificity rather than relying on order. A local-only check will not catch this.

An element cannot be restyled by its own container query

If the layout change alters the container's own display, the container-type has to sit one level up.

A negative z-index child paints over its parent's background

Used as a "bar behind a button", it swallows the button's own fill. And a box-shadow spread inherits the element's border-radius, so it paints a rounded slab, not a bar. Both need a real wrapper.

Flex columns do not collapse margins; inline-blocks inflate the line box

A 1px gap on a flex column lands on top of negative margins and throws a 38px row pitch to 39. An inline-block child sits on a baseline and pushes a 44px row to 49 — make the row a flex container and the problem disappears.

Behaviour and its styling must ship together

A page called initLightbox but did not import the sheet that styles .lightbox. The overlay was built and un-hidden on click, with no position, no scrim and no sizing — so the image simply never appeared to zoom. If a helper is shared, its CSS belongs in the shared sheet.