SourceThe websiteThe chrome every page wears

Part X, Chapter 4

Part X, Chapter 4: The chrome every page wears

Package: site/src/

Language: TypeScript

Raw source

The chrome every page wears

Every page of the site opens with the same nav and closes on the same footer, and a slash typed anywhere raises the same search palette over the top. None of it belongs to a page, so all of it lives here.

The chrome needs to know what the site contains. That knowledge is one list of pages, written once and read three times over: the nav marks the current page, the palette groups its results under the page each one came from, and the footer links out. A page added to that list appears in all three.

The chrome draws its markup with a builder it does not own. Foldkit hands the site's root view a builder named h, typed to the site's messages, and no module can make one for itself. So every function here that draws markup takes h as its last argument, and the view passes its own builder down to each piece of chrome it renders. The marks and icons borrowed from the components carry no handlers, so they draw themselves and take no builder at all.

The pages the nav names

Four pages sit in the nav, in the order a reader meets them: the docs and the community either side of the wordmark, then the argument for Loom and the source it is written in. Each carries its own woven mark, which the palette shows beside its name.

The nav's own labels are lower case, because the whole row is set in capitals by the stylesheet and a capitalised label would come out shouting.

TypeScript
import { Array, Match, Option, pipe } from 'effect'
import type { Attribute, Html, HtmlBuilder } from 'foldkit/html'
import {
  ClosedSearch,
  drawsInPlace,
  MovedFocus,
  OpenedDrawer,
  OpenedHit,
  OpenedSearch,
  Typed,
  WentTo,
  type Message,
  type Model,
  type Route,
  type SearchGroup,
  type SearchHit,
} from './model'
import {
  externalIcon,
  broadSeam,
  burgerIcon,
  carpetMark,
  communityCarpet,
  exampleCarpet,
  docsCarpet,
  loomMark,
  sourceCarpet,
  whyLoomCarpet,
  type Carpet,
} from './components'

export type NavPage = {
  readonly route: Route
  readonly label: string
  readonly href: string
  readonly carpet: Carpet
}

export const navPages: ReadonlyArray<NavPage> = [
  { route: 'docs', label: 'docs', href: '/docs', carpet: docsCarpet },
  { route: 'community', label: 'community', href: '/community', carpet: communityCarpet },
  { route: 'why-loom', label: 'why loom', href: '/why-loom', carpet: whyLoomCarpet },
  { route: 'source', label: 'source', href: '/source', carpet: sourceCarpet },
]

The nav across the top

The nav is a row of links with the wordmark set into the middle of it, so the site's name reads as part of the navigation rather than as a badge pinned beside it. The two pages before the wordmark and the two after it come from the same list, split in half.

Loom is in beta, and the nav says so on every page: a small tag in the accent hangs off the wordmark's top corner. It hangs outside the row rather than taking a place in it, so the wordmark stays in the middle of the nav.

Search closes the row. It says search on a wide window and navs on a narrow one, where it has taken over from the links entirely, and the key badge beside it shows the slash that opens it.

On a narrow window, where the links have gone and the wordmark has moved to the left edge, the Loom mark stands beside the wordmark. It is the same mark a page's head shows beside its name, and on a narrow window the head drops it, so the mark moves up into the nav rather than appearing twice.

On the documentation and the source page, the two pages with a menu of chapters, the nav carries one more control. Below a thousand pixels the chapter menu no longer fits beside the sections, so a burger button at the nav's left end opens it in a drawer. The burger is drawn on every page of either and hidden by the stylesheet wherever the menu fits. Their nav also gets a class of its own, docs-nav, which the stylesheet uses to pin it to the top of the window on a narrow screen.

A link switches the page on the spot only when the page it opens draws in place. A link to the source page leaves the move to the browser, which keeps the current page up until the source page arrives from the server, already drawn. switchesTo gives a link the click that switches the page, or no click at all.

TypeScript
const switchesTo = (route: Route, h: HtmlBuilder<Message>): ReadonlyArray<Attribute<Message>> =>
  drawsInPlace(route) ? [h.OnClick(WentTo({ route }))] : []

const navLink = (page: NavPage, here: boolean, h: HtmlBuilder<Message>): Html =>
  h.a(
    [
      h.Class(here ? 'nav-link here' : 'nav-link'),
      h.Href(page.href),
      ...switchesTo(page.route, h),
    ],
    [page.label],
  )

const hasChapters = (route: Route): boolean => route === 'docs' || route === 'source'

