SourceThe websiteA Loom document, written out

Part X, Chapter 7

Part X, Chapter 7: A Loom document, written out

Package: site/src/

Language: TypeScript

Raw source

A Loom document, written out

Telling a reader what a Loom document looks like is weaker than showing one being written. This section types one out, character by character, on a stage that behaves like an editor: the caret moves, the line under it lights, and the document scrolls when the caret runs off the bottom.

The card wears no poster. Before the first press the document stands on it whole and still, so a reader who only wants to read one has one in front of them; pressing play wipes it back to the first character and writes it out again.

The document it types is about Loom, written in Loom. It opens with a heading, explains itself in prose, turns into code at an arrow, steps back to prose at a tilde, names a file in brackets, and composes itself from two anchors. By the end a reader has met every mark the vocabulary section then lists.

When the typing finishes, two buttons appear — and — and pressing either drops a panel showing the file that command writes. That is the whole claim of the framework in one section: this document, and these two files out of it.

Nothing here is a recording. The replay is deterministic, driven off the clock, and the reader holds the transport.

The threads a line is written in

A line of the document is a row of tokens, and a token is a piece of text with a colour, a weight, and — where it stands for one of Loom's marks — a wash behind it. The washes are the same colours the vocabulary section gives those marks, so a reader who meets => here in green meets it in green again a screen later.

A line also carries its height, whether its whitespace is kept, and a rest: a pause after it, measured in characters rather than milliseconds so that speeding the playback up shortens the pauses with it.

TypeScript
import { Array, Match, Option, pipe } from 'effect'
import type { Html, HtmlBuilder } from 'foldkit/html'
import {
  Copied,
  type Message,
  type Model,
  type Player,
  type PlayerOutput,
  ReplayedDocument,
  ShowedOutput,
  SkippedToEnd,
  ToggledFullHeight,
  ToggledPlay,
} from './model'
import {
  checkIcon,
  copyIcon,
  backIcon,
  broadSeam,
  carpetMark,
  collapseIcon,
  exampleCarpet,
  expandIcon,
  pauseIcon,
  playTriangle,
  replayIcon,
  skipIcon,
} from './components'

const BLUE = '#2E6FF2'
const VIOLET = '#8B5CF6'
const GREEN = '#00996B'
const ORANGE = '#E8501F'
const GREY = '#7C6F64'
const INK = '#14130F'

type Token = {
  readonly text: string
  readonly colour: string
  readonly wash: string
  readonly padded: boolean
  readonly bold: boolean
}

type Line = {
  readonly tokens: ReadonlyArray<Token>
  readonly tall: boolean
  readonly kept: boolean
  readonly rest: number
}

const WASH = {
  blue: 'rgba(46, 111, 242, 0.10)',
  violet: 'rgba(139, 92, 246, 0.10)',
  green: 'rgba(0, 153, 107, 0.12)',
  orange: 'rgba(232, 80, 31, 0.12)',
  grey: 'rgba(124, 111, 100, 0.14)',
  ink: 'rgba(20, 19, 15, 0.06)',
}

const plain = (text: string, colour: string = INK, bold: boolean = false): Token => ({
  text,
  colour,
  wash: 'transparent',
  padded: false,
  bold,
})

const chip = (text: string, colour: string, wash: string, bold: boolean = true): Token => ({
  text,
  colour,
  wash,
  padded: true,
  bold,
})

const line = (tokens: ReadonlyArray<Token>, kept: boolean, rest: number): Line => ({
  tokens,
  tall: true,
  kept,
  rest,
})

const gap: Line = { tokens: [], tall: false, kept: false, rest: 0 }

The document it types

A beat is fourteen characters' worth of time, and every pause in the document is written as a count of beats. Measuring a pause in characters rather than in milliseconds is what keeps the two in proportion: the client turns the whole clock into seconds at one rate, so winding that rate up shortens the pauses along with the typing. The marks each get their own constructor, so the document below reads as the document rather than as a table of colours.

TypeScript
const BEAT = 14

