skip to the page

Making it look like yours

You do not style a Loom page by writing CSS for it. You name three things on the root node, and every primitive underneath changes appearance without being touched.

The three are a palette — the colors — a font pack — the typefaces and the sizes they step through — and a style preset — the corners, the spacing and how fast anything moves. Each is a registered thing with an id, and the tree carries the ids and nothing else.

Three ids nobody chose by handlive · rendered through the runtime

Hello from a tree

Nothing here was written as markup.

A palette nobody painted

Three hues went in. Seventeen slots came out, each measured against the ink it has to carry.

Read the archive
Propose a change:

Every one of these goes through the same pipeline a model’s answer would: interpreted, analysed, weighed, judged, applied, appended. The log is real and it is in this tab — reload the page and the example is back as it was.

show the tree
{
  "kind": "element",
  "id": "n_derivedtheme12",
  "type": "loom.page",
  "props": {
    "fills": true,
    "width": "readable",
    "loom:theme": {
      "palette": "tide",
      "fontPack": "grotesque",
      "stylePreset": "technical"
    }
  },
  "children": [
    {
      "kind": "element",
      "id": "n_derivedtheme2",
      "type": "loom.heading",
      "props": {
        "level": 1
      },
      "children": [
        {
          "kind": "text",
          "id": "n_derivedtheme1",
          "value": "Hello from a tree"
        }
      ]
    },
    {
      "kind": "element",
      "id": "n_derivedtheme4",
      "type": "loom.prose",
      "props": {},
      "children": [
        {
          "kind": "text",
          "id": "n_derivedtheme3",
          "value": "Nothing here was written as markup."
        }
      ]
    },
    {
      "kind": "element",
      "id": "n_derivedtheme11",
      "type": "loom.card",
      "props": {
        "tone": "surface",
        "padding": "loose"
      },
      "children": [
        {
          "kind": "element",
          "id": "n_derivedtheme6",
          "type": "loom.heading",
          "props": {
            "level": 2
          },
          "children": [
            {
              "kind": "text",
              "id": "n_derivedtheme5",
              "value": "A palette nobody painted"
            }
          ]
        },
        {
          "kind": "element",
          "id": "n_derivedtheme8",
          "type": "loom.prose",
          "props": {
            "tone": "muted"
          },
          "children": [
            {
              "kind": "text",
              "id": "n_derivedtheme7",
              "value": "Three hues went in. Seventeen slots came out, each measured against the ink it has to carry."
            }
          ]
        },
        {
          "kind": "element",
          "id": "n_derivedtheme10",
          "type": "loom.action",
          "props": {
            "href": "https://example.com/archive",
            "variant": "primary",
            "scale": "small"
          },
          "children": [
            {
              "kind": "text",
              "id": "n_derivedtheme9",
              "value": "Read the archive"
            }
          ]
        }
      ]
    }
  ]
}
The palette on this one was derived from three hues and checked against the contrast bar rather than picked. The font pack and the style preset are two more of the registered set, and no component knows which.

The tree above is the same handful of nodes as everywhere else on this site. The only difference is three strings.

The three ids, and where they go

props: {
  "loom:theme": {
    palette: "tide",
    fontPack: "grotesque",
    stylePreset: "technical",
  },
}

loom:theme is a reserved prop — the loom: prefix marks props the runtime reads rather than props your primitive declared. Rendering resolves those three ids against the theme registry you passed, and hands the result to the root primitive, which mounts them as CSS custom properties on its own element. Everything below is styled from var(--loom-*) and never learns which theme it is wearing.

Two things follow, and both are why it is done this way.

A re-theme is an ordinary change. Swapping the palette is a configure against one node, judged by the Gate and recorded in the log like any other (0049). There is no second mechanism for appearance.

And the registry is an allowlist. A proposal may only name a palette you registered, so "make it look different" has a bounded set of answers instead of an open one. Press the re-theme chip above and watch a whole page change from a delta that touches one node.

Every palette declares every slot

A palette is not a few brand colors. It is seventeen named slots, all of them filled, and the completeness is the point rather than bureaucracy: a tree themed with one palette has to be re-themable with any other, and a slot some palette left undefined would be a hole that only shows up on the page that used it.

Slotminimaleditorialbold

Surfaces — what the page and the things on it are painted

bg-canvas
#ffffff
#fafaf7
#0a0a0a
bg-surface
#ffffff
#ffffff
#1a1a1a
bg-surface-muted
#f4f4f5
#f0eee9
#0f0f0f
bg-overlay
#ffffff
#ffffff
#1a1a1a

Inks — what carries meaning

fg-default
#0a0a0a
#0a0a0a
#f5f5f5
fg-muted
#52525b
#525252
#a3a3a3
fg-subtle
#6e6e78
#6a6a6a
#8a8a8a
fg-on-accent
#ffffff
#ffffff
#0a0a0a