const burger = (route: Route, h: HtmlBuilder<Message>): ReadonlyArray<Html> =>
  hasChapters(route)
    ? [
        h.button(
          [
            h.Class('nav-burger'),
            h.Type('button'),
            h.AriaLabel('Chapters'),
            h.OnClick(OpenedDrawer()),
          ],
          [burgerIcon()],
        ),
      ]
    : []

export const nav = (model: Model, h: HtmlBuilder<Message>): Html =>
  h.nav(
    [h.Class(hasChapters(model.route) ? 'nav docs-nav' : 'nav'), h.Role('navigation')],
    [
      ...burger(model.route, h),
      ...Array.map(Array.take(navPages, 2), (page) => navLink(page, page.route === model.route, h)),
      h.a(
        [
          h.Class('nav-word'),
          h.Href('/'),
          h.AriaLabel('Loom, in beta'),
          h.OnClick(WentTo({ route: 'home' })),
        ],
        [
          h.span([h.Class('nav-mark')], [loomMark('paper', 0.46)]),
          'Loom',
          h.span([h.Class('nav-beta'), h.AriaHidden(true)], ['beta']),
        ],
      ),
      ...Array.map(Array.drop(navPages, 2), (page) => navLink(page, page.route === model.route, h)),
      findButton('nav-find', 'Search Loom — press /', h),
    ],
  )

The button that opens the palette

The search button is a word and a key cap showing the slash that opens the palette. It takes its class from whoever draws it, and the class decides where it sits: the nav's class pins it to the right edge, so the links stay centred whatever the button says.

TypeScript
const findButton = (className: string, title: string, h: HtmlBuilder<Message>): Html =>
  h.button(
    [
      h.Class(className),
      h.Type('button'),
      h.AriaLabel('Navigate and search'),
      h.Title(title),
      h.OnClick(OpenedSearch()),
    ],
    [
      h.span([h.Class('wide')], ['search']),
      h.span([h.Class('narrow')], ['navs']),
      h.span([h.Class('nav-key'), h.AriaHidden(true)], ['/']),
    ],
  )

The pager that names its neighbours

Every page ends by naming the two either side of it. The site has a reading order — the docs, the community, Loom itself, the argument, then the source — and the pager is that order made walkable, so a reader who finishes a page is never left at a dead end.

A card is only its mark and two lines of type. Under the cursor those two lines take the colour of the page they lead to, so the card names its destination twice over — once in words and once in ink. That colour is the destination's own hue deepened until it holds against paper, since the bright value each page wears on a dark card would read as a highlighter here.

The first page has nothing before it and the last nothing after, so a pager can carry one card rather than two. The card that remains keeps its side of the row.

TypeScript
type Neighbour = {
  readonly route: Route
  readonly label: string
  readonly href: string
  readonly carpet: Carpet
  readonly ink: string
}

const readingOrder: ReadonlyArray<Neighbour> = [
  {
    route: 'docs',
    label: 'Docs',
    href: '/docs',
    carpet: docsCarpet,
    ink: '#1D4FBF',
  },
  {
    route: 'community',
    label: 'Community',
    href: '/community',
    carpet: communityCarpet,
    ink: '#6D3BD4',
  },
  {
    route: 'home',
    label: 'Loom',
    href: '/',
    carpet: exampleCarpet,
    ink: '#00714F',
  },
  {
    route: 'why-loom',
    label: 'Why Loom',
    href: '/why-loom',
    carpet: whyLoomCarpet,
    ink: '#C08A00',
  },
  {
    route: 'source',
    label: 'Source',
    href: '/source',
    carpet: sourceCarpet,
    ink: '#C2410C',
  },
]

A card is the mark, then the eyebrow that says which direction it goes, then the page's name. The next card mirrors it: the text ranges right and the mark sits after it, so the two cards read outward from the middle of the row rather than both pointing the same way.

The hover tint arrives as a pair of custom properties on the card, because a hover cannot be written inline — the rule that uses them lives in the stylesheet and the values belong to the destination.

TypeScript
const pagerCard = (
  page: Neighbour,
  direction: 'Previous' | 'Next',
  h: HtmlBuilder<Message>,
): Html => {
  const mark = carpetMark(page.carpet, '42px', broadSeam)
  const words = h.div(
    [h.Class('pager-words')],
    [h.span([h.Class('eyebrow')], [direction]), h.span([h.Class('pager-title')], [page.label])],
  )
  return h.a(
    [
      h.Class(direction === 'Next' ? 'pager-card next' : 'pager-card'),
      h.Href(page.href),
      h.Style({ '--pg-ink': page.ink }),
      ...switchesTo(page.route, h),
    ],
    direction === 'Next' ? [words, mark] : [mark, words],
  )
}

