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.
Hello from a tree
Nothing here was written as markup.
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 treehide 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 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.
Surfaces — what the page and the things on it are painted
Inks — what carries meaning
Accent — the one color that means “this one”
Brand secondary — areas, never letterforms
Borders — the rules and rings
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
#3eda91
1.73:1 on the canvas — cannot be ink
What the derivation put in accent
#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:
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 call | it hands you | you need it |
|---|---|---|
themeStyle(theme) | every --loom-* property a primitive reads, as a React style object | around any excerpt |
themeGround(theme) | the paper: a background color, a text color and a color-scheme | only 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.
- 01
Connect what you already use
Point it at the repository, the board and the docs. Nothing moves and nothing is copied.
- 02
Describe the change in a sentence
Say what should be different. It plans against the page as it stands, not as it was.
- 03
Approve what you meant
Every change arrives as a diff with its reasoning attached, and every one of them comes back out.
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.
- 01
Connect what you already use
Point it at the repository, the board and the docs. Nothing moves and nothing is copied.
- 02
Describe the change in a sentence
Say what should be different. It plans against the page as it stands, not as it was.
- 03
Approve what you meant
Every change arrives as a diff with its reasoning attached, and every one of them comes back out.
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:
| Declaration | What it came back as |
|---|---|
| backgroundColor | #111827 |
| color | #f3f4f7 |
| colorScheme | dark |
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 | #ffffff | 1.000 | the paper |
| fg-default | #0a0a0a | 0.003 | the 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 as | Registers? | The answer |
|---|---|---|
| six-digit hex#111827 · #f3f4f7 | yes | "dark" |
| three-digit hex#123 · #eef | yes | "dark" |
| hsl()hsl(220 30% 11%) · hsl(220 25% 95%) | yes | undefined |
| rgb()rgb(17 24 39) · rgb(243 244 247) | yes | undefined |
| a named colormidnightblue · whitesmoke | yes | undefined |
| hex with an alpha#111827ff · #f3f4f7ff | yes | undefined |
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".undefinedmeans 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 — onescheme === "dark" ? dark : lightinstead, and a palette written inhsl()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.