SourceThe websiteThe paper scheme

Part X, Chapter 1

Part X: The website

Chapter 1: The paper scheme

Package: site/src/

Language: CSS

Raw source

The paper scheme

The site is set on paper. A warm cream ground carries three steps of ink, two weights of rule, and the woven mark's own eight colours; the type pairs one display face against one monospace, and every measurement lands on a four-pixel grid. Three schemes — colour, type and measure — govern all of it, and this chapter is where they live.

A scheme only holds if there is one place to change it. So each load-bearing value is a custom property declared once at the top, and every rule below reads the property rather than repeating the number. A value that is not load-bearing stays a literal in the rule that needs it, and the artwork keeps its own fills throughout: the pixel carpets, the file icons and the isometric folder are drawings, not tokens.

Paper, ink and the mark's palette

Colour divides into grounds, ink, rules and accents.

Three grounds carry the site. Paper is the page itself. A card lifts a region just off the paper, a shade lighter than the ground it sits on. The footer inverts to near-black, the one dark surface the page ends on. A code fence washes the paper with a trace of its own ink rather than taking a ground of its own.

One kind of card does not use that ground. The bordered cards that fill Why Loom and Community sit on the mark's own pale accent instead, which reads a touch warmer and keeps a page of twenty-odd cards from looking like a stack of white boxes. They take --pale rather than --card, and the difference is deliberate rather than drift.

Ink runs three steps and stops. Primary ink sets headings, body and any code meant to be read. Secondary ink sets interface chrome and the copy inside a mock panel. Muted ink sets eyebrows, captions and meta — never a sentence. On the footer's dark ground the ramp collapses to two: cream for text, grey for the blurb beneath it.

The accents are the woven mark's own palette and the page takes nothing from outside it. Eight colours name themselves; four of them do second duty naming a command, since a command's pill and its illustration must always agree. The loom init green sits a shade deeper than the mark's green, because a pill needs to hold its own against paper.

Why Loom names three approaches and Loom itself, and each name carries a colour wherever it appears. Every one comes as a pair: the bright value for the navy card it is printed on, and a deeper value for paper.

The navy card is the infographic's ground, and it runs its own small scheme — a ground, cream ink, a muted blue for labels, and a guide colour barely lifted off the ground.

CSS
:root {
  /* Grounds */
  --paper:       #F7F4EC;
  --card:        #FCFAF4;
  --footer-bg:   #111111;
  --fence:       rgba(20, 19, 15, 0.032);

  /* Ink on paper */
  --ink:         #14130F;
  --ink-2:       #4A453C;
  --ink-3:       #7C6F64;

  /* Ink on the footer ground */
  --footer-ink:   #F7F4EC;
  --footer-muted: #A9A29A;

  /* Rules */
  --rule:        #E4DCCB;
  --rule-faint:  #EFEAE0;

  /* The mark's palette */
  --navy:        #14204A;
  --orange:      #FF6A3D;
  --blue:        #2E6FF2;
  --green:       #00B37E;
  --violet:      #8B5CF6;
  --magenta:     #E8318A;
  --amber:       #FFC53D;
  --pale:        #F2EFE6;

  /* A command's colour, worn by its pill and its illustration alike */
  --init:        #00996B;
  --tangle:      #2E6FF2;
  --weave:       #8B5CF6;
  --eject:       #CE3F5C;

  /* An approach's colour, bright on the navy card and deeper on paper */
  --prompt:        #FF6A3D;
  --prompt-paper:  #C2410C;
  --spec:          #FFC53D;
  --spec-paper:    #8A5A00;
  --hooks:         #F45C9F;
  --hooks-paper:   #C41E63;
  --loom:          #19D69C;
  --loom-paper:    #00714F;

  /* The navy card */
  --navy-ink:    #F2EFE6;
  --navy-muted:  #8494C4;
  --navy-guide:  rgba(242, 239, 230, 0.13);

  /* The accent — a mark of state, not of linkage */
  --accent:        #E8501F;
  --accent-hover:  #C4441A;
}

Two families and a fixed scale

Two families dress the whole site. Space Grotesk at 500 sets display type and the wordmarks, and it sets nothing else. One monospace sets everything that remains — prose, interface, code, file names, commands and pixel labels alike. Naming the mono once, as a property, is what keeps the promise: no rule may write a family of its own, so two monospaces can never meet on a page.

Every size on the site comes off one list, and the list has no half-pixels in it. The scale runs from the hero wordmark down to a pixel badge, and each step names the role it serves rather than a t-shirt size, so a rule asks for the heading size and not for "large".

Running text has exactly one treatment, and --t-body is it. A section heading and a card's sub-heading share one treatment at two sizes, both 500 weight tracked at -0.02em; colour may vary between them to say which thing a heading names, but never size or weight as well. Block code sits one step down at --t-code, and only because a fence is not a sentence.

The example's stage takes the one step in between, --t-stage. A document being written is neither running text nor a quoted fence, and it is the only place on the site where a reader watches a line arrive rather than reads it whole.

Two leadings carry the site. Headings hold at 1.25. Anything that runs past one line — prose and code equally — opens right out to 2, which is what gives the page its air. Single-line chrome keeps normal, and a glyph box takes 0.

CSS
:root {
  /* Faces */
  --display: 'Space Grotesk', system-ui, -apple-system, 'Noto Color Emoji', sans-serif;
  --mono:    'Lilex', 'IBM Plex Mono', ui-monospace, Menlo, 'Noto Color Emoji', monospace;

  /* Sizes, by the role each one serves */
  --t-wordmark:        56px;
  --t-footer-wordmark: 43px;
  --t-glyph:           38px;
  --t-hero:            32px;
  --t-head:            21px;
  --t-sub:             16px;
  --t-body:            15px;
  --t-stage:           14px;
  --t-code:            13px;
  --t-label:           12px;
  --t-eyebrow:         11px;
  --t-micro:           10px;
  --t-pixel:           8px;

  /* Leading */
  --lead-head: 1.25;
  --lead:      2;
}

Four pixels at a time

Measure starts from a four-pixel base, and the spacing steps climb it: 4, 8, 12, 16, 24, 32, 44, 58, 80 and 96. That scale governs the rhythm between sections. Inside a card or a piece of chrome it gives way to optical values — a twenty-two-pixel bottom that pays for the mono's descender, a five-pixel offset that lands a first baseline on a card's top edge — and those stay literal in the rules that need them.

The container is a single width the whole site holds to. Eighty pixels of side padding inside 1260 leaves an eleven-hundred-pixel measure, and every section takes it.

Three of these values run tighter than the scale suggests, and deliberately so: the air below a section, the air above the footer, and the rhythm between a heading and its content. Each is floored where it stands rather than left free to grow, so the page reads closer than a scale drawn from the steps alone would set it.

A gap narrows as the window does. The side padding steps down through fifty-six and forty to twenty-four, and the gutter between columns follows it. The air below a section and above the footer barely move, because they are what separates one idea from the next and a narrow window needs that separation more, not less. The rhythm does not move at all: a heading sits thirty-six pixels above its content at every width.

These are the landing's own numbers, read from the page itself rather than from a scheme laid over the site. The pages of this site genuinely disagree on their measure, so each takes the one it was drawn with.

Radii come in two steps and a pill. Two rounds a chip, a pixel or a small fence; four rounds a card, a panel or a code fence; the mark's pill is fully round, and a dot is a circle.

CSS
:root {
  /* The container and its air */
  --measure:      1260px;
  --side:         80px;
  --section-pad:  64px;
  --footer-pad:   48px;

  /* Rhythm inside a section */
  --rhythm:       36px;
  --col-gap:      44px;
  --stack:        18px;
  --label-gap:    10px;

  /* Radii */
  --radius:       4px;
  --radius-sm:    2px;
}

@media (max-width: 1179px) {
  :root { --side: 56px; --col-gap: 40px; }
}

@media (max-width: 999px) {
  :root { --side: 40px; --col-gap: 32px; }
}

@media (max-width: 639px) {
  :root { --side: 24px; --section-pad: 58px; --col-gap: 24px; }
}

The page on paper

The base sets the page in the mono on paper, at the body size and the open leading everything else inherits from. The monospace carries contextual ligatures, which is what turns => and :: in a quoted mark into the shapes an editor draws. Every family ends in Noto Color Emoji, so an emoji renders the same on every client instead of in each platform's own set.

The page stops narrowing at four hundred and thirty pixels. Below it the page scrolls sideways rather than going on compressing: a column that keeps shrinking eventually breaks a word in half or crushes a row of controls into each other, and a reader would rather drag the page an inch than read either.

The pager sets that floor, because it is the first thing on the page to fail. Its two cards stand side by side at every width, since back and on are a pair rather than a sequence, and four hundred and thirty pixels is the least room the two of them can share.

The page and the canvas take different grounds. The canvas is the surface a browser paints outside the document — what a rubber-band scroll uncovers above the nav and below the footer — and it takes the colour set on the root element, while the page takes the colour set on the body. So the root wears the footer's near-black and the body wears paper. A scroll flung past either end then runs out onto the dark the page ends on, instead of flashing a band of cream the reader never asked to see.

The base does not touch font smoothing, and that is deliberate. Setting -webkit-font-smoothing: antialiased moves a Mac from subpixel rendering to grayscale, which thins every stroke on the page by roughly a shade. On a site set entirely in a monospace, at fifteen pixels, on cream, that reads as a lighter and weaker face than the one the type was chosen for. The browser's own default is the right one here, so no rule overrides it.

A link is set in body ink rather than in a colour of its own, and fades to muted ink under the cursor. Nearly every anchor on this site is a piece of chrome — a nav item, a card, a pager — and those say what they are by their shape, so a colour would only compete with the shape.

Prose is the exception, because a sentence has no shape to read. A link inside a documentation article takes the accent, which is the one place on the site where a run of text has to announce that part of it is a link.

That accent is not a link colour. It marks state: the rail's current chapter, the outline's current heading, a token chip. Naming it for what it does keeps it from being sprinkled on anything merely clickable.

A selection washes the mark's green over the text at a quarter strength and leaves the ink alone. The browser's own blue belongs to no scheme on this page, and green is the one colour here that reads as a thing the reader did rather than a thing the page is saying.

The wash holds on the two dark grounds, but the ink cannot: near-black type under a dark green wash on near-black paper is a smudge. So the footer and the search palette each turn the selected text over to the cream they already set everything else in. A rule matching the region alone would not reach the text inside it, since the page-wide rule matches those descendants too and beats an inherited value — hence the pair of selectors.

CSS
* { box-sizing: border-box; }

html, body { margin: 0; padding: 0; }

html { background: var(--footer-bg); }

body {
  min-width: 430px;
  background: var(--paper);
  color: var(--ink);
  font-family: var(--mono);
  font-variant-ligatures: contextual common-ligatures;
  font-feature-settings: 'calt' 1, 'liga' 1;
  font-size: var(--t-body);
  line-height: var(--lead);
}

a { color: var(--ink); text-decoration: none; }
a:hover { color: var(--ink-3); }

.docs-prose a { color: var(--accent); }
.docs-prose a:hover { color: var(--accent-hover); }

::selection { background: rgba(0, 179, 126, 0.24); color: var(--ink); }

.footer::selection,
.footer ::selection { color: var(--footer-ink); }

.palette-card::selection,
.palette-card ::selection { color: var(--navy-ink); }

The Loom mark's quilts drift while they show. A band of the pattern tiles every hundred and twenty-eight pixels, so shifting the background by exactly one tile and looping leaves no visible jump. Bands laid across the pill travel sideways, and bands laid down it travel upward.

A reader who has asked their system for less motion gets none: the drift stops, and the mark holds still.

CSS
@keyframes loomWeave   { to { background-position-x: 128px; } }
@keyframes loomWeaveUp { to { background-position-y: -128px; } }

@media (prefers-reduced-motion: reduce) {
  .weave-band { animation: none !important; }
}

Every section in one container

One container rules the page, and every section is that container. The side padding lives on the section itself, so a section's ground can run to the window's edge while its content still lands on the measure.

