Theme Contract
Where the Theme comes from
site.theme (PhiSiteTheme, gateway/site-config.ts) is part of the Site config @phis/server returns
from GET /api/v1/site. It is the Site's stored Theme record, read per request.
site.themeRevision carries publishedRevisionId and workingDraftRevisionId, the Theme scope pointers.
They are kept apart from the visual payload.
A stored record holds only which blocks the Site follows and the values its author changed. Everything
else is resolved from the blocks on every read (theme/phi-theme-composition.ts). The author's values
always win over the blocks.
resolvePhiThemeRuntimePayload(...) (theme/phi-theme-runtime.ts) folds the blocks into one ordinary
Theme record once, in PhiRootLayout. Every consumer below reads that record; nothing downstream knows
which fields a block supplied, and a resolved value is never written back.
Theme blocks
A Theme is composed of four parts, each of which a Module can ship (theme/phi-theme-blocks.ts):
A Set (PhiThemeSetBlock) names one block of each part by key and may carry a logo
(PhiSiteThemeBrandLogos). It composes, it does not contain.
Every block has key, positive integer version, title, and optional description. Core ships exactly one
block of each kind plus one Set, all keyed phis; they are the floor every selection falls back to. The core
Set's Logo is a placeholder -- a dashed frame reading "your logo" -- because that Set dresses every
installation that has stated nothing, and what stands there must be nobody's mark.
Modules contribute blocks through their Server catalog entry (types/cms-module-descriptors.ts):
themes: PhiCmsThemePresetDescriptor[] for palettes -- themeKey, title, optional description,
presetVersion, and loadPreset().
themeBlocks: PhiCmsThemeBlockDescriptor[] for the other four kinds -- blockKind is exactly style,
ground, fonts, or set, plus blockKey, title, optional description, presetVersion, and
loadBlock().
Loaders run only for an active Module. Theme selection reads only active Module descriptor bindings; global
registries and registration side effects are invalid.
Selection and resolution
theme.blocks selects { set?, palette?, style?, ground?, fonts? }, each { key, version? }.
Each part resolves on its own: an explicit part selection wins, then the Set's part, then (palette only)
theme.preset. A part that names nothing or an unavailable block runs on the core block; the selection
itself is never rewritten, so re-activating the Module restores the look.
theme.preset and theme.presetVersion name the palette the runtime payload resolves colour against.
A resolved composition reports which parts are unavailable, so the Theme workspace can say that a Module
is gone instead of presenting the core block as a choice.
A Module's Theme is offered, never taken. Installing a Module that ships blocks changes nothing a
visitor sees; its blocks join the list the Theme workspace shows, and somebody chooses. This is the one
place where a Module contribution does
not displace the shipped default, and the difference from a
Public address is worth stating, because the rule there runs the other way ("A landing is an offer", in
THIRD_PARTY_MODULES.md): an address can be held by
exactly one Module, so somebody has to win and activation decides. A look is one value among many
candidates --
@phis/example alone ships thirteen Sets -- so "the Module wins" answers nothing, and
installing a second Module with a Theme would silently repaint a Site that never asked.
Three consequences make the explicit choice the cheaper one:
A Theme is a stored record, not a declaration. It carries the author's own values, it is drafted and
published like a Page, and it survives its Module: a selection whose block is gone falls back to core and
comes back when the Module returns. An automatic selection would overwrite a decision that was made.
Adoption is one-way. The moment a Theme resolving to a Module's blocks is saved, those blocks become
the Site's own and the pictures land in its Media library (see
Adoption on save).
A look that was never chosen would become permanent the first time anybody saved anything.
Nothing chosen is visible. The core Set's Logo is the "your logo" placeholder, so a Site that has
made no choice says so in its own header. That is the prompt; an automatic Theme would remove it.
Adoption on save
Saving a Theme that resolves to a Module's palette, style (with its Control shape), ground, or fonts copies
that block into the Site's own fields (theme/phi-theme-adoption.ts). Parts the author already set are kept;
the block key stays in blocks as provenance. A picture a Module ground carries inline is uploaded into the
Site's Media library by the same save, and so is a Module Set's Logo. Core blocks are followed, never
copied -- the Set included, so the placeholder Logo never becomes a Site's own Asset.
Theme workspace selection
The Theme workspace lives at /builder/phis/ui/theme (owned by @phis/ui/modules/theme). Its Set select
offers the Module Sets plus two Site entries, site:<siteKey>:published and site:<siteKey>:draft
(theme/phi-theme-selection.ts). The initial entry is the draft when a Working Draft pointer exists, else the
published Theme when a Published pointer exists, else the core Set phis. Site ownership is read from the
revision pointers only, never inferred by comparing Theme payloads.
The workspace's Light/Dark switch selects the half of the Theme being previewed and authored; it does not
persist a mode.
site.theme field reference
All fields are optional. Absent means "follow the block" or the documented default. Persisted config stays
sparse: readers normalize absent values.
Mode
mode?: "light" | "dark" -- the half of the Theme a record or draft was authored in. The mode a viewer is
shown does not come from here:
resolvePhiThemeMode(...) (
theme/phi-theme-mode.ts) uses the viewer
preference (
light | dark | system, default
system) and, for
system, the browser's
prefers-color-scheme carried in the
phis_color_scheme cookie, falling back to
light. A live
themeMode or
theme signal to the Core Runtime Controller overrides the projection for as long as the
page stays open (
SIGNALS.md).
Where that preference lives, and why in two places at once: on the account, as user_accounts.theme_mode
(NULL means system, and the endpoint is PATCH /api/v1/auth/profile/theme), and mirrored into the
phis_theme_mode cookie, which is what resolvePhiThemeMode actually reads. The mirror is not a
convenience -- the static proxy picks a render, and the bootstrap script decides before the first paint,
and neither of them can resolve a session. The server writes the cookie on login and on every change, so
the account is what follows a person between browsers and the cookie is what this request can see.
system is the absence of the cookie rather than a third value in it.
The account's own answer also travels as runtime.viewer.preferredThemeMode, which is the choice and may
say system. runtime.viewer.themeMode beside it is the resolution and always names a half. A Settings
panel reads the first: shown the second, it would tell somebody they chose "Light" when they chose to be
asked. The pair stands to each other as preferredLocale does to locale.current.
The Header switch and the Settings panel are the same setting. The switch states two halves and writes
the account where there is one (storePhiThemeModePreferenceOnAccount); the panel is where the third
answer lives, because no switch can state "System".
Blocks
preset?: string, presetVersion?: number -- the palette key and version.
Colour
palette?: PhiThemePalette (theme/phi-theme-presets.ts), the same shape a palette block ships:
seed -- seeds shared by both modes (colorPrimary, colorInfo, colorSuccess, colorWarning,
colorError, colorLink).
modes.light / modes.dark, each with
seed -- the per-mode base seeds colorTextBase and colorBgBase;
overrides -- explicit colour tokens that are not seeds, such as colorLinkHover or colorBgLayout;
customColors -- the ten Phi custom colours custom1 through custom10;
gradients -- the three gradient slots gradient1 through gradient3, each a direction and two or
more colour stops (theme/phi-theme-gradients.ts). They are per mode for the reason a mode seed is:
the run that reads as the brand on a light page can be a hole in a dark one. A slot the palette leaves
out is derived from the mode's own resolved colours (primary, warning, info), so every Theme offers
all three without anybody drawing them. A palette block's slots and theme.palette's are merged per
slot (phi-theme-presets.ts). They reach the page as the tokens phiGradient1-phiGradient3
(with a matching …Text colour), which Ant Design writes as --ant-phi-gradient-1 and so on; a
Control or a Background reads the property and is in the right mode wherever it stands.
Style
style?: { token?: Record<string, unknown> } -- mode-free structural Ant Design tokens: radii, control
heights, spacing, typography sizes.
shape?: { controls?: PhiControlShapeCorners } -- see
Control shape.
buttons?: { shadow?: { default?, primary?, danger? } } -- one step per Button kind, each
none | soft | strong (theme/phi-button-shadow.ts). A step becomes the Button component token
(defaultShadow, primaryShadow, dangerShadow) per mode, because a shadow that reads on a light ground
disappears on a dark one. A kind left unset keeps Ant Design's tinted line.
buttons.primaryGradient?: "gradient1" | "gradient2" | "gradient3" | null -- the Theme gradient every primary
Button is filled with (theme/phi-button-shadow.ts, resolved in phi-theme-gradients.ts). Absent, primary
Buttons keep their one colour. Hover and press lay a filter over the run instead of swapping it for a
second picture, because Ant Design transitions every Button property and a swapped picture crossfades
badly. A single Button can still choose a gradient of its own with the gradient variant.
typography?: { headings?: "body" | "serif" | "display" } -- the font slot h1 to h3 take
(theme/phi-theme-typography.ts). Absent means body. A non-body choice sets --phi-font-heading on the
root; a slot without a family falls back to the body font.
components?: Record<string, Record<string, unknown>> -- Ant Design component overrides, merged per
component over the shared component defaults, never replacing the whole object.
Ground
root?: PhiSiteThemeRoot (types/site-theme.ts):
root.background.light / root.background.dark -- the Root Background per mode, on the canonical
structured Background contract (PhiCmsBackgroundWidgetConfig). A mode without one uses the resolved
Ant Design colorBgLayout.
root.chrome.light /
root.chrome.dark -- the Chrome Overlay the Header, Sider, and Footer Regions share.
Any Surface may show it as its ground with the Background Base
theme, in the mode of its tone
(
LAYOUTING.md).
root.chrome.shadow.header | sider | footer -- the Shadow each Chrome family casts at its outer edge
(PhiShadow), independent of mode.
What each layer may contain, which Regions paint it, and how a Region overrides it are owned by
SHELL.md. The ground is site-wide; there is no Area
override.
Fonts
fonts?: { body?, mono?, serif?, accent?, display? } -- five slots (PhiSiteFontSlots). A slot holds a
family name or a phis:asset/<id> reference to a Site-owned font Asset.
There is no rem setting. One rem is 16px everywhere (PHI_REM_ROOT_PX in theme/phi-css-vars.ts):
the document's font size, every length this package converts, and Ant Design's generated structure. A
Theme that wants everything larger moves fontSize and the size tokens.
Brand
brand?: PhiSiteThemeBrand, rendered by the Brand Widget. Site identity lives here and never in an Area
preset.
homeLinkTarget?: PhiLinkTarget -- where the Brand leads: a Page by reference or an external address,
the same target every link stores (
REFERENCES.md). The Page
target names the public Area, since the Brand leads there wherever it is drawn. It is resolved on the
Server in the render's one batch of link targets, and the Brand Widget is handed the address. Absent,
or a Page that no longer resolves, is the Site root in the language being read. The server's reference
index reads CMS trees and not the Theme record, so this target is not among the affected references a
path change counts.
eyebrow?: string -- the small line above the Wordmark.
logo?: { light?, dark? } -- one Logo per mode, each one of
{ sourceKind: "asset", assetId: number, url?: string } (url is resolved on read and written by the
picker alongside the id),
{ sourceKind: "url", sourceUrl: string } (a Set's inline Logo until a save uploads it),
{ sourceKind: "none" } (explicitly no Logo, so a Set's Logo does not show through).
A mode the record leaves unset shows the followed Set's Logo; where that Set is a Module's, the first
save takes it into the Media library. The core Set's is followed and never copied, like every other
core block -- its Logo is the "your logo" placeholder, and a Site must not come to own a blank.
logoAlt?: string -- falls back to the Asset's own alt text.
logoYOffset?: number -- pixels the Logo is nudged against the Wordmark, to correct the artwork's own
whitespace. Applied only where the Logo is drawn. Absent means none.
slogan?: { label?, icon? }, location?: { label?, icon? } -- the two frame lines. A line with no
label draws nothing at all, icon included; only the icon has a default (antd:star, antd:location,
PHI_BRAND_LINE_FALLBACK_ICONS), and an icon in front of an empty line would report something missing
rather than something not asked for.
wordmark?: { fontFamily?, fontWeight?, fontStyle?: "normal" | "italic", letterSpacing?, parts? } with
parts[] of { text, color?, fontWeight? }. What a part states wins over the Wordmark.
A placement never restates any of this. The Brand Widget is asked one thing -- mode, which of
lockup | logo | wordmark | slogan | location it draws here -- and reads the rest from the
record. There is no per-instance title, eyebrow, logo toggle or offset; a Brand that differs between two
places in a Site would be two Brands.
The default Public preset places the Widget four times: slogan and location in the header top,
lockup in the header main, wordmark in the footer. It writes no text for any of them. A Preset is
followed by every Site that adopts it, so a slogan written there would be in everybody's header, and a
Site that has stated neither line shows neither -- the strip keeps its shape.
What a Site gets without stating anything is therefore one thing and not four: the followed Set's Logo.
A Set points at a palette, a style, a ground and a pair of fonts, and the only value it carries is that
Logo (theme/phi-theme-blocks.ts); a Wordmark belongs to the look it was drawn for and a slogan belongs
to the Site, so neither is a Set's to offer. Where nothing is set, the Wordmark falls back to the Site's
name and then to its key, and the lines fall back to nothing.
In the Builder canvas an unwritten line is drawn as itself -- its fallback icon, and a sentence naming
which part of the Theme is empty -- because there a Widget has to be visible to be worked with, and the
slot it occupies offers no + of its own.
contact?: { label?, href?, icon? } -- the one way to reach whoever runs the Site, as the frame offers it
(helpers/brand-contact.ts). Defaults: label Contact, href mailto:info@<site hostname>, icon antd:mail.
widgets.locale?: { mode?: "label-list" | "compact-pill", showText?: boolean } -- read by the Locale
Widget; defaults compact-pill and true.
widgets.account?: { variant?: "full" | "compact" | "icon-only", showLabel?: boolean, showChevron?: boolean }
-- read by the Account Widget; its own instance config wins.
These are Site-wide presentation defaults only. Widget instance config belongs to CMS nodes, never under
theme.widgets.*.
Shell
shell?: PhiShellTheme (components/shell/shell-types.ts) -- Shell-level defaults that are not Widget
instance config:
light / dark: { background?, color? } -- global Shell surface per mode.
header, sider, footer: each with family-level light / dark and metrics, and per-Region entries
header.top | main | bottom, sider.left | right, footer.top | main | bottom. Each entry has
light / dark { background?, color? } plus height, width, collapsedWidth, sticky, offsetTop,
and zIndex.
A surface value resolves Region-specific, then family-level, then global; the dark Header main and Sider
fall back to
#001529. Metrics feed the default presets and the Shell bridge variables
(
--phi-shell-*,
theme/phi-css-vars.ts). These surfaces do not replace
root.background or
root.chrome. The variable split is owned by
SHELL.md.
Resolution into Ant Design
Per mode, resolvePhiRootTheme(...) (components/root/phi-root-theme-resolver.ts) builds one token set:
- 1.
structural defaults (buildPhiThemeStructuralTokens, derived from the Phi scale),
- 2.
colour tokens from the palette block with theme.palette merged over it,
- 3.
- 4.
fontFamily / fontFamilyCode from the resolved body / mono slots.
Ant Design's algorithm derives the rest, except the type scale: every size is set from PHI_FONT_SIZES
(theme/phi-tokens.ts) -- xs = 9, sm = 13, base = 16, lg = 21, xl = 26, xxl = 34,
display = 55 -- each with a line height of its size plus 8 px. The ladder is phi from the body size 16,
the browser's own: sm, lg, xxl and display are the Fibonacci sizes around it, xl is 16 x phi, and
xs keeps the 9 a counter or a superscript needs. Body text is base, 16. Headings h1-h5 are 34, 26,
21, 16, 13; h4 and h5 sit at the body and sm sizes and are set apart by weight and colour. sm-xl and the headings are Ant Design
tokens and may be overridden in style.token; xs, xxl and display have no token and are fixed until v1. Size tokens are numeric values such as padding = 21. Components are the shared
component defaults merged per component with theme.components, the Button shadow steps, and the Control
shape tokens.
The published Theme has one server projection and one client projection:
Ant Design's structure is not rendered. The root theme sets zeroRuntime (and hashed: false), and the
structure of every component comes from @phis/ui/antd-static.css. That specifier names no file of
this package: withPhiNextConfig from @phis/ui/next/config, which wraps the Site's next.config,
generates the structure from the Ant Design the Site installed -- once per version, into
node_modules/.cache/phis-ui/ -- and points the specifier at it. A Site updates Ant Design within the
peer range without a release of this package. Generating fails, and with it the Site's build, when
a style hook Ant Design declares is not reached; pnpm antd-css:check and the package build run the
same generation against this package's own Ant Design. The structure reads every Theme value through
var(--ant-...), so one file serves every Theme, mode and nested scope. It is imported before this package's sheets, so a rule of ours that ties
on specificity wins.
AntdRegistry in PhiRootLayout extracts what is still rendered during SSR: the --ant-* variables
Server Component markup and plain CSS use, per theme scope.
resolvePhiPublishedRootTheme(...) (server-only) emits only Phi-owned structural variables that have no
Ant Design token.
PhiConfigProvider is the single Client boundary for Ant Design locale and theme configuration. Its
usePhiConfig() value carries token, customColors, fonts, layout, mode, and presets. The Server
Root passes only serializable data across it; Client theme modules never import server resolvers or
server-only modules, and pnpm audit:graph must report no Client-to-server-only reachability.
The root resolves on the Server, for both modes: resolvePhiRootThemeState(...) in PhiRootLayout hands
PhiRootLiveThemeProvider the antd theme per mode plus the Shell Chrome Overlay's custom properties, and
the provider only picks the mode on screen, so the mode switch needs no round trip. The provider fetches
the resolver with import() only when a Theme draft arrives live from the Builder's Theme editor (the
theme signal); until it has resolved, the frame keeps the Theme it has. The resolver, the Shell region
styles and the Overlay are not on any first load, and scripts/validate-area-client-reach.mjs holds the
root to that (ROOT_SERVER_RESOLVED_THEME_FILES).
The Server resolves a Theme once per process:
resolvePhiRootThemeState(...) keeps the resolved state by
the content it was resolved from and hands it out frozen (
gateway/CACHES.md).
The stated vocabulary
The token names are Ant Design's spelling and they are this house's words too. colorPrimary,
paddingSM and controlHeight mean here what they mean there, a Site has them stored under those
names, and the Theme workspace writes them under those names. Nothing is renamed and nothing is
translated. What is owned here is the list.
theme/phi-theme-tokens.ts states it:
PHI_THEME_TOKEN_KEYS -- every name a render may read or write. PhiThemeTokens is cut from Ant
Design's GlobalToken by that list, and it is what usePhiConfig().token hands a Client Component;
PhiServerThemeTokens is cut from the same list, so the two projections cannot drift apart.
PHI_THEME_OWN_TOKEN_KEYS -- the names this house adds to Ant Design's set. paddingXXL is one:
buildPhiThemeStructuralTokens hands it over with the rest of the scale and the cssVar pass emits a
variable for every token it is given, so --ant-padding-xxl is on the page although AliasToken has
no such field. Ant Design's type does not know the name; its output does. Such a name is read as a
CSS variable, never through the hook, because it cannot be cut from a type that lacks it.
PHI_THEME_PALETTE_SEED_KEYS and PHI_THEME_STYLE_SEED_KEYS -- what a palette may seed and what a
style token may say beyond the vocabulary (wireframe, which is not a value on the page but the
switch the derivation runs under).
A stored Theme is held to the list. assertPhiThemeVocabulary reads palette.seed,
palette.modes.*.seed, palette.modes.*.overrides and style.token at the folds where they resolve,
and the palette block and the Site record separately, while they can still be told apart -- after the
merge a wrong key belongs to nobody. It throws and names every offending key with where it stood. This
is the same strictness a stored Widget config is read with: a Theme derived past a name nobody has
hides the mistake, because the page comes up and the one thing an author set is simply not there.
The bridge is where Ant Design's own spelling and types appear, and nowhere else:
theme/phi-theme-tokens.ts (the one line that derives the value types, which is also the drift check --
a name Ant Design drops stops compiling in the list rather than in the files that read it),
theme/phi-antd-token-resolver.ts (the alias derivation out of antd/es/theme/themes/*), and
components/root/phi-config-provider.tsx (the root adapter). Above the bridge everything reads the
stated names; below it one implementation turns a stored Theme into their values.
So what swapping Ant Design would mean is stated and checkable: the names are the contract, the
algorithm behind them is not. A different implementation has to answer the same list -- including the
names Ant Design derives rather than seeds, which is the part that is genuinely Ant Design-shaped today
-- and nothing above the bridge changes.
Two things stay outside the boundary on purpose:
theme.components is Ant Design's component tree (Layout.*, Menu.*, some hundreds of names) and
stays an open record. Holding it to a list would take that whole tree in as a contract for the sake of
the handful of overrides a Site writes.
The Theme workspace (plugins/runtime-modules/theme/widgets/brand-controls/client.tsx) edits these
tokens rather than reading them -- it walks them by name and writes them back -- so it lives below the
bridge with Ant Design's own types. That is a decision, not a leak.
scripts/validate-control-boundaries.mjs holds both halves. It decides which files may import Ant
Design at all, and it checks which names cross: every --ant-* variable written anywhere in the tree is
read back as a token name and must be one the list states. That is the way in that needs no import --
BUILDER.md permits those variables on purpose -- and a typo in one resolves silently to nothing.
Component tokens are the exception and each is named with its reason. A stated name nothing reads or
writes fails the check, and so does a permission nobody uses.
Control shape
theme.shape.controls is { topLeft, topRight, bottomRight, bottomLeft }, each
square | subtle | rounded | pill (theme/phi-control-shape.ts). Absent means the style block's shape (core:
rounded). Anything other than four known names throws. A record with differing corners is valid but not
drawable yet: resolving it throws.
square removes rounding; pill is a full capsule on rectangular Controls and a circle on square
icon-only Controls. Both apply at every Control size.
rounded is the numeric radius scale unchanged; subtle is that scale shifted one step smaller.
pill is derived centrally from the rendered Control height, never persisted as border-radius: 50%.
A Control that grows with its content -- a Textarea, a multiple Select whose tags wrap, a Mentions box --
keeps the capsule of ONE Control line at every height. Half of a box four rows tall is an arc across its
side, not a capsule, so pill there is half the Control height rounded up rather than the full radius.
The other three shapes state a number and are unaffected.
The shape reaches the default Control size through Ant Design component tokens and the small and large sizes
through styles/control-shape.css, which reads --phi-control-radius-* variables built from the live tokens.
Grown Controls take all three sizes from that stylesheet, the default size included: that is the size where
the component token is wrong for them, and no Button -- whose -circle and -round shapes the other rules
stay clear of -- can grow.
It governs Control bodies and triggers, plus the Menu item: a Sider entry stands one Control line high in a
column of Controls, so its selected ground rounds with them, through itemBorderRadius and
subMenuItemBorderRadius and never through Menu's own borderRadius, which draws a submenu title's arrow
and nothing else. borderRadiusSM, borderRadius, and borderRadiusLG keep
governing surfaces: Layouts, Cards, Trees, Modals, Drawers, and popups, including the popup of a
Select, Picker, or Cascader.
The choice also reaches the surfaces, on the surface scale rather than the Control one
(resolvePhiSurfaceShapeRadius): square takes no radius, subtle borderRadiusSM, rounded
borderRadius, and pill borderRadiusLG. A capsule around a grid or around a column of content is not a
thing, so pill there is the widest step of the scale and not the full radius. The number rides on the root
as --phi-surface-radius and, where a component has a token for it, as that token:
Table -- headerBorderRadius, which Ant Design's internal tableRadius and therefore the container's
top corners and a footer's bottom come from. The bottom corners are not drawn by Ant Design at all;
styles/controls.css clips them from the variable when a footer or a summary row closes the Table.
Collapse -- its panels, through collapsePanelBorderRadius, the token Ant Design derives from
borderRadiusLG and draws a panel's container, first header and last body from. It is what the
Collapsible Layout renders with.
Tree -- the node grounds through a component-level override of the global borderRadius, which is what
Ant Design's Tree reads and what it declares no token of its own for; the frame of a bordered Tree from the
variable.
The eight boxes drawn from the global borderRadiusLG, each through a component-level override of
that global: a Card's container, header, cover and actions bar; a Modal's content, which is what an
Overlay renders as; and the panels a Dropdown, a Select, a DatePicker, a Cascader, a
Popover and a collapsed Sider's Menu submenu open over the page. An overlay and a dropdown are
surfaces like any other -- what stays off them is the Control shape, not the scale. A Drawer (flush to the viewport
edge) is not a surface this answers for, and the icon tile a Card draws beside its title is an element
inside one, so it keeps the numeric scale.
Tooltip -- the same step through its own borderRadius, except under pill, where it takes half the
Control height (resolvePhiTooltipShapeRadius). The full-radius abbreviation cannot be used here: Ant
Design computes the Tooltip's minimum width from this number, so 9999 would ask for a tooltip twenty
thousand pixels wide. Half the Control height is the same capsule, because a Tooltip's minimum height is
the Control height, and a Tooltip that wraps holds that corner instead of growing an arc.
Layout -- the box of a Layout with a Surface, wherever the author configured no corner. A corner in a
custom border always wins: the step answers silence, it is not a ceiling. Outside the Provider the
variable is absent and a Layout falls back to no radius. A Layout without a Surface states no corner,
because there is no box there to round. The step is applied as longhands, so it never stands beside the
corners a configured border writes. For Phi Controls the shape wins over conflicting component radius
overrides. The Split Card is one card split in two, so its corner is simply its Surface's.
A Phi Control that draws its own surface -- the frame of a bordered Tree, and a media tile together with
the skeleton standing in for it. There is no component token to carry the step to a box phis draws itself, so
it reads the variable, bare. The Config Provider declares it on its element in the server render and mirrors
it onto the document for portals, and nothing of phis renders outside it (usePhiConfig throws there), so
an --ant-* fallback beside it would only hide a missing Provider.
Intrinsic geometry stays authoritative: Switch stays a capsule, Checkbox and Radio keep their shapes, joined
groups round only their outer boundary.
--ant-border-radius means one thing on a surface and another on a Control, so a Widget that draws its own
box around a Control must not read it. Ant Design ships a component token in CSS variable mode as a local
redefinition of the same variable on the Control element (.ant-input-css-var, .ant-select-css-var), which
is how the shape reaches the default size at all: on a surface the variable carries the surface radius, on a
Control it carries the Control shape -- under pill the full radius. A field rendered borderless whose
wrapper supplies the border and the corner therefore takes --phi-control-radius-grown-*, with no fallback
beside it. The same holds for every --phi-control-radius-* in styles/control-shape.css.
Persist the semantic value only. Pixel radii, percentages, Ant Design token names, and per-component overrides
are not part of this field.
Fonts and typography
The five slots are body, mono, serif, accent, and display. The core fonts block names
Fira Sans, Fira Mono, and Lora for the first three.
A family name is mapped through the font catalogue (theme/phi-font-catalogue.ts) to its CSS variable:
--phi-font-source-body, --phi-font-source-mono, --phi-font-source-serif for the core families, plus
the variables Modules declare. A name not in the catalogue reaches the browser as written. A
phis:asset/<id> slot is resolved to @font-face rules for the Site-owned Asset
(theme/phi-theme-fonts.server.ts).
accent and display have no core family. Widget font keys resolve them to body when unset.
Widget typography keys (types/site-theme.ts, components/widgets/helpers/):
fontFamily = inherit | system | body | mono | serif | accent | display, resolved from
usePhiConfig().fonts and the Ant Design token; system is the token fontFamily.
fontSize = inherit | xs | sm | base | lg | xl | xxl, resolved to PHI_FONT_SIZES.xs, fontSizeSM,
fontSize, fontSizeLG, fontSizeXL, PHI_FONT_SIZES.xxl. display is not offered there.
Sizes use Ant Design tokens and --ant-font-size* variables; there is no --phi-font-size-* or
--phi-font-family-* contract.
Declaration, preloading, Module font contributions, and Site-owned font files:
NEXT_INTEGRATION.md.
Tokens and CSS rules
theme.palette and theme.style.token, resolved over the followed blocks, are the only source of Ant Design
design tokens. cssVar output and SSR --ant-* variables derive from that source. The px2rem pass over
the generated structure is a rem bridge, not a token source.
Client Components read Ant Design semantics from usePhiConfig().token (or theme.useToken() inside a
nested Ant Design provider). Plain CSS and Server Components use var(--ant-...). Server Components never
depend on ConfigProvider or client theme hooks.
--phi-* is reserved for Phi-owned structural or technical values that Ant Design does not model -- Shell
geometry, Builder scaffold geometry, effects, font sources, Control shape radii. Do not
mirror Ant Design colours, spacing, radii, typography, shadows, control sizes, or motion into --phi-*.
Custom colours are data in usePhiConfig().customColors, never global CSS variables; consumers do not
reconstruct them from CSS or substitute semantic tokens. The Theme workspace is the only place that supplies
an unpublished draft palette explicitly.
A component-scoped custom property may bridge a live token into a selector or pseudo-element; it is an
implementation detail, not a second theme API.
Use inline styles for geometry and per-instance layout values. Do not hydrate token-derived visual end
values (color, background, border*, boxShadow) through inline styles in Client Components when a
class or CSS variable expresses them; browsers normalize them and cause hydration mismatches.
PHI_* constants (theme/phi-tokens.ts) are seed and default values, not an active runtime theme source.
Stable Shell dimensions come from theme.shell, with PHI_* only as defaults.
Server components may read runtime.site.theme only for Site-owned contracts Ant Design does not model,
such as Shell, Brand, and Contact.
Prefer Ant Design theme and component tokens and component variants over CSS overrides. Hardcoded colours,
radii, typography values, or bespoke tuning are added only on explicit operator request.
ConfigProvider carries Ant Design configuration only, never Phi Site or runtime config. Provider-level
component styles are never hardcoded in the root layout; visual defaults flow through the shared token
helpers and stay overridable by site.theme.
Feature code persists semantic Phi values (for example theme.shape.controls); adapter token names and CSS
values are resolved centrally and never appear in Module or Widget config.
Phi scale
The base spacing scale is global across Phi Sites (PHI_PADDING, theme/phi-tokens.ts):
xxs = 3, xs = 8, sm = 13, base = 21, md = 34, lg = 55, xl = 89, xxl = 144.
It is one scale for every distance a Phi surface states, inside a frame (padding) and between two things
(gap) alike: server styles and presets write PHI_SPACE (var(--ant-padding-*)), and the spacing
choices in the Inspector offer that one scale. Ant Design's margin* family is seeded from the same
sequence because its own components read it; Phi code does not write it.
Site Themes override from that base; they do not replace it with an unrelated spacing system. Theme resolution
has three layers: the root CSS baseline, the default Ant Design theme derived from the Phi scale, and the Site's
site.theme values.
CSS files
PhiRootLayout imports antd/dist/reset.css directly and hosts the providers; it does not own Shell
topology.
styles/root.css holds the base html / body rules, root-level effect rules, and small utilities.
styles/control-shape.css applies the Control shape to small and large Control sizes.
styles/shell.css and Region chrome:
SHELL.md.
styles/layout.css,
layout-authoring-scaffold.css,
layout-affordances.css, and
builder-scaffold.css:
LAYOUTING.md. Builder scaffold styles are imported only by Builder authoring,
never by the Root or a Public runtime entry.
Rules
site.theme holds Site-wide presentation defaults. It is not the page-metadata owner; page title and
description come from the Page record.
Widget instance config lives on CMS nodes; Area presets own Region, Layout, and Widget structure.
A Widget or Layout is a shell around Ant Design: inside one, Ant Design is free, and a Widget may arrange its
own Controls with Flex, Space, Row, or Col. Composing several Widgets belongs to Layouts and their
slots only, never to an Ant Design layout primitive or hand-written CSS. A Widget never contains another
Widget.