export const pager = (route: Route, h: HtmlBuilder<Message>): Html => {
  const at = Array.findFirstIndex(readingOrder, (page) => page.route === route)
  const step = (by: number): Option.Option<Neighbour> =>
    pipe(
      at,
      Option.flatMap((index) => Option.fromNullishOr(readingOrder[index + by])),
    )
  return h.div(
    [h.Class('pager'), h.Id('loom-pager')],
    [
      h.div(
        [h.Class('pager-row')],
        [
          ...Option.toArray(Option.map(step(-1), (page) => pagerCard(page, 'Previous', h))),
          ...Option.toArray(Option.map(step(1), (page) => pagerCard(page, 'Next', h))),
        ],
      ),
    ],
  )
}

Where the palette's results come from

The palette searches everything the site shows, and the server answers each query from an index of every page, which the chapter "Searching the site" builds. So the palette holds no text of its own to search. What it keeps is how each page of the site looks among the results: its name, the address it answers on, and the colour its results wear while one of them is in focus. These are the palette's own inks, lifted for a dark ground, so they run brighter than the same page's woven mark. The book wears Loom's mint.

TypeScript
export type SearchPage = {
  readonly route: Route
  readonly label: string
  readonly path: string
  readonly ink: string
  readonly rgb: string
}

export const searchPages: ReadonlyArray<SearchPage> = [
  { route: 'docs', label: 'Docs', path: '/docs', ink: '#6E9BFF', rgb: '110, 155, 255' },
  { route: 'source', label: 'Source', path: '/source', ink: '#19D69C', rgb: '25, 214, 156' },
  { route: 'why-loom', label: 'Why Loom', path: '/why-loom', ink: '#FF6A3D', rgb: '255, 106, 61' },
  { route: 'community', label: 'Community', path: '/community', ink: '#F45C9F', rgb: '244, 92, 159' },
  { route: 'home', label: 'Loom', path: '/', ink: '#F2EFE6', rgb: '242, 239, 230' },
  { route: 'license', label: 'License', path: '/license', ink: '#FFC53D', rgb: '255, 197, 61' },
]

const searchPageOf = (route: Route): SearchPage =>
  pipe(
    Array.findFirst(searchPages, (page) => page.route === route),
    Option.getOrElse(() => searchPages[0] as SearchPage),
  )

The results, one flat list

The results arrive already grouped, one group for each page that has any. The arrow keys walk one flat list, even though the palette draws two levels. Flattening the groups, each page followed by its own results, is what lets a single focus number address either kind of row. A page's row leads to the page itself, and a result's row to its section.

TypeScript
export type Focusable =
  | { readonly kind: 'page'; readonly page: SearchPage }
  | { readonly kind: 'hit'; readonly page: SearchPage; readonly hit: SearchHit }

export const focusables = (groups: ReadonlyArray<SearchGroup>): ReadonlyArray<Focusable> =>
  Array.flatMap(groups, (group) => {
    const page = searchPageOf(group.route)
    return [
      { kind: 'page' as const, page },
      ...Array.map(group.hits, (hit) => ({ kind: 'hit' as const, page, hit })),
    ]
  })

const focusedIn = (list: ReadonlyArray<Focusable>, focus: number): Option.Option<Focusable> =>
  Option.fromNullishOr(list[Math.min(focus, Math.max(0, list.length - 1))])

const hrefOf = (focusable: Focusable): string =>
  Match.value(focusable).pipe(
    Match.when({ kind: 'page' }, (row) => row.page.path),
    Match.when({ kind: 'hit' }, (row) => row.hit.href),
    Match.exhaustive,
  )

The keys the palette answers to

Escape closes the palette. The arrows move the focus within however many rows the results hold, and Enter opens whichever row holds it, as a click on that row's link would. Every other key belongs to the input, so the palette answers it by moving the focus nowhere.

