---
language: "en"
title: "Tasty Style Rules for AI Agents"
canonical: "https://tasty.style/docs/ai-agents"
source: "docs/ai-agents.md"
index: true
---

# Tasty Style Rules for AI Agents

A compact ruleset for **writing correct `@tenphi/tasty` styles**. It is not an API tour — most rules here are machine-checked by [`@tenphi/eslint-plugin-tasty`](https://www.npmjs.com/package/@tenphi/eslint-plugin-tasty), so following it generally means clean lint output. The plugin ships separately and can lag a new DSL rule, so treat this page as the source of truth and the plugin as the enforcement that catches most of it. For explanations and the complete API, see [Style DSL](/docs/dsl), [Style Properties](/docs/styles), [React API](/docs/react-api).

Notation: ❌ wrong → ✅ correct.

***

## 0. Read the project config first

Color tokens (`#name`), custom properties (`$name`), `preset` names, `recipe` names, state aliases (`@mobile`) and extra units are **project-defined** in `tasty.config.ts` or a `configure({ … })` call. Look them up before writing styles. Never invent a name — reuse an existing one, or add it to the config.

Only Tasty's built-ins exist without config: `#white`, `#black`, `#border`, `#outline`, `#current`, `#clear`, and `$gap`, `$radius`, `$border-width`, `$outline-width`, `$transition`, `$sharp-radius`, `$bold-font-weight`, `$icon-size` (only where a `preset` applies), `$font-sans-fallback`, `$font-mono-fallback`.

## 1. Where styles go

```jsx
const Card = tasty({ as: 'div', styles: { … }, styleProps: ['padding'] }); // new component
const Hero = tasty(Card, { styles: { … } });                              // extend a component
const cls = useStyles({ … });                                             // ad-hoc class name
tastyStatic('.card', { … });                                              // build-time extraction
```

* Wrap, don't pass styles per instance: ❌ `<Box styles={{ padding: '2x' }} />` → ✅ `const Box = tasty({ styles: { padding: '2x' } })`.
* Style values are **static**. Route anything conditional through a state map plus `mods` (or `tokens` / `styleProps`):
  ❌ `fill: isOpen ? '#primary' : '#surface'` → ✅ `fill: { '': '#surface', open: '#primary' }` with `mods={{ open: isOpen }}`.
* Keep JavaScript identifiers, calls, computed keys, and interpolated templates
  out of style values. Use token references inside strings for values supplied
  at runtime. Spell out style properties rather than spreading objects; if a
  runtime spread is unavoidable, add a
  `// eslint-disable-next-line tasty/no-style-spread -- reason` comment directly
  above it. `tastyStatic()` cannot use spreads.

## 2. Property names

Keys are camelCase — a Tasty property or a real CSS property. No kebab-case, no invented names.

Prefer the Tasty shorthand over its CSS equivalents:

| Use                                      | Instead of                                                                            |
| ---------------------------------------- | ------------------------------------------------------------------------------------- |
| `fill`                                   | `backgroundColor`, `background`                                                       |
| `image`                                  | `backgroundImage`                                                                     |
| `border`                                 | `borderColor`, `borderWidth`, `borderStyle`, `borderTop`/`Right`/`Bottom`/`Left`      |
| `radius`                                 | `borderRadius`                                                                        |
| `outline`                                | `outlineColor`, `outlineWidth`, `outlineStyle`, `outlineOffset`                       |
| `shadow`                                 | `boxShadow`                                                                           |
| `padding`                                | `paddingTop`/`Right`/`Bottom`/`Left`                                                  |
| `margin`                                 | `marginTop`/`Right`/`Bottom`/`Left`                                                   |
| `inset`                                  | `top`, `right`, `bottom`, `left`                                                      |
| `width` / `height`                       | `minWidth`/`maxWidth`, `minHeight`/`maxHeight`                                        |
| `flow`                                   | `flexDirection`, `flexWrap`, `flexFlow`, `gridAutoFlow`                               |
| `preset`                                 | `fontSize`, `lineHeight`, `letterSpacing`, `fontWeight`, `fontStyle`, `textTransform` |
| `font`                                   | `fontFamily`                                                                          |
| `scrollbar`                              | `scrollbarWidth`, `scrollbarColor`, `scrollbarGutter`                                 |
| `gridColumns` / `gridRows` / `gridAreas` | `gridTemplateColumns` / `Rows` / `Areas`                                              |
| `textOverflow: 'ellipsis / 3'`           | `lineClamp`                                                                           |
| `hide: true`                             | `display: 'none'`                                                                     |
| `flexGrow` / `flexShrink` / `flexBasis`  | `flex`                                                                                |

Use logical category styles when layout must follow writing mode or direction: `blockSize`/`inlineSize`, `blockPadding`/`inlinePadding`, `blockMargin`/`inlineMargin`, `blockInset`/`inlineInset`, `blockScrollMargin`/`inlineScrollMargin`, `blockScrollPadding`/`inlineScrollPadding`, and `blockBorder`/`inlineBorder`. Use `start`/`end` value modifiers; do not replace them with physical sides. Native logical CSS properties remain valid as ordinary keys when an individual declaration is needed.

The last row goes the other way — longhands over the shorthand — because `flex` is lossy: it resets the components you omit to non-initial values (`flex: '0'` silently sets `flex-basis: 0%`) and cannot express `flexShrink: 0` at all. The longhands also carry separate state maps.

## 3. Values

### Tokens

| Syntax                | Meaning                                                                                            |
| --------------------- | -------------------------------------------------------------------------------------------------- |
| `#name`               | color token → `var(--name-color)`                                                                  |
| `#name.50`            | token at 50% opacity — digits are a decimal fraction: `.5` = `.50` = 50%, `.05` = 5%, `.100` = 10% |
| `#name.$opacity`      | opacity from a custom property                                                                     |
| `#clear` / `#current` | `transparent` / `currentcolor`                                                                     |
| `#a+#b.04`            | `#b` at 4% opacity over `#a`, as one color; layers stack left to right (`#a+#b.1+#c.04`)           |
| `$name`               | custom property → `var(--name)`                                                                    |
| `(#a, #b)`            | fallback chain                                                                                     |
| `$$name` / `##name`   | the custom property *name* (not its value) — inside `transition`                                   |
| `$$name(…)`           | call a CSS `@function` declared via `@function`, `useFunction`, or config                          |

* ❌ `#f5f5f5`, `rgb(0 0 0)`, `oklch(…)`, `okhsl(…)`, `okhst(…)`, `red` → ✅ `#surface` (add the token to the config if it doesn't exist).
* CSS system colors (`Canvas`, `CanvasText`, `Highlight`, `ButtonText`, `GrayText`, …) are the exception, for `forced-colors` styles: hold one in a token (`'#forced-border': 'CanvasText'`) or write it under a `@media(forced-colors: active)` state.
* ❌ `var(--gap)` → ✅ `$gap` · ❌ `$accent-color` → ✅ `#accent` · ❌ `transparent` → ✅ `#clear` · ❌ `currentColor` → ✅ `#current`.
* `#clear` is a default replace token for `transparent`, so `#clear.5` and `#clear.$opacity` stay transparent. It does not read `--clear-color`; override it through `replaceTokens` if needed.
* A `$name` must be declared as a `'$name': value` key in the same styles object, or in the config.
* Names are case-sensitive and start lowercase — `$myVar` → `--myVar`; a leading capital folds (`$Foo` → `--foo`). Prefer kebab-case.
* A `$name` reference is untyped: it fills the first free slot in a shorthand (`border: '1bw $my-style'` → the line style). Write colors as `#name`; `$name-color` is a fallback for pointing at a raw CSS custom property that holds a color.
* `preset` and `transition` take token *names*, not values, so a `$name` cannot stand in for one (it warns in dev and is ignored).

### Units

Tasty units and their defaults: `x` 8px spacing step, `r` 6px radius, `cr` 10px card radius, `bw` 1px border width, `ow` 3px outline width, `sf` → `minmax(0, Nfr)`. They compile to literal values; change them through `units` in the config, not with `$gap`/`$radius` tokens. All CSS units work too, plus anything in `units` in the config. Any other unit is an error. Inside `@media(…)` / `@(…)` conditions units resolve to their literal length (`@media(w < 40x)` → `width < 320px`), unless the project points that unit at a custom property; then write query lengths in `px`, `em`, or `rem`.

Prefer units over raw pixels: `8px` → `1x` … `64px` → `8x`; `radius: '6px'` → `'1r'`; `border: '1px …'` → `'1bw …'`.

### Math

❌ `calc(100% - 2x)` → ✅ `(100% - 2x)`. Parentheses are wrapped in `calc()` automatically — and must be balanced.

### `true`

`true` means "the design-system default" and is accepted **only** by: `border`, `radius`, `outline`, `shadow`, `padding`, `margin`, `gap`, `inset`, `width`, `height`, `color`, `fade`, `preset`, `font`, `scrollbar`, `scrollMargin`, `scrollPadding`, and the enhanced logical category styles listed above, plus `hide`. Anywhere else it is an error.

### `!important`

Never use it. Tasty owns specificity through doubled selectors and state ordering; `!important` breaks that. Express the exception as a state instead.

### Modifiers

A value is `[values…] [modifiers…]`, and several groups can be comma-separated (later groups override earlier ones). A group that names **direction** modifiers takes a **single** value, applied to every direction it names — per-side values come from comma groups. A group naming no direction keeps plain CSS shorthand order (1–4 values). (`inset` + `dock` is the one exception: a second value insets the spanned sides.) Only the modifiers a property knows are valid:

| Property                                     | Modifiers                                                                                                                                          |
| -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| `padding`, `margin`, `fade`                  | `top` `right` `bottom` `left`                                                                                                                      |
| `inset`                                      | the four directions + `dock`                                                                                                                       |
| `border`                                     | the four directions + `solid` `dashed` `dotted` `double` `groove` `ridge` `inset` `outset` `none` `hidden`                                         |
| logical axis spacing/inset styles            | `start` `end`                                                                                                                                      |
| `blockBorder`, `inlineBorder`                | `start` `end` + the border style keywords above                                                                                                    |
| `outline`                                    | the style keywords above                                                                                                                           |
| `radius`                                     | `top` `right` `bottom` `left`, corners `top-left` `top-right` `bottom-right` `bottom-left`, `inherit` + shapes `round` `ellipse` `leaf` `backleaf` |
| `width`, `height`, `blockSize`, `inlineSize` | `min` `max` `fixed`                                                                                                                                |
| `flow`                                       | `row` `column` `row-reverse` `column-reverse` `wrap` `nowrap` `dense`                                                                              |
| `overflow`                                   | `visible` `hidden` `scroll` `clip` `auto` `overlay`                                                                                                |
| `position`                                   | `static` `relative` `absolute` `fixed` `sticky`                                                                                                    |
| `shadow`                                     | `inset`                                                                                                                                            |
| `preset`                                     | `name / strong` (or `bold`) `italic` `icon` `tight` `normal`                                                                                       |
| `scrollbar`                                  | `thin` `auto` `none` `stable` `both-edges` `always`                                                                                                |
| box properties                               | `longhand` — emit CSS longhands instead of the shorthand                                                                                           |

Directional modifiers beat placeholder zeros:

* ❌ `padding: '0 0 2x 0'` → ✅ `padding: '2x bottom'`
* ❌ `padding: '1x 1x 2x 1x'` → ✅ `padding: '1x, 2x bottom'`
* ❌ `border: '0 0 1bw 0'` — four tokens parse as *one* border value, so this renders no border at all → ✅ `border: '1bw bottom'`
* ❌ `padding: '2x 4x top right'` — a directional group takes one value, so `4x` is dropped → ✅ `padding: '2x top, 4x right'`
* ❌ `fade: '3x 1x top bottom'` → ✅ `fade: '3x top, 1x bottom'`

Value-only properties reject both colors and modifiers: `gap`, `columnGap`, `rowGap`, `opacity`, `zIndex`, `order`, `flexGrow`, `flexShrink`, `flexBasis`, `aspectRatio`, `lineClamp`, and `tabSize`. Logical axis category styles accept one or two values in start/end order and the `start`, `end`, and `longhand` modifiers. `fill`, `color`, `caretColor`, and `accentColor` take a color only (use `#clear` for transparent). An overlay is a compound color, in any color slot: ❌ `fill: '#surface #dark.04'` (deprecated overlay layer) · ❌ `fill: '#dark.04, #surface'` (half-transparent mix) → ✅ `fill: '#surface+#dark.04'`. Write it as one word, with no spaces around `+`. Every layer after the first needs an opacity suffix, and its token must be opaque: a translucent layer token, or one that is `#clear` in some state, leaves the result see-through, so vary the suffix (`#surface+#dark.$hover`) or the whole compound per state instead. A comma mix in `fill` is deprecated too: ❌ `fill: '#dark 4%, #surface'` → ✅ `fill: '#surface+#dark.04'`. For any other mix, write `color-mix()` itself: ✅ `fill: 'color-mix(in srgb, #dark $weight, #surface)'`.

### `transition`

Use semantic names, not CSS property names: `fade` `fill` `color` `theme` `border` `radius` `shadow` `outline` `preset` `text` `gap` `opacity` `translate` `rotate` `scale` `filter` `image` `background` `width` `height` `zIndex` `inset`.

❌ `transition: 'background-color 0.2s'` → ✅ `transition: 'fill'`

Omit the duration to use `$fill-transition` with `$transition` as fallback, or
use a duration token explicitly (`transition: 'fill $transition'`). Avoid raw
time values in duration slots. An explicit zero can be useful to disable motion.

Use `transition` when defining a complete transition, and the native CSS
`animation` shorthand when defining a complete animation. Tasty accepts
`animation` directly and tracks local keyframes referenced by it; no special
animation handler is needed. Use duration tokens in either form. Keep
`transitionDuration`, `animationDuration`, and other motion longhands when a
state or extension changes only one part of an existing effect. A shorthand
resets omitted parts, including an animation's timeline and range.

## 4. State maps

A property value can be an object of `state: value`. Key order is priority — later keys win.

| Key                      | Generated selector                              |
| ------------------------ | ----------------------------------------------- |
| `hovered`                | `[data-hovered]` (boolean modifier from `mods`) |
| `theme=danger`           | `[data-theme="danger"]` (value modifier)        |
| `:hover`                 | pseudo-class                                    |
| `.active`                | class selector                                  |
| `[aria-expanded="true"]` | attribute selector                              |
| `hovered & .active`      | AND                                             |
| `hovered \| focused`     | OR (`,` also means OR)                          |
| `!disabled`              | NOT                                             |
| `hovered ^ focused`      | XOR — exactly one (keep chains ≤ 4 operands)    |

Precedence `!` > `^` > `|` > `&`; use parentheses to override.

Rules:

1. **`''` comes first.** The bare default is the lowest-priority state. Written later, it triggers a `MISPLACED_DEFAULT_STATE` warning and Tasty moves it to the front.
2. **Every state map needs `''` or `_`** — except when extending (`tasty(Parent, …)`), where omitting `''` merges into the parent's states and including `''` replaces them wholesale.
3. **`_` is standalone-only** and always first (with `''` right after it, if present). `_` is a never-negated fallback floor for cases where a higher-priority branch may be *unknown* (`@supports`, container queries). If a map contains only `_` and `''`, drop the `''`.
4. **No nested maps:** ❌ `{ hovered: { pressed: 'x' } }` → ✅ `{ 'hovered & pressed': 'x' }`
5. **State keys never sit at the top level** of a styles object — `:hover`, `.active`, `[open]` belong inside a property value.
6. **Write keys Tasty can fully read.** Names start with a letter (a single letter is fine: `x` → `[data-x]`); join states with an operator (`hovered & focused`, not `hovered focused`); no space between `@media` and `(`. Tasty warns (`INVALID_STATE_KEY`) and skips what it cannot read.
7. **Prefer automatic native states:** use `disabled`, `checked`, and `hidden` in Tasty component styles instead of `[disabled]`, `[checked]`, and `[hidden]`. Tasty sets `data-disabled`, `data-checked`, and `data-hidden` from the native props automatically.

```jsx
color: { '': '#text', hovered: '#accent', disabled: '#text.40' }
```

Advanced states:

| Prefix         | Use                                                                              | Example                                             |
| -------------- | -------------------------------------------------------------------------------- | --------------------------------------------------- |
| `@media(…)`    | media query; dimensions `w` `h`; types `@media:print` `:screen` `:all` `:speech` | `@media(w < 768px)`, `@media(600px <= w < 1200px)`  |
| `@(…)`         | container query; dimensions `w` `h` `is` `bs`                                    | `@(layout, w >= 600px)`, `@($variant=primary)`      |
| `@supports(…)` | feature query; `$` first argument tests a selector                               | `@supports(display: grid)`, `@supports($, :has(*))` |
| `@root(…)`     | condition on `:root`                                                             | `@root(schema=dark)`                                |
| `@parent(…)`   | condition on an ancestor; `, >` for the direct parent                            | `@parent(hovered, >)`                               |
| `@own(…)`      | a sub-element's own state — **only inside sub-element styles**                   | `@own(:hover)`                                      |
| `@starting`    | `@starting-style` entry animation                                                | `@starting`                                         |
| `@name`        | project state alias                                                              | `@mobile`                                           |

* At root level write the selector directly: ❌ `'@own(:hover)'` → ✅ `':hover'`.
* `@name` aliases must exist in `states` in the config or be declared locally as an `'@name': '<state expression>'` key; alias keys start with `@` and their value must be a valid state expression.
* `:is()` / `:has()` / `:not()` / `:where()` work in state keys but support at most 2 levels of nested parentheses, and `:has()` is expensive — prefer `@parent()`, `@own()` and modifiers.

When extending a parent's state map: `'@inherit'` reuses the parent's value for that state, `null` removes a state (or resets a property, letting recipes fill in), `false` is a tombstone that blocks it entirely.

## 5. Sub-elements

A **capitalized** key targets `[data-element="Name"]`, and its value must be a style object.

```jsx
styles: { Title: { preset: 'h3' }, Icon: { $: '>', color: { '': '#text', '@own(:last-child)': '#accent' } } }
```

❌ nested-selector keys (`'& .title'`, `'&:hover'`) → ✅ sub-elements and state maps. Use the `$` affix property inside a sub-element to control how its selector attaches (`>` direct child, `@` placeholder for the element itself, `&::before` for a root pseudo-element).

When `$` ends in a pseudo-element, `@own(…)` is the pseudo-element's own state (`'@own(:focus)'` → `::part(label):where(:focus)`). A pseudo-element has no attributes, classes, or tree position, so `@own([open])`, `@own(hovered)`, or `@own(:first-child)` there is ignored with a warning. Put a condition on the element before the pseudo-element in `$`: `$: 'a[rel="next"]::before'`.

## 6. Special top-level keys

| Key              | Shape                                                                                                       |
| ---------------- | ----------------------------------------------------------------------------------------------------------- |
| `@keyframes`     | `{ name: { '0%': styles, … } }`                                                                             |
| `@property`      | `{ '$name': { syntax, inherits, initialValue } }`                                                           |
| `@font-face`     | `{ 'Family Name': descriptors \| descriptors[] }`                                                           |
| `@counter-style` | `{ name: descriptors }`                                                                                     |
| `@function`      | `{ '$$name': { args, returns?, result, '$local'? } }`                                                       |
| `recipe`         | a **string** of configured recipe names: `'card elevated'`, `'reset input / autofill'`, `'none / disabled'` |

`recipe` resolves on the component root and independently in each sub-element's styles: `styles: { Panel: { recipe: 'card elevated' } }` is supported. Configured recipe definitions must be flat and cannot contain their own `recipe` key; references inside recipe values are not recursively expanded. Compose names where the recipe is applied, not inside `configure({ recipes })`. See [Recipes](/docs/dsl#recipes).

At-rule keys match the real CSS at-rule names, so they are kebab-case, not camelCase. Inside `@function`, the callable is `$$name` but its parameters and local variables are `$name`:

```jsx
styles: {
  '@function': { $$negative: { args: ['$value'], result: '(-1 * $value)' } },
  margin: '$$negative(2x) top',
}
```

## 7. `tastyStatic()`

The selector must be a string literal and valid CSS. Values must be static — strings, numbers, booleans, `null`, or objects/arrays of those. No variables, template literals, function calls, or spreads.

## 8. Checklist

* Token, preset, recipe, unit and `@alias` names exist in the project config.
* Tasty shorthand chosen over CSS longhands; `flexGrow`/`flexShrink`/`flexBasis` over `flex`; `hide: true` over `display: 'none'`.
* Logical category styles keep native `block`/`inline` declarations and accept `start`/`end`, never physical direction modifiers.
* Colors are `#tokens`, not hex/rgb/oklch/named; `$prop` not `var(--prop)`.
* Spacing uses `x`/`r`/`bw`/`ow` units; math uses `(…)`, not `calc(…)`.
* `true` only on the properties that accept it; no `!important`.
* Modifiers valid for the property; directional shorthand instead of placeholder zeros.
* Every state map starts with `''` (or `_`), is flat, and lives inside a property value.
* `@own()` only inside sub-elements; sub-element keys are capitalized and hold objects.
* At-rule keys are kebab-case (`@property`, `@font-face`, `@counter-style`, `@function`); `@function` names use `$$`, their args and locals use `$`.
* Values are static; dynamic behavior comes from `mods` / `tokens` / `styleProps`.