Three paddings vary. A section sits close under the one above it and leaves its own air beneath. The hero opens far taller, because it is the first thing a reader meets, and it draws in as the window narrows. The last section before the footer carries the cream air above that dark edge as its own bottom padding — the footer's top padding only ever sizes the footer's own content.

CSS
.section {
  max-width: var(--measure);
  margin: 0 auto;
  padding: 8px var(--side) var(--section-pad);
}

.section-hero { padding: 104px var(--side) 116px; }
.section-last { padding-bottom: var(--footer-pad); }

@media (max-width: 1179px) { .section-hero { padding: 96px var(--side) 104px; } }
@media (max-width: 999px)  { .section-hero { padding: 80px var(--side) 88px; } }
@media (max-width: 639px)  { .section-hero { padding: 56px var(--side) 64px; } }

A mark, an eyebrow, a heading

Every section opens the same way: a forty-two-pixel woven mark, then a stack of two lines beside it — the eyebrow that names the section's subject, and the heading that says what it is. Sixteen pixels separate the mark from the stack and four separate the two lines, which is close enough that they read as one block.

Between that block and the content beneath it sits the rhythm — thirty-six pixels, closer than the air around the section itself. A heading belongs to what follows it, so it sits nearer to its content than to its neighbours.

The two lines have to fit the mark, and that is what sets the eyebrow's leading. The heading takes 1.25 over twenty-one pixels, and the gap takes four, which leaves the eyebrow about thirteen — the browser's own leading over eleven pixels of type. Together they stand a little over forty, and the mark beside them is forty-two. Let the eyebrow inherit the running text's leading of two instead and it alone grows to twenty-two, the stack overruns the mark by ten pixels, and every section head on the site is wrong in the same way.

The eyebrow is the widest-tracked type on the site, uppercase in muted ink. A heading takes the mono, not the display face — Space Grotesk stops at the hero — and a sub-heading is the same treatment one step down.

Below six hundred and forty pixels the eyebrow goes. A phone's width belongs to the heading, and of the two lines the subject is the one a reader can do without — the heading names the section, and the eyebrow only says what it is about. The stack is one line then rather than two, so the mark centres against it instead of hanging from its top. Only a section head loses its eyebrow; the pager keeps the pair above each destination, since there the eyebrow says which way the card leads.

CSS
.section-head {
  display: flex;
  align-items: flex-start;
  gap: 16px;
  margin-bottom: var(--rhythm);
}

.section-head .mark { flex: none; width: 42px; height: 42px; }

.section-head .lines {
  display: flex;
  flex-direction: column;
  gap: 4px;
}

.eyebrow {
  font-size: var(--t-eyebrow);
  line-height: normal;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--ink-3);
}

@media (max-width: 639px) {
  .section-head { align-items: center; }
  .section-head .eyebrow { display: none; }
}

h1 {
  margin: 0;
  font-family: var(--display);
  font-weight: 500;
  font-size: var(--t-hero);
  line-height: var(--lead-head);
  letter-spacing: -0.02em;
  text-wrap: balance;
}

h2, .subhead {
  margin: 0;
  font-weight: 500;
  font-size: var(--t-head);
  line-height: var(--lead-head);
  letter-spacing: -0.02em;
}

.subhead { font-size: var(--t-sub); }

.label {
  font-size: var(--t-label);
  letter-spacing: 0.04em;
  color: var(--ink-3);
}

One treatment for running text

Every sentence on the site is one thing: one family, one size, one leading, one ink. The hero's lead, a section's prose, a caption, a per-item description, the code set inside a sentence — all of them. Code inside a sentence keeps the body size too, so the line does not jump where it appears.

A run of prose never takes the full thousand-pixel measure. Sixty-two characters is the line, and 62ch states it directly: the same element carries the size and the measure, so ch resolves against the face actually setting the text. Measured any other way — on a wrapper that has no size of its own — the line silently runs long.

Where prose fills a section by itself it flows across two balanced columns, each one still sixty-two characters wide. Multi-column layout ignores the flex gap between its children, so a paragraph inside one carries its own bottom margin instead.

A label and the thing it introduces — a line of copy, a fence — sit ten pixels apart. That gap is explicit, never the accident of a line box.

CSS
.prose {
  max-width: 62ch;
  font-size: var(--t-body);
  line-height: var(--lead);
  color: var(--ink);
}

.prose-columns {
  column-count: 2;
  column-gap: var(--col-gap);
}

.prose-columns > p {
  max-width: 62ch;
  margin: 0 0 var(--stack);
}

.caption {
  margin-top: 20px;
  max-width: 62ch;
  color: var(--ink);
}

.meta { color: var(--ink-3); }

.introduces { margin-bottom: var(--label-gap); }

A fence is not a sentence

Block code steps down to thirteen pixels and keeps everything else — the same family, the same open leading, the same ink. Only the size differs, because a fence is not a sentence. A code fence washes the paper with a trace of ink rather than sitting on a ground of its own, and rounds to the card radius.

A syntax token is the one thing that may take a colour of its own inside a fence. A file label and a legend chip are set as block code too, since neither is prose.

CSS
.fence {
  padding: 16px;
  border-radius: var(--radius);
  background: var(--fence);
  font-size: var(--t-code);
  line-height: var(--lead);
  color: var(--ink);
  overflow-x: auto;
}

.code { font-size: var(--t-code); }

.chip {
  padding: 2px 8px;
  border-radius: var(--radius-sm);
  background: var(--fence);
  font-size: var(--t-code);
  line-height: normal;
  color: var(--ink-2);
}

.pill {
  display: inline-flex;
  align-items: center;
  border-radius: 999px;
  font-size: var(--t-label);
  line-height: normal;
}

One file tree, everywhere

A file tree looks the same wherever the site draws one, so its treatment lives here rather than in each section that shows a corpus. A row stands thirty-two pixels tall and sets its label in the mono at eleven pixels, weight 600, eleven pixels after the icon. Children indent to forty-eight pixels and a guide line runs down behind them.

The two icons are pixel drawings, and a drawing keeps its own fills: the folder is green, and the file is cream with a dot in the colour of whatever it holds. Those fills stay literal here, beside the ramps rather than inside them, because nothing else on the site reads them.

CSS
.tree { display: flex; flex-direction: column; }

.tree-row {
  display: flex;
  align-items: center;
  gap: 11px;
  height: 32px;
}

.tree-row .name {
  font-size: var(--t-eyebrow);
  font-weight: 600;
  line-height: normal;
  color: var(--ink);
}

.tree-children {
  display: flex;
  flex-direction: column;
  margin-left: 48px;
  border-left: 1px solid #DFD8C8;
}

The nav across the top

The nav is a single row fifty-six pixels tall, and it centres its links around the wordmark rather than pushing them to one side. It sets its own leading back to the browser's default, because the base sets a leading of two for running prose and a nav is not prose — a link with no height of its own would otherwise stand twenty-two pixels tall for eleven pixels of text, and the row of them would read as a stack of blocks rather than a line of words. Its type is the site's smallest: eleven pixels, tracked wide, in capitals and muted ink. A link warms to the faint rule and darkens to primary ink when a reader hovers it, and the page a reader is already on wears that same treatment permanently, so the nav always says where they are.

The wordmark is the one thing here in Space Grotesk, and it does not change colour on hover — a logo is not a link that needs to prove it is one. The beta tag hangs off its top-right corner, positioned against the wordmark rather than laid out in the row, so the row stays centred on the word. It is drawn back over the corner by three tenths of its own width, so it reads as part of the mark rather than a word beside it. It is set small in mono capitals, in violet.

Search sits apart from the row. It pins to the right edge rather than joining the flow, so the links stay centred whatever it says. The key badge beside it shows the slash that opens the palette.

On a narrow window the Loom mark stands beside the wordmark, a little over half its full width, eight pixels from the word. It is hidden on a wide window, where the wordmark sits between the links and the page head shows the mark at full size.

The nav's side padding steps down with the window, from eighty pixels to twenty-four. The links drop away earlier than that, at seven hundred and sixty-seven pixels, because search is pinned to the right edge rather than laid out in the row: nothing pushes back when the links grow toward it, and by seven hundred pixels the last of them is already under the button. So the row hands over while it still has forty pixels to spare, and from there the wordmark moves to the left edge, search says navs instead, and the palette becomes the way through the site.

CSS
.nav {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 26px;
  height: 56px;
  line-height: normal;
  max-width: var(--measure);
  padding: 0 var(--side);
  margin: 0 auto;
  font-size: var(--t-eyebrow);
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--ink-3);
}

.nav-link {
  display: inline-flex;
  align-items: center;
  padding: 5px 8px;
  border-radius: var(--radius-sm);
  background-color: transparent;
  color: var(--ink-3);
  transition: background-color .14s ease, color .14s ease;
}

.nav-link:hover,
.nav-link.here { background-color: var(--rule-faint); color: var(--ink); }

.nav-word {
  position: relative;
  flex: none;
  margin: 0 6px;
  font-family: var(--display);
  font-weight: 500;
  font-size: 19px;
  letter-spacing: -0.03em;
  text-transform: none;
  color: var(--ink);
}

.nav-word:hover { background-color: transparent; color: var(--ink); }

.nav-beta {
  position: absolute;
  left: 100%;
  top: -2px;
  margin-left: 2px;
  font-family: var(--mono);
  font-size: 9px;
  font-weight: 500;
  line-height: 1;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--violet);
  transform: translateX(-30%);
}

.nav-find .narrow { display: none; }

.nav-find {
  appearance: none;
  cursor: pointer;
  position: absolute;
  right: var(--side);
  top: 50%;
  transform: translateY(-50%);
  display: flex;
  align-items: center;
  gap: 9px;
  flex: none;
  padding: 4px 6px 4px 8px;
  border: none;
  border-radius: var(--radius-sm);
  background: transparent;
  font-family: var(--mono);
  font-size: var(--t-eyebrow);
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--ink-3);
  transition: background-color .14s ease, color .14s ease;
}

.nav-find:hover { background-color: var(--rule-faint); color: var(--ink); }

.nav-key {
  display: flex;
  box-sizing: content-box;
  align-items: center;
  justify-content: center;
  flex: none;
  width: 18px;
  height: 18px;
  border: 1px solid var(--rule);
  border-radius: var(--radius-sm);
  font-size: var(--t-eyebrow);
  letter-spacing: 0;
  text-transform: none;
  color: #A79A8C;
}

@media (max-width: 1179px) {
  .nav { padding: 0 56px; }
  .nav-find { right: 56px; }
}

@media (max-width: 999px) {
  .nav { gap: 20px; padding: 0 40px; }
  .nav-find { right: 40px; }
}

@media (max-width: 767px) {
  .nav { justify-content: flex-start; }
  .nav-link { display: none; }
  .nav-find .wide { display: none; }
  .nav-find .narrow { display: inline; }
}

@media (max-width: 639px) {
  .nav { padding: 0 24px; }
  .nav-find { right: 24px; }
}

.nav-mark { display: none; }

@media (max-width: 767px) {
  .nav-word { display: inline-flex; align-items: center; gap: 8px; margin-left: 0; }
  .nav-mark { display: block; }
  .nav-mark .loom-mark { width: 52px; height: auto; }
}

On the documentation, below a thousand pixels, the nav is the one thing that stays on screen. It pins to the top of the window on the paper ground. It is as tall as the nav on the other pages, so a chapter's head starts as far down the window as any other page's head. Under its lower edge a gradient twenty-eight pixels deep fades from paper to clear, so the page scrolling up beneath it fades out rather than meeting a hard line.

The burger that opens the drawer sits at the bar's left end, its lines starting on the same line as the content below, which is why it stands nine pixels left of the side air. On the narrowest windows, where the wordmark has moved to the left edge, the wordmark steps forty pixels right to make room for it. Wherever the menu fits beside the sections, the burger is hidden.

CSS
.nav-burger { display: none; }

