Lankford Labsthe product practice of Dave Lankford Live

Style guide

The rules, and why each one is a rule.

This page is generated from the site's own stylesheet. Every specimen below is drawn by the rules that draw the real thing — not by a copy of them. There is nowhere for a second version to live, so if this page looks wrong after a change, this page is right and the change is the news. Rebuild it with python3 _admin/style-build/mkstyle.py.

Color

Eight colors, and four of them exist because the other four can't carry type.

Every hue here is paired with the version of itself that was taken down until white would hold on it. That is not a tint system — it is the same color at the weight legibility demands, and the pair is the unit.

--paper #f4f2ed The ground. Everything is drawn on this, not on white.
--ink #16181d The wire, at rest. Not black — black is a different thing.
--saber #14a9ff Racing blue. Marks what is live: an inherited stroke, a measurement being taken, the planned end of a roadmap.
--rebo #0f7cbc Saber, taken down until white holds. Carries the archived badge.
--rival #ff2e1f Rival red. The counterweight to blue — the arc among the primitives, an outburst.
--rebel #df0f00 Rival, taken down the same way. Carries the status bar and the live badge.
--warm #ffc46b The mark, lit. Only on a dark ground.
--deep #c98614 Warm, taken down until it holds on paper. Carries the in-progress badge.

The contrast floor is 4.5:1, and it is measured, not eyeballed

Small text needs 4.5:1 under WCAG AA, and every label on this site is small text — 10 and 11px mono. The table is what each color can actually carry. A color that fails here does not get used for type. It gets taken down until it passes, and the taken-down version gets its own name.

TokenHexWhite on itInk on itCarries type?
--rebel#df0f004.993.56Yes, in white
--rebo#0f7cbc4.543.86Yes, in white
--deep#c986143.045.84See exceptions
--rival#ff2e1f3.714.79No — use --rebel
--saber#14a9ff2.586.89No — use --rebo
--warm#ffc46b1.5711.30No — dark grounds only

Rival is the instructive one. Ink on rival measures 4.79:1 and passes, so by the number it was fine — and it read as mud. Rival is a high-chroma color that is optically dark, so a dark type color has almost nothing to separate from it. Taking the ground to #df0f00 put white at 4.99:1: legible and compliant, where before it was one or the other. The lesson is that passing is necessary, not sufficient.

Derived values are ink, not new colors

--rule is ink at 12%, --mute ink at 55%, --faint ink at 3.5%. They are not separate colors and must never be given hex values of their own. In dark mode they invert with the ground; the eight hues do not change. There was a fourth, --lift, white at 55%: defined in both schemes, written down here, and read by nothing. A token nothing uses is a colour the site does not have, so it is gone.

A story is not a license to add a color

Anything outside the eight is a local prop, named as one and deleted with the thing that needed it. The palette does not grow to fit a layout.

Type

The Wire has the personality. Nothing else is allowed any.

There are three typefaces on this site and only one of them is mine. Lankford Wire draws the lockup, the wordmark, and every section numeral on home and on this page — those come out of glyphs.json at build time, so the "01" above is the same alphabet as the mark. Everything else is the reader's own system font, because a second voice would argue with the first one.

RoleStackWhere
--sanssystem UI stackAll prose, all headings
--monosystem mono stackEvery label, badge, count and eyebrow
Lankford Wirestroked SVG pathsThe mark, the wordmark, the section numerals, and everything /letterform sets

No webfont is loaded anywhere on this site. No @font-face, no Google Fonts link. The one exception is /readouts/texas-find-em, which imports Bungee and Overpass inside its own stylesheet so that the case study can show the real product's components in the real product's type. That cost lands on that page alone and nowhere else.

The scale, and the measure that goes with it

Every size below is in use. A size not on this list needs a reason. Measure caps matter as much as size — prose that runs past about 66 characters stops being read and starts being skimmed.

ElementSize / leadingMeasure
body16px / 1.62
h1 (page title)2.6rem / 1.0818ch
h2 (section)1.7rem / 1.1426ch
.p h3 (card title)1.35rem
.standfirst1.18rem / 1.5660ch
p16px / 1.6266ch
.p .q (card blurb)1rem58ch
.fsay (footer)16px52ch
.eyebrow11px mono, .2em
.art / .nav a11px mono, .07 / .14em
.chip / .tg / .track span10px mono, .12–.14em
.facts dd14.5px
.foot13.5px

Labels are sentence case; the mono does the shouting

Uppercase is applied by text-transform, never typed. Write "Send feedback" and let the stylesheet decide. Two reasons: the text stays readable to a screen reader, and a label can change case sitewide without anyone retyping it.

Body copy is not display copy

A sentence that works at 16px does not become a headline by being set at 40px. The hero line on the home page was body copy promoted into display once, and it was wrong at every size I tried before the fix turned out to be structural: shorten the line, fold the rest into prose.

