SourceThe websiteThe marks and the pieces

Part X, Chapter 3

Part X, Chapter 3: The marks and the pieces

Package: site/src/

Language: TypeScript

Raw source

The marks and the pieces

Every page of the site wears the same small set of parts: Loom's own woven mark, a handful of line icons, and the runtime logos the install rows lead with. This chapter draws each one once, and the pages that follow compose them.

The mark and the four command illustrations are not drawn here at all. They are vendored SVGs — the design draws them, and this chapter only says how they are placed and at what size. Each carries its own animation in SMIL, so none of them asks the model for a clock.

The woven carpet

Loom's mark is a woven carpet, and the site draws it at every size — seventeen pixels beside a page's name in the search palette, forty-two at the head of a section, and the whole pill in the hero and the footer. A carpet is a small square grid of coloured cells. Rows of glyphs say where each thread runs, and a key says what colour each glyph stands for: a dot is always the navy ground, a p is always paper, and the remaining letters name whatever colours that particular mark is woven from.

One row becomes one horizontal gradient with a hard stop at every cell boundary, and the rows stack into a square by background position. Drawn this way a carpet needs no image file and no element per cell: seven gradients paint a seven-by-seven mark, and it stays sharp at any size the page asks for.

Gradient stops that meet exactly leave a hairline seam wherever the browser rounds a fraction of a pixel. So every layer bleeds a little past its own row, and at larger sizes each cell reaches a little into the next.

A fine seam covers a mark a few pixels across — the seventeen-pixel glyphs in the search palette need nothing more. A mark drawn at forty-two pixels or above shows that rounding, so it takes a broader bleed and holds the whole carpet a pixel inside its own box, painting into the content edge rather than the border edge. Without that inset the widened bleed would spill past the mark.

TypeScript
import { Array, Match, Option, pipe } from 'effect'
import { type Html, inertHtml as ih } from 'foldkit/html'

const NAVY = '#14204A'
const PAPER = '#F2EFE6'

export type Carpet = {
  readonly rows: ReadonlyArray<string>
  readonly threads: Record<string, string>
}

export type Seam = {
  readonly bleed: number
  readonly overlap: number
  readonly inset: number
}

export const fineSeam: Seam = { bleed: 0.5, overlap: 0, inset: 0 }
export const broadSeam: Seam = { bleed: 0.8, overlap: 0.2, inset: 1 }

A row turns into its gradient one cell at a time. Each cell claims the slice of the width its position gives it, and paints it in the colour its glyph names — falling back to the navy ground for any glyph the key does not mention.

TypeScript
const pct = (value: number): string => `${Number(value.toFixed(4))}%`

const rowStops = (row: string, threads: Record<string, string>, overlap: number): string =>
  pipe(
    row.split(''),
    Array.map((glyph, cell) => {
      const colour = pipe(
        Option.fromNullishOr(threads[glyph]),
        Option.getOrElse(() => NAVY),
      )
      const from = (cell * 100) / row.length
      const upTo = Math.min(100, ((cell + 1) * 100) / row.length + overlap)
      return `${colour} ${pct(from)} ${pct(upTo)}`
    }),
    Array.join(', '),
  )

The carpet's style gathers those rows into one background: a gradient per row, a position per row, and a row height of one seventh of the mark plus the seam's bleed.

TypeScript
const insetBox = (inset: number): Record<string, string> =>
  inset === 0
    ? {}
    : {
        padding: `${inset}px`,
        backgroundOrigin: 'content-box',
        backgroundClip: 'content-box',
      }

export const carpetStyle = (carpet: Carpet, seam: Seam): Record<string, string> => ({
  ...insetBox(seam.inset),
  backgroundRepeat: 'no-repeat',
  backgroundSize: `100% calc(100% / ${carpet.rows.length} + ${seam.bleed}px)`,
  backgroundPosition: pipe(
    carpet.rows,
    Array.map((_, row) => `0 ${pct((row * 100) / (carpet.rows.length - 1))}`),
    Array.join(', '),
  ),
  backgroundImage: pipe(
    carpet.rows,
    Array.map((row) => `linear-gradient(90deg, ${rowStops(row, carpet.threads, seam.overlap)})`),
    Array.join(', '),
  ),
})