@media (max-width: 999px) {
  .docs-nav {
    position: sticky;
    top: 0;
    z-index: 40;
    background: var(--paper);
  }

  .docs-nav::after {
    content: '';
    position: absolute;
    left: 0;
    right: 0;
    top: 100%;
    height: 28px;
    background: linear-gradient(var(--paper), rgba(247, 244, 236, 0));
    pointer-events: none;
  }

  .nav-burger {
    appearance: none;
    cursor: pointer;
    position: absolute;
    left: 31px;
    top: 50%;
    transform: translateY(-50%);
    display: grid;
    place-items: center;
    width: 32px;
    height: 32px;
    padding: 0;
    border: 0;
    border-radius: var(--radius-sm);
    background: transparent;
    color: var(--ink);
  }

  .nav-burger:hover { background: rgba(20, 19, 15, 0.06); }
}

@media (max-width: 767px) { .docs-nav .nav-word { margin-left: 40px; } }
@media (max-width: 639px) { .nav-burger { left: 15px; } }

The palette over the page

The search palette is the one place on the site that goes dark. It drops a navy card over a blurred wash of the page, because a reader who opens it has stopped reading and started looking.

The card holds four bands stacked in a column: the query, then either the page pills or the grouped results, and a key hint along the bottom. It never grows past five hundred and sixty pixels wide or three quarters of the window tall, and the results band is the only one that scrolls.

Behind the card, the scrim fills the whole shell, so a click anywhere off the card lands on it. The card is positioned too, which lifts it above the scrim.

The palette draws its own caret. A block caret cannot be styled on a real input, so the input's own caret is hidden and a mirror of the typed text sits behind it, transparent except for a white block at the caret's position. The mirror shares the input's family, size and leading exactly — any difference and the block drifts away from the letter it should be sitting on.

CSS
.palette-shell {
  position: fixed;
  inset: 0;
  z-index: 70;
  display: flex;
  align-items: flex-start;
  justify-content: center;
  padding: 17vh 16px 16px;
  background: rgba(20, 19, 15, 0.14);
  backdrop-filter: blur(2.5px) saturate(106%);
  -webkit-backdrop-filter: blur(2.5px) saturate(106%);
}

.palette-scrim {
  position: absolute;
  inset: 0;
}

.palette-card {
  position: relative;
  display: flex;
  flex-direction: column;
  width: 100%;
  max-width: 560px;
  max-height: 74vh;
  background: var(--navy);
  border: 1px solid #2D3B6B;
  border-radius: var(--radius);
  box-shadow: 0 20px 56px rgba(11, 18, 48, 0.34);
  overflow: hidden;
}

@media (max-width: 780px) {
  .palette-shell { padding: 12vh 12px 12px; }
  .palette-card { max-width: none; max-height: 76dvh; }
}

.palette-head {
  display: flex;
  align-items: center;
  gap: 12px;
  flex: none;
  padding: 15px 18px;
}

.palette-field {
  position: relative;
  display: block;
  flex: 1;
  min-width: 0;
}

.palette-input {
  display: block;
  width: 100%;
  padding: 0;
  border: none;
  outline: none;
  background: transparent;
  font-family: var(--mono);
  font-size: var(--t-body);
  line-height: 1.4;
  color: var(--navy-ink);
  caret-color: transparent;
}

