Tokens, tones and recipes
Tokens, tones and recipes
The look is configuration too
The post screen styles itself from config.theme, inline, so it renders correctly whether or not you
import barakopress/styles.css. That import is optional and a screen that needs it renders unstyled
for anyone who skips it, which is exactly what happened: barakocms.com never imported it, got an
article at x=0 on a blank page, and stopped using the screen at all.
export const config = defineConfig({
site: { name: "Client Three", url: "https://clientthree.example" },
theme: { colors: { accent: "#008060" }, layout: { prose: "68ch" } },
});
Each group merges over the defaults on its own, so setting one colour keeps the other seventeen. The
groups are colors (18 values), fonts (heading, body, mono), radii (panel, control,
pill) and layout (prose, wide, gutter). DEFAULT_THEME is exported if you want to read the
values or build a palette from them.
Loading the faces is the site's job, not the engine's. The default theme names Sora, Manrope and
JetBrains Mono with real fallback stacks, and a <link> in your root layout is what makes them
arrive. createSiteLayout writes that link for you: a family name from Google Fonts, or the
stylesheet named in theme.fontSources when the face is not loaded from there, held to
PRESS_FONT_ORIGINS either way.
Three slots, for what belongs to the site rather than the engine:
<PostView
config={config}
post={post}
headerBackdrop={<Bean />} {/* decoration behind the header band */}
beforeBody={<RoleStrip />} {/* a wide band under the header */}
afterBody={<Newsletter />} {/* the foot of the reading column */}
/>
They are not a way to compose a post out of arbitrary sections. Anything that has to sit between two paragraphs belongs to the block model, which is issue #6, because only the body knows where it goes.
A theme that draws the page itself. A deployment that ports an existing design registers its own
blocks under the built-in type names and ships its own stylesheet. Two things would still be the
engine's, and neither can be undone from a stylesheet because both are inline: the page frame
(main, its padding, the title and the breadcrumbs) and the gap between blocks. So:
export default createPage(config, blocks, { bare: true }); // only the blocks
body { --bp-gap: 0; } /* every block list, at every level */
bare is also taken by createHome and createViewerPage. --bp-gap falls back to
theme.space.lg, so a site that sets neither renders as it did.
Tokens and tones
The theme's slots are the engine's roles. A design has its own palette on top of them: a colour per
product, a tint for a badge, a length the design repeats. Those are Tokens and Tones in the site
settings, or theme.tokens and theme.tones in defineConfig. A tenant's entries merge over the
configured ones name by name.
"Tokens": { "accent": "#E4572E", "cms-ink": "#1D3A8A", "cms-bg": "#E8EEFD", "gutter": "24px", "serif": "'Zilla Slab', Georgia, serif" },
"Tones": { "cms": { "ink": "cms-ink", "bg": "cms-bg", "edge": "#B9C8F5" } }
A token is a name and one value: a colour (checked as Colors is), a length or a clamp() of three
(checked as Text is), or a font stack of plain or quoted family names. A name is a letter, then
letters, digits and hyphens, up to 40. Each token is emitted on the root as --t-<name> by
createSiteLayout (through themeVariablesCss), so a site's stylesheet and its blocks write
var(--t-accent) instead of a hex value. A name or a value that fails its check is dropped and the
rest are kept. Up to 200.
A tone is a name and three colours: ink, bg and edge (hairlines and borders). Each is a token
name, a Colors slot name, or a colour written out, looked up in that order when the tone is drawn,
so changing a token changes every tone that names it. Text of every kind on the tone is the ink,
and a filled accent is the ink with the bg as its text. A name is lower case letters, digits and
hyphens; a built-in name is refused, since those follow Colors. A tone with a colour that resolves
to nothing is dropped. Up to 40.
Every block field that picks a tone offers the site's tones after the built-in six, and a block
stores "tone": "cms" the way it stores "tone": "accent". HeaderTone and FooterTone take them
too. /api/blocks lists them on every tone field: a build-time site's own, and on a request-time
site the requesting tenant's, when the route is mounted as createBlockSchemaRoute(config, blocks).
A site that sets neither gets no --t- property and no new option anywhere, and renders byte for
byte as it did.
Read time is derived from the body at 200 words a minute, with fenced code blocks excluded, so there is no field to fill in and nothing to keep in sync.
Style recipes
A primitive draws itself from tokens, which keeps every page one design and also means a designed
section cannot be built from primitives: a card with its own padding, border, corner and shadow is
not something any token names. A recipe is that card, said once in the site settings as
StyleRecipes (or theme.recipes in defineConfig), and a block wears it with recipe.
"StyleRecipes": {
"card": {
"class": "lift",
"style": {
"padding": "22px 24px",
"background": "{colors.surface}",
"border": "1px solid {colors.hairline}",
"border-radius": "16px",
"box-shadow": "0 1px 2px rgba(16,18,35,.04)",
"display": "flex",
"flex-direction": "column",
"gap": "{space.sm}"
}
},
"eyebrow": {
"style": {
"font-family": "{fonts.mono}",
"font-size": "11px",
"letter-spacing": ".16em",
"text-transform": "uppercase",
"color": "{colors.muted}"
}
}
}
{ "type": "panel", "props": { "recipe": "card", "content": [[
{ "type": "text", "props": { "value": "01 Products", "recipe": "eyebrow" } }
]] } }
A recipe is a name and two keys, both optional but not both empty:
class: class names, space separated, put on the element beside the style. A style attribute cannot say:hover, a focus ring or a media query, so those go in the site's own stylesheet under this class. Up to 8, each a letter or underscore, then letters, digits,_and-.style: CSS property names, written as a stylesheet writes them, to values. Up to 40.
A value is CSS text, and {name} in it stands for a theme value: {accent} is the token of that
name from Tokens, and {colors.<slot>}, {space.<step>}, {radii.<name>}, {text.<role>},
{fonts.<role>} and {layout.<name>} are the theme's own. References are resolved when the block
draws, against the requesting tenant's theme, so changing a token changes every recipe that names
it. A declaration whose reference does not resolve is left out and the rest of the recipe draws.
The properties a recipe may set:
| Group | Properties |
|---|---|
| box | display, position (static or relative only), box-sizing, width, min-width, max-width, height, min-height, max-height, aspect-ratio, overflow, overflow-x, overflow-y, vertical-align, opacity |
| spacing | margin, padding and their four sides, margin-block, margin-inline, padding-block, padding-inline, gap, row-gap, column-gap |
| typography | font-family, font-size, font-weight, font-style, font-variant-numeric, line-height, letter-spacing, text-align, text-transform, text-decoration, text-underline-offset, text-wrap, text-overflow, white-space, overflow-wrap, word-break |
| colour | color, background, background-color |
| border | border, border-top, border-right, border-bottom, border-left, border-color, border-style, border-width |
| radius | border-radius |
| shadow | box-shadow, text-shadow |
| grid | grid-template-columns, grid-template-rows, grid-auto-flow, grid-auto-rows, grid-column, grid-row, justify-items, place-items, place-content |
| flex | flex, flex-direction, flex-wrap, flex-grow, flex-shrink, flex-basis, align-items, align-content, align-self, justify-content, justify-self, order |
| engine | --bp-ink, --bp-ink-soft, --bp-muted, --bp-hairline, --bp-accent, --bp-on-accent (the tone the blocks inside read), --bp-gap, --bp-list (a block list's gap and display), --bp-code-ink, --bp-code-bg, --bp-code-size, --bp-code-pad, --bp-code-radius (inline code in a text block) |
The list is also exported as RECIPE_PROPERTY_GROUPS, for an editor.
The values reach a style attribute, which React writes by joining name:value; with no CSS
escaping, so a value is held to a narrow shape rather than cleaned. It is letters, digits, spaces
and # % . , ( ) / + * -, up to 240 characters, with quotes only around a plain family name
('JetBrains Mono'). Parentheses balance, and a function is one of calc, min, max, clamp,
minmax, repeat, fit-content, var (naming a custom property and nothing else), the colour
functions (rgb, rgba, hsl, hsla, hwb, lab, lch, oklab, oklch, color-mix) and the
gradients. So ;, :, braces, angle brackets, a backslash, !important, @, a comment, url(),
expression(), image-set() and attr() are all refused: a recipe cannot load anything and
cannot leave its own declaration. A property off the list, or a value that fails, is dropped and
the rest of the recipe kept, the way Colors drops one bad colour. The check runs again when the
block draws, on the resolved value, so a theme built by hand gets it too. A name is lower case
letters, digits and hyphens, up to 40. Up to 400 recipes, merged over the configured ones name by
name: barakocms.com's pages are about three hundred looks, one per element its design styles.
What wearing one does to a block:
- The block is its own cell. Every block in a list sits in a wrapper, and under a recipe the wrapper
is
display: contents, as a transparent block's is, so the recipe's element is what its parent lays out: a lede that takesflex: 1 1 420pxbeside a claim, a card that is the grid's item. A name the site has no recipe for keeps the wrapper along with the block's own look. That holds for an inline element too. Alinkor aspantext wearingdisplay: inline-blockin a list, which is a column, is that column's flex item and is stretched to its width like any other; without a recipe it sits inside a block wrapper at its own width. A recipe that wants its own width says so:align-self: flex-start, orwidth: fit-content. - The recipe replaces the block's own inline look on its outer element outright. It is not merged
over it: the card's own padding under a recipe that only set the corner is a look nobody drew.
The block's token props for that element (
padding,radius,borderand so on) are not applied. - What makes the block work stays: a
stickyBarkeepsposition: sticky, aflowkeeps its cells (--bp-list: contents), arowand atabGroupstay wrapping rows, alistkeeps its marker, acomparisonTablekeeps its own horizontal scroll, anembedkeeps its width, shape and no border, afigurekeeps no margin, and arotatingTextkeeps the box its words stack in. - So does the layout the block's own props ask for: a
gridorflowwithcolumnskeeps its grid and track list, andalignandjustifyon astack,roworfloware kept. A prop left unset leaves the recipe to say it, so aflowwith nocolumnstakes its grid from the recipe. - A
tonenamed beside a recipe still sets the tone the blocks inside read. Without one, a recipe that changes the background sets--bp-inkand its neighbours itself. - A
tabPanel's recipe is its tab in the strip, and the open tab keeps its colours over it. recipeis a text field, so a preset passes its own prop through, and a bound name picks a look per row:"recipe": "card-{{item.Product}}".- A name the site has no recipe for draws the block's own look. A block that names none renders byte for byte as it did.