Accent — the one color that means “this one”

accent
#0a0a0a
#4a5b78
#ffd400
accent-strong
#176e44
#34425a
#e0b800
accent-subtle
#effbf5
#e6ebf2
#3a3000

Brand secondary — areas, never letterforms

brand-secondary
#72e3ad
#4a5b78
#ff3344
brand-secondary-strong
#1f985e
#34425a
#d81f2f

Borders — the rules and rings

border-default
#d4d4d9
#e5e5e5
#2a2a2a
border-strong
#0a0a0a
#0a0a0a
#f5f5f5
border-subtle
#e6e6ea
#e8e8df
#1f1f1f
border-accent
#72e3ad
#4a5b78
#ffd400

The names are jobs, not colors. fg-muted is the ink that recedes, so a primitive asks for it without knowing whether today's answer is grey, warm brown or pale blue. Nothing in a tree ever says #0a0a0a.

brand-secondary is worth a second look, because it is the slot that catches people out. It is reserved even in palettes that have only one accent — those mirror their accent into it — so that every palette has the same shape, and a primitive that paints an area with the second brand color cannot be handed a palette where that slot is missing.

The type ramp and the spacing scale work the same way, and for the same reason: both have exactly eight steps, in every font pack and every style preset. A primitive that reads --loom-spacing-7 must get a length back from whichever preset is registered, or a re-theme quietly unstyles it.

Can it actually be read?

This is the question a palette has to answer, and the one a brand deck never asks. Loom answers it with a function you can run rather than a promise it makes.

auditPalette measures every place a primitive puts ink on a ground — read off the components themselves, not imagined — and reports the contrast ratio of each against the 4.5:1 bar that WCAG sets for body text.

Here is that audit, run against every palette this runtime registers, as this page was built:

21

palettes registered

26

pairings measured in each

0

with a painted failure

8

with a composed one

What the bar says today

  • bold: fg-subtle on accent-subtle (loom.perk note inside an accent section) is 3.80:1, under 4.5:1 — composed, reported not asserted
  • slate: fg-subtle on accent-subtle (loom.perk note inside an accent section) is 4.46:1, under 4.5:1 — composed, reported not asserted
  • midnight: fg-subtle on accent-subtle (loom.perk note inside an accent section) is 3.99:1, under 4.5:1 — composed, reported not asserted
  • carbon: fg-subtle on accent-subtle (loom.perk note inside an accent section) is 3.76:1, under 4.5:1 — composed, reported not asserted
  • plum: accent on accent-subtle (loom.faq marker inside an accent section) is 4.43:1, under 4.5:1 — composed, reported not asserted
    plum: fg-subtle on accent-subtle (loom.perk note inside an accent section) is 4.42:1, under 4.5:1 — composed, reported not asserted
  • forest: fg-subtle on accent-subtle (loom.perk note inside an accent section) is 4.27:1, under 4.5:1 — composed, reported not asserted
  • ember: fg-subtle on accent-subtle (loom.perk note inside an accent section) is 4.23:1, under 4.5:1 — composed, reported not asserted
  • obsidian: fg-subtle on accent-subtle (loom.perk note inside an accent section) is 4.32:1, under 4.5:1 — composed, reported not asserted

Two kinds of failure, and the difference matters.

A painted failure is one primitive setting both the ink and the ground beneath it — loom.card putting body copy on its own surface. No tree can avoid it, so it is a defect in the palette with exactly one fix, and it is the list a host asserts empty.

A composed failure is an ink set by one primitive on a ground set by whatever it was placed in. It is reachable in an ordinary page and it is reported, not asserted, because whether your deployment reaches it depends on trees nobody has written yet, and the fix is not always the palette's — an ink that fails on one ground and clears the other three may be a panel that wants moving (0089).

Where your brand color actually goes

Now the part that surprises people, and the reason this page exists rather than a paragraph on another one.

accent is ink. It is the eyebrow above a section, the current item in a nav, the marker on a disclosure, the label on a filled button. So a hue you give Loom for that slot is darkened — or lightened, in a dark palette — until it can carry text, and a pale mint or a bright yellow comes back a long way from where the brand book left it.

derivePalette takes three hues and a mode and solves the rest, searching for a lightness that clears the bar with a margin rather than picking one and hoping. Here it is, run on a mint:

The color as your brand book prints it

Read the archive

#3eda91

1.73:1 on the canvas — cannot be ink

What the derivation put in accent

Read the archive

#17794c

5.19:1 on the canvas — can be ink

The brand color is not thrown away. It goes where a color is an area rather than a letterform:

border-accent#4bdd99
brand-secondary#48d831

Every pairing the primitives paint clears 4.5:1 in the derived palette — derivePaletteChecked said so, here, as this page built.