A mark is that style on an empty square. It carries no text and says nothing a reader needs, so it is hidden from anything reading the page aloud.

TypeScript
export const carpetMark = (carpet: Carpet, size: string, seam: Seam = fineSeam): Html =>
  ih.span(
    [
      ih.AriaHidden(true),
      ih.Style({
        ...carpetStyle(carpet, seam),
        display: 'block',
        flex: 'none',
        width: size,
        height: size,
      }),
    ],
    [],
  )

The marks a page wears

Four pages carry a mark of their own, and each is woven from the colours that page answers to. Docs is a blue letter form. Why Loom is a diamond that runs amber through green to a magenta centre, the three approaches meeting Loom's own colour. Community is a blue square held inside a violet one. Source crosses blue against orange.

TypeScript
export const docsCarpet: Carpet = {
  rows: ['.......', '.aaaaa.', '.a...a.', '.aaa.a.', '.a...a.', '.aaaap.', '.......'],
  threads: { '.': NAVY, p: PAPER, a: '#2E6FF2' },
}

export const whyLoomCarpet: Carpet = {
  rows: ['...a...', '..aba..', '.abpba.', 'abpcpba', '.abpba.', '..aba..', '...a...'],
  threads: { '.': NAVY, p: PAPER, a: '#FFC53D', b: '#00B37E', c: '#E8318A' },
}

export const communityCarpet: Carpet = {
  rows: ['.......', '.aaaaa.', '.a.b.a.', '.abpba.', '.a.b.a.', '.aaaaa.', '.......'],
  threads: { '.': NAVY, p: PAPER, a: '#8B5CF6', b: '#2E6FF2' },
}

export const sourceCarpet: Carpet = {
  rows: ['.......', '..a.a..', '.a...a.', 'b.....b', '.a...a.', '..a.a..', '.......'],
  threads: { '.': NAVY, p: PAPER, a: '#2E6FF2', b: '#FF6A3D' },
}

The marks the landing's sections wear

Each section of the landing opens on a carpet of its own, and each is a little picture rather than a letter form. A seedling of green threads climbs through an orange bloom where the example sits; a magenta block meets green where the vocabulary is; and the operations grow a violet flower on a green stem.

They are read from the same eight threads as everything else, so a section mark can never introduce a colour the page does not already use.

TypeScript
export const exampleCarpet: Carpet = {
  rows: ['....g.g', '...gg..', 'g..g...', '.gooo..', '.goyo.g', '..ooo.v', '.g.....'],
  threads: { '.': NAVY, p: PAPER, g: '#00B37E', o: '#FF6A3D', y: '#FFC53D', v: '#8B5CF6' },
}

export const vocabularyCarpet: Carpet = {
  rows: ['....gg.', '...g...', '..g....', 'mmmggg.', 'mym.gg.', 'mmmg.gg', '...o...'],
  threads: { '.': NAVY, p: PAPER, g: '#00B37E', m: '#E8318A', y: '#FFC53D', o: '#FF6A3D' },
}

export const operationsCarpet: Carpet = {
  rows: ['....g..', 'gg.....', 'g..vvv.', '.g.vpv.', '..gvvv.', '...g...', 'o...g.g'],
  threads: { '.': NAVY, p: PAPER, g: '#00B37E', v: '#8B5CF6', o: '#FF6A3D' },
}

The flower a chapter wears

Each chapter of the documentation opens on a mark, as each section of the landing does, and the marks are flowers. The docs can hold any number of chapters, so these marks cannot be drawn one by one like the landing's three. They come instead from nine shapes of plant: a sunflower, a tulip, a spray of bells, a berry branch, a two-leaf sprout, a fern, a bouquet of three, a clover and a tuft of grass.

