Ramonda

Names the stylesheet sees

A block is one element's rule. Anything that names something for the whole stylesheet is not that: inside a block it would nest inside the element's class rule, as .r-…{@keyframes slide{…}}, which no browser resolves. So it is reported:

@media (min-width: 40rem) { … }     ✓  a condition on this element's rule
@supports (display: grid) { … }     ✓
@container (min-width: 20rem) { … } ✓
@keyframes slide { … }              ✗  reported
@font-face { … }                    ✗  reported
@property --brand { … }             ✗  reported

The last three have somewhere else to go, and a custom property has two: a token your project declares, or one @@property of its own.

A token your project declares

Declare it in ramonda.css.ts and read it the way it is declared — a group's name starts with $:

// ramonda.css.ts
import { kind } from "@ramonda/css/config";

export default {
  tokens: {
    $color: kind("color", { accent: "#10b981", surface: "#ffffff" }),
    $space: kind("length", { gutter: "16px" }),
  },
};
const card = @@(
  background: $color.surface;
  border-left: 4px solid $color.accent;
  padding: $space.gutter;
);

Every group is made with kind( … ), and the kind is what checks each value in it — a colour group takes colours. A group written as a plain object is refused, by the config's type and when the config loads, rather than declaring nothing.

$color.accent compiles to var(--color-accent). The name is the path, so the stylesheet is readable, and the path is the only spelling — there is no string to get wrong.