const says = (tokens: ReadonlyArray<Token>, rest: number = 0): Line => line(tokens, false, rest)
const prose = (text: string, rest: number = 0): Line => says([plain(text)], rest)
const code = (text: string, rest: number = 0): Line => line([plain(text)], true, rest)
const heading = (text: string, rest: number): Line =>
  says([chip('#', BLUE, WASH.blue), plain(` ${text}`, BLUE, true)], rest)
const part = (text: string, rest: number): Line =>
  says([chip('##', BLUE, WASH.blue), plain(` ${text}`, BLUE, true)], rest)
const opens = (rest: number): Line => says([chip('=>', GREEN, WASH.green)], rest)
const closes = (rest: number): Line => says([chip('~', ORANGE, WASH.orange)], rest)
const anchor = (name: string, rest: number): Line =>
  line([chip(`::${'['}${name}]`, VIOLET, WASH.violet, false)], true, rest)

The document explains Loom in Loom, so the marks a reader is being taught are the marks doing the teaching. It says the three things that trip people up: prose comes first, a block of code runs on to the next heading unless a tilde closes it, and the brackets beside a heading name the file its anchors compose.

TypeScript
const DOCUMENT: ReadonlyArray<Line> = [
  heading('Loom, explained in Loom', 2 * BEAT),
  gap,
  prose(
    'Prose first. You are writing for a person — the code lives inside the explanation.',
    2 * BEAT,
  ),
  gap,
  part('Greeting', BEAT),
  gap,
  prose('Everything under a heading is prose, until an arrow opens the code.', 2 * BEAT),
  gap,
  opens(BEAT),
  gap,
  code('export function greet(who: string) {'),
  code('  return `Hello, ${who}`'),
  code('}', 2 * BEAT),
  gap,
  part('Run it', BEAT),
  gap,
  prose(
    'Nothing closed that block — code runs on to the next heading. A tilde is only for stepping back into prose mid-section.',
    2 * BEAT,
  ),
  gap,
  opens(0),
  gap,
  code("console.log(greet('world'))", BEAT),
  gap,
  closes(BEAT),
  gap,
  prose('Like so.', 2 * BEAT),
  gap,
  says(
    [
      chip('##', BLUE, WASH.blue),
      plain(' Assembling ', BLUE, true),
      chip('[hello.ts]', GREY, WASH.grey, false),
    ],
    2 * BEAT,
  ),
  gap,
  prose(
    'A file name in brackets becomes a target.\n\nCode after the arrow holds the composition order.',
    2 * BEAT,
  ),
  gap,
  opens(BEAT),
  gap,
  anchor('Greeting', BEAT),
  anchor('Run it', BEAT),
  gap,
  closes(2 * BEAT),
  gap,
  says(
    [
      plain('Each anchor pulls in a section it names. '),
      chip('loom tangle', INK, WASH.ink, false),
      plain(' writes hello.ts in that order.'),
    ],
    2 * BEAT,
  ),
  gap,
  prose("That's it."),
]

A hand does not type evenly

A constant rate reads as a machine. A hand does not: it reaches further for a bracket than for a vowel, breathes at a full stop, costs a little more on the first letter of a line, and now and then hesitates altogether. It also strikes two or three letters almost together and pays for them on the last one.

So every character carries a cost measured in character-units rather than in time. The costs are jittered by a deterministic noise — the same document plays the same way every time, which is what makes it a document and not an animation.

TypeScript
const noise = (index: number): number => {
  const raw = Math.sin(index * 12.9898 + 78.233) * 43758.5453
  return raw - Math.floor(raw)
}

const CLASSES: ReadonlyArray<readonly [string, number]> = [
  [' ', 0.75],
  ['.!?', 1.9],
  [',;:', 1.8],
  ['{}()[]<>$`~=#—', 1.9],
]

const reachOf = (char: string, opening: boolean): number =>
  opening
    ? 1.35
    : pipe(
        Array.findFirst(CLASSES, ([chars]) => chars.includes(char)),
        Option.map(([, cost]) => cost),
        Option.getOrElse(() => (char >= 'A' && char <= 'Z' ? 1.5 : 1)),
      )