That is not the tool being clumsy. It is the only honest answer to "can we put our green on white and write on it?" — and canCarryText will give you that answer for one color in one call, before you have built anything.

The brand color is not thrown away when the answer is no. It goes to border-accent and brand-secondary, where it is a rule, a ring or a tinted field — an area rather than a letterform — and accent takes a near-neutral ink instead. That is not a workaround; it is what the minimal palette this site wears reached by hand before the derivation existed.

Registering your own

import { createThemeRegistry } from "@jam-overture/loom"

const themes = createThemeRegistry({
  palettes: [ourLightPalette, ourDarkPalette],
  fontPacks: [ourFontPack],
  stylePresets: [ourStylePreset],
})

Pass it to renderLoomTree or renderRequest alongside your primitive registry and you are done.

One thing to know before you do: each list replaces the starter set rather than adding to it. Register two palettes and two palettes is what a proposal may name — which is usually what a deployment wants, because shipping your brand plus the other twenty-one palettes is a menu, not a design system. Spread STARTER_PALETTES in if you want both.

import { STARTER_PALETTES } from "@jam-overture/loom"

createThemeRegistry({ palettes: [...STARTER_PALETTES, ourLightPalette] })

Putting it on a page

A theme is registered and a tree names it. Something still has to put the colors on the screen, and which something depends on how much of the tree you are drawing.

Drawing a whole page? You do nothing. The root primitive mounts the theme on its own element, every primitive under it inherits the properties through the cascade, and there is no line for you to write. That is the ordinary case and this section is not about it.

Drawing part of a tree? One band on its own, inside your layout — a preview, a card, a page-builder's canvas. There is no root primitive above an excerpt, so the two jobs it was quietly doing are now yours, and there is one function for each.

you callit hands youyou need it
themeStyle(theme)every --loom-* property a primitive reads, as a React style objectaround any excerpt
themeGround(theme)the paper: a background color, a text color and a color-schemeonly when your frame is standing in for the page

Here is the difference, drawn. It is the same band, rendered once and placed in two frames. Both frames call themeStyle, so the words are set in the tree's ink in both. Only one of them asked what paper that ink was meant for.

How it works

Three moves, and the third is the only one you repeat

No migration, no new place to work, and nothing that changes without you saying so.

  1. 01

    Connect what you already use

    Point it at the repository, the board and the docs. Nothing moves and nothing is copied.

  2. 02

    Describe the change in a sentence

    Say what should be different. It plans against the page as it stands, not as it was.

  3. 03

    Approve what you meant

    Every change arrives as a diff with its reasoning attached, and every one of them comes back out.

themeStyle(theme)The variables are mounted, so the ink is the tree's. The ground is the one this site's own chrome was built with.1.1:1 — fails the 4.5:1 bar body text has to meet

How it works

Three moves, and the third is the only one you repeat

No migration, no new place to work, and nothing that changes without you saying so.

  1. 01

    Connect what you already use

    Point it at the repository, the board and the docs. Nothing moves and nothing is copied.

  2. 02

    Describe the change in a sentence

    Say what should be different. It plans against the page as it stands, not as it was.

  3. 03

    Approve what you meant

    Every change arrives as a diff with its reasoning attached, and every one of them comes back out.

themeStyle(theme) and themeGround(theme)The same frame, told what paper the excerpt would have been sitting on.16.1:1 — clears the 4.5:1 bar body text has to meet

The left frame is not a mistake anybody makes on purpose. It is what a frame looks like the day after the tree's palette moves: the chrome's background was decided once, in a stylesheet, in a place the tree cannot reach — so it kept painting the light ground it was written with, while the ink underneath it became a dark palette's ink. Nothing errors. Nothing warns. The words are simply not there.

themeGround is the fix, and it is a fix rather than a workaround because both ends come out of the same palette. A frame cannot be legible under one theme and invisible under another when the paper and the ink were read off one object:

DeclarationWhat it came back as
backgroundColor#111827
color#f3f4f7
colorSchemedark

bg-canvas rather than a surface, because the canvas is what the root primitive paints and therefore what any excerpt would have been sitting on. A band that paints a surface of its own paints it over this, exactly as it does in place.

That is the whole of it:

import type { LoomTree, ResolvedTheme } from "@jam-overture/loom"
import { renderLoomTree, themeGround, themeStyle } from "@jam-overture/loom/react"

const Excerpt = ({ tree, theme }: { tree: LoomTree; theme: ResolvedTheme }) => (
  <div style={{ ...themeStyle(theme), ...themeGround(theme) }}>
    {renderLoomTree(tree, { resolver: registry, validator: registry }).element}
  </div>
)

If you are drawing whole pages, you will never need the second one. Reach for themeGround when there is no loom.page above what you are rendering, and not before.

Light or dark, as a word

