# Styling: `<style>` and the `.scs` design system
Source of truth: `src/JHSCompiler.php` (`$styleLanguage`, `$scsElement`/`$scsView`, `$scsOptions`, `compileText`'s style handling), `src/Theme.php` (`compileCSS`, `DefaultConfig`), `src/styles/*.scs` (`tokens`, `dark`, `lib`, `controls`, `shadow`, `styles`), `docs/SCS_DESIGN_SYSTEM.md` (the system) and `docs/SimpleCSS.md` (the language).
**A theme is written in Simple CSS (`.scs`), and only in Simple CSS.** The sass design system — `src/sass`, the `theme.*` mixins, the `classes/*` `%placeholders` — is archived in `.mmo/sass`: none of it is loaded any more, so never write `@include theme.…`, `@use "classes/…"` or `@extend %…`.
## A plain `<style>` is Simple CSS, and the compiler adds the prelude
`JHSCompiler::$styleLanguage` is `scs`: a `<style>` with no `lang` goes to `MMO\client\SCSCompiler`, and no element of this package writes a `lang`. The compiler prepends a prelude to your content and compiles it with `emitThemes` and `sourceMap` off (`$scsOptions`): `@import "shadow.scs";` in a custom element (`$scsElement`), `@import "controls.scs";` in a `<view>` (`$scsView`). Either one gives your `<style>`:
- the design tokens, as references only — `$color.brand` compiles to `var(--mmo-color-brand)`, declared once on `:root` by the document stylesheet and inherited through the shadow boundary;
- `lib::` (`src/styles/lib.scs`) — the `@fn`s and the layout/surface `@style`s;
- `controls::` (`src/styles/controls.scs`) — the button, input, table, list, tag and tab recipes the document's own classes are painted with.
The element's prelude also emits three resets, and nothing else: `box-sizing: border-box`, form controls inheriting `font`/`color`, and a flat bare `<button>` (no browser bevel) — none of the document's reset reaches a shadow root. The view's emits nothing at all: a view rendered in the document adds its css there (see below), where those resets would beat the document stylesheet's own `button`.
The language in one breath (`docs/SimpleCSS.md` for the rest): every statement that is not a block ends with `;`; `@use name;` inlines a `@style`; a name from another file is qualified (`lib::truncate`, `controls::button`) — an unqualified one belongs to your own `<style>`; nesting and `&` work as in sass; `/ * + -` in a value are css, so wrap arithmetic in `${...}`; `@screen` is the media query.
```scs
<style>
:host {display: block;}
#list {
@use lib::surface($radius.md);
@use lib::col($space.sm);
padding: $space.lg;
color: $color.text;
border-color: lib::alpha($color.text, 12%);
}
#add {@use controls::button; @use controls::button-primary;}
#add:focus-visible {@use lib::focus-ring;}
@screen tablet {
#list {padding: $space.xl;}
}
</style>
```
A compile error becomes visible css on the element (`body::before, :host::before {content: "…"}`, from `SCSCompiler::renderError`) rather than failing the whole file — don't mistake "the page loaded" for "the styles compiled cleanly."
### `lang="sass"`: still there, for packages that carry sass
`<style lang="sass">` (or `lang="scss"`) is compiled by `MMO\client\DartSass` — which needs the dart binary (`{vendor}/dart/sass`) — with **no prelude** (`$sassElement`/`$sassView` are empty): no `theme.*`, no `classes/*`, only the load path the package that writes it configures on `DartSass`. It survives for other packages whose elements are still sass; those elements read the sass system's `--mmo-color-*` / `--mmo-size-*` variables, which `src/styles/compat.scs` still declares, mapped onto the new tokens. A sass block has to say `lang="sass"`: a plain `<style>` is Simple CSS. `lang="scs"` (or `simplecss`, `simple-css`) is accepted too, anything else is "unknown style language", and one element may hold a block of each. Never start new styles in sass.
## `:host` and shadow DOM
Every normal custom element (`CustomShadowElement`, the default) renders into its own shadow root. Style the element itself with `:host { ... }` / `:host(.some-class) { ... }` / `:host([attr]) { ... }` — plain top-level selectors style descendants inside the shadow root, not the element itself, exactly like standard Shadow DOM CSS. None of the document's css reaches inside; only inherited custom properties do, which is exactly how the tokens get there.
A `<view>` has no shadow root: its `<style>` is added where the view renders, so in the light DOM it applies to the **whole document**. Its prelude (`controls.scs`) emits no rule, so only yours get there — but every one of them does: a bare `button {…}` in a view restyles every button of the page. Scope every selector under a class of the view's own root. Most views need no `<style>` at all: in the light DOM they use the document's classes directly (`views/Issue.jhs` has none).
## Design tokens (`src/styles/tokens.scs`, the dark values in `src/styles/dark.scs`)
A token is `$group.name` in scs and always compiles to `var(--mmo-group-name)`; in a page's own css, an inline `style=` or JS, use the custom property itself: `var(--mmo-color-brand)`.
- **`color`**: grounds `bg, surface, surface-2, surface-3, overlay`; hairlines `line` (separates), `line-strong` (encloses); text `text, text-2, text-3, text-on-brand, text-on-ink`; accent `brand, brand-ink, brand-soft, brand-line`; states `ok, warn, bad, info`, each with a `-soft` ground; the ink ground `ink, ink-2, ink-text, ink-line`; `focus, selection`; charts `viz-1` … `viz-6`.
- **`space`** (4px grid): `none 0, xxs 2px, xs 4px, sm 8px, md 12px, lg 16px, xl 24px, xxl 32px, xxxl 48px, huge 72px` — `md` between related things, `xl` between sections.
- **`radius`**: `xs 6px, sm 8px, md 12px, lg 16px, xl 22px, full 999px`.
- **`text`** (font sizes): `xxs 11px, xs 12px, sm 13px, md 15px, lg 17px, xl 20px, xxl 24px, h3 19px, h2 26px, h1 34px, display 48px`.
- **`font`** `ui, display, prose, mono` · **`weight`** `normal 400, medium 500, semi 600, bold 700` · **`line`** `tight, snug, normal, loose`.
- **`shadow`** `sm, md, lg` · **`motion`** `fast, base, slow, ease` · **`z`** `base, sticky, drawer, overlay, popover, toast` — never a raw `z-index` number.
- **`layout`**: `header, statusbar, sidebar, rail, panel, content, prose, control 34px, control-sm 28px, icon 20px`.
Dark mode is the same tokens redefined: `dark.scs` rewrites the colour and shadow properties under `[data-theme="dark"]` and inside `@media (prefers-color-scheme: dark)` (`:root:not([data-theme="light"])`). So always reach for a token, never a hex literal, and dark mode (and a client's reskin) "just works". The compile-time maps behind the tokens (`$palette`, `$brand`, `$hues`, `$viz`, `$ink` in `tokens.scs`) are literals that follow neither — don't reach into them from an element. A token is a `var()` at use time, so a compile-time `lighten()`/`darken()` cannot work on it: use `lib::alpha()`/`lib::blend()`, which emit a runtime `color-mix()`.
Breakpoints: `@screen tablet` / `desktop` / `wide` are `min-width` 481px / 981px / 1707px (`SCSCompiler::$screens`). A full query works too — `@screen (max-width: 980px) {...}` is the usual phone-and-tablet block.
## The library: `lib::` (all in `src/styles/lib.scs`)
`@fn`s produce values, `@style`s produce declarations that `@use` inlines; nothing here emits css on its own.
| Name | For |
|---|---|
| `alpha($color, $amount)` / `blend($color, $with, $amount)` | `@fn`: a token made transparent / mixed towards another, at runtime — `border-color: lib::alpha($color.text, 12%)`. |
| `step($n)` | `@fn`: `$n * 4px`, for the rare size that is not a spacing token. |
| `row($gap = $space.md, $align = center)` / `col($gap = $space.md)` / `center` | Flex layout (`min-width: 0` included). |
| `autogrid($min = 280px, $gap = $space.lg)` | A grid that fills its row. |
| `truncate` / `scroll-area` / `visually-hidden` | One-line ellipsis / a scroller with a thin scrollbar / screen-reader-only. |
| `sticky($layer = $z.sticky)`, `sticky-top($offset = 0)` / `-bottom` / `-left` / `-right` | A sticky box with the ground and layer it needs; use an edge (two for a corner cell). |
| `surface($radius = $radius.md)` / `raised(...)` / `floating(...)` / `ink-ground` | Ground + hairline + radius / + `$shadow.md` / + `$shadow.lg` / the dark marketing ground. |
| `control($height = $layout.control)` / `interactive` / `focus-ring($offset = 2px)` / `pill` | The shared control box / pointer + transitions / the focus outline / a pill badge. |
| `heading($size = $text.h3)` / `eyebrow` / `numeric` | Display heading / small uppercase label / tabular digits. |
For the exact body of any of them, open `src/styles/lib.scs` and search for `@style name` / `@fn name`.
## The components: `controls::` (all in `src/styles/controls.scs`)
What the `%placeholders` used to be: the recipes `parts/*.scs` paints the document's classes with, as `@style`s an element `@use`s on its own markup — so a control looks the same in a page and inside a shadow root.
- **buttons**: `button` (the secondary button a bare `<button>` is), with a modifier on top of it — `button-primary`, `button-transparent`, `button-square($size)`, `button-round($size)`, or any tone through `filled($tone, $hover, $text)`: `#save {@use controls::button; @use controls::button-primary;}`.
- **inputs**: `input` (a bare input/select/textarea), `field` + `field-input` (a box holding an input next to an icon or a unit), `label`, `checkbox($radius)`, `select-chevron`, `readonly`.
- **tables**: `table`, `table-data` (denser, with hover and `.selected` rows), `table-stack` (one card per row when the table is too wide for its box). Every table of the document stacks on its own: `MMO.core.ResponsiveTable` (`src/js/core/ResponsiveTable.js`) writes on each cell the header it sits under (`data-label`) and sets `data-stacked` when the table overflows; an element with its own `<table>` calls `MMO.core.ResponsiveTable.watch(this.shadowRoot)` and paints it with `@media screen {table {@use controls::table-stack;}}` (`mmo-table` does). So a new table needs a real `<thead>` (or a first row of `th`), `th` first in a row for its title, `colspan` headers for column groups; `class="no-stack"` keeps it a table. The full guide (do/don't, the matrix, the attributes) is the demo page `demo/www/contents/pages/styles/tables.phtml`: read it before writing a table.
- **lists**: `list` + `list-item` (`.active`), `dl-list`.
- **chrome**: `tag($tone, $text)`; `tabs` + `tab` + `tab-active` (the segmented control), and the quiet form on a rule, `tabs-line` / `tab-line` / `tab-line-active` / `tab-underline`, used after them; `rounded-element($size)`.
When in doubt, grep an existing element that visibly renders the pattern you want (`grep -rn "controls::input" src/customElements`) rather than reading the recipe in isolation.
## Global app styles vs. element styles
`src/styles/styles.scs` is the **light-DOM, app-wide** stylesheet: `Theme::compileCSS()` compiles it — or the project's own stylesheet that imports it, below — into `{bin}/mmo.client.css`, loaded once, outside any shadow root. It declares the tokens on `:root` and the dark overrides, then the element defaults (`core.scs`), the document's classes (`parts/*.scs`: `.button`, `.card`, `.panel`, `.tabs`, forms, tables, lists …) and the utilities that must win over them (`utilities.scs`: `.hflex`/`.vflex`/`.gap`, `.flex-1`, `.hidden`, `.text-center`, tone classes, print rules). The sass system's class names live on there, so markup written for it keeps working. Your `.jhs` element's own `<style>` is scoped to its shadow root and cannot rely on any of these classes inside it — `@use` the same recipe from `lib::`/`controls::` instead. A view, rendered in the light DOM, uses them directly.
## A project's own stylesheet
A project with document styles of its own gives the theme its own entry point, which imports the system first (`templates/project/src/styles/app.scs`, the demo's `demo/www/styles/demo.scs`):
```scs
@import "styles.scs" as self; // the whole design system, merged into this file's namespace
.invoice-total {font-weight: $weight.bold; color: $color.brand-ink;}
```
`styles.scs` is found through `SCSCompiler::$paths` (the package's `src/styles` by default). The file is the theme's `stylesheet`, and it is listed in `assets.css` — the files whose edit makes dev recompile the stylesheet. That list replaces the default one, so it names the package's sources as well; leave the project's own files out and editing `app.scs` never recompiles in dev:
```json
"MMO\\client\\Theme": {
"stylesheet": "{AcmePortal}/styles/app.scs",
"assets": {
"css": ["{MMO\\client}/styles/**.scs", "{AcmePortal}/styles/**.scs"]
}
}
```
`{AcmePortal}` is the project's namespace: `src/` in a checkout, its phar in a release — never write `{root}/src/...`. The stylesheet must be a `.scs` file; `Theme::compileCSS()` refuses anything else ("the stylesheet [...] is not a [.scs] file: a theme is written in Simple CSS."). A project with no styles of its own configures nothing: the default `stylesheet` is `{MMO\client}/styles/styles.scs`.
To check a stylesheet without writing anything, `\MMO\client\SCSCompiler::Shared()->compileFile($file)->css()` throws an `eSCS` whose `report()` gives the message, the file and line, and a code frame.
### Reskinning
The supported way is `MMO\client\ThemeConfigurator` (`docs/SCS_DESIGN_SYSTEM.md` §2, and the demo's `configs/config.json`). It is off by default: turn it on (`"enabled": true`), store the overrides in its json — `{"overrides": {"tokens:let::brand.s500": "#8B5CF6"}}`, or edit them in `<mmo-theme-config>` (dev mode, with `ThemeController` in the routes) — and import the file it generates **last**:
```scs
@import "styles.scs" as self;
/* ... the project's rules ... */
@import "theme.config.scs"; // tokens.scs and dark.scs printed back with the overrides: its :root wins
```
Its `target` must be where that import finds it (the stylesheet's own folder), and its `config` json belongs outside the document root. Overriding `$brand.s500` moves every token built on it (`color.brand`, `color.focus`, …) in the light theme; the dark theme keeps its own values (`dark:let::dark-color.brand` and friends).
Two things look like a reskin and are not (both checked by compiling): a `@let $brand = (...)` before `@import "styles.scs"` changes nothing, because `tokens.scs` declares its own `$brand`; and a `@theme {color {brand: …;}}` after the import moves that one property only (`color.focus` still reads the old `$brand.s500`), and its `:root` rule, emitted after `[data-theme="dark"]`, beats the dark theme's brand whenever `data-theme="dark"` is set.