The scan walks the document once and leaves a cost behind every character struck. It carries three things as it goes: what a burst still owes, how many letters are left in the burst, and the raw total, which is what normalises the costs back so that the rate the client runs the clock at keeps meaning what it says.

TypeScript
type Scan = {
  readonly costs: ReadonlyArray<number>
  readonly owed: number
  readonly burst: number
  readonly raw: number
  readonly struck: number
}

const strike = (scan: Scan, char: string, opening: boolean): Scan => {
  const index = scan.costs.length
  const reach =
    reachOf(char, opening) * (0.66 + noise(index) * 0.72) + (noise(index + 7) > 0.984 ? 5 : 0)
  const owed = scan.owed + reach
  const held = scan.burst > 1 && char !== ' '
  return {
    costs: [...scan.costs, held ? 0.02 : owed],
    owed: held ? owed - 0.02 : 0,
    burst: held ? scan.burst - 1 : 1 + Math.floor(noise(index + 31) * 3),
    raw: scan.raw + reach,
    struck: scan.struck + 1,
  }
}

Entering a line costs a keystroke too, and an expensive one. Every line but the first opens with a Return, and it lands on a beat of its own: the caret drops to the empty line, and only then does the first letter follow. A burst owes its cost to the character that ends it, and a Return ends any burst still running — so enter pays what the burst owed along with its own reach, and hands the next line a clean slate.

TypeScript
const enter = (scan: Scan): Scan => {
  const index = scan.costs.length
  const reach = 2.4 * (0.72 + noise(index) * 0.56)
  return {
    costs: [...scan.costs, scan.owed + reach],
    owed: 0,
    burst: 1,
    raw: scan.raw + reach,
    struck: scan.struck + 1,
  }
}

A rest is already time, so each of its beats costs a flat unit rather than a reach. rested pays a line's whole rest one beat at a time, and pays nothing at all for the lines that have none — most of them, since every blank line in the document rests for nothing.

TypeScript
const rest = (scan: Scan): Scan => ({ ...scan, costs: [...scan.costs, -1] })

const rested = (scan: Scan, beats: number): Scan =>
  beats <= 0 ? scan : rested(rest(scan), beats - 1)

const scanLine = (scan: Scan, source: Line, index: number): Scan =>
  pipe(
    index === 0 ? scan : enter(scan),
    (opened) =>
      Array.reduce(
        Array.flatMap(source.tokens, (token) => token.text.split('')),
        opened,
        (carried, char, at) => strike(carried, char, at === 0),
      ),
    (typed) => rested(typed, source.rest),
  )

const SCAN: Scan = Array.reduce(
  DOCUMENT,
  { costs: [], owed: 0, burst: 1, raw: 0, struck: 0 },
  scanLine,
)

The costs become a running clock: an array whose entry at any index is when that character lands. Playback then only has to ask which characters have been struck by a given point in the run, which a binary search over the clock answers.

TypeScript
const NORM = SCAN.struck === 0 ? 1 : SCAN.struck / SCAN.raw

export const CHARACTERS = SCAN.costs.length

const CLOCK: ReadonlyArray<number> = Array.reduce(
  SCAN.costs,
  [0] as ReadonlyArray<number>,
  (clock, cost) => [...clock, clock[clock.length - 1]! + (cost < 0 ? 1 : cost * NORM)],
)

export const RUN = CLOCK[CHARACTERS]!

export const struckBy = (beat: number): number => {
  const search = (low: number, high: number): number => {
    if (low >= high) {
      return low
    }
    const middle = (low + high) >> 1
    return CLOCK[middle + 1]! <= beat ? search(middle + 1, high) : search(low, middle)
  }
  return search(0, CHARACTERS)
}

The frame a moment shows

A frame is the document cut off after however many characters have been struck. Lines before the cut show whole, the line at the cut shows part of itself and carries the caret, and nothing after it exists yet. Opening a line spends a character on its Return, which is why an empty line stands under the caret for a moment before its first letter arrives.