Each shape is a carpet with its colours left open. The green of stems and leaves is fixed, and three letters stand for the colours of petals, centres and berries. A shape alone is not a mark until those three letters are given colours.

TypeScript
const GREEN = '#00B37E'

const sunflower = ['..aaa..', '.aacaa.', '.ac.ca.', '.aacaa.', '..aaa..', '..gg...', '...g...']
const tulip = ['.a.a.a.', '.aaaaa.', '..aaa..', '...g...', '.gg.g..', '..ggg..', '...g...']
const bells = ['.ggggg.', 'g....g.', 'a.g..a.', '..a..g.', '.....g.', '..b.gg.', '....g..']
const berries = ['a...a..', 'g..aga.', '.g..g..', '..g.g.b', '...gggg', '....g..', '....g..']
const sprout = ['...b...', '...g...', 'gg.g.gg', 'ggggggg', '.g.g.g.', '...g...', '..ggg..']
const fern = ['....b..', '...g.g.', '.g.g...', '..gg.g.', '...gg..', '.g.g...', '...g...']
const bouquet = ['a.....c', '.g.b.g.', '..g.g..', '...g...', '..ggg..', '...g...', '...g...']
const clover = ['.gg.gg.', '.gg.gg.', '...b...', '..ggg..', '..gg...', '...g...', '...g...']
const tuft = ['.....a.', '..a..g.', '..g..g.', 'g.g.g..', '.gg.g.g', '..ggg.g', '...g...']

const flowerShapes: ReadonlyArray<ReadonlyArray<string>> = [
  sunflower,
  tulip,
  bells,
  berries,
  sprout,
  fern,
  bouquet,
  clover,
  tuft,
]

const flowerThreads: ReadonlyArray<string> = [
  '#FF6A3D',
  '#8B5CF6',
  '#E8318A',
  '#2E6FF2',
  '#FFC53D',
  PAPER,
]

A chapter's flower follows from its place in the reading order, so it stays the same from one visit to the next and nothing about it is random. The first chapter wears the first shape, the second chapter the second, and so on round the nine. The colours turn with the place too: the three open letters take three consecutive colours of the palette, starting one colour further along for each chapter. So the first chapter, the quick start, opens on an orange sunflower with a magenta ring. Every second chapter faces the other way.

No two chapters next to each other share a shape or a colour, which is what lets the pager tell them apart at a glance.

TypeScript
const threadAt = (place: number, step: number): string =>
  pipe(
    Array.get(flowerThreads, (place + step) % flowerThreads.length),
    Option.getOrElse(() => PAPER),
  )

const mirrored = (rows: ReadonlyArray<string>): ReadonlyArray<string> =>
  Array.map(rows, (row) => row.split('').reverse().join(''))

export const flowerFor = (place: number): Carpet => {
  const shape = pipe(
    Array.get(flowerShapes, place % flowerShapes.length),
    Option.getOrElse(() => sunflower),
  )
  return {
    rows: place % 2 === 0 ? shape : mirrored(shape),
    threads: {
      '.': NAVY,
      g: GREEN,
      a: threadAt(place, 0),
      b: threadAt(place, 1),
      c: threadAt(place, 2),
    },
  }
}

The Loom mark

Loom's full mark is a pill with two carets on it, and it is the one piece of the site that moves. The carets blink, glance together, and a woven quilt drifts up behind them before fading out — a loop of twelve and four fifths seconds that runs on nothing but itself.

It is a vendored SVG, drawn in the design and applied here untouched. Two grounds, one mark: the paper copy for the hero and the documentation's bar, and a copy for the footer's single dark ground.

The mark is served as a file rather than written into the page. At forty kilobytes each the two would be a third of the document if they were inlined, and they gain nothing from being there — the mark carries no text, so it needs none of the page's type, and its animation runs inside an image as happily as in the document. The three command illustrations go the other way for exactly that reason: they set their file names in live text, and text in an image cannot reach the fonts the page loaded.

