© 2026 phis.dev. All rights reserved.
Brand logo
phis.dev
  • Home
  • Docs
Three overlapping colour swatches
Themes

One look, stated once

Palette, type, shape and ground as records a Site drafts and publishes, offered by Modules and never imposed.
On this page
Where the Theme comes from
Theme blocks
site.theme field reference
Resolution into Ant Design
The stated vocabulary
Control shape
Fonts and typography
Tokens and CSS rules
Rules
phis.dev
Quicklinks
Terms & Conditions
Contact

Theme Contract

This document owns site.theme, the Theme blocks a Site follows, and the rules for Theme tokens and CSS. Shell CSS topology, the Root Background and Chrome Overlay layers, and Region chrome are owned by SHELL.md. How font files reach the page is owned by NEXT_INTEGRATION.md. Shipping Theme blocks from a Module is walked through in THIRD_PARTY_MODULES.md. Locale availability is not a Theme concern; it is resolved by @phis/server (phis-server TRANSLATIONS.md, "Locale capability").

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):
PartTypeCarries
PalettePhiThemePaletteBlock (= PhiThemePresetPlugin)palette: colour seeds and per-mode colours
StylePhiThemeStyleBlockstyle.token (structural Ant Design tokens) and shape.controls (always stated)
GroundPhiThemeGroundBlockroot: Root Background, Chrome Overlay and Chrome Shadow
FontsPhiThemeFontsBlockfonts: the family each font slot names; body is always stated
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

  • blocks?: PhiThemeBlockSelection -- see Selection and resolution.
  • 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.
See Fonts and typography.

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

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.

Widget defaults

  • 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:
  • contentMax?: number
  • 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. 1.
    structural defaults (buildPhiThemeStructuralTokens, derived from the Phi scale),
  2. 2.
    colour tokens from the palette block with theme.palette merged over it,
  3. 3.
    theme.style.token,
  4. 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.