Space

Three tokens hold the page together.

TokenValueWhat reads it
--col1060pxOuter width of .wrap, the nav row and the hero — all three carry --gut inside it, so the content column is 1012
--gut1.5remSide padding everywhere. The bar overrides to 1rem under 396px
--bar2.1rem → 3remStatus bar height, body top padding, sticky nav offset

--bar is the one worth understanding. It is read by three things at once, so changing it moves the bar, the page's top padding and the nav's sticky offset together. When the bar had to grow on a phone, nothing else needed touching. Never hard-code a value that --bar should be supplying.

Sections are separated by space, not rules

A section is 4.6rem of padding, a numeral and a color. There are exactly two horizontal rules on the site, both in the footer. A rule is what you reach for when the spacing is not doing its job.

Two ways to number a section, and the seam decides which

Both are standard and both stay. A section number is either drawn — a Lankford Wire numeral, 44px, ink, written on scroll — or set, a small muted label running in the flow. The question is how hard the seam between two sections should read. Drawn is a break. Home and this page are indexes of separate things, and the reader should know when one has ended. Set is a continuation. /letterform and every readout is a single argument told in order, and a numeral announcing itself every few screens would keep interrupting it.

In progress

Drawn — a break

01 — Genesis

Set — a continuation

 DrawnSet
FormLankford Wire numeral, 44px, --ink01 — Name, 13px, .14em, --mute
Writes itselfYes, when the section scrolls inNo
BuysA seam the reader cannot missA page that reads as one argument
Where/ · /style/letterform · every readout

A page picks one and keeps it. Both on one page would be two tables of contents disagreeing about where the reader is.

The mark

One width, one radius, and a ratio that ties the mark to the alphabet.

The mark and the wordmark are the same construction at two scales. That is the whole idea: a logo that contains an alphabet.

MeasureWordmarkMark
stroke-width100184.1
bend radius112.5207.1
R ÷ stroke1.12501.1250
linecaproundbutt
cell300 × 450, outer 400 × 500
advance480 nominal, 440–520 kerned
row leading650
guidesstroke 14 at .35 opacity

Four canonical states, and the size decides which

The guides are a 14-unit stroke — 1/7.14 the weight of the wire, a fixed ratio — so whether they read at all is purely a function of rendered size:

Lockup widthGuide renders atResult
520px1.43pxReads as a rule
365px1.00pxOne pixel at 35% opacity: haze
300px0.82pxSub-pixel: no longer a line

The lockup markup is copied verbatim, never retyped

Home, the footer mark and this page all carry the same paths. They are lifted from /lockuplab character for character. Classes that look inert on a given page — mark-ink, word-ink — are hooks the animated pages use, and they stay so the markup does not fork.

Components

Every specimen below is live.

Two chip systems, separated by shape as well as color

A grey rectangle on the surface says what a thing is. A colored pill says where it is. Neither can be mistaken for the other at a glance, which is a firmer rule than filled-versus-outlined ever was.

Consumer appAI buildTypography
In progress Live Archived

Category tags · state badges

StateClassGroundTypeMeans
In progress.chip.prog--deep#fffBeing built now. Not paused.
Live.chip.now--rebel#fffShipped, and may still have a roadmap.
Archived.chip.arch--rebo#fffNo longer a consumer product. Not a failure.

The roadmap track: solid is committed, faint is an idea

Weight tracks commitment. Work under way is the serious one; a planned item is still a thought. I had these the other way round at first — the darker tick reads as more serious, so the darker tick is the one under way.

2 in progress · 4 planned
Nothing planned

Current: data analysis & design

A project with no track states its phase instead

Artefact links, and the slots that are empty on purpose

Every project shows the same row of artefacts whether or not they exist yet. The row states the format and its state at once: what this project will have, and what it does not have today. An empty slot is a <span>, not a link — nothing to tab into, no href pretending to have a target.

Three tiers, separated by weight rather than hue. The primary is filled, the secondary is outlined, the empty slot is the site's own --mute on a hairline rule — 15.87:1, 15.87:1 and 3.83:1 on paper. A fill rather than a fourth colour: state is the badge's job and it is not carried twice, and --deep is 2.72:1 against the page, under the 3:1 a non-text boundary needs. Nothing in this row mutes with opacity. Opacity multiplies against whatever sits behind it, so it cannot be measured once; an empty slot dimmed that way read 1.55:1 in light and 2.00:1 in dark.

Primary · secondary · two empty slots, each saying when (hover one)

A hover box still takes up room. A ::after tooltip is invisible until hover but laid out all the time, and it counts toward the page's scroll width. One on an inline word near the right edge pushed the whole page into horizontal scroll between 340 and 364px.