The pill is a hundred and twelve by forty-eight at its natural size, and every use scales from there — the hero at one and a fifth, the footer at a little over three quarters, the documentation's bar at just under a half. The wordmark beside it says Loom in text, so the image itself is decorative and carries no alternative of its own.

TypeScript
export type MarkGround = 'paper' | 'dark'

const MARK_WIDTH = 112
const MARK_HEIGHT = 48

export const loomMark = (ground: MarkGround, scale: number): Html =>
  ih.img([
    ih.Class('loom-mark'),
    ih.Src(ground === 'dark' ? '/mark-dark.svg' : '/mark.svg'),
    ih.Alt(''),
    ih.Width(String(Math.round(MARK_WIDTH * scale))),
    ih.Height(String(Math.round(MARK_HEIGHT * scale))),
  ])

The icons

The icons are the small marks the chrome and its buttons wear — a copy glyph, an open book, an external arrow, a forward arrow, a check, a terminal prompt, a burger that opens the documentation's drawer, and the GitHub octocat. The terminal prompt marks the install tab for the standalone executable, which no package manager's logo can. Each is an inline SVG drawn in the current text colour, so it takes the colour of whatever sits around it.

The two chevrons point opposite ways for the same reason. A bar that collapses points down, towards where it is going, and the handle it leaves behind points up, towards where it came from.

The octocat comes in two drawings. The one below is traced on a sixteen-unit grid, and it is the copy every inline link wears. The palette's pill carries a different tracing on a twenty-four-unit grid, baked into a data URI with its fill fixed, because a background image cannot follow the text colour around it.

TypeScript
import logos from './logos.json'

const strokeIcon = (size: string, paths: ReadonlyArray<Html>): Html =>
  ih.svg(
    [
      ih.Width(size),
      ih.Height(size),
      ih.ViewBox('0 0 24 24'),
      ih.Fill('none'),
      ih.Stroke('currentColor'),
      ih.StrokeWidth('2'),
      ih.StrokeLinecap('round'),
      ih.StrokeLinejoin('round'),
    ],
    paths,
  )

const strokePath = (d: string): Html => ih.path([ih.D(d)], [])

export const copyIcon = (): Html =>
  strokeIcon('13', [
    ih.rect([ih.X('9'), ih.Y('9'), ih.Width('11'), ih.Height('11'), ih.Rx('2')], []),
    strokePath('M5 15V5a2 2 0 0 1 2-2h10'),
  ])

export const bookIcon = (): Html =>
  ih.svg(
    [
      ih.Class('arrow'),
      ih.Width('14'),
      ih.Height('14'),
      ih.ViewBox('0 0 24 24'),
      ih.Fill('none'),
      ih.Stroke('currentColor'),
      ih.StrokeWidth('2'),
      ih.StrokeLinecap('round'),
      ih.StrokeLinejoin('round'),
      ih.AriaHidden(true),
    ],
    [
      strokePath('M12 7v14'),
      strokePath(
        'M3 18a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z',
      ),
    ],
  )

export const externalIcon = (): Html =>
  ih.svg(
    [
      ih.Width('13'),
      ih.Height('13'),
      ih.ViewBox('0 0 24 24'),
      ih.Fill('none'),
      ih.Stroke('currentColor'),
      ih.StrokeWidth('2'),
      ih.StrokeLinecap('round'),
      ih.StrokeLinejoin('round'),
      ih.AriaHidden(true),
    ],
    [
      strokePath('M15 3h6v6'),
      strokePath('M10 14 21 3'),
      strokePath('M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h6'),
    ],
  )

export const arrowIcon = (): Html =>
  ih.svg(
    [
      ih.Class('arrow'),
      ih.Width('14'),
      ih.Height('14'),
      ih.ViewBox('0 0 14 14'),
      ih.Fill('none'),
      ih.AriaHidden(true),
    ],
    [
      ih.path(
        [
          ih.D('M3 7h8M7 3l4 4-4 4'),
          ih.Stroke('currentColor'),
          ih.StrokeWidth('1.6'),
          ih.StrokeLinecap('round'),
          ih.StrokeLinejoin('round'),
        ],
        [],
      ),
    ],
  )