TypeScript
const paletteKey = (key: string, model: Model): Message => {
  const list = focusables(model.found)
  const stay = MovedFocus({ delta: 0, count: list.length })
  return Match.value(key).pipe(
    Match.when('Escape', () => ClosedSearch()),
    Match.when('ArrowDown', () => MovedFocus({ delta: 1, count: list.length })),
    Match.when('ArrowUp', () => MovedFocus({ delta: -1, count: list.length })),
    Match.when('Enter', () =>
      pipe(
        focusedIn(list, model.focus),
        Option.match({
          onNone: () => stay,
          onSome: (focusable) => OpenedHit({ href: hrefOf(focusable) }),
        }),
      ),
    ),
    Match.orElse(() => stay),
  )
}

The query and its own caret

The input holds the query, and a mirror of that query sits behind it carrying the caret. The mirror is transparent apart from a white block at the caret's position, showing the character underneath it in navy so the letter stays readable inside the block.

An empty query indents its placeholder clear of the block, which otherwise sits over the first word of it.

TypeScript
const caretMirror = (model: Model, h: HtmlBuilder<Message>): Html => {
  const at = Math.min(model.caret, model.query.length)
  return h.span(
    [h.Class('palette-mirror'), h.AriaHidden(true)],
    [model.query.slice(0, at), h.span([h.Class('palette-caret')], [model.query.charAt(at)])],
  )
}

const queryField = (model: Model, h: HtmlBuilder<Message>): Html =>
  h.span(
    [h.Class('palette-field')],
    [
      h.input([
        h.Class('palette-input'),
        h.Value(model.query),
        h.Placeholder('Go to a page, or search'),
        h.Spellcheck(false),
        h.Style({ textIndent: model.query === '' ? '13px' : '0' }),
        h.OnInput((query) => Typed({ query })),
        h.OnKeyDown((key) => paletteKey(key, model)),
      ]),
      caretMirror(model, h),
    ],
  )

The pills, when nothing is typed

With an empty query the palette offers the site itself rather than an empty list. One pill names each of the site's pages, behind its woven mark, and nothing else: the palette is for moving around Loom's own pages, so a link that leaves the site belongs in the footer instead. Each pill pops in a beat after the one before, so the row assembles in front of a reader instead of appearing whole.

TypeScript
type Pill = {
  readonly label: string
  readonly href: string
  readonly mark: Html
}

const pills: ReadonlyArray<Pill> = [
  ...Array.map(navPages, (page) => ({
    label: Match.value(page.route).pipe(
      Match.when('docs', () => 'Docs'),
      Match.when('community', () => 'Community'),
      Match.when('why-loom', () => 'Why Loom'),
      Match.orElse(() => 'Source'),
    ),
    href: page.href,
    mark: carpetMark(page.carpet, '17px'),
  })),
]

const pillRow = (focus: number, h: HtmlBuilder<Message>): Html =>
  h.div(
    [h.Class('palette-pills')],
    Array.map(pills, (pill, index) =>
      h.a(
        [
          h.Class(index === Math.min(focus, pills.length - 1) ? 'pill-link here' : 'pill-link'),
          h.Href(pill.href),
          h.Style({ animationDelay: `${40 + index * 34}ms` }),
        ],
        [pill.mark, pill.label],
      ),
    ),
  )

A page and its results, drawn

A page's row leads with a tilde, the mark a corpus wears, then its name and the path it answers on. Its results indent beneath it, each behind a chevron. A result shows the window onto its text, the matched words picked out in the page's own colour, and at its right end where it is: the chapter or page it sits on, then its heading.

Every row is a link to the place it names, so a reader can also open it in a new tab. Following one closes the palette, which matters when the section is on the page already showing, where the browser only scrolls to it.

The row holding the focus washes in its page's colour at low strength, so a reader sees which page they are about to open as well as which row they are on.

TypeScript
const wash = (page: SearchPage, focused: boolean): string =>
  focused ? `rgba(${page.rgb}, 0.15)` : 'transparent'

const whereOf = (hit: SearchHit): string =>
  hit.heading === '' || hit.heading === hit.place ? hit.place : `${hit.place} › ${hit.heading}`

const hitRow = (page: SearchPage, hit: SearchHit, focused: boolean, h: HtmlBuilder<Message>): Html =>
  h.a(
    [
      h.Class(focused ? 'palette-row here' : 'palette-row'),
      h.Href(hit.href),
      h.Style({ background: wash(page, focused) }),
      h.OnClick(ClosedSearch()),
    ],
    [
      h.span([h.Class('sigil'), h.Style({ color: focused ? page.ink : '#4E5D8C' })], ['>']),
      h.span(
        [h.Class('fragment')],
        [hit.pre, h.span([h.Class('palette-hit'), h.Style({ color: page.ink })], [hit.hit]), hit.post],
      ),
      h.span([h.Class('where')], [whereOf(hit)]),
    ],
  )