A tip on a chip stays inside its card and is safe. A tip on running text is not — use a native title there, which the browser paints and which cannot affect layout at any width. The footer's "Say so" was that native title until it became a link with somewhere to go, which is the better answer again: the rule is the reason it never became a ::after.

Cards are furniture, so cards may be lifted

The project card sits on a surface lighter than the paper with two shadow layers — a tight one for the edge, a wide soft one for the lift — both in ink rather than black, because the palette is explicit that black is a different thing.

This does not license depth anywhere else. The mark and the letterform stay flat and do their depth by clearance. Chrome may be elevated; artwork may not.

The status bar

White on --rebel, fixed, and it is the only fixed element on the site. Its status is one unbreakable unit — the dot belongs to the word — and the practice line beside it is two unbreakable phrases, so the only place it can break is between them. Below 560px the bar drops the name, because the lockup directly beneath it says "Lankford Labs" a great deal louder.

The feedback dialog says what it is

The dialog says which of the two things it is doing. With no endpoint it composes a message and hands it to the reader's own mail app, and its primary reads "Open in email"; with one it posts and the reader stays on the page, and the primary reads "Send it". The endpoint lives in exactly one place — the form's action — and the script reads the attribute rather than keeping a second copy, so there is no state where the button promises one thing and the code does the other. The subject is the project's name and the word Feedback either way, so a reply lands already filed.

Three ranks under that. If the script fails after the dialog is open, the browser submits the form natively and the endpoint answers with its own page. If a POST fails, the error says so and the three escapes are still sitting under it holding everything that was typed — a failure is never a dead end. And the honeypot is off-canvas rather than display:none, because a bot that reads styles skips what is hidden.

One message, three ways out of the page. mailto: is the primary because it is the only one that assumes nothing about who the reader mails with — but on a desktop it hands off to whatever the OS believes the mail client is, which for anyone reading webmail in a browser is the wrong app entirely. So Gmail gets a direct compose URL, and copying needs no mail client at all. Neither is a dependency: they are links until someone clicks them, and nothing on the page requests either host. The copy control is only rendered where navigator.clipboard exists, because a control that might do nothing is worse than one that is not there.

The trigger is a real mailto: link, and stays one. The dialog is rank 2 of the same cascade the artwork uses: with no scripting, or on an engine without <dialog>, the click is never intercepted and the mail client opens directly. A control that needs JavaScript to do anything at all is a control that can do nothing. It has no specimen here, because a modal cannot be shown open on a page of specimens without lying about how it behaves — home has six of them.

Two sticky bars, and the page's job picks which

They are the same mechanism and two different briefs. A navigation bar holds anchors — home and this page are indexes, and the bar says where you are in one. A functional bar holds controls — /letterform is a specimen you operate, and its bar changes what is on the screen without going anywhere. Nothing else differs: both stick at var(--bar), directly under the fixed masthead; both ghost the paper at 92% and blur what runs beneath; both watch a 1px sentinel with an IntersectionObserver to know when they have stuck; and both offer the back-to-top arrow only once they have, because until then it has nowhere to go.

 NavigationFunctional
Class.nav.controls
Where/ · /style/letterform
HoldsSection anchors<button aria-pressed> toggles
Leaves the pageNo — it scrolls to a sectionNo — it redraws one
Sentinel.sentinel.ctl-mark
Sticks atvar(--bar)var(--bar)

The rule goes on the row, not on the bar. Both bars are full-bleed so the blurred ground covers the page edge to edge, but the hairline under them has to stop where every other rule on the site stops — the 1012px column. Same reason the footer's rules hang off content boxes rather than off <footer>.

A link goes somewhere; a button does something

That is the whole rule, and it decides the tag before it decides the clothes. Every control in an artefact row is an <a>, because every one of them leads out — including Send feedback, which is a real mailto: in the markup. /letterform's toggles are <button> with aria-pressed, because they change what is on the screen without going anywhere, and so are the two controls in home's feedback dialog. This page said "nothing on home is a button" for exactly as long as that was true.

The button looks like something you press. A pill, lifted, white on the paper — the one component on the site that is more elevated than a card, because a card is furniture and this is a switch. Pressed, it fills with ink and the lift drops away, which is the same argument the artefact row makes: a filled control cannot be hovered by washing its ground, so it states its state with the fill and its lift with the shadow.

Resting · pressed · resting — hover, press and tab to them, they are live. Pinned to paper in both schemes, because the page they belong to has only one

Back to the top is an arrow, and it is the same arrow everywhere

Shaft and head, not a bare chevron. A chevron on its own is a direction; an arrow is an instruction, and this control gives one. It is drawn in the site's own language — one wire, 2.2, round caps and joins, currentColor — so it takes the ink of whatever it sits in and needs no colour of its own. Home and /letterform each carried a chevron of their own until the three were put side by side.