While a line's rest is being paid the caret holds still and blinks, like a pen resting on the line just written. While letters are landing it stops blinking, because a caret that blinks mid-word reads as a fault.

TypeScript
type Shown = {
  readonly tokens: ReadonlyArray<Token>
  readonly tall: boolean
  readonly kept: boolean
  readonly caret: boolean
  readonly held: boolean
  readonly lit: boolean
}

type Cut = {
  readonly taken: ReadonlyArray<Token>
  readonly left: number
  readonly whole: boolean
}

const cutTokens = (tokens: ReadonlyArray<Token>, left: number): Cut =>
  Array.reduce(tokens, { taken: [], left, whole: true } as Cut, (cut, token) => {
    if (cut.left <= 0) {
      return { ...cut, whole: false }
    }
    const take = Math.min(cut.left, token.text.length)
    return {
      taken: [...cut.taken, { ...token, text: token.text.slice(0, take) }],
      left: cut.left - take,
      whole: cut.whole && take === token.text.length,
    }
  })

export const frameAt = (struck: number, playing: boolean): ReadonlyArray<Shown> => {
  const walk = (index: number, left: number, out: ReadonlyArray<Shown>): ReadonlyArray<Shown> => {
    if (index >= DOCUMENT.length || (left <= 0 && index > 0)) {
      return out
    }
    const source = DOCUMENT[index]!
    const opened = index === 0 ? left : left - 1
    const cut = cutTokens(source.tokens, opened)
    const after = cut.whole ? cut.left - source.rest : cut.left
    const resting = playing && cut.whole && after <= 0 && source.tokens.length > 0
    const shown: Shown = {
      tokens: cut.taken,
      tall: source.tall,
      kept: source.kept,
      caret: playing && after <= 0,
      held: resting,
      lit: playing && after <= 0,
    }
    return after <= 0 ? [...out, shown] : walk(index + 1, after, [...out, shown])
  }
  return walk(0, struck, [])
}

Before the reader presses anything the stage is not blank, and it is not a poster either. It shows the whole document, standing still, clipped by the card and faded into the transport at the foot. A reader who came to read it can read it; a reader who came to watch it arrive presses play and the run starts over from the first character.

TypeScript
export const wholeDocument = (): ReadonlyArray<Shown> => frameAt(CHARACTERS, false)

What the two commands write

The panel under the buttons shows the file the pressed command writes. Tangling this document writes five lines of TypeScript — the greeting and the call, in the order the anchors named. Weaving it writes the corpus as data, which is what any site generator reads to render the document as a page.

The woven JSON is coloured by a small pass over each line: a string before a colon is a key, a string anywhere else is a value, a number or a keyword is a literal, and everything else is punctuation.

TypeScript
const TANGLED: ReadonlyArray<string> = [
  'export function greet(who: string) {',
  '  return `Hello, ${who}`',
  '}',
  '',
  "console.log(greet('world'))",
]

const WOVEN: ReadonlyArray<string> = [
  '{',
  '  "nav": [],',
  '  "loose": [',
  '    { "number": "", "title": "Loom, explained in Loom", "slug": "hello" }',
  '  ],',
  '  "pages": [',
  '    {',
  '      "slug": "hello",',
  '      "title": "Loom, explained in Loom",',
  '      "blocks": [',
  '        {',
  '          "type": "heading",',
  '          "source": { "chapter": "hello", "section": "greeting" },',
  '          "level": 2,',
  '          "title": "Greeting",',
  '          "id": "greeting"',
  '        },',
  '        {',
  '          "type": "prose",',
  '          "source": { "chapter": "hello", "section": "greeting" },',
  '          "markdown": "Prose runs until an arrow opens the code."',
  '        },',
  '        {',
  '          "type": "code",',
  '          "source": { "chapter": "hello", "section": "greeting" },',
  '          "language": "typescript",',
  '          "code": "export function greet(who: string) {\\n  return `Hello, ${who}`\\n}",',
  '          "links": []',
  '        }',
  '      ]',
  '    }',
  '  ]',
  '}',
]