const groupBlock = (
  group: SearchGroup,
  focus: number,
  from: number,
  h: HtmlBuilder<Message>,
): Html => {
  const page = searchPageOf(group.route)
  return h.div(
    [h.Class('palette-kids-wrap')],
    [
      h.a(
        [
          h.Class('palette-group'),
          h.Href(page.path),
          h.Style({ background: wash(page, focus === from) }),
          h.OnClick(ClosedSearch()),
        ],
        [
          h.span([h.Class('sigil'), h.Style({ color: page.ink })], ['~']),
          h.span([h.Class('name')], [page.label]),
          h.span([h.Class('path')], [page.path]),
        ],
      ),
      h.div(
        [h.Class('palette-kids')],
        Array.map(group.hits, (hit, index) => hitRow(page, hit, focus === from + 1 + index, h)),
      ),
    ],
  )
}

The palette over the page

The palette stacks four bands: the query, then the pills or the results, and the key hint along the bottom. Which middle band shows depends only on whether anything has been typed and whether results have come back yet.

The hint bar and the loading stitch both take the colour of the page the focus is on, so the palette's edges answer to wherever a reader is headed. With nothing typed yet, that page is the first in the list.

TypeScript
const focusedPage = (model: Model): SearchPage =>
  pipe(
    focusedIn(focusables(model.found), model.focus),
    Option.map((focusable) => focusable.page),
    Option.getOrElse(() => searchPages[0] as SearchPage),
  )

const results = (model: Model, h: HtmlBuilder<Message>): Html => {
  const groups = model.found
  const starts = pipe(
    groups,
    Array.map((group) => group.hits.length + 1),
    Array.reduce([0] as ReadonlyArray<number>, (acc, size) => [
      ...acc,
      (acc[acc.length - 1] ?? 0) + size,
    ]),
  )
  return h.div(
    [h.Class('palette-body')],
    groups.length === 0
      ? [h.span([h.Class('palette-empty')], [`no match for “${model.query.trim()}”`])]
      : Array.map(groups, (group, index) =>
          groupBlock(group, model.focus, starts[index] ?? 0, h),
        ),
  )
}

While the first results are on their way the palette shows a stitched bar drawing itself sideways, the shuttle of a loom, in the colour of the page in focus, with a single word beneath it.

TypeScript
const searching = (page: SearchPage, h: HtmlBuilder<Message>): Html =>
  h.div(
    [h.Style({ display: 'flex', flexDirection: 'column', flex: 'none' })],
    [
      h.span(
        [
          h.Class('palette-stitch'),
          h.AriaHidden(true),
          h.Style({
            background: `repeating-linear-gradient(90deg, ${page.ink} 0 6px, transparent 6px 12px)`,
          }),
        ],
        [],
      ),
      h.span([h.Class('palette-loading')], ['searching']),
    ],
  )

The count is of results, not rows, so a page's own row does not count. It reads as a sentence rather than a number, so one result does not come out as "1 results".

TypeScript
const countOf = (total: number): string => `${total} ${total === 1 ? 'result' : 'results'}`

const hintBar = (model: Model, page: SearchPage, h: HtmlBuilder<Message>): Html =>
  h.div(
    [
      h.Class('palette-bar'),
      h.Style({
        background: `rgba(${page.rgb}, 0.13)`,
        borderTopColor: `rgba(${page.rgb}, 0.28)`,
        color: page.ink,
      }),
    ],
    [
      h.span([], ['↑↓ move']),
      h.span([], ['⏎ open']),
      h.span(
        [h.Class('count')],
        [countOf(Array.reduce(model.found, 0, (total, group) => total + group.hits.length))],
      ),
    ],
  )

The middle of the card is one of three things, and never two: the pills when nothing is typed, the stitch while the first results are on their way, and the results once they have come. While a reader goes on typing, the last results stay in place until the next ones arrive, so the list never blanks between letters.

TypeScript
const paletteMiddle = (
  model: Model,
  page: SearchPage,
  h: HtmlBuilder<Message>,
): ReadonlyArray<Html> =>
  Match.value({
    typed: model.query.trim() !== '',
    settling: model.searching && model.found.length === 0,
  }).pipe(
    Match.when({ typed: false }, () => [pillRow(model.focus, h)]),
    Match.when({ settling: true }, () => [searching(page, h)]),
    Match.orElse(() => [results(model, h), hintBar(model, page, h)]),
  )

