Developer docs  /  Themes

The schema format

A section is one .fluid file in sections/ containing markup followed by an inline{% schema %} … {% endschema %} block — JSON metadata the builder reads and the rendererstrips before parsing (ThemeV2SchemaBlock). Keeping code and schema in one file means theycannot drift apart. A cut-down sections/hero.fluid:

<section class="hero"{% if Section.Settings.backgroundImage %} style="background-image:...url('{{ Section.Settings.backgroundImage }}')..."{% endif %}>
<div class="container">
<h1>{% if Section.Settings.heading %}{{Section.Settings.heading}}{% else %}Welcome to {{Organisation.Name}}{% endif %}</h1>
...
</div>
</section>

{% schema %}
{
"name": "hero",
"label": "Hero banner",
"settings": [
{ "id": "heading", "type": "text", "label": "Heading",
"info": "The large headline at the top of the banner.",
"default": "Secure storage made simple" },
{ "id": "showButton1", "type": "checkbox", "label": "Show first button", "default": true },
{ "id": "buttonLink", "type": "url", "label": "First button link", "default": "/t/spaces" },
{ "id": "backgroundImage", "type": "image", "label": "Background image" }
]
}
{% endschema %}

Schema shape (ThemeV2SectionSchema):

  • nameMeaning: The section type — must match the file name (sections/{name}.fluid); it's what Section.Type holds and what {% include Section.Type %} resolves.
  • labelMeaning: Human name shown in the builder.
  • settingsMeaning: Array of setting definitions (below).
  • blocksMeaning: Repeatable item definitions: { "type", "name", "settings": [...] }.
  • defaultBlocksMeaning: Starter block instances ({ "type", "settings": { id: value } }) seeded when the builder adds the section — and by the hydrator when a stored section has an empty block list — so it arrives with editable example content instead of nothing.
  • maxBlocksMeaning: Cap on block count in the builder.

Sections without an inline block can alternatively ship a standaloneschema/sections/{name}.json (legacy path, still read by GetSectionSchemasAsync).

Setting types the builder renders

