# Styling

*Open when you are about to set a colour, padding, border, font or label position — or when a form came back looking unstyled and you cannot see why.*

## `theme` and `styleIntent` are authoring props that get deleted

You style a Suppa form through two transient props, not the real ones:

- top-level `theme`, a sibling of `schema`/`settings` and **not** inside `settings` (the settings allow-list drops unknown keys): `{ accentColor, tintStrength: "subtle"|"normal", radius: "sm"|"md"|"lg"|"pill", density: "compact"|"normal"|"airy", shadow: "none"|"sm"|"md" }`. Defaults `#3381E5`, `normal`, `lg` (12px), `normal` (16px padding), `sm`.
- per-element `styleIntent`, a comma-separated string: `card`, `sectionHeader`, `pageTitle`, `navyHeader`, `pill`, `primaryButton`, `secondaryButton`, `muted`, `emphasis`, `detailRow`, `badge:success|warning|danger|info|neutral`, `tile:orange|green|purple|pink|yellow|blue`. Unknown tokens are dropped with a warning, never an error.

`applyTheme` expands both into real `containerSettings` / `labelSettings` / `valueSettings` / `customStylesSCSS`, then **unconditionally deletes them** — including when expansion produced nothing. Neither prop exists in the component manifest. A draft that reaches the platform without that pass having run ships with zero styling: the tokens are inert strings on a schema that has no such key.

Expansion writes only **absent** keys, so `styleIntent: "card"` on an element that already carries `containerSettings.backgroundColor` keeps that background. The exception is `customStylesSCSS`: a `badge:`/`pill` intent replaces SCSS beginning `.v-select__selection` (so a badge can be recoloured on an edit turn) and leaves any other SCSS alone. A baseline you are editing is always already expanded — there is never a `styleIntent` in it to echo.

## Half-styling a section stops it from being styled

An unstyled `container` that directly holds a field gets card chrome for free: padding, `1px solid #E0E1E3`, the theme radius, white background, shadow. "Unstyled" means `containerSettings` has **none** of `padding`, `backgroundColor`, `boxShadow`, `border.all`, `border.radius`, non-empty `customStylesSCSS`. Any one of them and the pass skips that container entirely.

```jsonc
// Intent: "a bit more room inside this section." Result: a bare box — no border, no card.
{ "type": "container", "containerSettings": { "padding": { "all": "24px" } } }

// As an intent, the padding rides along with the rest of the chrome.
{ "type": "container", "styleIntent": "card" }   // + form-level "theme": { "density": "airy" }
```

Style a container by hand and it is all or nothing: write the full set, or none of it.

## Values you wrote get rewritten to kit scales

Hand-written values are snapped toward the kit afterwards. A colour within 14 RGB units of a kit token is **rewritten** to that token (`brand_color_snapped`); further off it is **kept** and only warned about (`off_kit_color`). So `#3380E4` becomes `#3381E5`, while `#2C6EC2` (80 away) stands. Radius snaps ±2px onto 4/8/12/16 (ties round up; ≥100 exempt as pill), padding/margin/`gap` ±1px onto 4/6/8/12/16/20/24, `fontSize` ±1px onto 9/11/12/14/16/18/20/23. Hex inside `customStylesSCSS` is warned about, never rewritten.

## Control heights: two scales that disagree at `sm`

The kit gives a control three heights, and a **field** and a **button** are not the same scale:

| `size` | field — `input`, `select`, date trigger | `button` | font |
|---|---|---|---|
| `lg` | 44px | 44px | 14px |
| `md` *(default — omit the prop)* | 36px | 36px | 14px |
| `sm` | **28px** | **24px** | 12px, labels and the error row included |

So a `sm` button placed beside a `sm` input sits **4px short**, and nothing reports it — pair them
at `md`, or accept the step deliberately. These are min-heights, i.e. **design floors**: content
may grow a control, never shrink it. A button's bare glyph is sized by the button itself — 16px at
`sm`/`md`, 20px at `lg` — unless the icon carries a class of its own.

Two field looks, and the default is the bordered one: `variant: "outlined"` (an always-visible box)
or `"flat"` (borderless at rest, bordered once open). A placeholder is grey in **every** variant and
state — the blue idle placeholder was removed platform-wide on 2026-08-26, so do not restore it in
`customStylesSCSS`. A `sm` select also scales its dropdown: 24px option rows, 12px font, a 28px
search row and 24px footer buttons, against 36px throughout at `md`/`lg`.

## Typography bags

`labelSettings`, `valueSettings`, `descriptionSettings`, `placeholderSettings` each take `{ fontSize, fontWeight, fontStyle, color }`; `labelSettings` also takes `padding`. `fontWeight` is a **string** here: `"400"` (default), `"500"`, `"600"`, `"700"`.

On a `button`, `valueSettings.color` applies **only** while `color: "custom"` and is silently ignored for every preset colour (`fontSize`/`fontWeight`/`fontStyle` apply to all of them). `customColor` paints the button **background** on every variant, so `variant: "text"` + `customColor` is a solid blob; for a quiet text action use `variant: "text"` + `color: "third"`.

## Label position

`labelLocation` is an object. `flexDirection`: `"column"` = top (default), `"flex"` = **left**, `"flexReverse"` = **right**, `"columnReverse"` = bottom. `flexReverse` is the token most often written when left was meant, and it is accepted.

`width` sizes the **label** column, `widthContent` the **field** column — omit it and the value stretches to fill the row; `"auto"` makes it hug its content, which with `justifyContent: "space-between"` is the label-left/value-hard-right detail row. `gap` unset is not zero-by-default but zero by absence: no margin is emitted and the label sits flush against the field. `styleIntent: "detailRow"` writes this whole shape.

## Style is one value for all breakpoints

`containerSettings`, `labelSettings`, `valueSettings` and `labelLocation` hold **one** value shared across `lg`/`md`/`sm`; only `layout` is per-breakpoint, and no options path writes a responsive style. To vary a style by width, put a block in `containerSettings.customStylesSCSS` targeting the breakpoint the element host publishes as both a class and an attribute — `&.md { padding-left: 0 !important; }` or `&[data-breakpoint="md"] { … }` — for a breakpoint the form declares in `settings.breakpoints`. Not `@media`: the breakpoint resolves from the form *container's* width, so a 700px dialog on a 4K screen is `md` while the media query is false.