const JSON_TOKEN = /("(?:[^"\\]|\\.)*")(\s*:)?|(-?\d+)|(true|false|null)|([^"\d]+)/g

const jsonTokens = (source: string): ReadonlyArray<Token> =>
  pipe(
    Array.fromIterable(source.matchAll(JSON_TOKEN)),
    Array.flatMap((match) =>
      Match.value(match).pipe(
        Match.when(
          (found) => found[1] !== undefined && found[2] !== undefined,
          (found) => [plain(found[1]!, BLUE), plain(found[2]!, GREY)],
        ),
        Match.when(
          (found) => found[1] !== undefined,
          (found) => [plain(found[1]!, GREEN)],
        ),
        Match.when(
          (found) => found[3] !== undefined || found[4] !== undefined,
          (found) => [plain(found[3] ?? found[4]!, VIOLET)],
        ),
        Match.orElse((found) => [plain(found[5] ?? '', GREY)]),
      ),
    ),
  )

const outputLines = (which: PlayerOutput): ReadonlyArray<ReadonlyArray<Token>> =>
  Match.value(which).pipe(
    Match.when('tangle', () => Array.map(TANGLED, (source) => [plain(source)])),
    Match.when('weave', () => Array.map(WOVEN, jsonTokens)),
    Match.exhaustive,
  )

const outputFile = (which: PlayerOutput): string =>
  which === 'tangle' ? 'hello.ts' : 'hello.woven.json'

const outputMeta = (which: PlayerOutput): string =>
  which === 'tangle' ? '5 lines' : 'the corpus as data'

The stage

The stage draws the frame. A line's own background lights while the caret is on it, and it bleeds past both edges of the column so the highlight reads as a row of the editor rather than as a box around the text.

TypeScript
const tokenSpan = (token: Token, h: HtmlBuilder<Message>): Html =>
  h.span(
    [
      h.Class(token.padded ? 'pl-tok chip' : 'pl-tok'),
      h.Style({
        color: token.colour,
        background: token.wash,
        fontWeight: token.bold ? '600' : '400',
      }),
    ],
    [token.text],
  )

const shownLine = (shown: Shown, h: HtmlBuilder<Message>): Html =>
  h.div(
    [
      h.Class(shown.kept ? 'pl-line kept' : 'pl-line'),
      h.Style({ minHeight: shown.tall ? '1.4em' : '0.8em' }),
    ],
    [
      ...(shown.lit ? [h.span([h.AriaHidden(true), h.Class('pl-cur')], [])] : []),
      ...Array.map(shown.tokens, (token) => tokenSpan(token, h)),
      ...(shown.caret
        ? [h.span([h.AriaHidden(true), h.Class(shown.held ? 'pl-caret blink' : 'pl-caret')], [])]
        : []),
    ],
  )

The two command buttons appear only once the document is finished, and each names both the command and the file it writes. Pressing one opens its panel; pressing it again closes it, so the two are a pair of drawers rather than a pair of tabs.

TypeScript
const commandButton = (which: PlayerOutput, player: Player, h: HtmlBuilder<Message>): Html =>
  h.button(
    [
      h.Class(
        Option.contains(player.output, which) ? `pl-out ${which} showing` : `pl-out ${which}`,
      ),
      h.Type('button'),
      h.OnClick(ShowedOutput({ which })),
    ],
    [
      h.span([h.Class('pl-out-cmd')], [`$ loom ${which}`]),
      h.span([h.Class('pl-out-file')], [`writes ${outputFile(which)}`]),
    ],
  )

const outputPanel = (which: PlayerOutput, h: HtmlBuilder<Message>): Html =>
  h.div(
    [h.Class(`pl-result ${which}`)],
    [
      h.div(
        [h.Class('pl-result-head')],
        [
          h.span([h.Class('pl-result-file')], [outputFile(which)]),
          h.span([h.Class('pl-result-meta')], [outputMeta(which)]),
        ],
      ),
      h.div(
        [h.Class('pl-result-body')],
        Array.map(outputLines(which), (tokens) =>
          h.div(
            [h.Class('pl-result-line')],
            Array.map(tokens, (token) => tokenSpan(token, h)),
          ),
        ),
      ),
    ],
  )