export const checkIcon = (): Html => strokeIcon('13', [strokePath('M20 6 9 17l-5-5')])

export const terminalIcon = (): Html =>
  strokeIcon('14', [strokePath('m4 17 6-6-6-6'), strokePath('M12 19h8')])

export const burgerIcon = (): Html =>
  strokeIcon('16', [
    ih.line([ih.X1('3'), ih.Y1('6'), ih.X2('21'), ih.Y2('6')], []),
    ih.line([ih.X1('3'), ih.Y1('12'), ih.X2('21'), ih.Y2('12')], []),
    ih.line([ih.X1('3'), ih.Y1('18'), ih.X2('21'), ih.Y2('18')], []),
  ])

The player's transport wears five more. They are drawn filled rather than stroked, because a transport control reads as a solid shape at fourteen pixels where an outline reads as a smudge — the exception is the replay arrow, which is a circle a stroke can draw and a fill cannot.

Back and skip are the same triangle with a bar beside it, mirrored: the bar stands where the playhead would come to rest.

TypeScript
const solidIcon = (width: string, height: string, box: string, parts: ReadonlyArray<Html>): Html =>
  ih.svg(
    [
      ih.Width(width),
      ih.Height(height),
      ih.ViewBox(box),
      ih.Fill('currentColor'),
      ih.AriaHidden(true),
    ],
    parts,
  )

export const backIcon = (): Html =>
  solidIcon('14', '12', '0 0 14 12', [
    ih.path(
      [ih.D('M13 1.1v9.8a.6.6 0 0 1-.93.5l-7.2-4.9a.6.6 0 0 1 0-1L12.07.6a.6.6 0 0 1 .93.5z')],
      [],
    ),
    ih.rect([ih.X('0.8'), ih.Y('0.6'), ih.Width('2.2'), ih.Height('10.8'), ih.Rx('1.1')], []),
  ])

export const skipIcon = (): Html =>
  solidIcon('14', '12', '0 0 14 12', [
    ih.path(
      [ih.D('M1 1.1v9.8a.6.6 0 0 0 .93.5l7.2-4.9a.6.6 0 0 0 0-1L1.93.6A.6.6 0 0 0 1 1.1z')],
      [],
    ),
    ih.rect([ih.X('11'), ih.Y('0.6'), ih.Width('2.2'), ih.Height('10.8'), ih.Rx('1.1')], []),
  ])

export const playTriangle = (): Html =>
  solidIcon('13', '15', '0 0 13 15', [
    ih.path(
      [ih.D('M1 1.1v12.8a.7.7 0 0 0 1.08.59l9.6-6.4a.7.7 0 0 0 0-1.17L2.08.52A.7.7 0 0 0 1 1.1z')],
      [],
    ),
  ])

export const pauseIcon = (): Html =>
  solidIcon('12', '14', '0 0 12 14', [
    ih.rect([ih.X('1'), ih.Y('0.5'), ih.Width('3.4'), ih.Height('13'), ih.Rx('1')], []),
    ih.rect([ih.X('7.6'), ih.Y('0.5'), ih.Width('3.4'), ih.Height('13'), ih.Rx('1')], []),
  ])

export const replayIcon = (): Html =>
  ih.svg(
    [
      ih.Width('15'),
      ih.Height('15'),
      ih.ViewBox('0 0 16 16'),
      ih.Fill('none'),
      ih.Stroke('currentColor'),
      ih.StrokeWidth('1.8'),
      ih.StrokeLinecap('round'),
      ih.AriaHidden(true),
    ],
    [
      strokePath('M2.6 8a5.4 5.4 0 1 0 1.9-4.1'),
      ih.polyline([ih.Points('1.4 1.9 1.4 5.4 4.9 5.4')], []),
    ],
  )

Two more sit in the player's top corner, where a reader asks for the card to grow to the height of the window and then asks for it back. Each is four bare corners on a fourteen-pixel square, drawn with square caps so the elbows meet flush. The expand icon turns its corners outwards and the collapse icon turns the same four inwards, which is the whole difference a reader needs to read the button's state.

