The config file
ramonda.css.ts sits at the root of a project and does two jobs. It declares what the project
owns — the tokens $ reads — and it narrows what a block may
say. Both reach every consumer: your editor, ramonda-css, and the build.
It is TypeScript rather than JSON because a setting may depend on the environment, and because you get completion for the property names.
// ramonda.css.ts
import { kind } from "@ramonda/css/config";
export default {
tokens: {
$color: kind("color", { accent: "#10b981" }),
},
properties: {
"<color>": { hardcoded: false },
},
};
A project needs no config at all. Without one, every block is checked against CSS itself and nothing more.
What happens when it changes
Save it and the running dev server picks it up: css-system/ is written again and every file
holding a block is checked against the new rules on its next request. In CI,
ramonda-css codegen --check fails if what is committed no longer matches the config beside it.
A config that does not parse is refused, rather than loaded as an empty one. That matters more than it sounds: TypeScript recovers from a syntax error and hands back what it could build, so a missing brace would otherwise mean every rule in the file silently switching off and the build shipping without them.
Narrowing: three selectors, in order
properties is keyed three ways, and each binds more tightly than the one before:
export default {
properties: {
"*": { shorthand: false }, // every property
"<length>": { hardcoded: false }, // every property that takes a length
"padding-left": { units: ["px"] }, // this one
},
};
A <kind> is CSS's own classification — <color>, <length>, <time>, <angle> and the rest.
Naming a property directly wins over naming its kind, which wins over the sweep, so an exemption is
written where the exception is:
export default {
properties: {
"<length>": { hardcoded: false },
"border-radius": { hardcoded: true }, // except here
},
};
Ask the tool rather than working it out. ramonda-css explain reads the same answer the checker
uses, and says which selector decided each part:
$ npx ramonda-css explain padding-left
padding-left a length or a percentage
shorthand false "*"
hardcoded false "<length>"
units px "padding-left"
from ramonda.css.ts
What a project can narrow
units — which units may be written
At the top level it is keyed by family, and a family you do not name is not constrained:
units: { length: ["px", "rem"] },
That permits 16px and 1.5rem, refuses 2em, and says nothing about 200ms or 45deg — those
are the time and angle families, and this config did not mention them. The families are length,
percentage, angle, time, frequency, resolution and flex; an empty list bans a family
outright.
Per property it is a plain list, because one property has one set of units and no family to separate:
properties: { "padding-left": { units: ["px"] } },
hardcoded: false — no values written out
properties: { "<color>": { hardcoded: false } },
color: #ff0000 is then refused and color: $color.accent is not — a colour has to come from a
token. It is the setting that turns a palette from a recommendation into something the build
enforces. hardcoded: true on one property exempts it again.
Each value is judged as its own kind. width takes a length or a percentage, and
"<length>": { hardcoded: false } holds both; set percentages free beside it and width: 100% is
written while width: 12px still has to be a token:
properties: {
"<length>": { hardcoded: false },
"<percentage>": { hardcoded: true },
},
When a token already holds the value written out, the report names it — 12px under a $space.m
of 12px says to write $space.m.
Three things it deliberately lets through: a bare 0, which needs no unit and is nobody's hardcoded
brand colour (0px is refused); var(), which is the escape CSS itself provides; and a word that
names another value rather than being one — currentcolor, and CSS's own inherit, initial,
unset and revert. transparent is a colour, and is refused like any other.
values — a closed list
properties: { "z-index": { values: [0, 1, 10] } },
Anything else is refused, in the editor and in the build. It belongs to one property or one kind;
on "*" it is refused, because a list of permitted values for every property is not a thing anybody
means.
shorthand — switching a shorthand off
properties: {
"*": { shorthand: false },
"padding": { shorthand: true },
}
Writing a shorthand that is off is reported as shorthand-not-allowed, and the message lists the
longhands to write instead. It is also removed from the property map, so your editor no longer
completes it.
arity — how many values go in
properties: { "padding": { arity: 2 } },
padding: 8px 12px goes in and padding: 8px 12px 16px 20px does not.
The other keys
externalCustomProperties — names from a stylesheet this does not compile
A var(--name) is checked against every name the build sets. When the name comes from a stylesheet
the compiler never sees — a design system you install, a theme file — list it:
externalCustomProperties: ["--brand-hue", "--brand-chroma"],
externalContainerNames — container names from a stylesheet this does not compile
@container sidebar (min-width: 40rem) is checked against every container name the build gives —
container: sidebar / inline-size in any block, or container-name and container-type side by side.
A name always comes with its type, because a name alone never answers a size condition. A name no
element is given never matches either, so it is reported. When an outside stylesheet gives the name,
list it:
externalContainerNames: ["sidebar"],
unknownCustomProperties — no names made up on the spot
unknownCustomProperties: false,
--brand: red; and var(--brand) are then refused in a block and in a style attribute — any name
that is not a token and not in externalCustomProperties. A name invented in one place and read in
another works by agreement, and a typo breaks the agreement in silence. With this off, every custom
property comes through a door that is checked:
- a design value is a token —
$color.accent; - a value from code is a
@@property( … ), read asvar($(name)); - a name an outside stylesheet sets goes in
externalCustomProperties.
"same-block" allows one more: a local. A name one block both sets and reads is fine there —
--gap: 4px; padding: var(--gap); — and nowhere else: a read the block does not set is refused (the
usual typo), and so is a setting the block does not read, since that could only be for another
element. A style attribute counts as one block. A value shared between elements is a token or a
@@property.
unknownCustomProperties: "same-block",
Left out, any name is allowed, as in CSS. Stylesheets of your own are not checked for it.
styleOtherElements — a block styles its own element
styleOtherElements: false,
A parent reaching into a child — .title { … }, & > img — or into a sibling — & + .card — makes
two elements that are composed apart depend on each other, and neither file says so. With this off,
a selector is refused when its subject, the part after the last space, >, + or ~, is not &.
So &:hover, &::before, &.active, &:has(> img) and [data-theme="dark"] & stay: each of them
styles this element. Give the child a block of its own, or pass one to it as a prop.
Markup nobody made a component of — from Markdown, from a CMS — has no element to give a block, so it is styled from its container, with the reason written above:
const prose = @@(
/* ramonda-css-ignore the markup comes from Markdown, not from components */
& p { margin: 0 0 1em; }
);
outDir — where css-system/ goes
outDir: "src/css-system",
For a project that already has a folder by that name. It must stay inside the project, and it must be a plain string: your editor reads this key out of the file's text rather than running it, so a computed one is refused rather than quietly disagreeing with what the build wrote.
rules — turning a report off
rules: { "unknown-unit": "off" },
Every rule is an error by default. A rule this config turned ON itself cannot be switched off here —
units: { length: ["px"] } and then "unit-not-allowed": "off" is refused, because the same
constraint also reaches the types and no rule severity can reach those. Change the setting instead.
When a setting depends on the environment
The default export may be a function:
export default (env) => ({
units: { length: env.production ? ["px"] : ["px", "rem"] },
});
env.production is what the bundler says it is: Vite passes its mode, esbuild reads define, then
minify, then NODE_ENV. It cannot be async — your editor asks for the config synchronously, so
there is nowhere to await it. Read what you need at the top level instead.
Next
- Project settings — the names a block emits, and who they are for.
- What is checked — every rule, and what each one says.