Any loom, highlighted

The documentation quotes looms of its own, and a reader should see them in the same colours the example uses. loomCanvas draws any loom's text on a card like the example's, without the player around it. Each mark wears the colour the vocabulary gives it. A heading is blue, and a sink in square brackets on it is a blue chip too. A specifier in braces is a deep amber chip. An arrow is a green chip and a tilde an orange one, and an anchor is a violet chip wherever it stands. The frontmatter fences are violet, and each field inside them names its key in violet.

The classifier reads the text one line at a time and carries three facts from line to line: whether it is inside the frontmatter, whether it is inside code, and whether that code was opened by the explicit arrow. Code runs from an arrow to the next tilde or heading, as it does in Loom, and code an explicit arrow opened runs to the tilde alone. Each line takes the first rule that fits it, in that order: a frontmatter fence, a frontmatter field, a tilde, a heading, an arrow, and then whatever text is left. A code line keeps its spaces and never wraps; a prose line wraps to the card.

A reader who wants the loom should not have to select it by hand, so the card carries a small copy button in its top corner. It copies the loom exactly as the chapter wrote it, and while the copy flashes its clipboard turns into a check.

TypeScript
const AMBER = '#8A5A00'
const AMBER_WASH = 'rgba(255, 197, 61, 0.2)'

type CanvasLine = { readonly tokens: ReadonlyArray<Token>; readonly kept: boolean }

type Reading = {
  readonly lines: ReadonlyArray<CanvasLine>
  readonly inFrontmatter: boolean
  readonly inCode: boolean
  readonly explicit: boolean
}

const withAnchors = (text: string): ReadonlyArray<Token> =>
  pipe(
    text.split(/(::\[[^\]]*\])/),
    Array.filter((piece) => piece !== ''),
    Array.map((piece) =>
      piece.startsWith(`::${'['}`) ? chip(piece, VIOLET, WASH.violet, false) : plain(piece),
    ),
  )

const headingTokens = (marks: string, title: string): ReadonlyArray<Token> => [
  chip(marks, BLUE, WASH.blue),
  plain(' '),
  ...pipe(
    title.split(/(\{[^}]*\}|\[[^\]]*\])/),
    Array.filter((piece) => piece !== ''),
    Array.map((piece) =>
      Match.value(piece.charAt(0)).pipe(
        Match.when('{', () => chip(piece, AMBER, AMBER_WASH)),
        Match.when('[', () => chip(piece, BLUE, WASH.blue, false)),
        Match.orElse(() => plain(piece, BLUE, true)),
      ),
    ),
  ),
]

const fieldTokens = (text: string): ReadonlyArray<Token> =>
  Option.match(Option.fromNullishOr(/^([^:]+:)(.*)$/.exec(text)), {
    onNone: () => [plain(text)],
    onSome: ([, key, value]) => [plain(key ?? '', VIOLET, true), plain(value ?? '')],
  })

