In progress
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.
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.
| Token | Hex | White on it | Ink on it | Carries type? |
|---|---|---|---|---|
| --rebel | #df0f00 | 4.99 | 3.56 | Yes, in white |
| --rebo | #0f7cbc | 4.54 | 3.86 | Yes, in white |
| --deep | #c98614 | 3.04 | 5.84 | See exceptions |
| --rival | #ff2e1f | 3.71 | 4.79 | No — use --rebel |
| --saber | #14a9ff | 2.58 | 6.89 | No — use --rebo |
| --warm | #ffc46b | 1.57 | 11.30 | No — 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.
| Role | Stack | Where |
|---|---|---|
| --sans | system UI stack | All prose, all headings |
| --mono | system mono stack | Every label, badge, count and eyebrow |
| Lankford Wire | stroked SVG paths | The 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.
| Element | Size / leading | Measure |
|---|---|---|
| body | 16px / 1.62 | — |
| h1 (page title) | 2.6rem / 1.08 | 18ch |
| h2 (section) | 1.7rem / 1.14 | 26ch |
| .p h3 (card title) | 1.35rem | — |
| .standfirst | 1.18rem / 1.56 | 60ch |
| p | 16px / 1.62 | 66ch |
| .p .q (card blurb) | 1rem | 58ch |
| .fsay (footer) | 16px | 52ch |
| .eyebrow | 11px mono, .2em | — |
| .art / .nav a | 11px mono, .07 / .14em | — |
| .chip / .tg / .track span | 10px mono, .12–.14em | — |
| .facts dd | 14.5px | — |
| .foot | 13.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.
| Token | Value | What reads it |
|---|---|---|
| --col | 1060px | Outer width of .wrap, the nav row and the hero — all three carry --gut inside it, so the content column is 1012 |
| --gut | 1.5rem | Side padding everywhere. The bar overrides to 1rem under 396px |
| --bar | 2.1rem → 3rem | Status 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.
Drawn — a break
01 — Genesis
Set — a continuation
| Drawn | Set | |
|---|---|---|
| Form | Lankford Wire numeral, 44px, --ink | 01 — Name, 13px, .14em, --mute |
| Writes itself | Yes, when the section scrolls in | No |
| Buys | A seam the reader cannot miss | A 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.
| Measure | Wordmark | Mark |
|---|---|---|
| stroke-width | 100 | 184.1 |
| bend radius | 112.5 | 207.1 |
| R ÷ stroke | 1.1250 | 1.1250 |
| linecap | round | butt |
| cell | 300 × 450, outer 400 × 500 | — |
| advance | 480 nominal, 440–520 kerned | — |
| row leading | 650 | — |
| guides | stroke 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 width | Guide renders at | Result |
|---|---|---|
| 520px | 1.43px | Reads as a rule |
| 365px | 1.00px | One pixel at 35% opacity: haze |
| 300px | 0.82px | Sub-pixel: no longer a line |
- With guides — canonical at 520px of lockup width and above.
- Without guides — canonical below it. Hard floor 365px.
- Either, still — a static lockup is the same markup that never receives
.drawn. There is no separate flat version to maintain.
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.
Category tags · state badges
| State | Class | Ground | Type | Means |
|---|---|---|---|---|
| In progress | .chip.prog | --deep | #fff | Being built now. Not paused. |
| Live | .chip.now | --rebel | #fff | Shipped, and may still have a roadmap. |
| Archived | .chip.arch | --rebo | #fff | No 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.
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)
.art.pri— the one thing this row is for. Ink fill, paper type. One per row..art— a real link that is not the primary one. Ink type on an ink hairline..art.off— a slot with nothing behind it yet. Muted type on the standard rule, and no opacity..tip— goes on a dead slot to say when, and needs no wrapper around it. Every empty slot carries one, and they all read the same. A slot that explained itself differently from the one beside it would read as a different kind of empty. Hover is scoped toa.art, so nothing with an empty slot behind it lights up under the cursor.- The arrow
.xmeans the link goes somewhere, internal or external alike. It sits at half strength and comes to full on hover — the one part of the row that says "this leads out", so it is the right thing to brighten when you point at it. - Hovers differ by tier, and they have to. A secondary washes its ground with
--faint. The primary cannot — the wash is its ground, and a rule that washed it anyway outranked the fill and left paper type on paper. So the primary rises 1px and the shadow under it deepens, which is the move the cards and the credit links already make. It carries a resting shadow for that lift to grow out of: a 1px rise with nothing beneath it is a change nobody can see.
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.
| Navigation | Functional | |
|---|---|---|
| Class | .nav | .controls |
| Where | / · /style | /letterform |
| Holds | Section anchors | <button aria-pressed> toggles |
| Leaves the page | No — it scrolls to a section | No — it redraws one |
| Sentinel | .sentinel | .ctl-mark |
| Sticks at | var(--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
- Rest —
#fff, a hairline of ink at 5%, and two shadow layers. - Hover — rises 2px and both layers deepen. The same move the primary artefact link makes, because it is the same problem: a filled thing cannot wash.
- Active — drops back to 0 and the shadow collapses to
--rule. [aria-pressed="true"]— ink fill, paper type, and the lift goes. The attribute is the selector. There is no.is-onclass to fall out of step with the state a screen reader is told.- Focus —
:focusis bare and:focus-visiblecarries a 3px--saberring at 45%, so a mouse press does not leave a ring behind but a tab does. - Under 520px it drops to 12px and tightens its padding.
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.
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.
| Rank | Selector | Does |
|---|---|---|
| 1 | bare | The finished artwork. This is what a reader with no JS sees. |
| 2 | .js | Rewinds it — only once scripting has proven itself. |
| 3 | .drawn | Attaches the animations. |
| 4 | prefers-reduced-motion | Forces 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.
| Width | What changes | Because |
|---|---|---|
| 760px | Lockup guides hide; Dave section goes single column | Guides fall under 1px; two columns stop fitting |
| 720px | Project card goes single column, badge moves above | The right rail has no room left |
| 680px | Footer grid goes single column | Mark and text stop sitting side by side |
| 560px | Bar drops the name; nav tightens its tracking | The practice line stops fitting one row |
| 396px | Bar grows to 3rem and tightens its padding | The 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.
- I write as myself. The site is one person's practice, so it says "I", not "we" and not "Lankford Labs believes".
- American English throughout. Color, not colour. This applies to every page and every file.
- Say the thing, then stop. No sub-line under a heading restating the heading. No summary of a list the reader can see.
- Name states plainly. "In progress", not "on the bench" — which sounds like I paused. "Archived", not "killed" — an archived project is not a failure, it is just no longer a consumer-facing product.
- "If I think something should exist, I want to try to build it." This line is verbatim and stays that way. The word carrying it is try. "I build things that should exist" is the same sentence with the honesty removed.
- Credit the people who help. Feedback that changes something gets named on that project's readout.
Misuse
Six things not to do to the mark.
- Don't stretch it. A non-uniform scale changes the wire on one axis and the ratio with it. Scale whole artwork.
- Don't rotate it. It is written from the top of the stem down. Turned, it is not the mark, it is a shape.
- Don't recolor it freely. Ink on paper, or warm on a dark ground. Not whatever the layout wants.
- Don't close the gaps. They are clearance, not decoration. Filled in, the line stops passing behind itself.
- Don't crowd it. R of clear space on every side. Nothing enters it.
- Don't redraw it. The wire is 184.1 — one value, everywhere. Traced by hand or set at another weight, it is a different mark. The favicon is the only redrawing there is.
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.
| Rule | Exception | Standing |
|---|---|---|
| 4.5:1 for type | The in-progress badge is white on --deep at 3.04:1 | Held deliberately, to keep the gold exactly as the palette states it. #a16b10 would clear it. |
| No webfonts | /readouts/texas-find-em imports Bungee and Overpass | Held. The case study shows the real product's type; the cost is on that page alone. |
| Artwork stays flat | Project cards are lifted with two shadow layers | Not an exception — cards are chrome. The rule governs artwork. |
| The mark's clear space | At the 24px minimum, clearance renders at 0.59px | Open. 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 page | Open. 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.