# Simple CSS (`.scs`)
`SCS` stands for **S**imple **C**ascading **S**tyle.
It is a small compile-time layer over css, compiled to plain css by [`MMO\client\SCSCompiler`](../src/SCSCompiler.php).
Unlike [`DartSass`](../src/DartSass.php), nothing is shelled out: the lexer, the parser, the evaluator and the
emitter are pure php, so a stylesheet compiles anywhere the framework runs — no binary, no node, no cache
directory.
```text
.scs → Lexer → Parser → ast → evaluation (variables, @fn, php bridge)
→ style expansion → themes → nesting → @screen → @order → css
```
The four rules the whole language rests on:
```text
@xxx a language instruction
$xxx a compile-time variable
xxx::yyy a name another file declares -- a file is a namespace
@theme the only construct that produces runtime css variables
; ends every statement that is not a block, as in css
```
---
> **Editor support:** [`ide/`](../ide/README.md) has a TextMate bundle for `.scs` — grammar, its own colors
> (so the highlighting does not depend on the scheme) and a file icon. Install it with
> `ide/install-phpstorm.sh --with-icon`, then restart the IDE.
>
> **See it running:** the demo application has a *Simple CSS* section (`demo/www/contents/pages/scs/`) where
> every construct below is compiled live by this compiler, with a playground to try your own source:
> `Framework → Simple CSS`.
## Statements end with `;`
Every statement that is not a block ends with a semicolon, exactly as in css and in sass: a declaration, a
`@let`, a `@use`, an `@import`, a `return`. A block is ended by its `}` and takes nothing after it.
```css
@let $spacing = 16px; /* a statement */
.button { /* a block: no `;` after its `}` */
padding: $spacing;
@use lib::control;
}
```
The newline is whitespace, so a statement may be wrapped and several may share a line. A missing `;` is
reported on the statement that is missing it:
```text
Error: [;] is expected at the end of the statement.
at src/styles/parts/buttons.scs:14
```
## Quick start
```php
use MMO\client\SCSCompiler;
$css = SCSCompiler::Shared()->compileText(<<<'SCS'
@theme {colors {primary: #6366f1;}}
@let $spacing = 16px;
@component button {
padding: $spacing;
background: $colors.primary;
}
SCS)->css();
```
```css
:root {
--colors-primary: #6366f1;
}
.button {
padding: 16px;
background: var(--colors-primary);
}
```
Compile a file, or a file into a file:
```php
$result = SCSCompiler::Shared()->compileFile('{basepath}/styles/app.scs');
SCSCompiler::Shared()->compile('{basepath}/www/app.css', '{basepath}/styles/app.scs');
```
`compile()` never throws: a broken stylesheet is logged and written as a visible css error message, exactly
like `DartSass::compile()`.
---
## Compile-time variables — `@let`
```css
@let $spacing = 16px;
@let $large = $spacing * 2;
@let $font = "Inter";
@let $rounded = true;
@let $transition = 150ms ease;
@let $sizes = [4, 8, 16];
@let $labels = (sm: small, md: medium);
```
A `@let` is **replaced** during compilation. It never becomes `--spacing`, and never becomes `var(--spacing)`.
Inside a block, `@let` shadows the outer variable instead of overwriting it:
```css
@let $spacing = 16px;
@style card {
@let $spacing = 24px;
padding: $spacing; /* 24px */
}
.b {padding: $spacing;} /* 16px */
```
### Arithmetic and units
`+ - * / %` work on numbers, and units must stay compatible:
| expression | result | why |
|---|---|---|
| `8px * 3` | `24px` | one side is unitless |
| `8px / 2` | `4px` | the divisor is unitless |
| `8px / 2px` | `4` | identical units cancel out |
| `10px + 2rem` | **error** | incompatible units |
| `10px * 2rem` | **error** | the result would have no css unit |
### `${...}` — evaluate inside a css value
In a declaration, `/ * + -` are css, not arithmetic (`grid-area: 1 / 2 / 3 / 4` must survive). Wrap an
expression in `${...}` when you do want it computed:
```css
@let $spacing = 8px;
.card {
padding: $spacing ${$spacing * 4 / 2}; /* 8px 16px */
grid-area: 1 / 2 / 3 / 4; /* untouched */
}
```
`${...}` also works in selectors and inside strings — `#{...}` is accepted as a synonym:
```css
@for $i in 1..6 {
.mt-${$i} {content: "row ${$i}";}
}
```
---
## Design tokens — `@theme`
`@theme` is the one construct that stays **dynamic at runtime**: it emits css custom properties.
```css
@theme {
colors {
primary: #6366f1;
danger: #ef4444;
}
spacing {sm: 8px, md: 16px;}
}
```
```css
:root {
--colors-primary: #6366f1;
--colors-danger: #ef4444;
--spacing-sm: 8px;
--spacing-md: 16px;
}
```
Groups nest to any depth and are flattened with `-`. Tokens are separated by a newline or a comma — a comma
only ends a token when another `key:` follows it, so `body: "Inter", sans-serif` stays one value. Token values
are ordinary compile-time expressions (`lg: spacing(3)` works).
A token is referenced by its `$path`, and always compiles to a `var()`:
```css
.button {background: $colors.primary;} /* background: var(--colors-primary); */
```
A `@let` and a theme group may share a name: `$spacing` reads the variable, `$spacing.md` reads the token.
### Scoped themes
```css
@theme dark {colors {primary: #818cf8;}}
```
```css
[data-theme="dark"] {--colors-primary: #818cf8;}
```
Switching `data-theme` at runtime re-themes the page with no recompilation. The selector is configurable
(`themeSelector`), and every generated property can be prefixed (`varPrefix`, e.g. `--mmo-colors-primary`).
---
## Reusable styles — `@style` / `@use`
```css
@style center {
display: flex;
align-items: center;
justify-content: center;
}
@style size($width, $height) {
width: $width;
height: $height;
}
@style spacing($padding = 16px) {padding: $padding;}
.avatar {
@use center;
@use size(48px, 48px);
@use spacing;
}
```
A `@style` generates nothing by itself. It may `@use` other styles; a cycle is reported as a
`circular dependency` error with the chain that closes it.
The body of a definition resolves its own unqualified names **where it was declared**: a `lib::card` that
uses `center` means `lib::center`, wherever it is expanded from.
---
## Functions — `@fn`
```css
@fn spacing($n) {
return $n * 4px;
}
@fn buttonColor($type) {
@if $type == primary {return #6366f1;}
@if $type == danger {return #ef4444;}
return #64748b;
}
.card {padding: spacing(4); background: buttonColor(primary);}
```
A `@fn` body may contain `@let`, `@if`, `@for` and `return`. It produces a value, never css.
### Built-ins
`lighten()` `darken()` `mix()` `rgb()` `rgba()` `px()` `rem()` `em()` `percent()` `percentage()` `round()`
`floor()` `ceil()` `abs()` `unit()` `unitless()` `length()` `nth()` `get()` `keys()` `quote()` `unquote()`
`to-upper-case()` `to-lower-case()` `if()`
### Css functions are preserved
A css function is emitted as written, arguments included — which is what makes `calc()` work:
```css
@let $gap = 20px;
.a {
width: clamp(200px, 50vw, 800px);
height: calc(100% - $gap); /* calc(100% - 20px) -- the `-` stays css */
}
```
Anything that is neither an `@fn`, a built-in nor a known css function is reported as
`undefined function [spacng()]`. Set `strictFunctions` to `false` (globally, per compilation, or with
`@config strictFunctions = false`) to let unknown functions through instead.
---
## Components and variants
```css
@component button {
padding: 8px;
&:hover {background: blue;}
@variant primary {background: blue; color: white;}
@variant outline {background: transparent;}
}
@component .btn {display: block;}
```
```css
.button {padding: 8px;}
.button:hover {background: blue;}
.button--primary {background: blue; color: white;}
.button--outline {background: transparent;}
.btn {display: block;}
```
A bare name goes through `componentSelector` (`.{name}` by default), an explicit selector is used as
written. Variants go through `variantSelector` (`{selector}--{variant}`). Both are configurable, so a design
system can emit `.mmo-button.is-primary` instead.
---
## Nesting
```css
.card {
padding: 16px;
.title {font-size: 20px;}
&:hover {transform: scale(1.02);}
[data-state="on"] & {color: blue;}
}
```
Nesting is flattened for maximum browser compatibility. `&` is replaced by the parent selector; without a
`&`, the child is a descendant. A selector list nests as a list.
---
## Responsive — `@screen`
```css
@config @screen.tablet = 768px;
.card {
padding: 16px;
@screen tablet {padding: 24px;} /* @media (min-width: 768px) */
@screen 1200px {padding: 32px;} /* @media (min-width: 1200px) */
}
@screen tablet {
.panel {padding: 24px;}
}
```
A `@screen` target is a configured breakpoint name, a bare length (read as `min-width`), a media type
(`print`) or a full query (`(max-width: 600px)`). An unknown name is an error rather than a guess.
Responsive rules are always emitted **after** the unwrapped ones, so a breakpoint never loses to the rule it
overrides, whatever the source order.
Default breakpoints mirror the `devices` of [`_default_config.scss`](../src/sass/_default_config.scss):
`tablet: 481px`, `desktop: 981px`, `wide: 1707px`.
---
## Conditions and loops
```css
@let $rounded = true;
.card {
@if $rounded {
border-radius: 8px;
} @else if $flat {
border-radius: 0;
} @else {
border-radius: 4px;
}
}
@for $i in 1..6 {
.mt-${$i} {margin-top: spacing($i);}
}
@let $sizes = [4, 8, 16];
@for $index:$size in $sizes {
.p-${$index} {padding: ${$size}px;} /* $index is 0 based */
}
@for $key:$label in (sm: small, md: medium) {
.is-${$key} {content: "${$label}";}
}
```
Operators: `== != < > <= >=`, `and`/`&&`, `or`/`||`, `not`/`!`. Only `false` and `null` are falsy.
---
## Emission order — `@order`
Every rule is at order `0` by default and emitted in source order. `@order` moves a block earlier or later:
```css
@order -100 {
.reset {margin: 0;}
}
```
Inside a rule, `@order` reorders the **declarations of that rule** rather than emitting a second one:
```css
@component button {
padding: 8px;
@order -1 {border: none;}
}
```
```css
.button {
border: none;
padding: 8px;
}
```
---
## Namespaces and imports
A stylesheet of any size is several files, and reading one of them you need to know where a `@use`, a
function or a variable comes from. **A file is a namespace**, named after itself, and a reference to
another file says so:
```css
// lib.scs -- the namespace `lib`
@let $gutter = 12px;
@fn step($n) {return $n * 4px;}
@style center {display: flex; align-items: center;}
```
```css
// parts/buttons.scs -- the namespace `buttons`
@import "../lib.scs";
@style filled {font-weight: 600;} // declared here
.button {
@use lib::center; // ... and this one comes from lib.scs
@use filled; // no prefix: it is this file's own
padding: lib::step(2);
gap: $lib::gutter;
background: $color.brand; // a theme token: global, unchanged
}
```
| written | means |
|---|---|
| `@use center` | a `@style` of **this** file |
| `@use lib::center` | the `@style` `lib.scs` declares |
| `step(2)` | an `@fn` of this file, else a built-in, else a css function |
| `lib::step(2)` | the `@fn` `lib.scs` declares |
| `$gutter` | a variable of this file, or of an enclosing block |
| `$lib::gutter` | the top-level `@let` of `lib.scs` |
| `$color.brand` | a theme token — **not** namespaced, it belongs to the design system |
| `php::round(…)`, `context::density` | the reserved namespaces of the php bridge |
`@style`, `@fn`, `@component` and top-level `@let` belong to their file. A `@let` inside a rule, a `@style`
or a `@fn` stays an ordinary local variable.
### Naming a namespace
```css
@namespace ui; // first instruction of the file: it answers to `ui`, whatever the file is called
```
Without it, `lib.scs` is `lib` and `parts/buttons.scs` is `buttons` (the file name, never the folder). Two
files claiming the same name is an error — rename one with `@namespace`, or at the import with `as`.
A source with no file name of its own — an inline stylesheet, an element's `