.palette-input::placeholder { color: #6C7CAE; }

.palette-mirror {
  position: absolute;
  left: 0;
  top: 0;
  bottom: 0;
  display: flex;
  align-items: center;
  max-width: 100%;
  white-space: pre;
  overflow: hidden;
  font-family: var(--mono);
  font-size: var(--t-body);
  line-height: 1.4;
  color: transparent;
  pointer-events: none;
}

.palette-caret {
  display: flex;
  align-items: center;
  justify-content: flex-start;
  flex: none;
  width: 1ch;
  min-width: 8px;
  height: 17px;
  background: #FFFFFF;
  color: var(--navy);
  animation: palCaret 1.05s steps(1, end) infinite;
}

@keyframes palCaret { 0%, 52% { opacity: 1; } 53%, 100% { opacity: 0; } }

.palette-esc {
  appearance: none;
  cursor: pointer;
  display: flex;
  align-items: center;
  gap: 6px;
  flex: none;
  padding: 2px 7px;
  border: 1px solid #6C7CAE;
  border-radius: var(--radius-sm);
  background: transparent;
  font-family: var(--mono);
  font-size: var(--t-eyebrow);
  letter-spacing: 0.04em;
  color: var(--navy-muted);
  transition: border-color .12s ease, color .12s ease;
}

.palette-esc:hover { border-color: #8FA0D6; color: var(--navy-ink); }

.palette-esc .cross { font-size: var(--t-code); line-height: 1; }

With nothing typed, the palette offers the site itself: a pill per page and a pill per place Loom lives elsewhere. Each pops in a beat after the one before it, so the row assembles rather than appearing.

A pill stands thirty-four pixels tall, which is smaller than a finger. So each one carries an invisible box behind it, centred on the pill and at least forty-four pixels square, and that box is what a thumb actually hits. The box has to be positioned against the pill it belongs to — left to itself it would resolve against the palette card and every pill would claim the same patch of the middle.

A pill measures itself the old way, as the mark does. Thirty-four pixels of content inside a one-pixel edge stands thirty-six tall, which is what the row is drawn to — under the stylesheet's border-box default the edge would eat into the height instead and the pill would come out two pixels short. The same holds for the key box beside the search label, so both say content-box for themselves.

Being focused and being hovered are two different states, and they look different. The focused pill is the one the arrow keys are on: it lifts to a fourteen-percent wash behind a pale edge, and its label goes to full cream. A hovered pill lifts less — ten percent behind a dimmer edge — and does not touch the label at all. So a reader moving the mouse over the row can still see which pill the keyboard would open, because only one of the six wears the brighter state.

CSS
.palette-pills {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  max-width: 100%;
  flex: none;
  padding: 18px 18px 20px;
}

.pill-link {
  position: relative;
  display: inline-flex;
  align-items: center;
  gap: 9px;
  box-sizing: content-box;
  height: 34px;
  padding: 0 14px 0 11px;
  border: 1px solid #6C7CAE;
  border-radius: 999px;
  background: transparent;
  white-space: nowrap;
  font-family: var(--mono);
  font-size: var(--t-code);
  letter-spacing: 0.04em;
  color: #C4CCE4;
  animation: rxPop .2s cubic-bezier(.16, .84, .28, 1.04) both;
  transition: background-color .12s ease, border-color .12s ease;
}

.pill-link::before {
  content: '';
  position: absolute;
  left: 50%;
  top: 50%;
  width: 100%;
  min-width: 44px;
  height: 44px;
  transform: translate(-50%, -50%);
}

.pill-link.here {
  background: rgba(242, 239, 230, 0.14);
  border-color: #B9C4E8;
  color: var(--navy-ink);
}

.pill-link:hover {
  background: rgba(242, 239, 230, 0.10);
  border-color: #8FA0D6;
}

@keyframes rxPop {
  from { opacity: 0; transform: scale(.86) translateY(3px); }
  to   { opacity: 1; transform: none; }
}

A query in flight shows a stitched bar drawing itself sideways, in the colour of whichever page the focus is on. It is the loom's own shuttle, and it holds the place for the moment between the last keystroke and the results.

CSS
.palette-stitch {
  display: block;
  height: 2px;
  background-size: 12px 2px;
  animation: palStitch 520ms linear infinite;
}

@keyframes palStitch { from { background-position: 0 0; } to { background-position: 12px 0; } }

.palette-loading {
  padding: 13px 18px 15px;
  font-family: var(--mono);
  font-size: var(--t-code);
  letter-spacing: 0.04em;
  color: var(--navy-muted);
}

@media (prefers-reduced-motion: reduce) {
  .palette-stitch, .palette-caret, .pill-link { animation: none !important; }
}

Results group under the page that holds them. A page's row carries a tilde, its name and its path. Its results indent beneath, each row marked with a chevron and showing the fragment that matched, with where it is, the chapter and the section, set small and muted at the row's right end. The fragment takes the room the row has, and either one ends in an ellipsis when the row runs short. The focused row washes in its page's own colour, and the matched words inside a fragment take that colour too, so the eye lands on the page and the phrase at once.

Every row is a link, so it drops a link's underline and keeps the row's own colour.

CSS
.palette-body {
  display: flex;
  flex-direction: column;
  flex: 1 1 auto;
  min-height: 0;
  padding: 6px 0 10px;
  overflow-y: auto;
  overscroll-behavior: contain;
  scrollbar-width: thin;
  scrollbar-color: rgba(242, 239, 230, 0.2) transparent;
}

.palette-body::-webkit-scrollbar { width: 6px; }
.palette-body::-webkit-scrollbar-track { background: transparent; }
.palette-body::-webkit-scrollbar-thumb { background: rgba(242, 239, 230, 0.2); border-radius: var(--radius-sm); }

.palette-group,
.palette-row {
  appearance: none;
  cursor: pointer;
  display: flex;
  align-items: center;
  gap: 10px;
  width: 100%;
  flex: none;
  border: none;
  background: transparent;
  text-align: left;
  font-family: var(--mono);
  font-size: var(--t-code);
  letter-spacing: 0.04em;
  text-decoration: none;
  transition: background-color .12s ease;
}

.palette-group { height: 36px; padding: 0 18px; font-weight: 600; color: var(--navy-ink); }
.palette-row   { height: 34px; padding: 0 18px 0 15px; color: var(--navy-muted); }
.palette-row.here { color: var(--navy-ink); }

.palette-group .sigil,
.palette-row .sigil { flex: none; }

.palette-group .name,
.palette-row .fragment,
.palette-row .where {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.palette-row .fragment { flex: 1 1 auto; }

.palette-row .where {
  flex: 0 1 auto;
  max-width: 40%;
  font-size: var(--t-eyebrow);
  color: var(--navy-muted);
}

.palette-group .path {
  margin-left: auto;
  flex: none;
  font-size: var(--t-eyebrow);
  color: var(--navy-muted);
}

.palette-kids-wrap { display: flex; flex-direction: column; flex: none; }

.palette-kids { display: flex; flex-direction: column; margin: 0 0 8px 26px; }

.palette-hit { font-weight: 600; }

.palette-empty {
  padding: 14px 18px 16px;
  font-family: var(--mono);
  font-size: var(--t-code);
  line-height: 1.7;
  color: var(--navy-muted);
}

.palette-bar {
  display: flex;
  align-items: center;
  gap: 14px;
  flex: none;
  padding: 10px 18px;
  border-top: 1px solid transparent;
  font-family: var(--mono);
  font-size: var(--t-eyebrow);
  letter-spacing: 0.04em;
  transition: background-color .14s ease, border-color .14s ease, color .14s ease;
}

.palette-bar .count { margin-left: auto; color: var(--navy-ink); }

The footer is the site's one dark ground, and the page above it hands over cleanly: the cream air before the edge belongs to the last section, so the footer's own top padding only ever sizes the footer's content.

The footer takes the container's side air, so its brand starts on the same line as every section above it and the page reads as one column down to its last word.

Two columns divide it, a hundred and forty pixels apart. The brand sits on the left — the Loom mark beside its wordmark, with a single line at body size saying what Loom is — and every link the site offers takes the wider right-hand column as a set of chips.

The chips are one flat list, not three groups. Each is a bordered box forty-four pixels tall that wraps when the row runs out, and the set is held to five hundred and twenty pixels and pushed to the right edge, so it stacks into a block against the brand rather than trailing across the whole column.

Under the cursor a chip takes its destination's colour in both its edge and its label. These are the bright values, not the deepened ones the pager uses: the same hue has to carry on near-black here and on paper there, and one value cannot do both.

Under both sits the copyright, and nothing rules it off. Sixty-eight pixels of air below the chips is separation enough, and a line drawn there would be the third horizontal edge in as many inches. On a narrow window the two columns stack and the chips fall into two of equal width. When the count is odd, the last chip spans both columns so the block ends square; when it is even, every chip keeps its own cell.

CSS
.footer { background: var(--footer-bg); }

.footer-inner {
  position: relative;
  max-width: var(--measure);
  margin: 0 auto;
  padding: 76px var(--side) 64px;
}

.footer-top {
  display: grid;
  grid-template-columns: minmax(0, 1.5fr) minmax(0, 3fr);
  column-gap: 140px;
  row-gap: 44px;
  align-items: start;
  padding-bottom: 68px;
}

.footer-brand {
  display: flex;
  flex-direction: column;
  gap: 22px;
  min-width: 0;
}

.footer-mark { display: flex; align-items: center; gap: 13px; }

.footer-word {
  font-family: var(--display);
  font-weight: 500;
  font-size: var(--t-footer-wordmark);
  line-height: 1;
  letter-spacing: -0.03em;
  color: var(--footer-ink);
}

.footer-blurb {
  max-width: 30ch;
  font-size: var(--t-body);
  line-height: var(--lead);
  color: var(--footer-muted);
  text-wrap: pretty;
}

.footer-chips {
  display: flex;
  flex-wrap: wrap;
  justify-content: flex-end;
  align-content: start;
  gap: 8px;
  width: 100%;
  max-width: 520px;
  margin: 0 0 0 auto;
}

.footer-chip {
  display: flex;
  align-items: center;
  height: 44px;
  padding: 0 16px;
  border: 1px solid #2C2C2C;
  border-radius: var(--radius-sm);
  gap: 8px;
  font-size: var(--t-code);
  white-space: nowrap;
  color: var(--footer-ink);
  transition: border-color 140ms ease, color 140ms ease;
}

.footer-chip svg { flex: none; opacity: 0.7; }

.footer-chip:hover { border-color: var(--chip-ink); color: var(--chip-ink); }

.footer-bottom {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  width: fit-content;
  gap: var(--stack);
  font-size: var(--t-code);
  letter-spacing: 0.14em;
  color: var(--footer-muted);
}

@media (max-width: 999px) {
  .footer-top { grid-template-columns: minmax(0, 1fr); }
  .footer-chips { justify-content: flex-start; max-width: none; margin: 0; }
}

@media (max-width: 699px) {
  .footer-inner { padding: 48px var(--side) 40px; }
  .footer-top {
    display: flex;
    flex-direction: column;
    align-items: stretch;
    row-gap: 32px;
    padding-bottom: 40px;
  }
  .footer-chips { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .footer-chip:last-child:nth-child(odd) { grid-column: 1 / -1; }
}

The hero

The hero stacks two things: the masthead, and a pair of columns beneath it. The masthead centres itself while everything below ranges left, which is what makes it read as a masthead rather than as an oversized first line.

The claim and the install line sit side by side down to seven hundred and sixty-five pixels. On a wide window the claim's column runs a little wider than the install line's, which needs only as much room as its longest command, and a seventy-six-pixel gutter holds the two apart. That gutter is much wider than the site's usual column gap, because these are two separate offers rather than two columns of one thing.

Below a thousand pixels the two columns even up and the gutter closes to thirty-six. A narrow window costs the claim more than it costs the install line, which still has to show its longest command, npm install -g @athrio/loom, on as few lines as it can. Below seven hundred and sixty-five the paragraph and the command box give up on sharing a row and stack.

The claim's paragraph stops at forty-six characters rather than the site's usual sixty-two. It has a column of its own to fit inside, and a longer line here would run beneath the install box.

CSS
.hero-mark {
  display: flex;
  align-items: center;
  gap: 16px;
  align-self: center;
  margin-bottom: 40px;
  white-space: nowrap;
}

.hero-word {
  padding-right: 25px;
  font-family: var(--display);
  font-weight: 500;
  font-size: var(--t-wordmark);
  line-height: 1;
  letter-spacing: -0.03em;
}

.section-hero {
  display: flex;
  flex-direction: column;
  gap: var(--rhythm);
  align-items: stretch;
}

.hero-columns {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 0.86fr);
  gap: 76px;
  align-items: start;
  width: 100%;
}

.hero-claim { display: flex; flex-direction: column; gap: 26px; min-width: 0; }

.hero-lead {
  margin: 0;
  max-width: 46ch;
  font-size: var(--t-body);
  line-height: var(--lead);
  color: var(--ink);
  text-wrap: pretty;
}

.hero-right {
  display: flex;
  flex-direction: column;
  gap: 26px;
  min-width: 0;
  padding-top: 6px;
}

@media (max-width: 1179px) { .hero-columns { gap: 48px; } }
@media (max-width: 999px)  { .hero-columns { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 36px; } }
@media (max-width: 764px)  { .hero-columns { grid-template-columns: minmax(0, 1fr); } }

The install line is a row of tabs over one command. A tab draws a rule beneath itself only when it is the chosen one, but every tab reserves that rule in transparent, so the row's baseline never shifts as the choice moves along it.

The command itself is a button on the one pure-white ground the page uses. White here says "this is not prose, it is a thing you take", and the border darkens to ink under the cursor to confirm it. The curl command carries a long address with no space in it, so the line may break anywhere rather than push the button past a phone's edge.

CSS
.hero-install { display: flex; flex-direction: column; gap: 12px; align-items: stretch; width: 100%; }

.rt-tabs { display: flex; align-items: center; gap: var(--stack); }

.rt-tab {
  appearance: none;
  cursor: pointer;
  display: flex;
  align-items: center;
  gap: 7px;
  padding: 0 0 5px;
  border: 0;
  border-bottom: 1px solid transparent;
  background: transparent;
  font-family: var(--mono);
  font-size: var(--t-eyebrow);
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--ink-3);
  transition: color 120ms ease;
}

.rt-tab.here { border-bottom-color: var(--ink); color: var(--ink); }

.rt-tab svg,
.rt-tab img { display: block; width: 14px; height: 14px; }

.rt-command {
  appearance: none;
  cursor: pointer;
  display: flex;
  align-items: center;
  gap: 12px;
  width: 100%;
  padding: 13px 16px;
  border: 1px solid var(--rule);
  background: #FFFFFF;
  font-family: var(--mono);
  font-size: var(--t-code);
  color: var(--ink);
  text-align: left;
  transition: border-color 140ms ease;
}

.rt-command:hover { border-color: var(--ink); }
.rt-command .prompt { color: var(--ink-3); }
.rt-command .line { flex: 1; min-width: 0; overflow-wrap: anywhere; }

.rt-command .took {
  font-size: var(--t-micro);
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--ink-3);
  transition: color 120ms ease;
}

.rt-command .took.done { color: var(--init); }

.hero-onward {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 8px 26px;
  padding-left: 3px;
  font-size: var(--t-code);
  line-height: var(--lead);
}

.hero-link {
  white-space: nowrap;
  color: var(--ink);
  transition: color 140ms ease;
}

.hero-link:hover { color: var(--hero-ink); }

The vocabulary, one card at a time

Each of the eight marks gets a card, and the cards flow down columns three hundred pixels wide, at most three of them, held apart by the same twenty-four-pixel gutter the command cards take. Asking for a width rather than a count is what keeps a card from narrowing as the window does. The cards hold their measure and the count gives way instead, from three columns to two, and sharing both numbers with the commands means the two sets turn at the same width. Below nine hundred pixels they become a single row a reader swipes, each card snapping to centre, rather than a column of cards each far wider than the sentence it holds.

Flowing rather than gridding is what lets a card with a long sentence take the height it needs without dragging seven others down with it, and break-inside: avoid is what keeps one from splitting across a column boundary.

A card is quiet until the cursor reaches it. Then the ground goes to the mark's tint and the edge to the mark's own hue, both arriving over the same hundred and eighty milliseconds. Each card carries those two values as custom properties, since a hover rule cannot be written inline but the colours belong to the mark.

The glyph keeps the pill it always had, and the name now sits beside it in the mark's colour rather than in a column of its own. A reader meets the mark and its name as one object, which is what they are.

CSS
.mark-set {
  display: block;
  columns: 300px 3;
  column-gap: 24px;
  padding: 24px 0;
}

.mark-card {
  display: flex;
  flex-direction: column;
  gap: 8px;
  box-sizing: border-box;
  min-width: 0;
  break-inside: avoid;
  margin-bottom: 16px;
  padding: 20px 20px 22px;
  background: var(--pale);
  border: 1px solid var(--rule);
  border-radius: var(--radius);
  transition: background 180ms ease, border-color 180ms ease;
}

.mark-card:hover { background: var(--mark-tint); border-color: var(--mark-ink); }

.mark-card-head { display: flex; align-items: center; gap: 12px; }

.mark-glyph {
  display: inline-block;
  padding: 2px 7px;
  border-radius: var(--radius-sm);
  font-family: var(--mono);
  font-weight: 600;
  font-size: var(--t-code);
  line-height: normal;
}

.mark-name {
  font-weight: 500;
  font-size: var(--t-body);
  line-height: var(--lead);
  color: var(--mark-ink);
}

.mark-says {
  font-size: var(--t-body);
  line-height: var(--lead);
  color: var(--ink);
  text-wrap: pretty;
}

@media (max-width: 899px) {
  .mark-set {
    display: flex;
    columns: auto;
    gap: 12px;
    margin: 0 calc(-1 * var(--side));
    padding: 24px var(--side) 4px;
    overflow-x: auto;
    scroll-snap-type: x mandatory;
    overscroll-behavior-x: contain;
    scrollbar-width: none;
  }

  .mark-set::-webkit-scrollbar { display: none; }

  .mark-card { flex: none; width: 268px; margin-bottom: 0; scroll-snap-align: center; }
}

The commands and their pictures

Four command cards sit in columns three hundred pixels wide, the width of the stage a card carries, and the section takes as many of those columns as it can hold up to three. The fourth wraps under the first, so the set reads as a sequence rather than a menu. A section too narrow for three columns at that width takes two instead of squeezing all three, and the four square up into two rows. Below nine hundred pixels they become a rail to swipe, bleeding to the window's edges so a card can sit centred.

The rail takes over at nine hundred rather than at the phone widths below it, because two columns are the fewest a card set reads well in. One column of four cards leaves each card far wider than the drawing and the sentence inside it, and a card swimming in its own width is worse than a row a reader drags.

A card in the rail is three hundred and thirty pixels, and never narrower than three hundred however far the window closes. The rail exists so that the cards keep their measure and the row moves instead, and a card that shrank with the window would give that back.

Every illustration stands on the same stage, three hundred pixels by two hundred and sixteen, and never wider than the card holding it. A narrower card shrinks the stage in proportion, so the four frames stay level with one another at every width. The frame around the stage takes the pale accent rather than the card ground, because the drawing inside it is already made of cards.

Three of the drawings are SVG and scale themselves. The woven JSON is text and cannot, because its lines break where the weave breaks them. Its widest line runs forty characters, and the monospace advances six tenths of an em per character, so the block measures twenty-four ems across at any type size. The block therefore takes its type size from the stage rather than from the type scale. A twenty-fourth of the stage fits it exactly, and a fraction under that keeps the outer characters clear of the frame's padding. Five lines of JSON fall short of two hundred and sixteen pixels, so the block sits centred in the height it leaves empty.

The frame clips at its edge, so no picture pushes the whole page sideways. A page that scrolls when it should not costs a reader more than a drawing missing its last few pixels.

The sentence under a drawing runs the card's whole width. A drawing has a measure of its own and holds to it, and the sentence has none, so a wider card gives the sentence longer lines rather than leaving the extra blank beside it.

CSS
.ops-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
  column-gap: 24px;
  row-gap: var(--rhythm);
}

.ops-card {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  box-sizing: border-box;
  flex: 0 1 auto;
  min-width: 0;
  scroll-snap-align: center;
}

.ops-head { display: flex; align-items: center; gap: 13px; height: 26px; }

.ops-pill {
  appearance: none;
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  gap: 7px;
  padding: 3px 8px;
  border: 0;
  border-radius: var(--radius-sm);
  font-family: var(--mono);
  font-size: var(--t-label);
  font-weight: 600;
  white-space: nowrap;
}