M 12 19 L 12 5 M 5 12 L 12 5 L 19 12

The caret, live — and the only path data on this page written out

The rules that draw it are .top, not .nav .top: placement belongs to the bar, the shape belongs to the site, which is why the specimen above can be a real one sitting outside a nav — and why all three pages call it .top. It is never a button. On /letterform it sat in the control bar wearing the same white pill as the toggles beside it, which said it was another toggle; it goes somewhere and they do not. style-truth.py holds the path identical across the three.

Motion

The finished artwork is the default. The animation is the enhancement.

Every drawn thing on this site is built as four ranks of cascade, in this order. Get the order wrong and the page has a state where it shows nothing.

RankSelectorDoes
1bareThe finished artwork. This is what a reader with no JS sees.
2.jsRewinds it — only once scripting has proven itself.
3.drawnAttaches the animations.
4prefers-reduced-motionForces the end state with !important.

A keyframe name means one animation, site-wide

br-* is the shared family: br-draw, br-appear, br-nudge, br-flip and the rest, and wherever a page defines one it defines the SAME one. /letterform called four of them lf-* and they have been renamed; br-nudge took that page's var(--ny, 75px) so a single definition serves both, and 75px is what home was already moving. A page's own animations keep a page prefix — /letterform has nineteen lf-* that home has no equivalent for, including an lf-emerge that travels the opposite way to br-emerge and therefore must not borrow its name. style-truth.py compares every shared name across the two files.

A missing keyframe name fails silently — the animation never runs, the path stays rewound, and round caps on a zero-length dash paint a dot at each path start. Three paths in "01" means three mystery dots and no error anywhere. check.py asserts every referenced keyframe is defined.

Every animated path carries pathLength="1"

So one keyframe drives every draw regardless of the path's real length, and timing is set per path by --d and --t rather than by re-deriving dash arrays.

The rewound state is 1 2 at 1.05, everywhere

One dash unit on, two off. The gap has to be longer than the path, because the dash pattern repeats: stroke-dasharray:1 is shorthand for 1 on and 1 off, so on a pathLength of 1 the next dash begins at 0.98 and two per cent of the stroke is still painted at the tail. At stroke-width:100 with round caps, that sliver renders as a full dot — one per path, sitting on the page until the animation starts. A 2-unit gap puts the next dash at 1.95, past the end.

Speed is one number

--spd scales every delay and duration uniformly, which preserves the property that matters: the mark is written at a single hand speed across three pieces of very different length. Do not scale the beats independently.

Safari has never supported CSS d

Any page that animates path shape needs a JS fallback. /letterform has one. Nothing else on the site animates d, and check.py asserts that rather than assuming it.

Responsive

Five breakpoints, each with a reason.

These are the only widths the site changes at. A new one needs to name what breaks at it.

WidthWhat changesBecause
760pxLockup guides hide; Dave section goes single columnGuides fall under 1px; two columns stop fitting
720pxProject card goes single column, badge moves aboveThe right rail has no room left
680pxFooter grid goes single columnMark and text stop sitting side by side
560pxBar drops the name; nav tightens its trackingThe practice line stops fitting one row
396pxBar grows to 3rem and tightens its paddingThe line takes two rows and a fixed bar clipped it

Test the band between your test widths. A tooltip overflow lived at 340–364px while both 320 and 390 passed clean. A width nothing tests is a width nothing protects.

And settle the layout before measuring it. A sweep that resizes the viewport and measures immediately reads the previous width's boxes and reports all clear. That produced a false pass across 1,120 widths. Open a fresh page per width, as check.py does, or wait after each resize.

Voice

First person, American English, and no marketing.

Misuse

Six things not to do to the mark.

Exceptions

Where the site knowingly breaks its own rule.

Every rule above has a place it is not obeyed. Those places are listed here rather than quietly tolerated, because an undocumented exception is indistinguishable from a bug.

RuleExceptionStanding
4.5:1 for typeThe in-progress badge is white on --deep at 3.04:1Held deliberately, to keep the gold exactly as the palette states it. #a16b10 would clear it.
No webfonts/readouts/texas-find-em imports Bungee and OverpassHeld. The case study shows the real product's type; the cost is on that page alone.
Artwork stays flatProject cards are lifted with two shadow layersNot an exception — cards are chrome. The rule governs artwork.
The mark's clear spaceAt the 24px minimum, clearance renders at 0.59pxOpen. The minimum size and the clearance rule disagree and one of them is wrong.
Every colour inverts in dark mode/letterform has no dark mode at all, and its buttons are white in both schemes — including the live specimens on this pageOpen. Sixteen hardcoded ink and paper values are left in that stylesheet, plus more in its SVG fill= attributes, which cannot take a var(). Not a rule worth bending; a job not yet done.

If you are about to add a fifth row, that is the moment to check whether the rule is still the right rule.