You have two versions of your logo — one drawn for a light page, one for a dark one. The tree names a palette. Which file do you serve?

themeGround cannot answer that. It hands you colors, and a PNG is not a color.

import { paletteScheme } from "@jam-overture/loom"

const scheme = paletteScheme(theme.palette)
// "light" | "dark" | undefined

Here is the whole measurement, in a sentence you can repeat: it compares the palette's own ink to its own canvas and tells you which of the two is lighter. Dark letters on pale paper is "light". The other way round is "dark".

That is the entire calculation, done on the palette this site is built from:

bg-canvas#ffffff1.000the paper
fg-default#0a0a0a0.003the ink
The ink is darker than the paper, so paletteScheme answers"light"

No threshold, no list of palettes it knows about, and no field on a palette for somebody to fill in wrongly. It is a comparison between two colors that are already in the same palette, which is why it keeps working for a palette nobody here has ever seen.

The one you paint with, and the one you choose with

  • themeGround(theme) hands you three colors. They are what you paint the frame with.
  • paletteScheme(theme.palette) hands you one word. It is what you choose between two things of your own with.

Everything in your chrome that is made of Loom's colors is handled by the first of those. This is for the things that are not, and the giveaway is always the same: they take the word, and there is nowhere to put a hex.

  • the logo, the illustration, the screenshot with a pale background baked into it
  • an embed you do not control — a map, a chart, somebody's code sample — whose options are "light" and "dark"
  • <meta name="theme-color">, and anything else the browser reads rather than a primitive

Every palette you start with answers, and you can check each one against your own eyes: the swatch is that palette's ink on that palette's canvas.

"light" — 12

  • minimal#ffffff · #0a0a0a
  • editorial#fafaf7 · #0a0a0a
  • paper#faf7f5 · #15120e
  • slate#f9fafb · #0e1115
  • sage#f6f8f6 · #0e150e
  • blush#fbf9f9 · #150e0f
  • harbour#fcfcfd · #0e1215
  • citrus#faf9f5 · #15150e
  • lilac#faf9fb · #130e15
  • graphite#fafafa · #131111
  • clay#f7f5f2 · #15130e
  • linen#f7f6f2 · #15130e

"dark" — 9

  • bold#0a0a0a · #f5f5f5
  • midnight#111827 · #f3f4f7
  • carbon#141613 · #f5f6f4
  • plum#1d1320 · #f6f3f7
  • forest#13201b · #f3f7f5
  • ember#1a1614 · #f7f4f3
  • dusk#1a1924 · #f3f3f7
  • obsidian#141414 · #f5f4f4
  • tide#132125 · #f3f6f7

Every registered palette answers. None of them comes back undefined.

The third answer is undefined, and it is the one to plan for

paletteScheme reads colors written as hex — three digits or six, #eef or #f3f4f7, and nothing else.

A palette may be written other ways, and a registry accepts all of them. That is the part worth knowing: you can write a perfectly legal palette and lose this measurement, and nothing will tell you that you have.

Written asRegisters?The answer
six-digit hex#111827 · #f3f4f7yes"dark"
three-digit hex#123 · #eefyes"dark"
hsl()hsl(220 30% 11%) · hsl(220 25% 95%)yesundefined
rgb()rgb(17 24 39) · rgb(243 244 247)yesundefined
a named colormidnightblue · whitesmokeyesundefined
hex with an alpha#111827ff · #f3f4f7ffyesundefined

It answers undefined rather than guessing, for the reason that also makes the contrast figures further up this page come back unmeasured. Reading hsl() means writing a parser, and a parser that is subtly wrong does not fail — it reports "light" about a dark palette, and the logo you then serve is the one nobody can see. I could not tell is something your chrome can handle. A confident wrong answer is not.

So two rules, and the second is the one that gets written wrong:

const MARKS = { light: "/mark-on-light.svg", dark: "/mark-on-dark.svg" } as const

const markFor = (resolved: ResolvedTheme): string | undefined => {
  const scheme = paletteScheme(resolved.palette)

  return scheme === undefined ? undefined : MARKS[scheme]
}
  • Write your palette's colors as hex and you get an answer. Every registered palette does, which is why the table above has no gaps.
  • Do not fall back to "light". undefined means the measurement is unavailable, not the palette is pale. The line above returns nothing so that the caller keeps doing whatever it would have done if it had never asked — one scheme === "dark" ? dark : light instead, and a palette written in hsl() silently gets the mark for a white page.

What a model gets shown

themes.catalogue() is the theme half of what a model is sent before it proposes anything: every registered id with its name and its one-line description, and no hex at all. A model choosing an appearance is choosing from a list, exactly as it chooses a primitive from the registry.

Which means the description you write on a palette is not documentation. It is the sentence a model reads when someone types "make this feel calmer", and it is the difference between getting the palette you would have picked and getting a coin toss.