const headingOf = (text: string): Option.Option<readonly [string, string]> =>
  Option.map(
    Option.fromNullishOr(/^(#{1,6}) (.*)$/.exec(text)),
    (found) => [found[1] ?? '#', found[2] ?? ''] as const,
  )

const readLine = (reading: Reading, text: string, at: number): Reading => {
  const add = (
    tokens: ReadonlyArray<Token>,
    kept: boolean,
    next: Partial<Reading> = {},
  ): Reading => ({
    ...reading,
    ...next,
    lines: [...reading.lines, { tokens, kept }],
  })
  const when = (holds: boolean, read: () => Reading): Option.Option<Reading> =>
    holds ? Option.some(read()) : Option.none()
  return pipe(
    when(text.trim() === '---' && (at === 0 || reading.inFrontmatter), () =>
      add([chip('---', VIOLET, WASH.violet)], false, { inFrontmatter: !reading.inFrontmatter }),
    ),
    Option.orElse(() => when(reading.inFrontmatter, () => add(fieldTokens(text), false))),
    Option.orElse(() =>
      when(/^~(\s|$)/.test(text), () =>
        add([chip('~', ORANGE, WASH.orange)], false, { inCode: false, explicit: false }),
      ),
    ),
    Option.orElse(() =>
      (reading.explicit ? Option.none() : headingOf(text)).pipe(
        Option.map(([marks, title]) => add(headingTokens(marks, title), false, { inCode: false })),
      ),
    ),
    Option.orElse(() =>
      when(!reading.inCode && /^\s*[=~]>/.test(text), () => {
        const arrow = text.trim().slice(0, 2)
        return add([chip(arrow, GREEN, WASH.green)], false, {
          inCode: true,
          explicit: arrow === '~>',
        })
      }),
    ),
    Option.getOrElse(() => add(withAnchors(text), reading.inCode)),
  )
}

const canvasLines = (source: string): ReadonlyArray<CanvasLine> =>
  Array.reduce(
    source.replace(/\n$/, '').split('\n'),
    { lines: [], inFrontmatter: false, inCode: false, explicit: false } as Reading,
    readLine,
  ).lines

const canvasCopy = (source: string, copied: string, h: HtmlBuilder<Message>): Html => {
  const id = `loom:${source}`
  const taken = copied === id
  return h.button(
    [
      h.Class(taken ? 'canvas-copy done' : 'canvas-copy'),
      h.Type('button'),
      h.Title(taken ? 'Copied' : 'Copy the loom'),
      h.AriaLabel('Copy the loom'),
      h.OnClick(Copied({ id, text: source })),
    ],
    [taken ? checkIcon() : copyIcon()],
  )
}

export const loomCanvas = (source: string, copied: string, h: HtmlBuilder<Message>): Html =>
  h.div(
    [h.Class('docs-canvas')],
    [
      canvasCopy(source, copied, h),
      ...Array.map(canvasLines(source), (line) =>
        h.div(
          [h.Class(line.kept ? 'pl-line kept' : 'pl-line'), h.Style({ minHeight: '1em' })],
          Array.map(line.tokens, (token) => tokenSpan(token, h)),
        ),
      ),
    ],
  )

The transport, and the button that grows the card

The transport is on the card from the first frame — back to the start, play or pause, skip to the end. Skipping hides itself at the end rather than moving, so the row does not reflow under the cursor.

The button in the top corner grows the card to the height of the window and gives the page back again. It is the one control here that changes the card rather than the run, which is why it sits over the stage instead of in the transport.

TypeScript
const fullTitle = (player: Player): string => (player.full ? 'Leave full height' : 'Full height')

const fullButton = (player: Player, h: HtmlBuilder<Message>): Html =>
  h.button(
    [
      h.Class('pl-full'),
      h.Type('button'),
      h.Title(fullTitle(player)),
      h.AriaLabel(fullTitle(player)),
      h.OnClick(ToggledFullHeight()),
    ],
    [player.full ? collapseIcon() : expandIcon()],
  )

const transport = (player: Player, h: HtmlBuilder<Message>): Html =>
  h.div(
    [h.Class('pl-bar')],
    [
      h.button(
        [
          h.Class('pl-back'),
          h.Type('button'),
          h.Title('Back to the start'),
          h.AriaLabel('Back to the start'),
          h.OnClick(ReplayedDocument()),
        ],
        [backIcon()],
      ),
      h.button(
        [
          h.Class('pl-play'),
          h.Type('button'),
          h.Title(playTitle(player)),
          h.AriaLabel(playTitle(player)),
          h.OnClick(ToggledPlay()),
        ],
        [playGlyph(player)],
      ),
      h.button(
        [
          h.Class(finished(player) ? 'pl-skip gone' : 'pl-skip'),
          h.Type('button'),
          h.Title('Skip to the end'),
          h.AriaLabel('Skip to the end'),
          h.OnClick(SkippedToEnd()),
        ],
        [skipIcon()],
      ),
    ],
  )

Three small questions get asked from more than one place, so they are answered once: whether the run has finished, what the play button should be called, and which of its three glyphs it should wear.

TypeScript
const finished = (player: Player): boolean => player.struck >= CHARACTERS

const playTitle = (player: Player): string =>
  Match.value(player).pipe(
    Match.when(
      (state) => state.playing,
      () => 'Pause',
    ),
    Match.when(finished, () => 'Play it again'),
    Match.orElse(() => 'Play'),
  )

const playGlyph = (player: Player): Html =>
  Match.value(player).pipe(
    Match.when(
      (state) => state.playing,
      () => pauseIcon(),
    ),
    Match.when(finished, () => replayIcon()),
    Match.orElse(() => playTriangle()),
  )

The section

The progress bar lies along the foot of the card and carries how far the run has reached as its own width, so nothing on the stage has to give up room to a track.

Three states the card can be in are written into its class. It runs while the document is being typed, which is what eases the bar between frames. It is open once the document has finished or the reader has grown it, and only then does the stage take the scroll back from the run — until then the caret is driving, and the reader would only fight it.

The stage scrolls itself as the caret runs past the bottom, which is why the run needs the clock and not just a count: the reader can pause at any point and the document stands where it stood.

The two commands are on offer once the document is finished. They are also on offer to a reader who has grown the card and paused partway, since the point of the extra height is to study what is there.

TypeScript
const cardClass = (player: Player, done: boolean): string => {
  const running = player.playing ? ' running' : ''
  const open = done || player.full ? ' open' : ''
  return `pl-card${running}${open}`
}

export const example = (model: Model, h: HtmlBuilder<Message>): Html => {
  const player = model.player
  const shown =
    player.struck === 0 && !player.playing
      ? wholeDocument()
      : frameAt(player.struck, player.playing)
  const done = finished(player)
  const progress = done ? 1 : Math.min(1, player.beat / RUN)
  const offering = done || (player.full && !player.playing)
  return h.div(
    [h.Class('section'), h.Id('loom-example')],
    [
      h.div(
        [h.Class('section-head')],
        [
          carpetMark(exampleCarpet, '42px', broadSeam),
          h.div(
            [h.Class('lines')],
            [
              h.span([h.Class('eyebrow')], ['Literate programming']),
              h.h2([], ['A Loom document, written out']),
            ],
          ),
        ],
      ),
      h.div(
        [h.Class(player.full ? 'pl-hold full' : 'pl-hold'), h.Id('loom-card')],
        [
          h.div(
            [
              h.Class(cardClass(player, done)),
              h.Style({ '--pl-run': `${Math.round(progress * 1000) / 10}%` }),
            ],
            [
              fullButton(player, h),
              h.span([h.AriaHidden(true), h.Class('pl-run')], []),
              h.div(
                [h.Class('pl-stage'), h.Id('loom-stage')],
                [
                  h.div(
                    [h.Class('pl-column')],
                    [
                      ...Array.map(shown, (line) => shownLine(line, h)),
                      ...(offering
                        ? [
                            h.div(
                              [h.Class('pl-outs')],
                              [
                                commandButton('tangle', player, h),
                                commandButton('weave', player, h),
                              ],
                            ),
                          ]
                        : []),
                      ...Option.match(player.output, {
                        onNone: (): ReadonlyArray<Html> => [],
                        onSome: (which) => (offering ? [outputPanel(which, h)] : []),
                      }),
                    ],
                  ),
                  h.div([h.AriaHidden(true), h.Class('pl-tail')], []),
                ],
              ),
              h.div([h.AriaHidden(true), h.Class('pl-fade')], []),
              transport(player, h),
            ],
          ),
        ],
      ),
    ],
  )
}

The module

The module is the threads a line is written in, the document itself, the cost of typing it, the frame a moment shows, what the two commands write, the stage, the transport, and the section that holds them.

Loom