The inspector control per type (from the admin frontend's ThemeBuilderFieldComponent):

  • textControl: single-line text input (also the fallback for unknown types) — Value shape: string
  • textareaControl: 3-row textarea — Value shape: string
  • urlControl: text input (renders via the default case) — Value shape: string
  • selectControl: native select fed by options: [{ "value", "label" }]Value shape: string
  • checkboxControl: slide toggle — Value shape: boolean
  • imageControl: URL input + upload button (uploads to the org's asset store and writes the URL back) with preview — Value shape: string (URL)
  • colorControl: colour swatch + hex text input — Value shape: string (#rrggbb)
  • rangeControl: slider; min/max/step/unit supported — Value shape: number
  • numberControl: numeric input; min/max/step supported — Value shape: number
  • locationsControl: multi-pick, orderable entity list of the org's locations — Value shape: number[] (ids, in display order)
  • categoriesControl: multi-pick, orderable entity list of categories — Value shape: number[]
  • attributesControl: multi-pick, orderable entity list of attributes — Value shape: number[]
  • attributeControl: single-pick select of attributes — Value shape: number (id)
  • spaceControl: single-pick select of spaces, with a "First available" null option — Value shape: number (id) or null

Real examples from the origin sections:

{ "id": "locationIds", "type": "locations", "label": "Locations to show",
"info": "Tick the locations you want to show, in the order you pick them; leave empty to show all locations." }

{ "id": "spaceId", "type": "space", "label": "Space to feature",
"info": "Pick the space to highlight; if none is chosen, the first available space is shown." }

{ "id": "reviewLimit", "type": "range", "label": "Reviews to show",
"min": 1, "max": 12, "step": 1, "default": 6 }

Gotcha: min/max/step/unit are declared in schema JSON and typed on the frontend, butthe backend ThemeV2SettingDefinition model doesn't carry them, so they are dropped when theschema round-trips through GetSectionSchemasAsync and the builder falls back to itsdefaults (0–100, step 1). If a range needs real bounds today, add the properties to the C#model first.

Every setting supports id, type, label, default and info (help text under thecontrol). Defaults matter beyond the inspector: ThemeV2SectionHydrator.Hydrate fills anymissing stored value from the schema default at render time, so your template can rely on adefaulted setting being present once it has a default in the schema.

Blocks

Blocks are the repeatable rows inside a section (FAQ questions, testimonials, gallery images).From sections/faq.fluid:

{% for item in Section.Blocks %}
<details class="faq-item">
<summary class="faq-question">{{ item.Settings.question }}</summary>
<div class="faq-answer">{{ item.Settings.answer }}</div>
</details>
{% endfor %}
"blocks": [
{ "type": "question", "name": "Question", "settings": [
{ "id": "question", "type": "text", "label": "Question", "default": "How do I book a storage unit?" },
{ "id": "answer", "type": "textarea", "label": "Answer", "info": "Shown when a visitor taps the question to expand it." }
]}
],
"maxBlocks": 20,
"defaultBlocks": [
{ "type": "question", "settings": {
"question": "What payment methods do you accept?",
"answer": "We accept all major credit and debit cards..." } }
]

Shared style overrides — appended to every section automatically

You never declare these in a section's schema. AdminThemeV2Service.GetSectionSchemasAsyncappends six shared settings to every section schema at read time(ThemeV2SectionStyleOverrides.AppendTo):

  • overrideTextScaleType: selectLabel: Text size override (""/0.9/1.1/1.25)
  • overrideTextColorType: colorLabel: Text colour override
  • overrideHeadingColorType: colorLabel: Heading colour override
  • overrideBackgroundColorType: colorLabel: Background override
  • overrideButtonColorType: colorLabel: Button colour override
  • overrideButtonTextColorType: colorLabel: Button text colour override

Empty value = inherit the theme. They are applied by the theme, not the platform: thesection wrapper's {% include 'section-style' %} (see §2.2) emits a style attribute thatre-scopes the global CSS variables on the wrapper. From snippets/section-style.fluid:

{%- assign so = Section.Settings -%}
{%- capture sectionStyleVars -%}
{%- if so.overrideTextScale and so.overrideTextScale != "" -%}--section-text-scale:{{ so.overrideTextScale }};font-size:calc(1em * {{ so.overrideTextScale }});{%- endif -%}
{%- if so.overrideTextColor and so.overrideTextColor != "" -%}--text-color:{{ so.overrideTextColor }};color:{{ so.overrideTextColor }};{%- endif -%}
{%- if so.overrideHeadingColor and so.overrideHeadingColor != "" -%}--heading-color:{{ so.overrideHeadingColor }};{%- endif -%}
{%- if so.overrideBackgroundColor and so.overrideBackgroundColor != "" -%}--bg-color:{{ so.overrideBackgroundColor }};--surface-color:{{ so.overrideBackgroundColor }};background:{{ so.overrideBackgroundColor }};{%- endif -%}
{%- if so.overrideButtonColor and so.overrideButtonColor != "" -%}--button-bg:{{ so.overrideButtonColor }};--button-hover-bg:{{ so.overrideButtonColor }};{%- endif -%}
{%- if so.overrideButtonTextColor and so.overrideButtonTextColor != "" -%}--button-text:{{ so.overrideButtonTextColor }};{%- endif -%}
{%- endcapture -%}
{%- if sectionStyleVars != "" %} style="{{ sectionStyleVars }}"{% endif -%}

Consequence for a new theme: your theme.css must actually style text, headings,backgrounds and buttons via --text-color, --heading-color, --bg-color / --surface-color,--button-bg / --button-hover-bg / --button-text, or the builder will show overridecontrols that do nothing. Ship a section-style snippet (copy origin's) and use the wrapper.

Theme-wide settings — schema/settings.json

The same idea at theme level. schema/settings.json declares grouped settings that appear inthe builder's design panel; saved values land in the free-form Settings.Design dictionary,keyed by setting id:

{
"name": "Origin",
"version": "1.0.0",
"groups": [
{ "id": "brand", "label": "Brand colours", "settings": [
{ "id": "primaryColor", "type": "color", "label": "Primary", "default": "#2563eb" },
{ "id": "secondaryColor", "type": "color", "label": "Secondary", "default": "#1e40af" }
]},
{ "id": "typography", "label": "Typography", "settings": [
{ "id": "bodyFont", "type": "select", "label": "Body font", "default": "system-ui, -apple-system, sans-serif", "options": [ ... ] }
]}
]
}

Origin's groups: general (colorMode: light/dark/auto), brand, status, neutrals,dark (dark-mode neutrals), typography (bodyFont, headingFont, baseFontSize, headingScale,bodyWeight, headingWeight), style (buttonRadius, cornerRadius, shadowStyle, containerWidth),branding (logoUrl, faviconUrl). The layout turns each into a CSS variable (§6.3) — adding anew design token is: one control in settings.json + one --var line in the layout + use itin theme.css. No backend changes.