Styles
Two ways to style a site: named design tokens with theme defaults, and one free stylesheet that overrides everything. Where each is stored, in which order the theme prints them, and what actually takes effect on documentation pages.
Two different things live under /site, and they are not interchangeable.
Design tokens are a closed set of named values: colours, the corner radius, the typography of eight levels, the content column width, paddings. Every token has a default, and the default comes from the theme itself, not from a list inside the service. You change only the values you care about.
Custom CSS is one free stylesheet per site: write anything, override anything.
Cascade order
The order is fixed on purpose, and custom CSS always wins over tokens, never the other way round. The theme prints into <head>:
wp_head priority | What | Where from |
|---|---|---|
| 1 | design and typography, <style id="onpress-sidepanel-typography"> | options onpress-sidepanel-design and onpress-sidepanel-typography |
| 2 | the remaining tokens, <style id="onpress-design-tokens"> | option onpress_design_tokens |
| 999 | the site’s custom CSS | option onpress_custom_css |
Tokens are printed under html:root, which is more specific than the :root of the theme’s _variables.css, so tokens beat the defaults whatever order the stylesheets load in. Custom CSS comes last and beats both.
Neither tokens nor custom CSS are baked into frames: they reach the page as CSS on every request, so a change needs no rebake. The page cache is another matter, see below.
Where the values live
Nothing parallel was invented: the theme already printed design and typography from two options, and the endpoint writes into them.
| Option | Holds | Written by |
|---|---|---|
onpress-sidepanel-design | theme, primaryColor, secondaryColor, bgColor, borderRadiusDefault | the sidepanel UI, v1 /design, this endpoint |
onpress-sidepanel-typography | per-level styles: key, fontFamily, fontSize, fontWeight, lineHeight, letterSpacing, color | the same three |
onpress_design_tokens | flat map of CSS variable to value, ready to print | this endpoint only |
The third option exists because the first cannot be extended: v1 PUT /design rebuilds it from exactly five keys, so anything else added there would be silently dropped the next time somebody saves from the sidepanel. Widths, paddings, the extra colours and the documentation accent live in their own row, which v2 owns.
Defaults are read from the theme: the service parses assets/css/parts/_variables.css of the site’s theme and takes the first declaration of each variable, the desktop block, since the mobile media queries further down redefine the same names. defaults_source in the answer names the file, or says builtin when the file could not be read. The theme is the site’s stylesheet option; pass theme to read another theme’s defaults.
What takes effect where
Read this before promising that a token will change something.
On ordinary theme pages every token works: the theme’s stylesheets consume them as CSS variables.
On documentation pages typography tokens do nothing. The docs template neutralises the typo-* system for the reference look: its stylesheet forces font-family, font-size, font-weight, line-height, letter-spacing and color to inherit inside the document body, so --h1-fz and its siblings are overridden whatever they hold. Layout tokens do nothing there either: the docs stylesheet uses no CSS variables and sets its own widths.
What does work on documentation pages is the accent. The reference bundle uses --primary, --primary-light and --primary-dark in about 370 places and never defines them. Setting color.primary fills all three (light and dark are derived by mixing in 25% white and 25% black), so the documentation picks up the brand colour. color.accent, color.accent_light and color.accent_dark set them explicitly.
The accent is stored as channels, not as a colour:
22 110 63, not#166e3f.
The bundle reads these variables only as rgb(var(--primary)). A hex value there turns into rgb(#166e3f), which is invalid; the browser falls back to black and the accent dies everywhere at once. The endpoint accepts hex, rgb() or a ready triplet on input, converts before storing, and returns the triplet. Do not convert again on the theme side, and do not write these three variables as hex in custom CSS.
The accent follows color.primary as long as it was derived: change the primary colour and the three variables are recomputed. Set any of them by hand and they stop following. Resetting color.accent, or color.primary while the accent is still derived, removes all three at once: they are written as a group, and a half-removed accent would leave the old light and dark variants with nobody to update them.
GET /site/design
Every token with its current value, its default and where the value came from.
curl -s -H "Authorization: Bearer $OP_TOKEN" "$BASE/site/design?blog_id=233"
{
"success": true,
"blog_id": 233,
"theme": "onpress-v2",
"defaults_source": "/var/www/…/wp-content/themes/onpress-v2/assets/css/parts/_variables.css",
"stores": {
"design": "onpress-sidepanel-design",
"typography": "onpress-sidepanel-typography",
"tokens": "onpress_design_tokens"
},
"total": 66,
"changed": [],
"css": "",
"tokens": {
"color.primary": {
"value": "#000000",
"default": "#000000",
"source": "default",
"css_var": "--primary-color",
"type": "color",
"description": "Primary color"
},
"layout.container_width": {
"value": "calc(910px + var(--padding, 2.5rem) * 2)",
"default": "calc(910px + var(--padding, 2.5rem) * 2)",
"source": "default",
"css_var": "--container-max-width",
"type": "length",
"description": "Content column width"
},
"color.accent": {
"value": "0 0 0",
"default": "0 0 0",
"source": "default",
"css_var": "--primary",
"type": "rgb-channels",
"description": "Accent of the documentation template: the --primary variable of the reference styles. Stored as a TRIPLE OF CHANNELS (\"22 110 63\"), because the bundle calls it as rgb(var(--primary)); hex is accepted on input as well. Defaults to the primary color"
},
"h1.size": { "value": 48, "default": 48, "source": "default", "css_var": "--h1-fz", "type": "px", "description": "h1: size" },
"…": "…"
}
}
source is default while the theme’s value stands and site once the site has its own. changed lists the overridden tokens, and css is exactly what the theme prints from the third option.
Token list
| Token | Type | CSS variable |
|---|---|---|
color.primary | colour | --primary-color; also fills the documentation accent |
color.secondary | colour | --secondary-color |
color.background | colour | --bg-color |
color.background_alt | colour | --bg-second-color |
color.text, color.text_muted | colour | --font-main-color, --font-second-color |
color.link, color.line | colour | --link-color, --line-color |
color.accent, color.accent_light, color.accent_dark | rgb channels | --primary, --primary-light, --primary-dark |
radius.default | px | --border-radius-default |
skin | lined or pure | none, the theme’s skin |
layout.container_width | length | --container-max-width |
layout.padding, layout.padding_mobile | length | --padding, --padding-container-mobile |
layout.header_height, layout.border_width | length | --header-height, --border-width |
<level>.font | string | --<level>-ff |
<level>.size | px, a number: 48 means 48px | --<level>-fz |
<level>.weight | integer 1 to 1000 | --<level>-fw |
<level>.line_height | ratio 0.5 to 4 | --<level>-lh |
<level>.letter_spacing | em, −1 to 1 | --<level>-ls |
<level>.color | colour | --<level>-color |
The eight levels are h1 to h6, subtitle and paragraph, 66 tokens in all. The theme stores line height and letter spacing as percentages; the endpoint takes and returns CSS units and converts both ways: 1.2, not 120%, and -0.05, not -5%.
PATCH /site/design
Changes individual values; everything untouched stays at the theme default.
| Field | Type | Description |
|---|---|---|
tokens | object | {"color.primary": "#166e3f", "h1.size": 44} |
dry_run | bool | validate and report, write nothing; the report uses the same changed field as a real call |
theme | string | the theme whose defaults are used; the site’s stylesheet by default |
A colour must be #rrggbb, rgb() or rgba(), or a colour keyword; a length must carry a unit or be a calc(); sizes, weights, line heights and letter spacings have ranges. A bad value is 400 bad_token_value, an unknown name is 400 unknown_token, and in both cases nothing is written.
curl -s -X PATCH -H "Authorization: Bearer $OP_TOKEN" -H 'Content-Type: application/json' \
"$BASE/site/design" -d '{
"blog_id": 235,
"dry_run": true,
"tokens": { "color.primary": "#166e3f", "h1.size": 44 }
}'
{
"success": true,
"dry_run": true,
"applied": false,
"blog_id": 235,
"changed": [
{ "token": "color.primary", "value": "#166e3f", "css_var": "--primary-color", "store": "onpress-sidepanel-design" },
{ "token": "h1.size", "value": 44, "css_var": "--h1-fz", "store": "onpress-sidepanel-typography" },
{ "token": "color.accent", "value": "22 110 63", "css_var": "--primary", "store": "onpress_design_tokens", "derived": true }
],
"css": "html:root{--primary:22 110 63;--primary-light:80 146 111;--primary-dark:17 83 47;}"
}
store says which of the three options each value goes to; derived: true marks what the endpoint computed itself, here the accent from the primary colour. A real call returns the same body with "applied": true and without dry_run.
{
"success": false,
"error": "bad_token_value",
"message": "\"h1.size\": a number is expected, got \"big\"",
"token": "h1.size"
}
{
"success": false,
"error": "unknown_token",
"message": "there is no token \"color.primaryy\"; look at GET /site/design — it lists every name with its default",
"token": "color.primaryy"
}
Setting one property pins the whole typography level. The theme stores typography one level at a time, so h1.size alone writes the remaining properties of h1 at their current defaults, and the whole level then reports site. Resetting a property releases it, and a level with nothing of its own left is removed entirely.
Design writes do not flush the page cache. Pages already in the page cache keep the old inline variables until they expire; flush them with POST /site/cache/flush and {"supercache": true}, see Sites and settings.
DELETE /site/design
Returns values to the theme defaults. With {"tokens": ["color.primary", "h1.size"]} only those names are released; the answer is reset with the list and the new css. With an empty body all three options are deleted, the answer is "reset": "all" with removed_options, and the site goes back to the theme entirely.
curl -s -X DELETE -H "Authorization: Bearer $OP_TOKEN" -H 'Content-Type: application/json' \
"$BASE/site/design" -d '{"blog_id": 233, "tokens": ["color.accent"]}'
How the theme prints tokens
The first two options are printed by the theme at wp_head priority 1. The third is one small block at priority 2, before custom CSS:
$tokens = json_decode((string) get_option('onpress_design_tokens', '{}'), true);
if (is_array($tokens) && $tokens) {
$css = '';
foreach ($tokens as $var => $value) {
$css .= $var . ':' . $value . ';';
}
echo '<style id="onpress-design-tokens">html:root{' . $css . '}</style>' . "\n";
}
The map is stored ready to print: names are already CSS names and values already CSS values, so nothing is converted on the theme side.
Custom CSS
The site’s stylesheet is an option of that one network site, not a network option (staging shares the database with production, and a network option would reach every site at once). Two rows: onpress_custom_css with the bare CSS and onpress_custom_css_meta with a record of the last write: when, by whom, how many bytes, sha256, what was stripped. The theme prints it with a plain get_option() inside <style> as the last thing in <head>.
GET /site/css
curl -s -H "Authorization: Bearer $OP_TOKEN" "$BASE/site/css?blog_id=233"
{
"success": true,
"blog_id": 233,
"home_url": "https://example.onpress.pro",
"option": "onpress_custom_css",
"exists": true,
"bytes": 109790,
"sha256": "35e2fde4b168baa136a2eb12a9bb8b7428a745e2c5d7fb51e55de775bd564e28",
"autoload": "no",
"meta": {
"updated_at_gmt": "2026-09-13 12:05:43",
"updated_by": 1,
"bytes": 109790,
"sha256": "35e2fde4b168baa136a2eb12a9bb8b7428a745e2c5d7fb51e55de775bd564e28",
"source": "node",
"stripped": []
},
"css": "…"
}
This site’s stylesheet is about 107 KB, over the autoload threshold, hence "autoload": "no".
PUT /site/css
Replaces the site’s CSS as a whole. POST on the same path does the same for clients that find PUT inconvenient.
| Field | Type | Default | Description |
|---|---|---|---|
css | string | — | the whole stylesheet, required; an empty string is allowed and means nothing |
dry_run | bool | false | validate only: the answer shows the cleaned CSS and what was stripped, the database is untouched |
flush_cache | bool | true | flush the site’s page cache after the write |
blog_id / domain | int / string | — | network site |
Replacing the whole stylesheet means exactly that. There is no endpoint to add a rule: read the current CSS, change it, send it back whole. Otherwise you overwrite someone else’s work, and on a site where several agents work in parallel that happens at once.
What gets stripped, and why
The contract is narrow and stated plainly: the stored string is safe inside <style>…</style>. Within that element a browser parses neither tags nor entities, and the only thing that ends it is </style. So the service removes what can break out of the style context or execute, not everything that looks alarming:
| Rule | What is removed |
|---|---|
style_close_tag | </style> in any casing or spacing, the only way out of the element |
script_tag | <script> and </script> tags; the text between them stays |
js_url | javascript: and vbscript: addresses |
ie_expression | expression( from old IE |
control_chars | control characters |
@import is kept, since it is valid CSS and fonts are loaded with it, but it raises a warning: an extra request before the first paint, and an external address may be blocked by the content security policy. An unterminated comment is closed automatically at the very end of the file, otherwise it would swallow every rule after it; the rules after its opening stay commented out, and the warning says so. Nothing is removed silently: stripped lists every rule with a count and samples, warnings holds the textual notes.
curl -s -X PUT -H "Authorization: Bearer $OP_TOKEN" -H 'Content-Type: application/json' \
"$BASE/site/css" -d '{
"blog_id": 233,
"dry_run": true,
"css": ".x{color:red}</style><script>alert(1)</script>\n.y{background:url(javascript:alert(1))}\n@import url(https://fonts.googleapis.com/css2?family=Inter);"
}'
{
"success": true,
"blog_id": 233,
"option": "onpress_custom_css",
"bytes": 111,
"max_bytes": 262144,
"sha256": "130bb118cda8ad37c36c3424318797ffd9b4b3183ca3c2b30d3287ec729c69d0",
"autoload": "yes",
"stripped": [
{ "rule": "style_close_tag", "count": 1, "reason": "закрывающий тег </style> прекращает элемент стилей и пускает остаток как разметку", "samples": ["</style>"] },
{ "rule": "script_tag", "count": 2, "reason": "тег <script> в стилях не нужен; внутри <style> он мёртв, но в любом другом контексте — нет", "samples": ["<script>", "</script>"] },
{ "rule": "js_url", "count": 1, "reason": "javascript:/vbscript: в url() — исполняемый адрес, а не стиль", "samples": ["javascript:"] }
],
"warnings": [
"@import встречается 1 раз(а): такие файлы грузятся отдельным запросом и тормозят первую отрисовку, а внешний адрес может быть заблокирован политикой безопасности"
],
"dry_run": true,
"applied": false,
"css": ".x{color:red}alert(1)\n.y{background:url(alert(1))}\n@import url(https://fonts.googleapis.com/css2?family=Inter);"
}
A real write returns "applied": true, updated_at_gmt and cache instead of the cleaned css.
Limits
The limit is 256 KB (CUSTOM_CSS_MAX_BYTES); above it is 413 css_too_large with the actual size in bytes and the limit in max_bytes. A stylesheet up to 64 KB (CUSTOM_CSS_AUTOLOAD_BYTES) is written with autoload = yes and arrives with the rest of the autoloaded options; a larger one is written with autoload = no, because an autoloaded option that size swells the option cache on every request. get_option() still returns it in one extra query, and nothing changes for the theme.
Page cache
After a write and after a clear, the service deletes this site’s WP Super Cache directory, otherwise the cache would keep serving pages with the old inline CSS. The outcome comes back in cache with the status flushed, empty, skipped or failed. A failed flush does not cancel the saved CSS, it is simply named. skipped means the service does not know where wp-content is (WP_CONTENT_DIR in its .env). Switch the flush off with "flush_cache": false.
DELETE /site/css
Deletes both options, and the site falls back to the theme’s styles. cleared says whether there was anything to delete; cache reports the flush.
curl -s -X DELETE -H "Authorization: Bearer $OP_TOKEN" -H 'Content-Type: application/json' \
"$BASE/site/css" -d '{"blog_id": 233}'
Error codes
| Code | HTTP | When |
|---|---|---|
unknown_token | 400 | no such token; the full list is in GET /site/design |
bad_token_value | 400 | the value does not fit the token’s type or range |
nothing_to_update | 400 | PATCH /site/design without a tokens object |
css_required | 400 | PUT /site/css without a css string; to clear, call DELETE |
css_too_large | 413 | the CSS is over 256 KB |
Custom fonts cannot be added through the API: the registry of available families is in the theme’s PHP. Theme variables outside the token list are changed in the theme file, or overridden with custom CSS, which is usually the right answer anyway.