Three things close the palette: the Escape key, the esc button in its head, and a click anywhere outside the card. A click outside lands on the scrim, an empty layer that fills the shell behind the card. The card stands above the scrim, so a click inside the card never reaches it.

TypeScript
export const palette = (model: Model, h: HtmlBuilder<Message>): Html => {
  const page = focusedPage(model)
  return h.div(
    [h.Class('palette-shell'), h.Role('dialog')],
    [
      h.div([h.Class('palette-scrim'), h.OnClick(ClosedSearch())], []),
      h.div(
        [h.Class('palette-card')],
        [
          h.div(
            [h.Class('palette-head')],
            [
              queryField(model, h),
              h.button(
                [
                  h.Class('palette-esc'),
                  h.Type('button'),
                  h.AriaLabel('Close search'),
                  h.OnClick(ClosedSearch()),
                ],
                [h.span([h.Class('cross')], ['×']), h.span([], ['esc'])],
              ),
            ],
          ),
          ...paletteMiddle(model, page, h),
        ],
      ),
    ],
  )
}

The footer carries the brand on the left and every link the site offers on the right. The brand is the Loom mark in its cream face, since the ground here is dark, beside the wordmark and the one sentence that says what Loom is.

The links are one flat list, and each is a chip rather than a line of a column. Eight short labels in one run beat three headed groups here: a footer is where a reader goes looking for one particular door, and eight doors side by side are read faster than eight sorted into piles. The order runs from what a reader most often wants to what they rarely do — the documentation first, the organisation last. That last chip leaves the site for the Quadrivium Academy foundation's own, at qda.ee, and the line under everything names the foundation and the year. A chip that leaves the site, for the foundation, the repository or the Telegram group, opens in a new tab, so a reader who follows it still has Loom's page open behind it. It says so before the click, with an outward arrow after its label.

TypeScript
type FooterLink = { readonly label: string; readonly href: string; readonly ink: string }

const leavesSite = (link: FooterLink): boolean => /^https?:\/\//.test(link.href)

const footerLinks: ReadonlyArray<FooterLink> = [
  { label: 'Docs', href: '/docs', ink: '#6E9BFF' },
  { label: 'Source', href: '/source', ink: '#FF6A3D' },
  { label: 'Why Loom?', href: '/why-loom', ink: '#FFC53D' },
  { label: 'Community', href: '/community', ink: '#A987FF' },
  { label: 'Telegram', href: 'https://t.me/athrio', ink: '#26A5E4' },
  { label: 'Github', href: 'https://github.com/athrio-com/loom', ink: '#19D69C' },
  { label: 'License', href: '/license', ink: '#FFC53D' },
  { label: 'About Nonprofit', href: 'https://qda.ee', ink: '#6E9BFF' },
]

export const footer = (h: HtmlBuilder<Message>): Html =>
  h.footer(
    [h.Class('footer')],
    [
      h.div(
        [h.Class('footer-inner')],
        [
          h.div(
            [h.Class('footer-top')],
            [
              h.div(
                [h.Class('footer-brand')],
                [
                  h.div(
                    [h.Class('footer-mark')],
                    [loomMark('dark', 0.78), h.div([h.Class('footer-word')], ['Loom'])],
                  ),
                  h.div(
                    [h.Class('footer-blurb')],
                    ['Literate programming framework where reasoning is the source.'],
                  ),
                ],
              ),
              h.div(
                [h.Class('footer-chips')],
                Array.map(footerLinks, (link) =>
                  h.a(
                    [
                      h.Class('footer-chip'),
                      h.Href(link.href),
                      h.Style({ '--chip-ink': link.ink }),
                      ...(leavesSite(link)
                        ? [h.Target('_blank'), h.Rel('noopener noreferrer')]
                        : []),
                    ],
                    [link.label, ...(leavesSite(link) ? [externalIcon()] : [])],
                  ),
                ),
              ),
            ],
          ),
          h.div([h.Class('footer-bottom')], [h.span([], ['© Quadrivium Academy Sihtasutus 2026'])]),
        ],
      ),
    ],
  )

The module

The module opens with what the site contains — the pages the nav names, the pages the palette searches, and the passages inside them — because every piece of chrome reads from it. Then the two bars and the button they share, the palette built up from its fragments and keys, and the footer.

Loom