Hover a token to see what it is — in a block or in code. The editor shows its kind, the custom property it is written as (var(--color-accent), the name your browser's style panel shows), what it starts as, and whether it may change: fixed, or the range it may take.

The group is $color everywhere: in the config, in a block, and in the import { $color } code uses. A group written without its $ — color: kind(…) — is refused by the config's type and when the config loads. The $ is not part of the CSS name: --color-accent, not --$color-accent.

$ and a name is only ever a token. A group the project does not have — $props.size — is reported with the groups it does have, because the usual cause is reaching for a value from code, which is written $( … ).

Where a group needs importing, and where it does not

A block is CSS, not TypeScript, and the compiler puts every group in scope while it checks one. So anywhere in a block you write a token bare, with no import anywhere in the file — in a value, in a match arm, in either branch of a choice:

declare const dark: boolean;

const card = @@(
  color: $(dark) ? $color.accent : $color.surface;
);

Inside $( … ) it is ordinary TypeScript, because that is what the escape holds. TypeScript resolves a name there the way it resolves every other name — and so it does in the rest of your file: a lookup table of values, an argument you pass around, a toStyle call. There, import the group by the name it has in a block:

import { read } from "@ramonda/css";
import { $color } from "../css-system";

declare const dark: boolean;

const tone = dark ? $color.accent : $color.surface;
const now = read(tone, document.body);

The rule is one line: inside the CSS, no import; in code, import the group. $color.accent is the same text in both places. Setting a token from code with toStyle takes one more thing — a range saying what it may become.

css-system/ is written by the compiler, and it is where the groups come from. Run npx ramonda-css codegen once, or let the build plugin do it; either way the folder holds index.ts (one export per group — $color, $space — and this project's types) and tokens.css (the values). Import the stylesheet once, wherever your app's CSS goes:

import "../css-system/tokens.css";

Commit that folder. It is generated, and it is also what your editor reads to check a block, so a fresh clone that has not built anything yet still gets the checking.

What kind buys, and it is not only spelling

The kind is what the checker knows the token IS, and it works in two directions.

A token of the wrong kind is refused where it is used, before anything runs:

const wrong = @@(
  padding-left: $color.accent;
);

Type Token<"color", Fixed<"#10b981">> is not assignable to type Narrowed<never, CssDimension<…> | Token<"length" | "percentage" | "length-percentage">>

And the browser holds the same line. codegen writes an @property registration for every token, so the kind is declared to the engine too:

@property --color-accent {
  syntax: "<color>";
  inherits: true;
  initial-value: #10b981;
}

A registered custom property set to something that is not of its syntax falls back to initial-value instead of poisoning the declaration that reads it. An unregistered --size set to not-a-length and read by height lays the element out at 0px, silently. Registered, the same value is ignored, height gets the 16px the registration declares, and the page keeps working.

A range, when the value is meant to move

A token declared with one value says it never changes, and the checker holds you to that. When a theme moves it at run time, say what it may become:

export default {
  tokens: {
    $color: kind("color", {
      accent: { value: "#10b981", range: "any" },
    }),
    $space: kind("length", {
      gutter: { value: "16px", range: ["8px", "16px", "24px"] },
    }),
  },
};

value is the initial value — what the registration carries and what the browser falls back to. range is what the TYPE permits: "any" for a value only the run time knows, or the closed list when there are three sizes and no fourth.

One custom property without a config

A @@property block declares a custom property on its own, and it is a binding like any other:

export const angle = @@property(
  syntax: "<angle>";
  inherits: false;
  initial-value: 0deg;
);

const turn = @@keyframes(
  from { $(angle): 0deg; }
  to { $(angle): 180deg; }
);

const dial = @@(
  transform: rotate(var($(angle)));
  animation: $(turn) 1.2s linear infinite;
);

Reach for this when the registration is the point. <angle> above is the reason rotate() can be animated at all — an unregistered custom property is a string to the engine and does not interpolate — and inherits: false is a choice $ does not offer, because a design token that does not inherit is not a design token.

It is also the shorter road when there is one custom property, local to one file, and no config yet.

A misspelling is not a CSS problem here, it is an unresolved name: var($(ackcent)) is Cannot find name 'ackcent'. Did you mean 'accent'?, from TypeScript, with the suggestion it already knows how to make.

A font, and an animation

const brand = @@font-face(
  font-family: "Brand";
  src: url("/brand.woff2") format("woff2");
  font-display: swap;
);

const slide = @@keyframes(
  from { opacity: 0; transform: translateY(4px); }
  to { opacity: 1; transform: none; }
);

const panel = @@(
  font-family: $(brand), sans-serif;
  animation: $(slide) 240ms ease-out;
);

The rule goes to the stylesheet and the site becomes its name. The name is a hash, because a whole @keyframes has no short spelling the way one declaration does — so the same animation written in two files is one rule.

slide is then an ordinary binding, and that is what makes the reference checkable: a typo is an unresolved identifier. Written in a stylesheet instead, the name would be a string on both sides and animation: slidein would be one typo away from silence.

A reference resolves when the file compiles, not on the element, so $(slide) costs no custom property.

$(brand) is the family the face declares — "Brand", exactly as written — so the family is a reference rather than a string to repeat: a typo is an error, and renaming the font is one edit. The rule itself keeps a hashed identity, so two faces of one family, a regular and a bold, stay two rules.

@@property names a custom property, so what it compiles to is --r-… with the dashes: that is the one name $( … ) may stand in where a property name goes, which is how the frames in the previous example set it.

A name nothing sets

A plain var(--name) is checked against every name the whole build sets — not only the block it is written in, so a parent setting what a child reads needs no ceremony:

const table = @@( --row-height: 32px; );
const row = @@( height: var(--row-height); );

A name nothing sets is reported, with the four things that would make it exist:

nothing in this build sets --brnad. Did you mean --brand? Set it in a block, register it with @@property, add it to externalCustomProperties in ramonda.css.ts if it comes from a stylesheet this does not compile, or give it a fallback — var(--brnad, <value>) — which says it may be absent.

A fallback is the answer most of the time, and it is CSS you would write anyway.

Theming

A theme is custom properties, and a block reads them. Nothing here is a theme system, which is the same position styling takes: switching a theme is one attribute on <html> — no render, no JavaScript per element, and the block does not know a theme exists.

Declare the tokens with a range that admits what the theme sets, and override them in an ordinary stylesheet:

[data-theme="dark"] { --color-accent: #34d399; --color-surface: #0b0b0b; }
const card = @@(
  background: $color.surface;
  border-left: 4px solid $color.accent;
);

The values on :root come from tokens.css, so the override is the only CSS you write.

A token with no range cannot be overridden. It was declared as one value, so setting it anywhere is refused — in a block, in a stylesheet the app loads (by the Vite or esbuild plugin, at the file and line), and in a style attribute (in the editor and ramonda-check). The message says to give it a range. A value outside a range is refused the same way.

light-dark() resolves where the token is set

A token is registered with @property, which gives it a type and a computed value — and that is what makes light-dark() behave differently here than in a hand-written stylesheet. The pair is resolved on the element that sets the token, and every descendant inherits the answer. A color-scheme further down does not change it:

:root  { color-scheme: light dark; --color-surface: light-dark(#ffffff, #0b0b0b); }
.panel { color-scheme: dark; }   /* the surface inside this is still the light one */

So a region that forces a scheme sets the values it wants, rather than switching the scheme and expecting the pair to follow:

[data-scheme="dark"] { --color-surface: #0b0b0b; }

That is also the form that works for every kind. light-dark() is colour only — a length written that way is dropped, and the token keeps its initial-value.

A value on the element is not a theme

A registered property set from style is the right answer for what varies per instance — a value this element has and the one beside it does not. A theme is the opposite of that, and the difference is who sets it.

A value set per element travels in the markup, once on every element that carries it — on a long server-rendered list, once per row. On :root it is written once, and every element inherits it.

And a theme switch done per element is a render: every element carrying the value has to render again to change it. A var() reading :root changes when the attribute on <html> changes, which is not a render at all.

:root does not go inside a block

const card = @@(
  :root { --accent: red; }
);

A block is one element's rule, so everything in it is nested inside that rule — and :root there means .r-… :root, the root under the element. :root is the <html> element, which is nobody's descendant, so the rule would apply nowhere; it is reported instead. A theme's own declarations belong in a stylesheet, and this project's own belong in ramonda.css.ts.

The other way round is fine: :root.dark & { … } is the element under the root, which is how a theme reaches a block.

Next

  • The config file — everything else ramonda.css.ts holds, including what a project can forbid.
  • Composing — reusing a block, conditions, and which declaration wins.