.ops-took {
  font-family: var(--mono);
  font-size: var(--t-micro);
  letter-spacing: 0.14em;
  text-transform: uppercase;
  opacity: 0;
  pointer-events: none;
  transition: opacity 180ms ease;
}

.ops-took.shown { opacity: 1; }

.ops-frame {
  display: flex;
  align-items: center;
  justify-content: center;
  box-sizing: border-box;
  width: 100%;
  margin-top: 15px;
  padding: 16px;
  overflow: hidden;
  background: var(--pale);
  border-radius: var(--radius);
}

.ops-art {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  container-type: inline-size;
  width: min(300px, 100%);
  aspect-ratio: 300 / 216;
}

.ops-art .tree { display: flex; flex-direction: column; }

.loom-mark { display: block; flex: none; }

.ops-svg { display: flex; max-width: 100%; }

.ops-svg svg { display: block; max-width: 100%; height: auto; }

.ops-says {
  margin: 20px 0 0;
  font-size: var(--t-body);
  line-height: var(--lead);
  color: var(--ink);
  text-wrap: pretty;
}

@media (max-width: 899px) {
  .ops-grid {
    display: flex;
    gap: 12px;
    overflow-x: auto;
    scroll-snap-type: x mandatory;
    overscroll-behavior-x: contain;
    scrollbar-width: none;
    margin: 0 calc(-1 * var(--side));
    padding: 0 var(--side) 4px;
  }
  .ops-grid::-webkit-scrollbar { display: none; }
  .ops-card { flex: none; width: clamp(300px, 84vw, 330px); }
}

The two icons are rectangles, not images, which is what lets a file's dot take its colour from the row beside it.

A folder is a tab, a body and a lighter lip over the body — the three greens in order. The lip covers the body exactly; the body sits beneath it so the shape has a darker edge to sit on rather than floating on the frame.

A file is a page with its corner turned. The page is clipped to five sides so the top-right corner is cut away, a triangle fills that corner in the fold's shade, two faint bars stand in for text, and the dot carries the file's own colour. A single-pixel shadow lifts it off the frame.

CSS
.px-folder {
  position: relative;
  display: inline-block;
  flex: none;
  width: 20px;
  height: 22px;
}

.px-folder-art {
  position: absolute;
  left: 0;
  top: -4px;
  width: 30px;
  height: 30px;
  transform: scale(0.62);
  transform-origin: 0 50%;
}

.px-folder-art .tab {
  position: absolute;
  left: 1px;
  top: 4px;
  width: 12px;
  height: 7px;
  border-radius: 1.5px 3px 0 0;
  z-index: 0;
}

.px-folder-art .body,
.px-folder-art .lip {
  position: absolute;
  left: 1px;
  top: 8px;
  width: 26px;
  height: 18px;
  border-radius: var(--radius-sm);
}

.px-folder-art .body { background: #00B37E; z-index: 1; }
.px-folder-art .lip  { background: #19D69C; z-index: 3; }

.px-file {
  position: relative;
  display: inline-block;
  flex: none;
  width: 17px;
  height: 22px;
  filter: drop-shadow(0 1px 1px rgba(20, 19, 15, 0.14));
}

.px-file .page {
  position: absolute;
  inset: 0;
  background: #FCFAF4;
  border-radius: var(--radius-sm);
  clip-path: polygon(0px 0px, 11px 0px, 17px 6px, 17px 22px, 0px 22px);
}

.px-file .fold {
  position: absolute;
  left: 11px;
  top: 0;
  width: 6px;
  height: 6px;
  background: #D8CFBB;
  clip-path: polygon(0 0, 100% 100%, 0 100%);
}

.px-file .rules {
  position: absolute;
  left: 3px;
  right: 3px;
  top: 8px;
  display: flex;
  flex-direction: column;
  gap: 2px;
}

.px-file .rules span { height: 1.5px; background: var(--ink-3); opacity: 0.24; }
.px-file .rules span:first-child { width: 100%; }
.px-file .rules span:last-child  { width: 72%; }

.px-file .dot {
  position: absolute;
  left: 3px;
  bottom: 3px;
  width: 4px;
  height: 4px;
  border-radius: 50%;
}

A tree's rows are flat and a file simply indents, with a guide running down behind the indent. Drawing one guide for the whole run rather than a branch per row keeps a five-row tree from looking like a diagram.

The weave's picture is JSON rather than a tree, so its tokens take their colours here — a key, a string, a number, and the punctuation that holds them apart. The block takes no type size of its own: it reads a fraction of the stage it stands on, which is what makes it scale with the drawings beside it.

CSS
.tree-row.nested { position: relative; padding-left: 28px; }

.tree-row.nested::before {
  content: '';
  position: absolute;
  left: 9px;
  top: -16px;
  width: 1px;
  height: 32px;
  background: #DFD8C8;
}

.tree-row.nested::after {
  content: '';
  position: absolute;
  left: 9px;
  top: 16px;
  width: 19px;
  height: 1px;
  background: #DFD8C8;
}

.tree-row.nested::before,
.tree-row.nested::after { z-index: 0; }

.tree-row .px-file,
.tree-row .px-folder { z-index: 1; }

.tree-row:not(.nested) { position: relative; z-index: 3; }

.tree-row.written {
  opacity: 0;
  transform: translateY(7px);
  transition: opacity 360ms ease, transform 360ms cubic-bezier(0.2, 0.8, 0.2, 1);
}

.tree-row.written.landed { opacity: 1; transform: none; }

.tree-row.nested::before,
.tree-row.nested::after { transition: opacity 380ms ease; }

.tree-row.nested.faded::before,
.tree-row.nested.faded::after { opacity: 0; }

/* The folder a corpus is ejected from is four layers, and two of them move. */
.px-folder .back {
  position: absolute;
  left: 1px;
  top: 8px;
  width: 26px;
  height: 18px;
  border-radius: 2px;
  z-index: 1;
  transition: background 240ms ease;
}

.px-folder .paper {
  position: absolute;
  left: 4px;
  top: 10px;
  width: 20px;
  height: 14px;
  border-radius: 2px;
  background: var(--card);
  z-index: 2;
}

.px-folder .front {
  position: absolute;
  left: 1px;
  top: 8px;
  width: 26px;
  height: 18px;
  border-radius: 2px;
  background: #E85A78;
  z-index: 3;
  transition: clip-path 420ms cubic-bezier(0.4, 0, 0.2, 1);
}

.tree.handed-back { transition: opacity 480ms ease; }

.tree { transition: opacity 480ms ease; }

.tree-row .name { transition: opacity 300ms ease 180ms; }

.tree-archive { transition: opacity 300ms ease 180ms; }

.px-folder-art { transition: transform 620ms cubic-bezier(0.4, 0, 0.2, 1), opacity 480ms ease; }

.tree-label { position: relative; display: flex; align-items: center; }

.tree-archive {
  position: absolute;
  left: 0;
  top: 50%;
  transform: translateY(-50%);
  display: flex;
  flex-direction: column;
  gap: 6px;
  opacity: 0;
  transition: opacity 380ms ease;
}

.tree-archive.shown { opacity: 1; }

.tree-archive .zip { font-size: var(--t-eyebrow); font-weight: 600; color: var(--ink); }
.tree-archive .said { font-size: 8px; letter-spacing: 0.14em; text-transform: uppercase; color: var(--eject); }



.tree-note {
  margin-left: 8px;
  font-size: var(--t-micro);
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--ink-3);
}

.px-json {
  display: flex;
  flex-direction: column;
  font-family: var(--mono);
  font-size: calc(100cqi / 24.4);
  line-height: var(--lead);
  white-space: pre;
}

.tok-key         { color: var(--blue); }
.tok-string      { color: var(--init); }
.tok-number      { color: var(--violet); }
.tok-punctuation { color: var(--ink-3); }

The document being typed

The example is a white sheet on the paper, and nothing draws its edge. It has no border and no hairline: one soft shadow lifts it off the page, and the change of ground does the rest. A stage is a place a document is written, and a sheet a hand would write on is whiter than the page it lies on.

A holder carries the height, and the card fills it. That split is what lets the card grow: the reader asks for the height of the window, the holder eases from one height to the other, and the stage inside keeps the line it was showing.

The stage opens forty-four pixels down, which is where the button in the top corner ends. The document's first line clears it rather than running under it, and the reader never has to choose between reading a line and pressing a control.

The stage scrolls itself and hides its scrollbar, since the caret leads the reader rather than the bar. Until the document is finished, or the card has grown, the stage does not scroll under the reader's hand at all — the run is driving it, and two things pulling on one document fight each other.

CSS
.pl-hold {
  width: 100%;
  height: 560px;
  transition: height 360ms cubic-bezier(0.2, 0.8, 0.2, 1);
}

.pl-hold.full { height: max(560px, calc(100vh - 48px)); }

.pl-card {
  --pl-run: 0%;
  position: relative;
  display: flex;
  flex-direction: column;
  width: 100%;
  height: 100%;
  overflow: hidden;
  border-radius: 10px;
  background: #FFFFFF;
  box-shadow: 0 2px 26px rgba(20, 19, 15, 0.08);
}

.pl-stage {
  flex: 1 1 auto;
  min-height: 0;
  padding: 44px 36px 72px;
  overflow-x: hidden;
  overflow-y: hidden;
  scrollbar-width: none;
  font-size: var(--t-stage);
  line-height: 2;
}

.pl-card.open .pl-stage { overflow-y: auto; }

.pl-stage::-webkit-scrollbar { width: 0; height: 0; }

.pl-column { max-width: 80ch; }

.pl-tail { height: 78px; }

@media (max-width: 639px) {
  .pl-hold { height: 380px; }
  .pl-hold.full { height: max(380px, calc(100vh - 48px)); }
  .pl-stage { padding: 44px 20px 60px; }
}

The progress bar lies along the foot of the card in the mark's mint, the one thing on this card that is not ink on white. It carries how far the run has reached as its own width, off one custom property the view sets. While the document is being typed the width eases between frames so the bar reads as a run rather than as a stutter; the moment the reader stops or skips it snaps, because a bar that keeps sliding after the press looks like a control that missed it.

The button in the top corner grows the card to the height of the window. It sits over the stage rather than in the transport, since it changes the card and not the run.

CSS
.pl-run {
  position: absolute;
  left: 0;
  bottom: 0;
  z-index: 5;
  width: var(--pl-run);
  height: 3px;
  border-radius: 999px;
  background: var(--loom);
  transition: width 0ms;
}

.pl-card.running .pl-run { transition: width 120ms linear; }

.pl-full {
  appearance: none;
  cursor: pointer;
  position: absolute;
  right: 10px;
  top: 10px;
  z-index: 6;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 34px;
  height: 34px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: transparent;
  color: var(--ink-3);
}

A line of the document holds its own row highlight, which bleeds far past both edges of the column so it reads as a row of the editor rather than a box drawn round the text. The wash is the page's own ink at two per cent — barely a tint, since it has to mark the line without competing with the words on it. The caret is a block, and it blinks only while the typing rests on a finished line.

CSS
.pl-line { position: relative; isolation: isolate; }
.pl-line.kept { white-space: pre; }
.pl-line:not(.kept) { white-space: pre-wrap; }

.pl-cur {
  position: absolute;
  z-index: -1;
  left: -36px;
  right: -200%;
  top: 0;
  bottom: 0;
  background: rgba(20, 19, 15, 0.022);
}

.pl-tok { border-radius: var(--radius-sm); }
.pl-tok.chip { padding: 2px 7px; }

.pl-caret {
  display: inline-block;
  width: 8px;
  height: 16px;
  margin-left: 1px;
  vertical-align: -2px;
  background: var(--ink);
}

.pl-caret.blink { animation: loomType 1.05s steps(1, end) infinite; }

@keyframes loomType { 0%, 55% { opacity: 1; } 56%, 100% { opacity: 0; } }
@keyframes loomRise { from { transform: translateY(6px); opacity: 0; } to { transform: none; opacity: 1; } }

@media (prefers-reduced-motion: reduce) {
  .pl-caret.blink { animation: none; }
}

The two command buttons rise into place when the document finishes, the second a beat after the first, and each names the command above the file it writes. A pressed button takes its command's colour and the panel under it takes the same, so the eye can tell at a glance which of the two files is on show.

CSS
.pl-outs { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 26px; }

.pl-out {
  appearance: none;
  cursor: pointer;
  display: flex;
  flex-direction: column;
  gap: 5px;
  padding: 10px 14px;
  border: 1px solid var(--rule);
  border-radius: var(--radius);
  background: transparent;
  text-align: left;
  font-family: var(--mono);
  line-height: 1.4;
  transition: border-color 140ms ease, background-color 140ms ease;
  animation: loomRise 300ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
}

.pl-out.tangle { animation-delay: 60ms; }
.pl-out.weave  { animation-delay: 220ms; }

.pl-out-cmd  { font-size: var(--t-code); letter-spacing: 0.04em; white-space: nowrap; }
.pl-out-file { font-size: var(--t-label); letter-spacing: 0.04em; white-space: nowrap; color: var(--ink-3); }

.pl-out.tangle .pl-out-cmd { color: var(--blue); }
.pl-out.weave  .pl-out-cmd { color: var(--violet); }

.pl-out.tangle:hover, .pl-out.tangle.showing { border-color: var(--blue); }
.pl-out.weave:hover,  .pl-out.weave.showing  { border-color: var(--violet); }

.pl-out.tangle.showing { background: rgba(46, 111, 242, 0.07); }
.pl-out.weave.showing  { background: rgba(139, 92, 246, 0.07); }

.pl-result {
  display: flex;
  flex-direction: column;
  margin-top: 14px;
  border: 1px solid var(--rule);
  border-radius: var(--radius);
  overflow: hidden;
  animation: loomRise 260ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
}

.pl-result.tangle { border-color: rgba(46, 111, 242, 0.4); }
.pl-result.weave  { border-color: rgba(139, 92, 246, 0.4); }

.pl-result-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  padding: 8px 14px;
  border-bottom: 1px solid var(--rule);
  font-size: var(--t-label);
  letter-spacing: 0.04em;
  color: var(--ink-3);
}