TypeScript
const cornerIcon = (corners: ReadonlyArray<string>): Html =>
  ih.svg(
    [
      ih.Width('14'),
      ih.Height('14'),
      ih.ViewBox('0 0 14 14'),
      ih.Fill('none'),
      ih.Stroke('currentColor'),
      ih.StrokeWidth('1.6'),
      ih.StrokeLinecap('square'),
      ih.AriaHidden(true),
    ],
    Array.map(corners, (points) => ih.polyline([ih.Points(points)], [])),
  )

export const expandIcon = (): Html =>
  cornerIcon(['1,5 1,1 5,1', '9,1 13,1 13,5', '13,9 13,13 9,13', '5,13 1,13 1,9'])

export const collapseIcon = (): Html =>
  cornerIcon(['5,1 5,5 1,5', '13,5 9,5 9,1', '9,13 9,9 13,9', '1,9 5,9 5,13'])

export const octocatIcon = (size: string): Html =>
  ih.svg(
    [
      ih.Width(size),
      ih.Height(size),
      ih.ViewBox('0 0 16 16'),
      ih.Fill('currentColor'),
      ih.AriaHidden(true),
    ],
    [
      ih.path(
        [
          ih.D(
            'M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.88.51-1.08-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82a7.4 7.4 0 0 1 2-.27c.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A7.995 7.995 0 0 0 16 8c0-4.42-3.58-8-8-8Z',
          ),
        ],
        [],
      ),
    ],
  )

The runtime marks

The install rows each lead with the mark of a runtime — bun, npm, pnpm — in the brand's own logo, not a redrawing. Each logo is the project's official SVG, vendored in logos.json and dropped into a span as inline markup, so the page carries no image file and the mark stays sharp at any size. Each keeps its own colours: npm's red block, pnpm's yellow-and-grey squares, bun's cream mascot.

The documentation's run picker needs a fourth mark, for Node.js. That one is a plain drawing rather than the project's own file: the green hexagon of Node's mark on its own, kept in logos.json beside the other three.

TypeScript
const svgMark = (svg: string): Html => ih.span([ih.Class('rt-logo'), ih.InnerHTML(svg)], [])

export const bunIcon = (): Html => svgMark(logos.bun)
export const npmIcon = (): Html => svgMark(logos.npm)
export const pnpmIcon = (): Html => svgMark(logos.pnpm)
export const nodeIcon = (): Html => svgMark(logos.node)

The emblems of the editors

The documentation's guide to editors gives each editor a section, and each of those sections opens on the editor's emblem. A reader looking for their editor spots its logo before they read its name.

Each emblem is the editor's official SVG, vendored in logos.json beside the runtime logos. VS Code's logo comes from Microsoft's repository, Zed's mark from Zed's, Gram's frog from Gram's repository on Codeberg, Helix's emblem from Helix's, and the Neovim mark from the Neovim website's. The coloured emblems keep their own colours. Zed and Gram draw their marks in black alone, so here the black is currentColor, and the two marks take the ink of the heading beside them.

The editorMark function finds a section's emblem by the section's heading. A heading that is exactly an editor's name wears that editor's emblem, and any other heading wears none.

TypeScript
const editorLogos: ReadonlyMap<string, string> = new Map([
  ['VS Code', logos.vscode],
  ['Zed', logos.zed],
  ['Gram', logos.gram],
  ['Helix', logos.helix],
  ['Neovim', logos.neovim],
])

export const editorMark = (heading: string): Option.Option<Html> =>
  Option.map(Option.fromNullishOr(editorLogos.get(heading)), (svg) =>
    ih.span([ih.Class('editor-logo'), ih.AriaHidden(true), ih.InnerHTML(svg)], []),
  )

The module

The module opens with the woven carpet, since the small marks a page wears are woven from it. Then come the full mark, the icons, the runtime logos and the editors' emblems.

Loom