Configuration
mirror.config.ts is the only file the token CLI reads, and this page documents every key in it. Read it before changing your token structure, because the utilities and themes are generated from whatever shape it describes.
mirror.config.ts sits at the root of your project and steers the whole token
build. It is the only file the CLI reads, and both commands need it, so a
project without one stops with an error rather than guessing.
import { extendMirrorConfig } from '@maas/mirror'
export default extendMirrorConfig({
source: { type: 'git', repo: 'magicasaservice/mirror-tokens' },
})
That is a whole config. source is the only key you have to write, and
everything else comes from ours: the config, application, radii, theme and
breakpoint targets, and the css, js, ts and json platforms. Run
mirror init to have exactly this written for you.
Extending ours or writing your own
There are two ways to export a config, and the import at the top of the file says which one you are using.
extendMirrorConfig deep-merges what you write over ours, so a config that
names nothing but source builds everything Mirror builds. Reach for it when
your tokens follow our structure.
defineMirrorConfig takes a complete config and merges nothing. Nothing of
ours survives, so you own every target, and a target we add later does not
quietly appear in your build. Reach for it when your token tree is your own.
import { defineMirrorConfig } from '@maas/mirror'
export default defineMirrorConfig({
source: { type: 'local', path: './tokens' },
options: { platforms: ['css', 'js', 'ts', 'json'] },
targets: {
config: { selector: ':root', files: [{ src: 'config' }] },
application: {
include: [{ src: 'config' }],
selector: ':root',
files: [{ src: 'application' }],
},
},
})
The merge happens inside extendMirrorConfig, at the moment you write the
file, rather than somewhere in the CLI. Three rules govern it.
Objects merge key by key, one level at a time, so patching a single key of one of our targets keeps the rest of it.
targets: { 'theme/dark/application': { selector: '[data-mode="dark"]' } }
Arrays replace rather than concatenate. Every array in this config is a set
rather than a list to append to, so naming platforms, files, include,
atRule, enum or tokens.ignore replaces ours outright.
options: { platforms: ['css'] }
And null drops one of our targets.
targets: { radii: null }
Our own pieces are exported as plain values, so a target of yours can reuse one of ours or sit on the same selector ladder.
import { extendMirrorConfig, stockSelectors, stockTargets } from '@maas/mirror'
export default extendMirrorConfig({
source: { type: 'local', path: './../design-tokens/tokens' },
targets: {
'theme/brand/application': {
include: [{ src: 'config' }],
selector: stockSelectors.theme('brand'),
files: [{ src: 'theme/brand' }],
},
'theme/print/application': {
...stockTargets.application,
atRule: ['@media print'],
},
},
})
stockConfig, the whole thing, and stockOptions are exported alongside them.
Where the file sits
Two commands read it, at two different moments, and neither of them assumes anything the file does not state.
tokens/*.json the source, one file per group
↓ mirror tokens, reading `source` and `targets`
.maas/tokens/{css,js,ts,json}/ one compiled file per target
↓ mirror css, reading `utilities`
.maas/tailwind.preset.css the classes and the @theme block
dist decides where that middle line is written and defaults to .maas. Both
commands read it, so moving the compiled output is one key rather than a flag
on each run.
Source
source says where the raw token JSON comes from, and it is required because
there is no sensible default for somebody else’s tokens. It is either a Git
repository or a local path.
Which type you pick depends on who owns the tokens. Point at a Git repository when the tokens are a shared source of truth that several projects follow: nothing is checked in, and a token change reaches every project on its next build. Point at a local path inside the project when the project owns its tokens: the files are checked in, and token changes go through review like any other code.
source: {
type: 'git',
repo: 'magicasaservice/mirror-tokens',
}
source: {
type: 'local',
path: './../design-tokens/tokens',
}
A local path is what mirror init --tokens writes. It copies the token tree
into ./tokens and sets source to { type: 'local', path: './tokens' }, so
the tokens are a directory under revision control rather than a repository
fetched on every build. See the
CLI reference.
Options
ios and android are opt-in through the same key. The ts platform writes
its declarations alongside the JS modules rather than into a directory of its
own. Leave js in the list whatever else you do, because the utility generator
reads the compiled JS modules rather than the CSS.
Build targets
targets is an object whose keys are output paths. Each entry describes one
output file, made up of a set of token files and a selector to emit them under.
export default extendMirrorConfig({
source: { type: 'local', path: './tokens' },
targets: {
'theme/brand/application': {
include: [{ src: 'config' }, { src: 'application' }],
selector: '[data-theme="brand"][data-theme][data-theme]',
files: [{ src: 'theme/brand' }],
},
},
})
The key is the file name, written once. theme/brand/application writes
css/theme/brand/application.css,
js/theme/brand/application.variables.js, json/theme/brand/application.json
and ios/theme_brand_application.swift, and each segment is kebab-cased on the
way, so write the key the way you want the file spelled. Add a target and you
get a file, and add that file to your css array and you get a theme.
Build order is the object’s key order, so a config builds top to bottom the way it reads.
A TokenInclude is a src and nothing else. A TokenFile is a src plus an
optional tokens.ignore, which is a list of globs over the token path with its
segments joined by /. The list is read the way .gitignore is: '**' drops
everything and a leading ! puts some of it back, so the pattern below emits
nothing but the palette.
files: [
{ src: 'config', tokens: { ignore: ['**', '!**/color/palette/**'] } },
]
Source files are read as JSON, and only the files a target lists can contribute
to it: a token is emitted when its own file path matches one of the target’s
src entries and the ignore list lets it through.
Selectors
The generated files load in any order, so the selectors do the ranking. Repeating an attribute selector adds one to its specificity without changing what it matches, and that gives every target a rung to sit on.
The base selector also names [data-color-mode="light"], so a light subtree
inside a dark document reads the base values again.
That ladder is a property of this config rather than of the build. Nothing in
the pipeline reads a selector or checks one against another, so the files end up
ranked by specificity or by load order, the way any CSS is. Repeating attributes
picks specificity, so Theming can say the files load in any
order. If your own config ranks by load order instead, every target can share
one selector, but the order of the css array becomes load-bearing and a theme
that arrives through a lazily imported stylesheet ends up wherever the bundler
puts it.
Building a theme
Everything a theme leaves out keeps resolving from the base file, so declare only what the theme changes.
export default extendMirrorConfig({
source: { type: 'local', path: './tokens' },
targets: {
'theme/brand/application': {
include: [{ src: 'config' }, { src: 'application' }],
selector: stockSelectors.theme('brand'),
files: [
{ src: 'theme/brand', tokens: { ignore: ['**', '!**/color/**'] } },
],
},
},
})
If the theme needs its own dark mode, add a second target one rung up and
restate only the tokens that move. stockSelectors.themeDark names all three
shapes for you, so the theme is found whether it sits on the same element as
the colour mode, inside it, or around it.
'theme/brand/dark/application': {
include: [{ src: 'config' }, { src: 'application' }],
selector: stockSelectors.themeDark('brand'),
files: [{ src: 'theme/brand/dark' }],
}
Then list the generated files in your css array, in any order. See
Theming for what a consumer does with them.
Component targets
Mirror ships no tokens/component/* of its own, since it has no components to
tokenise, but the mechanism is there for repositories that do. Generate targets
that follow a pattern with Object.fromEntries.
import { defineMirrorConfig, type TokenTarget } from '@maas/mirror'
const components = ['button', 'input', 'select']
const base = [{ src: 'config' }, { src: 'application' }]
export default defineMirrorConfig({
source: { type: 'local', path: './tokens' },
targets: {
...Object.fromEntries(
components.map((component): [string, TokenTarget] => [
`component/${component}`,
{
include: base,
selector: ':root, [data-color-mode="light"]',
files: [{ src: `component/${component}` }],
},
])
),
},
})
Reshaping the token tree
Nothing in the build knows what a colour is. It reads whatever JSON your token source holds, and the generator reads whatever the build produced, so the shape of the tree is yours. Three names are the exception.
Everything else is free, the primitive layer included. config is what our
source happens to name both that file and the root key inside it, and neither
of the two is fixed.
What is not free is the set of paths the shipped utility roles read, because the generator addresses them literally.
A discovery path that matches nothing is survivable: the role is skipped with a warning and its classes are absent. A literal that resolves to nothing is not, and the build stops with the path it could not find. That is the difference between reshaping colour, where every role discovers what it needs, and reshaping the focus ring, whose four values are literals.
There is one more rule the generator enforces while discovering. A family whose
value template mentions two sub-namespaces needs both of them to find the same
set of rungs, so fontSize.surface.title.* and letterSpacing.surface.title.*
have to agree; if they do not, the build stops and names the family.
When the tree moves, four things follow it.
- The
srcpaths inincludeandfiles, which are file names under your token source. - The
tokens.ignoreglobs, which are written against the token path. utilities.overrides.<role>.valuesandutilities.passthrough[].from, which are the paths above. See Extending utilities.utilities.targets, if you renamed a target the stylesheet reads.
A custom structure end to end
Say a brand keeps our typography but writes colour without a component
segment, and uses a density axis where we use a colour mode. The source is three
files.
tokens/config.json config.color.palette.*
tokens/application.json app.color.brand.bg.solid, app.color.ring.border, …
tokens/density/compact.json app.dimension.*, app.fontSize.*
Nothing of ours applies to a tree like that, so the config declares all three files itself and the selectors are whatever the brand wants to select on.
import { defineMirrorConfig } from '@maas/mirror'
export default defineMirrorConfig({
source: { type: 'local', path: './tokens' },
options: { platforms: ['css', 'js', 'ts', 'json'] },
targets: {
config: {
selector: ':root',
files: [{ src: 'config' }],
},
application: {
include: [{ src: 'config' }],
selector: ':root',
files: [{ src: 'application' }],
},
'density/compact/application': {
include: [{ src: 'config' }, { src: 'application' }],
selector: '[data-density="compact"][data-density]',
files: [{ src: 'density/compact' }],
},
},
})
That writes .maas/tokens/css/application.css and
.maas/tokens/css/density/compact/application.css, and the attribute is
data-density because that is what the selector says. Nothing else in Mirror
knows the name.
Then the utilities half, in the same file. The colour roles are pointed one segment shallower, and the focus ring, whose values are literals, is dropped and rewritten rather than narrowed.
utilities: {
overrides: {
bg: { values: { value: 'color.*.bg.**' } },
fg: { values: { value: 'color.*.fg.**' } },
border: { values: { value: 'color.*.border.*' } },
focus: false,
},
emitters: [
{
role: 'ring',
values: {},
families: [
{
prefix: 'focus-ring',
static: true,
properties: {
'border-color': '@color.ring.border',
'border-width': '@dimension.border',
'outline-color': '@color.ring.outline',
'outline-width': '@dimension.outline.focus',
'outline-style': 'solid',
},
},
],
},
],
}
The class names come out unchanged. A rung is built from the segments the
wildcards captured rather than from the path they sit at, so
app.color.brand.bg.solid.hover is bg-brand-solid-hover here exactly as
app.color.component.primary.bg.solid.hover is bg-primary-solid-hover in
ours. The typography roles keep working because the brand kept
fontSize.surface.*; a tree that renames those groups loses type-surface-*
with a warning on the console, and the answer is the same as for the focus ring,
meaning drop the roles and append your own.
One thing is left over. The density target defines sizes the base one does not,
so name it under utilities.targets and the generator folds both trees into
one. The next section covers that.
utilities: {
targets: ['application', 'density/compact/application'],
}
Reading a scoped build into the stylesheet
The utility generator discovers against one compiled target, application. If a
scale lives in a scoped build instead, such as a size, density or breakpoint
target, name the extra targets and the generator folds them into one tree.
utilities: {
targets: ['application', 'size/mid/application'],
}
The names are the keys of targets, since the compiled files are spelled the
same way in every platform. The first target wins every key it defines, and a
later one only contributes keys the earlier ones lack. A scoped build restates
the same variable names under a different selector, so the only useful thing
it can add is a key the base target does not have at all.
utilities.variables is the other key the generator takes from this file. Left
alone it reads the variables tree, whose leaves are var(--app-*) strings, so
the emitted utilities point at custom properties. See the
CLI reference for what turning it off costs.
What the CLI fixes
Some of the layout is not the config’s to decide.
Token sources are read as JSON, and the component and default segments are
dropped from every generated name in every platform.
Further reading
- Extending utilities: the other half of the
file, under
utilities. - CLI: the commands that read all of this.