Docs
Next

@systhemaui/core/client

Client tokens and the pixel readers (resolveTokenPx, tryResolveTokenPx, isPositiveTokenLength).

On this page

@systhemaui/core/client is the entry 'use client' code imports instead of @systhemaui/core. Why it exists and how it resolves is on Client and server code; this page covers the client-token snapshot and the readers that turn token values into numbers.

Client tokensLink to this section

A handful of components need resolved token scalars in the browser: the variant name lists behind button-* / card-* / accordion-* class names, the gap-token names the Payload block converters index into, the default theme and layout background a page falls back to, and the pixel gaps Swiper wants as JS numbers. Instead of shipping the dataset to compute them, the server resolves them once and forwards them.

<SysthemaConfigServer /> (rendered by <SysthemaProvider>, so every Systhema layout already has it) calls getSysthemaClientTokens() and hands the result to <SysthemaConfigClient />, which calls setSysthemaClientTokens() before any component renders.

type SysthemaClientTokens = {
  breakpoints: Record<string, string> // { sm: '0px', md: '640px', … }
  scopes: string[] // ['sm', 'md', 'lg']
  values: Record<string, Record<string, string>> // values[tokenPath][scope]
  variants: Record<string, string[]> // variants.button = ['primary', 'secondary']
  themes: string[] // colorSystem names: ['default', 'dark']
  layoutBackgrounds: string[] // first theme's layout backgrounds
}

Read it with the helpers rather than the raw object:

'use client'
import { clientVariants, clientTokenValue, clientBreakpoints } from '@systhemaui/core/client'

const variants = clientVariants('button') // ['primary', 'secondary']
const gaps = clientVariants('gap') // ['none', 'xs', 'sm', …]
const gap = clientTokenValue('gallery.slidesGapX', 'md') // '--spacing(12 / 4)'
const bp = clientBreakpoints().lg // '1192px'
const theme = clientThemes()[0] // 'default' — the project default theme
const layoutBg = clientLayoutBackgrounds()[0] // 'main'

Read them at render time, not at module scope — module bodies run before the snapshot is seeded.

What the snapshot carriesLink to this section

Only the paths and components declared in CLIENT_TOKEN_PATHS / CLIENT_TOKEN_VARIANTS are carried. Anything else resolves to undefined / [] in the browser while still resolving on the server — so it would render one value during SSR and another after hydration. Outside production the server logs a one-time warning naming the missing key; to fix it, add the path or component to those lists in packages/core/src/clientTokens/types.ts and refresh DEFAULT_CLIENT_TOKENS (a unit test pins the literal to the shipped tokens).

Mount <SysthemaProvider> — every Systhema layout and both templates already do. Without it the browser falls back to DEFAULT_CLIENT_TOKENS, i.e. Systhema's built-in defaults: a project that renames its button-*/card-*/accordion-* variants in the tokens would get the built-in variant names in the browser instead of its own.

Reading a token value as a pixel numberLink to this section

clientTokenValue() returns the token as a CSS string, and that string is not reliably a plain length. Paths matched by a spacing rule (gap.*, grid.*, container.*, …) come back as Tailwind's spacing function — '--spacing(12 / 4)', not '12px' — while a %screen%-based spacing config yields a viewport unit such as '1.6779vw'. So parseInt/parseFloat return NaN on the common case, and ?? will not catch that NaN.

Use resolveTokenPx(value, fallback) whenever you need a real number (Swiper's spaceBetween is the canonical case — it does its layout math in JS):

'use client'
import { clientTokenValue, resolveTokenPx } from '@systhemaui/core/client'

resolveTokenPx(clientTokenValue('gallery.slidesGapX', 'md'), 20) // 12
resolveTokenPx('--spacing(20 / 4)', 20) // 20  — core pins --spacing: 4px
resolveTokenPx('1rem', 20) // 16
resolveTokenPx(undefined, 20) // 20  — the fallback

It handles --spacing(…) calls (evaluating the numeric expression with a small hand-rolled parser — never eval), plain px/rem/em lengths, and vw/vh/% against the live viewport. Anything it cannot turn into a finite number — an unset token, an unknown form, or a viewport unit read during server rendering — returns the fallback, so always pass the design default rather than relying on ??.

resolveTokenPx is exported from the main @systhemaui/core entry as well, because the same trap catches server-side code. Reach for it wherever a token value has to become a number.

Picking the right readerLink to this section

resolveTokenPx is deliberately lossy: it cannot tell "genuinely zero" from "could not resolve". That is fine for a layout number with a sane design default, and wrong for a decision. Two siblings cover the other cases:

You needUseBecause
A layout number, with a design defaultresolveTokenPx(value, fallback)Ergonomic; the fallback is a legitimate answer.
A number where "unresolvable" must be handledtryResolveTokenPx(value)Returns number | null — a real 0 and an unresolved token stay distinct.
A yes/no question about the token's sizeisPositiveTokenLength(value)Needs no viewport at all, so it answers identically on server and client.
import { isPositiveTokenLength, tryResolveTokenPx } from '@systhemaui/core'

tryResolveTokenPx('0px') // 0     — genuinely zero
tryResolveTokenPx('11.09vw') // null  on the server; pixels in a browser
tryResolveTokenPx('var(--x)') // null

isPositiveTokenLength('--spacing(60 / 4)') // true
isPositiveTokenLength('11.0938vw') // true, with or without a window
isPositiveTokenLength('calc(60 / 1920 * 100vw)') // true — sign-determinable
isPositiveTokenLength('calc(100% - 20px)') // false — indeterminate without layout
isPositiveTokenLength('0vw') // false

isPositiveTokenLength never converts — every unit Systhema emits scales the token's numeric component by a positive factor, so the sign is the same in every unit and readable anywhere. Use it for any gate, and never resolveTokenPx(value, 0) > 0: server-side, a viewport-unit token has no pixel answer, collapses to the 0 fallback, and the gate silently reads false for a token that is plainly positive. @systhemaui/payload's hasArticleSidePadding() — the shared "Container" width gate for six blocks — is built on it for exactly that reason.

All three readers understand the calc() shape too, within one deliberate limit: a pure *// chain over a single unit (calc(60 / 1920 * 100vw)) only scales one magnitude, so its sign follows from the numeric chain and it parses. An expression that adds or subtracts across differing scales (calc(100% - 20px)) needs a real layout to decide which side wins, so it stays unparseable — null from tryResolveTokenPx, false from isPositiveTokenLength — rather than being guessed at. A viewport-unit calc() behaves like any other vw value for pixels: a sign is knowable without a viewport, pixels are not.

Documentation URLsLink to this section

docsUrl(route, anchor?) builds a public documentation URL for core's installed version. A stable 1.7.5 installation uses /v/1.7/; a prerelease uses /next/. Pass the page route without .md or a trailing /index, and pass a heading slug as the optional second argument.

import { docsUrl } from '@systhemaui/core/client'

const help = docsUrl('cli/doctor', 'checks')

The helper is also exported from @systhemaui/core and @systhemaui/core/utils. Both client implementations share it, and it imports no token data or Node modules.