.pl-result-file { color: var(--ink); }

.pl-result-body {
  display: flex;
  flex-direction: column;
  padding: 12px 14px;
  background: var(--fence);
  font-size: var(--t-code);
  line-height: 1.75;
  white-space: pre;
  overflow-x: auto;
}

.pl-result-line { min-height: 1em; }

@media (max-width: 639px) {
  .pl-result-body { white-space: pre-wrap; overflow-wrap: anywhere; overflow-x: hidden; padding: 10px 12px; }
}

The foot of the stage fades into the card so text does not run under the transport, and the transport itself sits over that fade. The transport is on the card from the first frame, because the document is on the card from the first frame too: a reader who has not pressed anything is reading it, not waiting for it.

None of the controls answers the cursor. They hold their ink whether it is over them or not, so the only thing moving on this card is the document. Play is the smallest of them and the one pressed most, so it carries an invisible forty-four-pixel square over its thirty: the target a finger needs, without the weight a thirty-pixel triangle would gain by growing to match. Skipping hides itself at the end rather than leaving the row, so nothing under the cursor moves when the run finishes.

CSS
.pl-fade {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 44px;
  height: 44px;
  pointer-events: none;
  background: linear-gradient(180deg, rgba(255, 255, 255, 0) 0%, #FFFFFF 100%);
}

.pl-bar {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 4;
  display: flex;
  align-items: center;
  justify-content: space-between;
  height: 44px;
  padding: 0 18px;
  background: #FFFFFF;
}

.pl-back, .pl-skip, .pl-play {
  appearance: none;
  cursor: pointer;
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex: none;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: transparent;
  color: var(--ink-3);
}

.pl-back, .pl-skip { width: 34px; height: 34px; }
.pl-play { width: 30px; height: 30px; color: var(--ink); }

.pl-play::before {
  content: '';
  position: absolute;
  left: 50%;
  top: 50%;
  width: 44px;
  height: 44px;
  transform: translate(-50%, -50%);
}

.pl-skip.gone { visibility: hidden; }

@media (max-width: 639px) {
  .pl-bar { padding: 0 14px; }
}

The page head

The pages past the landing open on a shared head rather than a masthead, though on the hero's padding all the same. The Loom mark stands beside the page's name at four fifths of its size, and the pair hold one line with sixteen pixels between them. Below seven hundred and sixty-eight pixels the head drops the mark, because the nav shows it there beside the wordmark. Under that, the paragraph and the two link cards share a row of equal halves until the window drops below a thousand pixels, where the cards fall under the paragraph. The gutter between the halves narrows with the window, as every column gap on the site does. The landing's hero lays its parts out as a spaced column, and this head sets its own spacing, so it goes back to an ordinary block.

CSS
.page-hero { display: block; }

.page-title {
  display: flex;
  align-items: center;
  gap: 16px;
  white-space: nowrap;
  margin-bottom: 30px;
}

.page-head-grid {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  gap: 44px;
  align-items: start;
}

.page-lead { margin: 0; padding-top: 5px; text-wrap: pretty; }

@media (max-width: 1179px) { .page-head-grid { gap: 40px; } }
@media (max-width: 999px)  { .page-head-grid { grid-template-columns: minmax(0, 1fr); gap: 36px; } }
@media (max-width: 767px) { .page-title .loom-mark { display: none; } }
@media (max-width: 639px) {
  .page-head-grid { gap: 28px; }
  .page-title h1 { font-size: 26px; }
}

A place card is a pale panel with a pixel emblem over two lines of type. It stays the ink colour under the pointer rather than taking the muted ink every other link fades to, because the card already answers the pointer with its ground and its edge. Each card answers in its own emblem's colour, amber for GitHub and blue for Telegram.

CSS
.page-places {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 16px;
  min-width: 0;
}

.place-card {
  display: flex;
  flex-direction: column;
  gap: 16px;
  min-width: 0;
  padding: 20px 20px 22px;
  background: var(--pale);
  border: 1px solid var(--rule);
  border-radius: var(--radius);
  color: var(--ink);
  transition: background 180ms, border-color 180ms;
}

.place-card:hover { color: var(--ink); }
.place-card.github:hover   { background: #FDF3DF; border-color: var(--amber); }
.place-card.telegram:hover { background: #E9F0FE; border-color: var(--blue); }

.place-emblem { display: block; flex: none; width: 49px; height: 49px; }

.place-lines { display: flex; flex-direction: column; gap: 4px; min-width: 0; }

.place-title {
  font-weight: 500;
  font-size: var(--t-sub);
  line-height: var(--lead-head);
  letter-spacing: -0.02em;
}

The argument page

The motivation stands in two columns, the question on the left and the answer on the right. Each is a stack of paragraphs, justified and hyphenated, and the gap between the columns is the one running text takes elsewhere on the site. Below six hundred and forty pixels the answer drops under the question.

Under the prose, the cards stand apart from it by twice the gap between a section's blocks, under a section head of their own. They come in groups. Each group opens on its title, set as a card's sub-heading, and lays its cards in a row of three columns of even height, two below twelve hundred pixels and one below six hundred and forty. The groups stand one block's gap apart. A card keeps the paper's pale panel until the pointer reaches it, and then takes the tint and the edge of its own hue, which the card carries as two custom properties.

CSS
.why-prose {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  column-gap: var(--col-gap);
  row-gap: var(--stack);
}

.why-column > p {
  max-width: 62ch;
  margin: 0 0 var(--stack);
  text-align: justify;
  hyphens: auto;
  text-wrap: pretty;
}

.why-column > p:last-child { margin-bottom: 0; }

.why-bug { display: block; flex: none; width: 14px; height: 14px; }
.why-a { display: block; flex: none; width: 10px; height: 10px; }

.why-cases { margin-top: calc(var(--rhythm) * 2); }

.why-group + .why-group { margin-top: var(--rhythm); }

.why-group-title {
  margin: 0 0 16px;
  font-size: var(--t-sub);
  font-weight: 500;
  line-height: var(--lead-head);
  letter-spacing: -0.02em;
}

.why-pair-set {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 24px;
}

.why-pair {
  display: flex;
  flex-direction: column;
  gap: 8px;
  padding: 20px 20px 22px;
  background: var(--pale);
  border: 1px solid var(--rule);
  border-radius: var(--radius);
  transition: background 180ms, border-color 180ms;
}

.why-pair:hover { background: var(--pair-tint); border-color: var(--pair-ink); }

.why-line {
  display: grid;
  grid-template-columns: 14px 1fr;
  column-gap: 12px;
  align-items: start;
  font-size: var(--t-code);
  line-height: var(--lead);
  text-wrap: pretty;
}

.why-line .why-bug { margin-top: 6px; }
.why-line .why-a { margin-top: 8px; margin-left: 2px; }

@media (max-width: 1179px) { .why-pair-set { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 639px) {
  .why-prose { grid-template-columns: minmax(0, 1fr); }
  .why-pair-set { grid-template-columns: minmax(0, 1fr); }
}

The channels

The community page lists its channels as cards packed into columns of uneven height: three at full width, two below twelve hundred pixels, and one below six hundred and forty. A card is a pale panel with the channel's name, the contribution it suits, and a sentence or two on what to bring, set at the size of block code. The whole card is the link. Under the pointer it takes the tint and the edge of its channel's hue, which the card carries as two custom properties, the way the argument page's pairs light.

CSS
.channel-set { column-count: 3; column-gap: 24px; }

.channel-card {
  break-inside: avoid;
  display: flex;
  flex-direction: column;
  gap: 12px;
  margin: 0 0 24px;
  padding: 20px 20px 22px;
  background: var(--pale);
  border: 1px solid var(--rule);
  border-radius: var(--radius);
  color: var(--ink);
  transition: background 180ms, border-color 180ms;
}

.channel-card:hover {
  color: var(--ink);
  background: var(--channel-tint);
  border-color: var(--channel-ink);
}

.channel-body { font-size: var(--t-code); line-height: var(--lead); text-wrap: pretty; }

@media (max-width: 1179px) { .channel-set { column-count: 2; } }
@media (max-width: 639px)  { .channel-set { column-count: 1; } }

The licence

The licence page reads like a document. Its plain account sits under the page's name at the width running text always takes. The licence follows in a single column at the same measure, never two, since a reader checking terms reads them in order and should not have to jump between columns. The column stands in the middle of the page, the way a printed document sits on its sheet, and the text in it stays ranged left. The copyright notice under the heading takes the muted ink, because it says whose the terms are rather than what they say.

CSS
.license-column { max-width: 62ch; margin: 0 auto; }

.license-plain > p { margin: 0 0 var(--stack); }
.license-plain > p:last-child { margin-bottom: 0; }

.license-body > p { margin: 0 0 var(--stack); text-wrap: pretty; }
.license-body > p:last-child { margin-bottom: 0; }

.license-notice { color: var(--ink-3); }

The pager

The pager carries the cream air above the footer as its own bottom padding, so it is the last thing on the page in the layout as well as in the reading.

Its two cards are as wide as their contents, the previous one at the left end of the row and the next one at the right, with at least forty-four pixels of air between them — they are two destinations, not a segmented control. When the two names are too long to share a line, the next card wraps onto a line of its own beneath the first and keeps to the right. So no width of window and no length of name ever wraps a name beside a card that did not wrap: a name breaks across lines only when it is wider than the whole row on its own, and then the card takes the full width. The order still reads as the pages do, previous first. The row is a wrapping flex line rather than a grid for exactly this reason: a grid splits the width in two whatever the names need, and a flex line lets each card claim what its name needs.

A card is not a card. It draws no ground, no edge and no padding of its own — only the mark, the eyebrow and the name of the page, standing on the paper as plainly as the section above them. Under the cursor the card takes the destination's own colour and both lines inherit it; the eyebrow stays a quarter transparent, which is what has always made it quieter than the name. So one colour moves and the hierarchy between the two lines survives the move. The colour reaches the card as a custom property, since a hover rule cannot be written inline but the colour belongs to whichever page the card leads to.

The mark and the words both hang from the top of the card. The two cards in a row stretch to the taller one's height, so a card that centred its contents would drop its eyebrow whenever the name across the row wrapped onto a second line. Hung from the top, both eyebrows start on the same line, and both marks with them, whatever the names do.

CSS
.pager {
  max-width: var(--measure);
  margin: 0 auto;
  padding: 8px var(--side) var(--footer-pad);
}

.pager-row {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  gap: 24px 44px;
  padding-top: 44px;
}

.pager-card {
  display: flex;
  align-items: flex-start;
  gap: 16px;
  box-sizing: border-box;
  flex: 0 0 auto;
  max-width: 100%;
  color: var(--ink);
  transition: color 140ms ease;
}

.pager-card:hover { color: var(--pg-ink); }

.pager-card .eyebrow { color: inherit; opacity: 0.75; transition: color 140ms ease; }

.pager-card.next { justify-content: flex-end; margin-left: auto; }

.pager-words {
  display: flex;
  flex-direction: column;
  gap: 4px;
  min-width: 0;
  align-items: flex-start;
  text-align: left;
}

.pager-card.next .pager-words { align-items: flex-end; text-align: right; }

.pager-title {
  max-width: 100%;
  font-weight: 500;
  font-size: var(--t-head);
  line-height: var(--lead-head);
  letter-spacing: -0.02em;
  color: inherit;
  transition: color 140ms ease;
}

The drawer, when the menu will not fit

Below a thousand pixels, where the menu no longer fits beside the sections, the drawer carries it instead. The panel slides in from the left edge over a scrim, and both move on their own curve — the panel on a long ease-out so it settles rather than stops, the scrim a little quicker so the dimming leads the movement.

The scrim also stops taking clicks when it is invisible. An invisible layer that still swallows a click is the kind of bug that only shows up on the page it breaks. The panel drops its shadow while it is shut, or the shadow would show as a faint strip down the left edge of the window.

CSS
.docs-page { position: relative; }

.docs-scrim {
  position: fixed;
  inset: 0;
  z-index: 40;
  background: rgba(20, 19, 15, 0.32);
  opacity: 0;
  pointer-events: none;
  transition: opacity 340ms cubic-bezier(0.33, 1, 0.68, 1);
}

.docs-scrim.open { opacity: 1; pointer-events: auto; }

.docs-drawer {
  position: fixed;
  left: 0;
  top: 0;
  bottom: 0;
  z-index: 50;
  display: none;
  flex-direction: column;
  width: 296px;
  max-width: 84vw;
  background: var(--paper);
  box-shadow: 0 0 48px rgba(20, 19, 15, 0.14);
  transform: translateX(-104%);
  transition: transform 420ms cubic-bezier(0.22, 1, 0.36, 1);
}

.docs-drawer.open { transform: translateX(0); }
.docs-drawer:not(.open) { box-shadow: none; }

.drawer-head {
  display: flex;
  align-items: center;
  gap: 7px;
  flex: none;
  min-height: 56px;
  padding: 0 12px;
  color: var(--ink);
}

.drawer-word {
  font-family: var(--display);
  font-weight: 500;
  font-size: 24px;
  letter-spacing: -0.03em;
}

.drawer-close {
  appearance: none;
  cursor: pointer;
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  flex: none;
  margin-left: auto;
  width: 30px;
  height: 30px;
  padding: 0;
  border: 0;
  border-radius: var(--radius-sm);
  background: transparent;
  color: var(--ink);
}

.drawer-close::before {
  content: '';
  position: absolute;
  left: 50%;
  top: 50%;
  width: 44px;
  height: 44px;
  transform: translate(-50%, -50%);
}

.drawer-close:hover { background: rgba(20, 19, 15, 0.06); }

.drawer-body {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  gap: 28px;
  padding: 0 24px 96px;
}

@media (max-width: 999px) {
  .docs-drawer { display: flex; }
}

The documentation shell

A chapter of the docs is laid out like the site's other pages, on the same measure and the same side air. Its head is the page head, with one change of proportion. The lead and the commands beside it split the row as the landing's hero splits it, a little more to the lead, with seventy-six pixels between them. The head also gives back most of the air a page head leaves under itself, sixty-four pixels instead of a hundred and sixteen, because the menu and the first section follow straight on.

Above the chapter's name, the line that says where it sits is set like an eyebrow: small capitals, muted ink, paler slashes between the steps. The steps that lead somewhere take the accent under the pointer. The chapter's own name stays in primary ink, since it is where the reader already is. Every gap between a heading and the text under it is twenty-four pixels, from the chapter's name down to the last section.

CSS
.section-hero.doc-hero { padding-bottom: var(--section-pad); }

.doc-hero-grid {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 0.86fr);
  gap: 76px;
  align-items: start;
}

.doc-hero .page-title { margin-bottom: 24px; }
.doc-hero .page-lead { padding-top: 0; }
.doc-hero .page-lead p { margin: 0; max-width: 62ch; }
.doc-hero .page-lead p + p { margin-top: 16px; }

.hero-cli { display: flex; flex-direction: column; gap: 12px; min-width: 0; }

.doc-crumbs {
  display: flex;
  align-items: center;
  gap: 10px;
  margin-bottom: 22px;
}

.doc-crumbs .crumb {
  appearance: none;
  cursor: pointer;
  padding: 0;
  border: 0;
  background: transparent;
  font: inherit;
  letter-spacing: inherit;
  text-transform: inherit;
  color: var(--ink-3);
  transition: color .12s ease;
}

.doc-crumbs .crumb:hover { color: var(--accent); }
.doc-crumbs .sep  { color: var(--rule); }
.doc-crumbs .here { color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

Under the head the page divides into two columns: the menu card, three hundred pixels wide, and the sections beside it. The menu card is the site's pale card, with the pale ground, the thin rule and the small corners of the link cards on the other pages. It sticks twenty-four pixels below the top of the window, and scrolls within itself when the docs hold more chapters than the window holds lines.

The sections run down one column. A section is the site's section without its own measure, since the body already holds it to one. A section opens on its mark and its heading, the mark centred on the heading's line, and leaves forty pixels before the next one.

An editor's emblem takes the same forty-two-pixel square as a flower. The emblems come in many shapes, from Helix's tall helix to Gram's wide frog, so each drawing scales to fit the square whole and sits at its centre.

The column of sections stretches to the height of the body, and the pager sits at its foot. At the end of a chapter the pager's lower edge lines up with the menu's, and both stop a section's padding above the footer, the gap every other page leaves before it. The body pads its foot by that padding, so the page ends as the site's other pages do rather than running hard into the footer.

The pager also keeps more room above it than a section leaves between blocks, so the last words of a chapter do not crowd the cards that lead away from it.

CSS
.docs-body {
  display: grid;
  grid-template-columns: 300px minmax(0, 1fr);
  gap: var(--col-gap);
  max-width: var(--measure);
  margin: 0 auto;
  padding: 0 var(--side) var(--section-pad);
}

.docs-article { display: flex; flex-direction: column; min-width: 0; }

.doc-pager.pager-row { margin-top: auto; padding-top: calc(var(--section-pad) - 24px); }

A chapter of the book wears the docs' head, so it keeps its spacing: the crumb line, then the mark and the name twenty-four pixels below, then the frontmatter. The head's own gap between its rows is set aside for that, since the head stacks its rows directly. The large line beside the mark is set exactly as a page's title is, though it is a label rather than a heading.

The frontmatter reads as text rather than as a card, so the head holds one box, not two. The fields take the page's body type, one to a line, at a heavier weight. Each key wears the violet the loom gives the frontmatter, and each value wears the mark's mint. The mint is deepened by a quarter of its paper shade, since the full mint is meant for bars and marks and reads faint as type on paper. A long field breaks wherever it must, since a package path has no spaces to break at.

Those rows take the head's left column, and the GitHub card takes its right, with its top level with the crumb line. The right column is exactly as wide as one of the cards in the other pages' heads, a quarter of the head less its gaps, so the card keeps the size it has everywhere else. Below a thousand pixels the columns stack, and the card drops under the frontmatter at half the width, as the other pages' cards drop under their lead.

Under the fields sits the Raw source link, set small in muted ink beside GitHub's mark, which takes the accent under the pointer like the site's other quiet links.

The chapter's name heads the article in the display face, at twenty-six pixels, with the chapter's emblem beside it as a section's mark sits beside its heading. That sits between the head's thirty-two-pixel line and the twenty-one-pixel section headings, so the name ranks above the sections without competing with the head. The opening under it ends forty pixels above the first section.

CSS
.section-hero.source-hero { gap: 0; }

.frontmatter { display: flex; flex-direction: column; min-width: 0; }
.front-field {
  margin: 0;
  line-height: 1.8;
  font-weight: 600;
  color: color-mix(in srgb, var(--loom), var(--loom-paper) 25%);
  overflow-wrap: anywhere;
}

.front-key { color: var(--violet); }

.source-head {
  display: grid;
  grid-template-columns: minmax(0, 1fr) calc((100% - 44px) / 4 - 8px);
  gap: 44px;
  align-items: start;
}

.source-head-main { min-width: 0; }
.source-head .page-places { display: block; }

@media (max-width: 999px) {
  .source-head { grid-template-columns: minmax(0, 1fr); gap: 36px; }
  .source-head .page-places { display: grid; }
}

.raw-source {
  display: flex;
  width: fit-content;
  align-items: center;
  gap: 8px;
  margin-top: 14px;
  font-size: 12px;
  line-height: 1.4;
  color: var(--ink-3);
  text-decoration: none;
  transition: color 140ms ease;
}

.raw-source:hover { color: var(--accent); }

.page-place {
  margin: 0;
  font-family: var(--display);
  font-weight: 500;
  font-size: var(--t-hero);
  line-height: var(--lead-head);
  letter-spacing: -0.02em;
}

@media (max-width: 639px) { .page-place { font-size: 26px; } }

.chapter-title { font-size: 26px; }
.doc-opening .chapter-head { align-items: center; margin-bottom: 24px; }
.doc-opening { margin-bottom: 40px; }

.section.doc-section { max-width: none; margin: 0; padding: 8px 0 40px; }
.doc-section .section-head { align-items: center; margin-bottom: 24px; }
.doc-section h2 { scroll-margin-top: 24px; }

.editor-logo { display: block; flex: none; width: 42px; height: 42px; }
.editor-logo svg { display: block; width: 100%; height: 100%; }

.doc-stack > * { margin: 0 0 18px; }
.doc-stack > *:last-child { margin-bottom: 0; }
.doc-stack h3 { margin: 12px 0 14px; font-size: var(--t-sub); }

The menu holds three levels of type, and each is set apart from the next. A part's name is an eyebrow. A chapter is fourteen pixels in muted ink, and the chapter on show sets in primary ink at a heavier weight rather than in colour. The open chapter's sections hang beneath it a step smaller and sixteen pixels in, in the palest ink.

Only one thing in the menu is ever orange: the section the reader has reached. A small orange square stands in front of it, a pixel of the woven marks. A row the pointer is over only darkens, so hovering never puts a second orange row beside the reader's place.

The drawer holds the same groups, so the rules below style both.

A menu that scrolls within itself scrolls its top padding too, so the padding would show a sliver of whatever row has passed up under it. A chapter scrolled to the top would then never look like the menu's first line. So the top padding is a strip of the menu's own ground, pinned to the top while the rows scroll beneath it. The strip fades from the solid ground at the card's edge to clear at its foot. At rest it is simply the padding. Scrolled, the rows fade out as they pass under it, rather than ending at a cut line. The strip runs the full width of the menu, over the side padding, and a negative margin takes back the gap the list sets after it, so at rest every row stands where it would without the strip. The card's strip is its pale ground, twenty-two pixels tall, and the drawer's is the paper of the drawer, twenty pixels.

CSS
.menu-card {
  position: sticky;
  top: 24px;
  align-self: start;
  display: flex;
  flex-direction: column;
  gap: 28px;
  max-height: calc(100vh - 48px);
  overflow-y: auto;
  padding: 0 22px 24px;
  background: var(--pale);
  border: 1px solid var(--rule);
  border-radius: var(--radius);
}

.menu-card::before,
.drawer-body::before {
  content: '';
  position: sticky;
  top: 0;
  z-index: 1;
  flex: none;
}

.menu-card::before {
  height: 22px;
  margin: 0 -22px -28px;
  background: linear-gradient(var(--pale) 25%, transparent);
}

.drawer-body::before {
  height: 20px;
  margin: 0 -24px -28px;
  background: linear-gradient(var(--paper) 25%, transparent);
}

.thin-scroll { scrollbar-width: thin; scrollbar-color: transparent transparent; }
.thin-scroll:hover { scrollbar-color: rgba(124, 111, 100, 0.22) transparent; }
.thin-scroll::-webkit-scrollbar { width: 6px; }
.thin-scroll::-webkit-scrollbar-track { background: transparent; }
.thin-scroll::-webkit-scrollbar-thumb { background: transparent; border-radius: var(--radius-sm); }
.thin-scroll:hover::-webkit-scrollbar-thumb { background: rgba(124, 111, 100, 0.22); }

.menu-part { display: flex; flex-direction: column; }
.menu-part .eyebrow { margin-bottom: 10px; }

.menu-item,
.menu-sub {
  appearance: none;
  cursor: pointer;
  padding: 0;
  border: 0;
  background: transparent;
  text-align: left;
  font-family: var(--mono);
  transition: color .12s ease;
}

.menu-item { padding: 4px 0; font-size: 14px; line-height: 1.5; color: var(--ink-2); }
.menu-item:hover { color: var(--ink); }
.menu-item.here { color: var(--ink); font-weight: 500; cursor: default; }

On the source page each chapter in the menu wears its language's emblem, sixteen pixels square and nine pixels before its name, the way an editor's sidebar marks a file. A marked chapter lays its emblem and name out in a row, the emblem centred on the name's first line, so a name long enough to wrap keeps its emblem beside its start. A chapter's name is set at one and a half times its size, with four pixels above and below, so a name that wraps keeps its two lines together while the rows stay apart. The sections under the chapter on show step in by the emblem's width as well, so they still hang under the name rather than under the emblem.

A language's emblem is an image, and it never stretches: it keeps its own size wherever a row or a heading would squeeze it.

CSS
.lang-icon { display: block; flex: none; }

.menu-item.marked { display: flex; align-items: flex-start; gap: 9px; }
.menu-item.marked > :first-child { margin-top: 2.5px; }
.menu-item.marked + .menu-subs { padding-left: 25px; }

.menu-subs { display: flex; flex-direction: column; padding-bottom: 6px; }

.menu-sub {
  position: relative;
  padding-left: 16px;
  font-size: 12.5px;
  line-height: 2;
  color: var(--ink-3);
}

.menu-sub:hover { color: var(--ink); }
.menu-sub.here { color: var(--accent); }

.menu-sub.here::before {
  content: '';
  position: absolute;
  left: 3px;
  top: 50%;
  width: 5px;
  height: 5px;
  margin-top: -2.5px;
  background: var(--accent);
}

The chapter's own prose is Markdown the weave carried through, so the elements it produces are styled here rather than by the views: a paragraph, a list, a table, and code set inside a sentence. Running text fills the column, as the code sheets and commands between its paragraphs do, so prose and code share one right edge rather than leaving a ragged gap beside every paragraph. Code inside a sentence sits on the same faint wash as a fence, in primary ink, so it reads as a word set in a different face rather than a highlight. A command inside a sentence is a button in the command's own colour: its text in that colour, on a faint wash of it inside a thin edge of it, and a small copy glyph after the command. The colour arrives as a custom property, since it belongs to the command rather than to the stylesheet. Under the pointer the edge takes the full colour and the wash deepens, and once the command is copied the glyph turns to a tick in the green the command blocks use for copied. A list's markers hang in its indent, inside the column. A numbered list's indent is four characters wide, so a two-digit number fits in it rather than crossing the column's left edge.

CSS
.docs-prose > :first-child { margin-top: 0; }
.docs-prose p { margin: 16px 0 0; }

.docs-prose ul,
.docs-prose ol { margin: 16px 0 0; padding-left: 24px; }

.docs-prose ol { padding-left: 4ch; }

.docs-prose li { margin: 4px 0 0; }

.docs-prose code {
  padding: 1px 5px;
  border-radius: 3px;
  background: var(--fence);
  font-size: 0.92em;
  color: var(--ink);
}

.inline-copy {
  appearance: none;
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 1px 6px 1px 5px;
  border: 1px solid color-mix(in srgb, var(--cmd-ink) 28%, transparent);
  border-radius: 3px;
  background: color-mix(in srgb, var(--cmd-ink) 9%, transparent);
  font: inherit;
  line-height: inherit;
  vertical-align: baseline;
  color: var(--cmd-ink);
  transition: border-color .14s ease, background-color .14s ease;
}

.inline-copy code { padding: 0; background: none; color: var(--cmd-ink); }
.inline-copy svg { opacity: 0.7; }
.inline-copy:hover { border-color: var(--cmd-ink); background: color-mix(in srgb, var(--cmd-ink) 14%, transparent); }
.inline-copy:hover svg { opacity: 1; }
.inline-copy.done svg { opacity: 1; color: var(--init); }

.docs-prose table {
  margin: 24px 0 0;
  border-collapse: collapse;
  font-size: var(--t-code);
}

.docs-prose th,
.docs-prose td {
  padding: 8px 16px 8px 0;
  border-bottom: 1px solid var(--rule-faint);
  text-align: left;
  vertical-align: top;
}

.docs-prose th {
  font-weight: 600;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--ink-3);
}

Every piece of code in a chapter sits on the menu's own card: the pale ground, the thin rule, the small corners. That holds for a quoted loom, for a code sheet, and for the command buttons, which on the landing stand on white. The card is flat on the page, with no shadow, since a quotation does not float above the text around it. Its type is a step smaller than the prose, so a full line of code fits the column, and a line too long for it scrolls sideways rather than wrapping.

The copy button sits in the card's top corner in muted ink, with no frame until the pointer reaches it, so it never competes with the code it copies.

A command is usually one short line, and stretched across the column it would be mostly empty card. So a command, and a picker of commands, takes four hundred and forty pixels, about the width of the commands beside a chapter's lead. A longer command widens its card to stay on one line, up to the width of the column, and only a command longer than the column wraps. A code sheet keeps the full width, since its lines run long. A picture stands on the page itself, with no frame. A landing drawing carries a pale ground of its own for the landing's card, and here that ground is made transparent too, so only the picture shows. In a chapter's head a picture takes its size from the text beside it: its column stretches to the row, and the drawing scales to four fifths of that height, centred in it, which keeps a small drawing from looming over the title.

CSS
.docs-canvas {
  position: relative;
  padding: 26px 28px;
  overflow-x: auto;
  border: 1px solid var(--rule);
  border-radius: var(--radius);
  background: var(--pale);
  font-size: var(--t-code);
  line-height: 2;
}

.sheet-code {
  margin: 0;
  font-family: var(--mono);
  font-size: var(--t-code);
  line-height: 2;
  white-space: pre;
}

.sheet-code code { padding: 0; background: none; color: var(--ink); font-size: inherit; }

.docs-page .rt-command { background: var(--pale); border-radius: var(--radius); }
.docs-page .rt-command:hover { border-color: var(--ink-3); }

.doc-stack .docs-command,
.doc-stack .hero-install { width: fit-content; min-width: min(440px, 100%); max-width: 100%; }

.doc-picture { margin-top: 0; padding: 0; background: none; }
.hero-cli:has(.doc-picture) { align-self: stretch; }
.hero-cli .doc-picture { height: 100%; }
.hero-cli .doc-picture .ops-svg { height: 100%; align-items: center; }
.hero-cli .doc-picture svg { width: auto; height: 80%; }
.doc-picture svg > rect:first-of-type { fill: transparent; }

.canvas-copy {
  position: absolute;
  top: 12px;
  right: 12px;
  display: grid;
  place-items: center;
  width: 30px;
  height: 30px;
  padding: 0;
  border: 1px solid transparent;
  border-radius: var(--radius);
  background: transparent;
  color: var(--ink-3);
  cursor: pointer;
  transition: color 140ms, border-color 140ms;
}

.canvas-copy:hover { color: var(--ink); border-color: var(--rule); }
.canvas-copy.done { color: var(--init); }

On the source page an anchor inside the code is a link, and it looks like one. It takes the accent's orange and a dotted underline, which turns solid under the pointer.

The rest of the code takes its colours from the marks the server sends, one class for each kind of token, drawn from the scheme's own inks. Keywords take the violet of weave, and the names of functions the blue of tangle. Strings keep the green the landing's JSON already gives them. Constants take the hooks' magenta and the names of types the spec's amber, both in the deeper shades the scheme keeps for text on paper. Comments recede into the muted ink, so the code a comment explains stays in front of it. No kind takes the accent's orange, which belongs to the anchors.

Every sheet of code on the source page wears a tag on its top edge, with the language's icon and its name in small muted capitals. The tag sits on a scrap of the page's paper that straddles the border, so it reads as cut into the frame rather than laid on the code. Half the tag stands above the sheet, so the sheet sits 30px below the block before it, where the stack's other blocks sit 18px apart. The tag then keeps about the stack's usual gap from the prose. The sheet's top margin carries the whole gap, because two margins that meet collapse into the larger of the two rather than adding up.

A sheet that scrolls clips whatever stands outside it. So on the source page the code inside the sheet scrolls sideways rather than the sheet itself, and the tag stays whole.

A note is a reader's annotation rather than part of the chapter, so it reads quieter than the prose around it. It sits on the pale card ground in muted ink, with a rule in the accent down its left edge.

CSS
.code-anchor {
  color: var(--accent);
  text-decoration: underline dotted;
  text-underline-offset: 4px;
}

.code-anchor:hover { text-decoration-style: solid; }

.tok-keyword  { color: var(--weave); }
.tok-function { color: var(--tangle); }
.tok-constant { color: var(--hooks-paper); }
.tok-type     { color: var(--spec-paper); }
.tok-comment  { color: var(--ink-3); }

.doc-stack > .source-code { margin-top: 30px; }

.source-code { overflow: visible; }
.source-code .sheet-code { overflow-x: auto; }

.code-tag {
  position: absolute;
  top: -11px;
  left: 20px;
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 2px 8px;
  border: 1px solid var(--rule);
  border-radius: var(--radius-sm);
  background: var(--paper);
  font-family: var(--mono);
  font-size: 11px;
  line-height: 1.4;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ink-3);
}

.doc-note {
  padding: 14px 20px;
  border-left: 3px solid var(--accent);
  border-radius: var(--radius-sm);
  background: var(--pale);
  color: var(--ink-3);
}

.doc-note blockquote { margin: 0 0 10px; padding: 0; border: 0; }

The page gives way as the window narrows. Below twelve hundred pixels the gap in the head narrows to fifty-six. Below a thousand the menu leaves the page for the drawer, the sections take the full width, and the line above the chapter's name goes with it, since the nav pinned to the top of the window now carries the way through the docs. The head keeps the same air above it as every other page's head. A heading scrolled to stops eighty pixels from the top of the window instead of twenty-four, which leaves it twenty-four pixels under the pinned nav. Below seven hundred and eighty the head stacks, the commands falling under the lead.

CSS
@media (max-width: 1179px) { .doc-hero-grid { gap: 56px; } }

@media (max-width: 999px) {
  .docs-body { grid-template-columns: minmax(0, 1fr); }
  .menu-card { display: none; }
  .doc-crumbs { display: none; }
  .doc-section h2 { scroll-margin-top: 80px; }
}

@media (max-width: 779px) {
  .doc-hero-grid { grid-template-columns: minmax(0, 1fr); gap: 32px; }
}
Loom