# Sections, blocks and element styles (/themes/elements)

You declare every section and block a theme adds **once, in TSX**, next to its component.
One `defineSection({...})` or `defineBlock({...})` object carries the type key, the picker
label and icon, the inspector settings, the accepted children, the style capabilities and
the component. The theme compiler reads these objects without running any code, and builds
the Customizer's catalogue, the code editor's layout JSON Schema and the publish checks from
them. There is no separate manifest file.

Layout and content stay in JSON beside the code: `pages/*.json`, `pages/custom/*.json`,
`layout/*.json` and `settings/values.json`. The Customizer and the code editor edit the same
files. The Customizer never changes `theme.json`.

## A section [#a-section]

```tsx title="src/sections/quote-wall.tsx"
import { EditableText, Slot, defineBlock, defineSection, field } from '@poltura/theme-sdk/elements';
import { cn } from '@poltura/theme-sdk/ui';

export const wallQuote = defineBlock({
  type: 'wall-quote',
  label: 'Quote',
  icon: 'quote',
  category: 'content',
  settings: {
    quote: field.textarea({ label: 'Quote', default: 'Best purchase this year.' }),
    author: field.text({ label: 'Author' }),
  },
  style: ['typography', 'color', 'spacing'],
  component: ({ settings }) => (
    <figure>
      <EditableText setting='quote' as='blockquote' className='text-lg' />
      {settings.author ? <figcaption>{settings.author}</figcaption> : null}
    </figure>
  ),
});

export const quoteWall = defineSection({
  type: 'quote-wall',
  label: 'Quote wall',
  description: 'Customer quotes in a grid',
  icon: 'quote',
  category: 'marketing',
  templates: ['home', 'page'],
  settings: {
    heading: field.text({ label: 'Heading', default: 'What customers say' }),
    columns: field.range({ label: 'Columns', min: 1, max: 4, default: 3 }),
  },
  blocks: { accepts: [wallQuote], max: 12 },
  style: ['spacing', 'background', 'visibility'],
  component: ({ attrs }) => (
    <section {...attrs} className={cn(attrs.className, 'theme-container py-14')}>
      <EditableText setting='heading' as='h2' className='mb-8 text-3xl font-semibold' />
      <Slot className='grid gap-6 md:grid-cols-3' />
    </section>
  ),
});
```

- `type` is the key layout JSON uses. Keys are unique per theme; reusing a platform key
  (`hero`, `heading`, `button`, `link-list`…) replaces the platform element everywhere.
- `settings` is an object literal. Its keys are the setting ids, its order is the inspector
  order, and the component's `settings` prop is typed from it with defaults applied and the
  shopper's language resolved. Every component receives `{ settings, attrs, id }`. A
  component written outside the definition, over a settings object kept in a `const`, is
  typed `ElementProps<typeof schema>`.
- `blocks.accepts` lists child types: imported definitions, type keys, or a wildcard (below).
  `blocks.max` caps the count.
- `templates` limits a section to template ids (`home`, `product`, `collection`, `page`, …).
  Omit it to offer the section everywhere.
- `slots: [{ name: 'media', label: 'Media' }, …]` declares named regions; render each with
  `<Slot name='media' />`.
- A `defineSection` component **must spread `attrs` onto one root node**. That node carries the
  element id the Customizer selects, the merchant's anchor and the scoped CSS of the merchant's
  Style tab. The scoped CSS targets a class the spread brings in `attrs.className`, so the
  node's own classes are merged into it with `cn(attrs.className, 'py-14')` from
  `@poltura/theme-sdk/ui`. A plain `className='py-14'` after the spread replaces that class:
  the merchant's spacing, background and visibility then do nothing, and the theme check
  reports it.
- A block needs nothing on its root: the platform draws every block inside its **frame**, a
  `div` (or the `li` or `span` its `frame` names) that carries the editor anchor, the in-page
  anchor and the merchant's style, and hands the block an empty `attrs`. Merchants can restyle
  every block without extra work from you.
- `context: { requires: ['product'] }` declares that a block reads the nearest product
  (`useNearestProduct` from `@poltura/theme-sdk/product`); `provides` declares the scopes an
  element opens for its children. The product template, every product card and a
  `ProductScope` open a product scope. Collection, category and all-products pages open a
  collection scope, whose listing `useListing` from `@poltura/theme-sdk/listing` reads. The
  Customizer refuses a block that requires a scope anywhere outside one.
- `presets` are named starting configurations (settings, style, child elements). Only a preset
  with the id `default` is applied when a merchant inserts the plain tile. Every other preset
  is its own tile in the picker, right after the element, labelled `Element (Preset)`. Name the
  preset a container should start with `default`, or it lands empty.
- `keywords: ['kosárba', 'ATC']` adds picker search terms. On sections, `groups: ['header']`
  offers the section in the header or footer group and `limit: 1` caps its instances per
  template or group.

### What "static" means [#what-static-means]

The compiler evaluates everything except `component` without running it. Allowed: literals,
`field.*` calls with literal arguments, `field.group('Label')` and `field.note('…')`,
references to `const` literals in the same file, and imported definitions in `accepts`.
Anything else (a function call, a spread from another module, a computed key) is a `schema`
error in the theme check and in the code editor, with its source position. `component` may be a local
function or an inline arrow, and one component may back several types.

### Section-owned blocks [#section-owned-blocks]

Every block has a `component` and draws itself through a region. The theme check reports
`accepted-without-component` for an accepted block without one. A block that only one section
accepts by name belongs to that section: the picker offers it there and nowhere else, as
with the default theme's FAQ split questions and mega menu columns.

## Field builders [#field-builders]

`field` comes from `@poltura/theme-sdk/elements`. Every builder takes `label`, and optionally
`description`, `placeholder`, `visibleIf: { field, value }`, `translatable`, `content`,
`renamedFrom` and `purpose` (one line on what the value says, which briefs whoever writes it).
Most also take a `default`; `video` and the catalogue pickers (`product`, `collection` and the
like) do not. `text`, `textarea` and `richtext` also take `maxLength`, the most characters the
design holds. `select` and `radio` also take `renamedValues: { old: 'new' }`: a store that saved an
option value you renamed keeps its choice under the new value. An update that renames a value asks
the merchant first, and an update that drops an option nothing renames is not published.

| Builder                                              | Value                                 |
| ---------------------------------------------------- | ------------------------------------- |
| `text`, `textarea`, `richtext`                       | string, translatable by default       |
| `number`, `range` (`min`, `max`, `step`, `unit`)     | number                                |
| `checkbox`                                           | boolean                               |
| `select`, `radio` (`options`; radio may add `icon`)  | the union of option values            |
| `color` (`darkDefault` adds `<id>_dark`), `gradient` | string                                |
| `image`                                              | a `media://` or `asset://` reference  |
| `url`                                                | a store path or an absolute URL       |
| `video`                                              | a source, or `{ src, poster?, autoplay?, loop?, muted?, alt? }` |
| `link` (`withLabel`)                                 | `{ href, new_tab?, rel?, label? }`    |
| `font`                                               | CSS font stack                        |
| `linkList`                                           | links with optional children          |
| `product`, `collection`, `category`                  | one id                                |
| `products`                                           | id array                              |
| `collectionList` (`max`, 12 unless set)              | `{ kind: 'collection' \| 'category', handle }[]` in the merchant's order |
| `page`                                               | a content page path such as `/about`; read with `usePage` |
| `post`                                               | `{ kind: 'post' \| 'guide', slug }`; read with `usePost` |
| `menu`                                               | a menu handle, which `useMenu` reads  |
| `menuItem`                                           | the id of one top-level link of the menu the element sits in |
| `list` (`fields`, `min`, `max`, 24 rows unless set)  | the rows' settings, in order          |
| `colorScheme`                                        | a named colour scheme of the theme; empty keeps the parent's, read with `useColorScheme` |
| `colorSchemeGroup`                                   | the theme's colour schemes, as a theme setting |
| `partnerBadges`                                      | `{ order, hidden }` badge keys        |
| `icon`                                               | a Lucide icon name or an uploaded SVG |
| `datetime`                                           | ISO 8601 string with offset, or empty |
| `copy` (`key`)                                       | none: edits a string in `texts/`      |
| `group('Label')`, `note('Text')`                     | none: inspector heading and help text |
| `variable`                                           | email only: the name of one of the email's variables |
| `keyValues`                                          | email only: rows of a translated label beside a variable value |
| `steps`                                              | email only: numbered rows of a translated title and description |
| `links`                                              | email only: rows of a translated link text and the variable holding its address |
| `html`                                               | email only: HTML sent as written      |
| `promotion`                                          | email only: the id of a store promotion that has a code |

`fieldSet` from the same module groups settings that several elements share, such as a button's
label and link: calling the set with a prefix spreads its fields into a schema as `<prefix>_<key>`.
`defineThemeSettings` declares the theme settings panel in `src/settings.ts` with the same `field.*` builders.

An `icon` setting picks from the full [Lucide](https://lucide.dev/icons) set (`shopping-bag`,
`truck`) or holds an SVG the merchant uploaded, sanitized and stored inline. Draw it with
`<Icon value={settings.icon} />` from `@poltura/theme-sdk/ui`: the storefront inlines only the
icons the page uses, so no icon library reaches the browser.

A `partnerBadges` setting holds the badges of the store's live feeds as
`{ order: [...], hidden: [...] }`: every live badge not in `hidden` shows, the keys in `order`
first, the rest in the order the feeds publish them, so a badge that goes live later appears on
its own. Keys of paused feeds stay in the value for when they return.

A `datetime` setting is picked with a calendar and a time on the store's clock and stored with
its offset (`2026-12-31T23:59:00+01:00`). `useCountdown` from `@poltura/theme-sdk/content` reads it the
way the platform countdown does: a bare date starts at midnight and a time without an offset is
read on the store clock, both in the store time zone.

A `link` setting is picked from the store's pages, products, collections, categories and forms,
or typed as an address, with a new-tab switch and, for other sites, `nofollow` and `sponsored`.
`default` is the starting href (`field.link({ label: 'Link', default: '/products' })`), and a
plain string in a layout file reads as the href. Pass the value to `Link` from
`@poltura/theme-sdk/routing`, which checks the href, applies the new tab and `rel`, and adds
`noopener noreferrer` to a new tab:

```tsx
<Link href={settings.cta} className='pol-cta' />
```

`withLabel: true` also asks for the link's visible text, stored as `label`; `Link` shows it when
it wraps nothing. That text is translated per language while the href, tab and `rel` stay the
same in every language.

A `richtext` setting is edited with a toolbar (bold, italic, links that may open in a new tab,
bulleted and numbered lists, level 3 and 4 headings; Cmd/Ctrl+B, I and K) and stored as text
with a small markup, never HTML: `**bold**`, `*italic*`, `[label](/path)`,
`[label](https://…){target=_blank}`, `- item`, `1. item`, `### Heading`, `#### Subheading`, and a
blank line between paragraphs. Render it with `EditableRichText` from
`@poltura/theme-sdk/elements`, which draws it and lets merchants edit it on the canvas. Store copy that arrives as rich text, such as a product description, is drawn
with `RichText` from `@poltura/theme-sdk/content`.

A `font` setting holds a CSS font stack from the font picker, such as
`ui-serif, Georgia, Cambria, serif`; apply it with `style={{ fontFamily: settings.font }}`. The
system stacks always render; a web font renders once the theme loads it. `number` and `range`
take a `unit` (`px`, `%`, `em`, `rem`, `vh`, `vw`, `ms`, `s`, `deg`) shown beside the value; the
stored value stays a plain number.

A `video` setting takes a YouTube or Vimeo link, or an MP4 or WebM file the merchant uploads, up
to 20 MB, with a cover image, how it plays (autoplay, loop, muted) and a text alternative.
`useVideoSetting` from `@poltura/theme-sdk/media` resolves it to the file or player to draw.

Merchant text in `text`, `textarea` and `richtext` fields is translated through the store's
languages (`settings_i18n` in the layout file). `style` is never translated.

`image`, `video`, `url` and `link` settings are shared by every language until a merchant gives
one language its own (a Hungarian banner, a `/hu/…` link) with "Use a different one for …" in the
Customizer. That value is stored in `settings_i18n[<locale>]` like translated text, is never
machine translated, and "Use the … one" drops it so the language shares the base value again.
The `settings` prop already carries the shopper's language, so an element needs nothing extra
to show the right image.

Image and video settings also offer the store's media library: every image and video uploaded
through the store admin, searchable by file name. Unused files can be deleted from the picker.

### Look and content [#look-and-content]

Every field is either look, which a style preset may overwrite, or content, which a preset
never touches. Colours, gradients, fonts, selects, radios, numbers, ranges and checkboxes are
look; everything else is content. `content: true` keeps a look-typed value that is really the
merchant's choice, and `content: false` makes a content-typed field part of the look:

```tsx
showReviews: field.checkbox({ label: 'Show reviews', default: true, content: true }),
```

Global settings in `src/settings.ts` take the same `content` key; see
[Style presets](/themes#style-presets).

## Style capabilities [#style-capabilities]

`style` lists the Style tab groups an element offers. The inspector shows only those, and the
theme check and the Customizer report stored style outside them.

| Capability         | Controls                                                                                         |
| ------------------ | ------------------------------------------------------------------------------------------------ |
| `layout`           | display, direction (and reversed), wrap, justify, align, gap, columns, overflow                  |
| `align`            | the element among its siblings: placement left, centre or right, grow, order, sticky, stacking   |
| `spacing`          | padding and margin boxes                                                                         |
| `size`             | width (including shares such as `1/3`), max width, min height, aspect ratio                      |
| `media`            | how the element's own image or video fits its box, and its focal point                           |
| `typography`       | size, weight, alignment, transform, tracking, leading, font role, italic, decoration, line clamp |
| `color`            | text colour (a role or a custom colour), hover text colour                                       |
| `background`       | background colour (role or custom) or gradient, hover fill                                       |
| `border`           | width and single sides, style, colour, radius and single corners, hover border colour            |
| `effects`          | shadow, opacity, filters, backdrop blur, and on hover: shadow, opacity, lift, filters            |
| `background-media` | background image and video per device, fit, focal point, fixed scrolling, overlay                |
| `motion`           | entrance animation played when the element scrolls into view                                     |
| `dividers`         | shape dividers on the top and bottom edge of a section                                           |
| `visibility`       | hide on desktop, tablet or mobile                                                                |

Values are **tokens**, never pixels. Colours may also be a strictly validated custom colour.
Any value may be responsive, `{ "base": "lg", "tablet": "md", "mobile": "sm" }`, with tablet
below `64rem` and mobile below `48rem`. Tablet inherits `base`, and mobile inherits tablet. Without `base`, desktop keeps the
element's own design and only the overridden devices change.

### Style tokens [#style-tokens]

| Style path                                                          | Tokens                                                                                                                                                                                                                                                     | CSS variable                      |
| ------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- |
| `spacing.padding.*`, `layout.gap`                                   | `none`, `3xs`, `2xs`, `xs`, `sm`, `md`, `lg`, `xl`, `2xl`, `3xl`                                                                                                                                                                                           | `--pt-sp-*`                       |
| `spacing.margin.*`                                                  | the spacing scale plus `auto`                                                                                                                                                                                                                              | `--pt-sp-*`                       |
| `size.minHeight`                                                    | the spacing scale plus `half-screen`, `screen`                                                                                                                                                                                                             | `--pt-sp-*`, `svh`                |
| `size.width`                                                        | `auto`, `full`, `fit`, or a share of the parent: `1/4`, `1/3`, `1/2`, `2/3`, `3/4`                                                                                                                                                                         | none                              |
| `size.aspect`                                                       | `auto`, `1:1`, `4:3`, `3:2`, `16:9`, `21:9`, `3:4`, `2:3`, `4:5`                                                                                                                                                                                           | none                              |
| `media.fit`, `media.position`                                       | `cover`, `contain`; `top-left`, `top`, `top-right`, `left`, `center`, `right`, `bottom-left`, `bottom`, `bottom-right`                                                                                                                                     | none                              |
| `size.maxWidth`                                                     | `narrow`, `prose`, `content`, `wide`, `full`                                                                                                                                                                                                               | `--pt-maxw-*`                     |
| `typography.size`                                                   | `xs`, `sm`, `body`, `lg`, `h4`, `h3`, `h2`, `h1`, `display` (sets the paired line height)                                                                                                                                                                  | `--pt-fs-*`                       |
| `typography.weight`                                                 | `regular`, `medium`, `semibold`, `bold`                                                                                                                                                                                                                    | `--pt-fw-*`                       |
| `typography.leading`                                                | `tight`, `normal`, `relaxed` (wins over the size's paired line height)                                                                                                                                                                                     | `--pt-lh-*`                       |
| `typography.tracking`                                               | `tight`, `normal`, `wide`                                                                                                                                                                                                                                  | `--pt-tracking-*`                 |
| `typography.align`                                                  | `left`, `center`, `right`, `justify`                                                                                                                                                                                                                       | none                              |
| `typography.transform`                                              | `none`, `uppercase`, `lowercase`, `capitalize`                                                                                                                                                                                                             | none                              |
| `typography.family`                                                 | `heading`, `body`, `accent` (the theme's font roles)                                                                                                                                                                                                       | `--pt-font-*`                     |
| `typography.style`, `typography.decoration`                         | `normal`, `italic`; `none`, `underline`, `line-through`                                                                                                                                                                                                    | none                              |
| `typography.clamp`                                                  | `none`, or at most `1` to `4` lines                                                                                                                                                                                                                        | none                              |
| `layout.display`                                                    | `block`, `flex`, `grid`                                                                                                                                                                                                                                    | none                              |
| `layout.direction`, `layout.wrap`                                   | `row`, `column`, `row-reverse`, `column-reverse`; `wrap`, `nowrap`                                                                                                                                                                                         | none                              |
| `layout.overflow`                                                   | `visible`, `hidden`, `clip`, `auto`                                                                                                                                                                                                                        | none                              |
| `layout.justify`                                                    | `start`, `center`, `end`, `between`, `around`                                                                                                                                                                                                              | none                              |
| `layout.align`                                                      | `start`, `center`, `end`, `stretch`                                                                                                                                                                                                                        | none                              |
| `layout.columns`                                                    | whole numbers `1` to `6` (with `display: grid`)                                                                                                                                                                                                            | none                              |
| `layout.self`                                                       | `left`, `center`, `right` (the `align` capability)                                                                                                                                                                                                         | none                              |
| `layout.grow`, `layout.order`                                       | `fill`, `fit`; `first`, `1` to `6`, `last` (the `align` capability)                                                                                                                                                                                        | none                              |
| `layout.position`, `layout.top`                                     | `static`, `relative`, `sticky`; `header` (the default) or the spacing scale                                                                                                                                                                                | `--pt-sticky-header`, `--pt-sp-*` |
| `layout.zIndex`                                                     | `base`, `raised`, `overlay`, `top`                                                                                                                                                                                                                         | `--pt-z-*`                        |
| `color.text`, `border.color`                                        | colour roles: `foreground`, `muted-foreground`, `primary`, `primary-foreground`, `secondary`, `secondary-foreground`, `accent`, `accent-foreground`, `background`, `card`, `card-foreground`, `muted`, `border`, `destructive`; or a custom colour (below) | `--<role>`                        |
| `color.background`                                                  | a colour role, a custom colour, or `linear-gradient(<0-999>deg, #hex <0-999>%, …)` with 2 to 8 hex stops                                                                                                                                                   | `--<role>`                        |
| `border.width`, `border.sides.*`                                    | `none`, `thin`, `medium`, `thick`; `sides` takes `top`, `right`, `bottom`, `left`                                                                                                                                                                          | `--pt-border-*`                   |
| `border.style`                                                      | `solid`, `dashed`, `dotted` (a width without a style draws solid)                                                                                                                                                                                          | none                              |
| `border.radius`, `border.corners.*`                                 | `none`, `sm`, `md`, `lg`, `pill`; `corners` takes `topLeft`, `topRight`, `bottomRight`, `bottomLeft`                                                                                                                                                       | `--pt-radius-*`                   |
| `effects.shadow`                                                    | `none`, `sm`, `md`, `lg`                                                                                                                                                                                                                                   | `--pt-shadow-*`                   |
| `effects.opacity`                                                   | percent, `0` to `100` in steps of `5`                                                                                                                                                                                                                      | none                              |
| `hover.text`, `.background`, `.borderColor`                         | as `color.text`, `color.background` and `border.color`                                                                                                                                                                                                     | `--<role>`                        |
| `hover.shadow`, `.opacity`, `.lift`                                 | as `effects.shadow` and `effects.opacity`; lift `none`, `sm`, `md`, `lg`                                                                                                                                                                                   | `--pt-lift-*`                     |
| `hover.transition`                                                  | `none`, `fast`, `normal`, `slow` (`normal` when a hover value is set)                                                                                                                                                                                      | `--pt-duration-*`                 |
| `effects.blur`, `effects.backdropBlur`                              | `none`, `sm`, `md`, `lg`, `xl`                                                                                                                                                                                                                             | `--pt-blur-*`, `--pt-backdrop-*`  |
| `effects.brightness`, `.contrast`, `.saturate`                      | percent, `0` to `200` in steps of `5` (`100` is unchanged)                                                                                                                                                                                                 | none                              |
| `effects.grayscale`                                                 | percent, `0` to `100` in steps of `5`                                                                                                                                                                                                                      | none                              |
| `hover.blur`, `.brightness`, `.contrast`, `.grayscale`, `.saturate` | as the `effects` filters; each replaces only its own filter                                                                                                                                                                                                | as above                          |
| `background.image`, `background.video`                              | a same-site path (`/media/…`) or an `http(s)` URL of at most 512 URL-safe characters, or `none` to drop it on a device                                                                                                                                     | none                              |
| `background.poster`                                                 | an address as above; one value for every device                                                                                                                                                                                                            | none                              |
| `background.fit`, `background.position`                             | as `media.fit` and `media.position`                                                                                                                                                                                                                        | none                              |
| `background.attachment`                                             | `scroll`, `fixed`                                                                                                                                                                                                                                          | none                              |
| `background.overlay`, `.overlayOpacity`                             | a colour role or custom colour; percent in steps of `5` (`50` when unset)                                                                                                                                                                                  | `--<role>`                        |
| `motion.entrance`                                                   | `none`, `fade`, `fade-up`, `fade-down`, `zoom`, `slide-left`, `slide-right`                                                                                                                                                                                | none                              |
| `motion.duration`, `motion.delay`                                   | `fast`, `normal`, `slow`; `none`, `sm`, `md`, `lg`                                                                                                                                                                                                         | `--pt-enter-*`, `--pt-delay-*`    |
| `motion.repeat`                                                     | `once`, `repeat`; one value for every device                                                                                                                                                                                                               | none                              |
| `dividers.top`, `dividers.bottom`                                   | `pattern` (one value): `wave`, `curve`, `tilt`, `triangle`, `zigzag`; `height`: `none`, `sm`, `md`, `lg`, `xl`; `color`: a colour; `flip`: `none`, `mirror`                                                                                                | `--pt-divider-*`                  |
| `visibility.desktop`, `.tablet`, `.mobile`                          | `false` hides the element on that device only                                                                                                                                                                                                              | none                              |

A **custom colour** is exactly `#rgb`, `#rrggbb`, `#rrggbbaa`, `rgb(r, g, b)` or
`rgba(r, g, b, a)` (channels `0` to `255`, alpha `0` to `1`, one optional space after each
comma). Colour names, `var(…)`, `hsl(…)` and padded strings are rejected. It is written into the
CSS as is. A link or button root with a background colour also gets a hover shade of that
colour, mixed toward its text colour, because the element's own fill replaces the variant's
hover colour.

**Placement** (`layout.self`, offered by the `align` capability) moves the element's own box
with auto inline margins, so it works where `typography.align` cannot: a button, a countdown, a
price or a capped image. It places the element in a flex or grid parent (every platform
container) and, in a block parent, any box narrower than it. A placed element keeps its own
width instead of stretching; an element that is full width by design shows its placement once
`size.maxWidth` caps it. `typography.align` only aligns the text inside the box. Give `align` to
any element a merchant drops freely into a container.

**Shares and order.** A `size.width` share is a percentage of the parent; in a flex row the
shares shrink together to make room for the gap, so `1/3` and `2/3` sit side by side.
`layout.grow: fill` makes a child take the space left in its row. `layout.order` and the
reversed directions change only the visual order, per device: "image right on desktop, image
first on phones" is `direction: { base: "row-reverse", mobile: "column" }`.

**Sticky** (`layout.position: sticky`) keeps the element in view from `layout.top`, below the
store header by default, and stops it stretching to its row, which would leave it nowhere to
stick. `layout.zIndex` also positions the element so its level applies. `header` follows the
header as it hides, reveals or shrinks (see [The store header](#the-store-header)).

**Borders.** `border.width` and `border.radius` set every side and corner; `border.sides` and
`border.corners` override single ones, so a top rule is `{ "sides": { "top": "thin" } }`. On a
divider (an `<hr>` root) an all-sides width draws one line, never two stacked ones.

**Aspect and media.** `size.aspect` shapes the box. The element's own image or video, a direct
child of its root, then fills the box, cropped by `media.fit` (cover unless set) around the
`media.position` focal point.

**Colour scheme.** The colour scheme is a setting, not a style: every section and every block
that draws a box takes `settings.color_scheme`, the id of one of the theme's named colour schemes
(the `color_schemes` theme setting). The box and everything inside it are drawn in the scheme's
colours, so every child that uses a colour role reads on it without restyling; empty keeps the
parent's scheme. An explicit `color.text` or `color.background` wins over the scheme.

**Hover.** The `hover` group holds what changes while the pointer is over the element, it has
keyboard focus, or the Customizer previews it (`data-pe-state="hover"` on the root). Each leaf
comes with the capability of its resting counterpart. A hover colour replaces a gradient fill
too, and a gradient without a hover fill gets a shade of its own. Transitions run at
`--pt-duration-*`, which the host sets to zero for shoppers who ask for reduced motion.

**Backgrounds** (`background-media`). An image and a video sit behind the content, above the
fill colour or gradient, with an overlay colour above both. Each device may have its own image
or video, or `none`; only the one a device shows loads. `fixed` keeps the image still while the
page scrolls over it, except for shoppers who ask for reduced motion. The video is muted and
loops, loads only as it nears the viewport, plays only while on screen, and stays paused on its
first frame or `poster` under reduced motion. The overlay colour resolves inside the box, so a
role follows its colour scheme: a dark scheme with a `background` overlay tints the image with
the scheme's ground and turns the text light. Addresses reach only DOM attributes, never CSS.
Platform Container, Column and the Custom section draw it; a theme element that declares
`background-media` renders `<ElementBackground />` from `@poltura/theme-sdk/elements` as the
first child of its root.

**Filters** (`effects`). Brightness, contrast, greyscale, saturation and blur apply to the
element's own pixels in a fixed order; `backdropBlur` frosts what shows through a translucent
fill, for glass panels. A hover filter replaces only its own function and animates with the
hover transition, so a greyscale logo wall can colour up on hover.

**Entrances** (`motion`). An element with an entrance carries `data-pe-motion` in the server
markup and nothing that hides it. Once JavaScript runs, the storefront holds it at its first
frame and plays the preset when a sixth of it is in view: `once`, or every time it comes back
into view. Nothing is hidden without JavaScript, and nothing moves for shoppers who ask for
reduced motion. `none` on a device turns an entrance off there. The Customizer replays an
entrance from the Motion group.

**Shape dividers** (`dividers`). A built-in inline SVG on the section's top or bottom edge,
hidden from assistive technology, in the page background colour unless set, so the band looks
cut. The pattern is one value for every device; set the height to `none` to drop it on a
device. The Custom section draws them; a theme section that declares `dividers` renders
`<ElementDividers />` from `@poltura/theme-sdk/elements` inside its root.

Anything else (a pixel value, an unknown key, a `url(…)`, a free CSS string) is dropped before
it reaches CSS and reported with its JSON path by the Customizer, the theme check and the code
editor, so a hand-edited layout file can never inject CSS.

```json
{
  "id": "q1",
  "type": "wall-quote",
  "settings": { "quote": "Fast delivery." },
  "settings_i18n": { "hu": { "quote": "Gyors szállítás." } },
  "style": {
    "typography": { "size": "h3" },
    "spacing": { "padding": { "top": { "base": "md", "mobile": "sm" } } }
  }
}
```

Tokens resolve to neutral CSS variables (`--pt-sp-*`, `--pt-fs-*`, `--pt-lh-*`, `--pt-fw-*`,
`--pt-radius-*`, `--pt-shadow-*`, `--pt-maxw-*`, `--pt-gutter`, `--pt-font-*`, `--pt-z-*`, `--pt-lift-*`,
`--pt-duration-*`, `--pt-sticky-header`, `--pt-blur-*`, `--pt-backdrop-*`,
`--pt-enter-*`, `--pt-delay-*`, `--pt-divider-*`, `--pt-page-background`) and colour roles to the theme's `--primary`,
`--foreground` and the rest. The platform ships defaults. Map the neutral layer onto your own
scale in `src/styles/tokens.css` so merchant choices look native:

```css title="src/styles/tokens.css (excerpt)"
:where(:root) {
  --pt-sp-md: var(--my-space-4);
  --pt-fs-h1: var(--my-font-h1);
  --pt-radius-md: var(--my-radius);
  --pt-font-heading: var(--my-display-font);
  --pt-font-body: var(--my-text-font);
  --pt-font-accent: var(--my-accent-font);
  --pt-maxw-content: var(--my-container);
  --pt-gutter: var(--my-gutter);
}
```

`--pt-maxw-content` and `--pt-gutter` are your `.theme-container`: its border-box max width and
its side padding. A boxed Custom section insets its content by exactly that pair, so map both
to the values your container uses or merchant sections will not line up with yours.

The storefront compiles each section's style into one `<style>` rule set scoped to
`[data-pe="<id>"]` at render time, which is why a defined section's root must carry `attrs`.

### Where platform elements take their style [#where-platform-elements-take-their-style]

- **Grid-native elements.** Columns, Product grid and Collection list render a grid of their
  own (`layout_default: 'grid'` in their schema). Direction or wrap never turn them into flex;
  only an explicit `layout.display` does. Their own breakpoint columns stay until a device gets
  its own `layout.columns`: a desktop count stays on desktop, and a phone follows a tablet
  count.
- **Inner parts inherit.** Weight, colour, letter case and tracking, where the element offers
  them, reach the sale price, the countdown digits and labels, the tab labels, the accordion
  body and the list icons.
- **Text colour on a box.** On Container, Column and the Custom section, a text colour also
  sets the `--foreground` role inside the box and a softer `--muted-foreground`, so the
  headings and text in it follow; a colour scheme recolours the box as a whole.
- **Add to cart** always sits in a box of its own (`data-pe-group`) holding its option pickers:
  spacing and size style that box, fill and border the button.
- **Container link.** A container with a link is clickable as a whole through a real link over
  the box, reachable with Tab and named after the first heading, button or image inside it.
- **Custom section.** Minimum height, where the content sits and the background image come
  from the Style tab (`size.minHeight`, `layout.justify`, `background`). Boxed content sits
  on the theme container (`--pt-maxw-content`, `--pt-gutter`), wide on `--pt-maxw-wide` with the
  same gutter, and side padding adds to that gutter. Full width runs backgrounds edge to edge
  and keeps the theme gutter for content until side padding sets the inset instead: `none`
  runs content edge to edge.

## Children: `Slot` and wildcards [#children-slot-and-wildcards]

A parent never draws a child itself. `<Slot />` without a `name` renders the children merchants
placed in the default region of the current element, in saved order, each through its own
component and frame, inside one wrapper `div` carrying `className`. `<Slot name='media' />` does
the same for a declared slot. `as` picks the region's tag (`div`, `ul`, `ol`, `nav`, or `none`
for no box); a region rendered `as='ul'` or `as='ol'` takes only blocks whose `frame` is `li`.
An empty region renders nothing and takes no space, on the live page and in the Customizer
alike: the Customizer draws its drop target over the page, labelled with the slot's `label`.
`useChildBlocks('media')` from `@poltura/theme-sdk/elements` returns the region's shown children
(`id`, `type` and settings in the shopper's language) for counts, tabs and layout decisions;
draw them with `Slot`.

`blocks.static` declares fixed children with developer ids. This excerpt of a definition
declares three:

```tsx
blocks: {
  accepts: [ELEMENT_WILDCARD],
  static: [
    { id: 'title', type: 'heading', slot: 'details' },
    { id: 'buy', type: 'add-to-cart', slot: 'details', required: true },
    { id: 'summary', type: 'text' },
  ],
},
```

Merchants may hide and restyle a static but not move or delete it; a `required` one cannot be
hidden either. A static with a `slot` renders in that region among the merchant's blocks; one
without renders in the default region, drawn by `<Slot />`.

Two wildcards open `accepts`:

- `ELEMENT_WILDCARD` (`'@element'`): every element any active provider offers. That covers
  platform elements (container, heading, text, button, image, product card…) and every plugin,
  app or theme block that renders itself.
- `THEME_BLOCK_WILDCARD` (`'@theme'`): the elements of every provider except the platform,
  meaning plugin, app and theme blocks.

A section's own named children always resolve against that section's own block list first,
so a theme block that shares a key with a platform section's child (`stat`, `quote`, `item`)
never changes that platform section.

### Slots and what they accept [#slots-and-what-they-accept]

A slot may narrow what it takes with its own `accepts`, a subset of the element's
`blocks.accepts`. This section imports `ELEMENT_WILDCARD`, `Slot` and `defineSection` from
`@poltura/theme-sdk/elements` and `cn` from `@poltura/theme-sdk/ui`:

```tsx
export const promoSplit = defineSection({
  type: 'promo-split',
  label: 'Promo split',
  icon: 'columns',
  category: 'marketing',
  settings: {},
  blocks: { accepts: [ELEMENT_WILDCARD], max: 12 },
  slots: [
    { name: 'media', label: 'Media', accepts: ['image', 'video'] },
    { name: 'body', label: 'Text and buttons' },
  ],
  component: ({ attrs }) => (
    <section {...attrs} className={cn(attrs.className, 'grid gap-8 md:grid-cols-2')}>
      <Slot name='media' />
      <Slot name='body' />
    </section>
  ),
});
```

Each child records its region in `"slot": "media"` in the layout file. A slot without
`accepts` takes everything its element accepts. The picker, the navigator's drag and drop,
save validation, the AI assistant and the code editor's JSON Schema all apply the same rule,
and the theme check reports a declared slot the component never renders.

## Inline text: `EditableText` [#inline-text-editabletext]

`<EditableText setting='heading' as='h2' />` renders a plain `text` or `textarea` setting of the
current element in the shopper's language and lets merchants retype it on the canvas. Outside
the Customizer it is exactly `<h2>text</h2>`, and nothing when the text is empty.

`<EditableRichText setting='body' className='flex flex-col gap-3' />` does the same for a
`richtext` setting: a double-click opens it with its formatting, Cmd/Ctrl+B and Cmd/Ctrl+I
toggle bold and italic, Enter starts a new paragraph, and Cmd/Ctrl+Enter or a click elsewhere
saves it. Escape discards the edit.

## Commerce elements [#commerce-elements]

Six platform elements show live catalogue data wherever a layout accepts elements:

| Element         | Type key          | Shows                                                                       |
| --------------- | ----------------- | --------------------------------------------------------------------------- |
| Product card    | `product-card`    | one product: image, name, price, optional badge and add to cart             |
| Product grid    | `product-grid`    | 2 to 24 products from a collection, a category or a manual pick             |
| Price           | `price`           | a product's price, with the original price when on sale                     |
| Add to cart     | `add-to-cart`     | a button with the product's options (size, colour…)                         |
| Collection list | `collection-list` | 2 to 24 tiles linking to collections, or to the subcategories of a category |
| Countdown       | `countdown`       | days, hours, minutes and seconds until `end_at` (a `datetime` setting)      |

`price` and `add-to-cart` with an empty `product` use the product of the product page they sit
on. Settings hold ids, never copies of catalogue data, so names, prices and stock are always
current and in the shopper's language and currency.

The storefront collects the catalogue data every commerce element on the page needs
(templates, site pages, header and footer, mega menu panels included) and loads it in
**one batched request** during server rendering, so the elements render fully on the first
paint, on every route. In the Customizer, an element you add fills itself with one follow-up
request. Labels left empty (add-to-cart wording, countdown units) use the platform's wording in
every store language. Filled labels are merchant text and are translated like any other.

## Store data bindings [#store-data-bindings]

A `text`, `textarea`, `richtext`, `image`, `url` or `link` setting can read live store data
instead of holding its own value. Merchants bind one from the database button beside the field
label in the inspector. In a layout file the setting holds a binding object:

```json
{
  "id": "b_12",
  "type": "heading",
  "settings": {
    "text": {
      "$bind": { "source": "product", "id": "organic-tee", "field": "title", "fallback": "Our tee" }
    }
  }
}
```

| Source       | Fields                                                                                    |
| ------------ | ----------------------------------------------------------------------------------------- |
| `product`    | `title`, `subtitle`, `description`, `price`, `compare_at_price`, `vendor`, `image`, `url` |
| `collection` | `title`, `url`                                                                            |
| `category`   | `title`, `description`, `url`                                                             |
| `store`      | `name`, `tagline`                                                                         |
| `customer`   | `first_name`                                                                              |

- **`id`** is the handle of a product, collection or category, as the product pickers store it.
  Leave it out to read the one the page is about: a product page's product, a category page's
  category, or the collection a listing filters by. In the Customizer, a page-product binding on
  a template without a product shows a sample product.
- **`field`** must suit the setting: text settings take text fields (and `description`),
  `image` settings take `image`, `url` and `link` settings take `url`. Save validation rejects
  anything else, and the layout JSON schema offers only the fields that fit.
- **`fallback`** (up to 500 characters) shows when the store has no value: a deleted or unknown
  product, a product with no subtitle, a guest for `customer`. Without a fallback the setting's
  own default applies.

Bound values are resolved on the server during rendering, through the same batched request as
the commerce elements, so the first paint already shows them and a page never issues one request
per binding. Prices are formatted in the shopper's currency. Titles and descriptions come in the
shopper's language through the store's catalogue translations. A bound setting is never machine
translated and holds no `settings_i18n` entry: binding a field drops its translations.

Descriptions are store copy, often HTML from an import. They never reach the page as markup: a
`richtext` setting reads them into the rich text grammar (paragraphs, headings, lists, bold,
italic, safe links) and every other text setting reads their plain words.

Element components receive the resolved value in `settings`, like any other. `EditableText`
leaves a bound setting out of in-place editing on the canvas.

## Base language [#base-language]

The base `settings` of every layout file hold the **store's default language**, named by
`base_locale` in `settings/values.json`. Every other language is an overlay
in `settings_i18n[<locale>]` holding only the fields that differ. Write a theme's default copy
in the language you ship it in and set `base_locale` to its bare language code (`"hu"`, not
`"hu-HU"`); a theme without it is read as English.

- Inline editing on the canvas and the inspector write the base settings in the base language
  and the overlay in any other.
- When a store's default language differs from its theme's base, the Customizer offers to
  re-base: that language's overlays become the base, the old base becomes an overlay, and
  translation fingerprints follow, so nothing is lost or retranslated.
- A theme entering a store (install, starter seed, reset) is re-based automatically only when
  the store's language already has every string; otherwise it stays as shipped.

## Conditions and anchors [#conditions-and-anchors]

Every section and element can carry an optional `conditions` key beside `settings` and
`style`, edited on the Advanced tab under **Visibility**:

```json
{
  "id": "b_07",
  "type": "button",
  "settings": { "label": "Sign in for 10% off", "anchor": "member-offer" },
  "conditions": {
    "audience": "guests",
    "from": "2026-11-27T00:00:00+01:00",
    "until": "2026-12-01T00:00:00+01:00"
  }
}
```

- **`audience`**: `guests` (shoppers who are not signed in) or `customers` (signed-in
  shoppers). `all` is the default and is never stored.
- **`from` / `until`**: `datetime` values on the store clock. `from` is the first moment the
  element shows, `until` the first moment it no longer does. A bare date means midnight at the
  start of that day in the store time zone.
- **`settings.anchor`**: the element's in-page link target. A link to `#member-offer` on the
  same page scrolls to it. Lower case `a-z`, `0-9`, `-` and `_`, unique within the layout
  file; never translated. Any element may set it without declaring an `anchor` field.

The storefront evaluates conditions on the server. An element for the other audience, or
outside its schedule, is left out of the rendered HTML and of the page data serialized into
it, so a customer-only code never reaches a guest. Element components never see conditions:
the block and section renderers gate them, and put the anchor on the element root as its `id`
(through `attrs`, so spread it).

The Customizer canvas follows **Preview as**: the other audience is left out exactly as on
the storefront. Elements outside their schedule stay on the canvas under a hatch with a
"Hidden now: scheduled" chip, and their navigator row shows a clock.

Conditions are evaluated on every request, so a page never shows an ended promotion or hides a
started one. An open page reloads its data at the next `from` or `until` of anything on it.

Save validation rejects unknown condition keys, an unknown audience, a malformed date and a
malformed or repeated anchor. `until` before `from` is allowed: that element never shows, and
the editor says so.

## Limits [#limits]

Save validation enforces the same caps for the Customizer, the code editor, the AI assistant
and publish, and the code editor's JSON Schema flags them while you type:

| Limit (`limit` in the error) | Maximum per layout file                                                                                                                         |
| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| `elements`                   | 1,000 sections and nested blocks together                                                                                                       |
| `sections`                   | 40 sections                                                                                                                                     |
| `children`                   | 24 children per section or container (a schema's `blocks.max` may only lower it)                                                                |
| `depth`                      | 8 levels of blocks under a section, in the Customizer and in the theme check alike                                                              |
| `style_size`                 | 12 KiB of serialized `style` per element (every leaf on every device, with seven background addresses of the longest length, is about 10.3 KiB) |
| `string_length`              | 20,000 characters per string in `settings` or `settings_i18n`                                                                                   |
| `file_size`                  | 1 MiB of serialized JSON                                                                                                                        |

A save over a cap is refused as a whole with a `layout_limit_exceeded` error naming the limit,
the maximum and the actual value. The Customizer explains it to the merchant in their language
and refuses an insert, paste or duplicate that would cross a cap before it happens.

## The store header [#the-store-header]

The promo bar and the header render inside one host box, `[data-theme-chrome="header"]`, whose
scroll behaviour comes from the header section's own settings in `layout/header.json`. The
storefront runs it for every theme. A theme only styles the states. The settings are:

| Setting        | Values                                                                                                   |
| -------------- | -------------------------------------------------------------------------------------------------------- |
| `behavior`     | `sticky` (the default), `reveal` (hides on scroll down, returns on scroll up), `static`                  |
| `pin`          | `all` (the default) or `last_row`: only the last bar row stays on screen; `sticky` and `reveal` only     |
| `shrink`       | `true` shrinks the pinned header once the page scrolls; `sticky` and `reveal` only                       |
| `transparent`  | `off` (the default), `home`, `all`: transparent over the first section                                   |
| `mobile_from`  | `tablet` (the default), `mobile` or `desktop`: from this size down the menu button replaces the menu bar |
| `drawer_side`  | `left` (the default) or `right`: the side the mobile menu opens from                                     |
| `drawer_width` | `sm`, `md` (the default) or `full`: the mobile menu's width                                              |

The settings are shared by every language. A header section without a setting gets its
default, and an unknown value falls back to the default field by field. The transparent and
the scrolled colours are the header section's style states on the Style tab, not settings.

The box carries the state as attributes. The server renders `data-header-behavior`,
`data-header-pin`, `data-header-overlay` (transparent on this page) and `data-header-shrink`.
The runtime adds `data-header-scrolled` (past the header's height), `data-header-hidden`
(`reveal` while scrolling down) and `data-header-shrunk`. The header section's root carries
`data-header-mobile-from`.

- **No layout shift.** An overlaid header is out of flow from the first byte, so nothing
  moves when the page scrolls. A shrinking header loses
  exactly `--pt-header-shrink-by`, which the platform gives back as the box's bottom margin in the
  same transition. A theme that shrinks declares that length on `:root` and shrinks a row by
  it: `:where([data-header-shrunk]) .my-row { height: calc(var(--my-row-h) - var(--pt-header-shrink-by)); }`.
  Without a declaration only the logo scales.
- **Under the header.** An overlaid header would cover the top of the page, so the page body
  starts below it: the platform pads it by the header's height at rest (promo bar included), which
  never changes while the header hides or shrinks. A full-bleed section that should sit under
  the transparent header opts out with `data-header-underlay` on its root; when it opens the
  page the padding goes and the section pads its own content by `--pt-header-underlay`
  (`0px` everywhere else): `padding-top: calc(4rem + var(--pt-header-underlay, 0px))`.
- **Offset.** `--pt-header-height` is the live height of the pinned header: the theme's
  `--sticky-top` at rest, less the shrink, `0px` while hidden or static. `--pt-sticky-header`
  (the `layout.top: header` offset) and `--sticky-top` follow it, so sticky elements sit right
  below the header as it moves.
- **Transparent.** While transparent the header bar's `--background` and `--border` are
  `transparent` and `--foreground` and `--muted-foreground` read the dark scheme's
  (`--pt-header-overlay-fg`, `--pt-header-overlay-muted`) until the overlay style state
  repaints them. A header that paints another role (a `--card` bar, a slab of its own colour) clears
  it under `:where([data-header-overlay]:not([data-header-scrolled]):not(:focus-within))`.
  Hover and keyboard focus turn it solid, so open menus and search never sit on a see-through
  ground.
- **Keyboard.** A hidden header stays in the tab order and comes back while anything in it has
  focus, so focus never lands under it.
- **Search.** A `search` block shown as a `field` opens its suggestions and results in a popover
  anchored under the field (`[data-search-results]`), so the header keeps its height while the
  shopper types; `icon` opens the field in such a popover and `overlay` in a dialog. Arrow keys
  move through the options, Enter opens one, and Escape or a click outside closes the popover.
- **Motion.** Transitions run at `--pt-header-duration` (`--pt-duration-normal`), which is zero
  for shoppers who ask for reduced motion.

A header whose upper rows should scroll away sets the header section's `pin` to `last_row`:
the box then rides up by the rows above its last bar row (promo bar included), measured by the
runtime into `--pt-header-pin-offset`, and only that row stays on screen. Editorial pins its
running head this way, and Pop pins the row holding its floating pill. Launch sets
`mobile_from` to `desktop`, which keeps the menu behind the button on every screen.

## Restyling platform elements [#restyling-platform-elements]

To keep a platform section's or block's settings and change only its markup, override its
renderer in a file named by its type:

Here `CatalogHero` is a component of your own theme:

```tsx title="src/sections/hero.tsx"
import { overrideSection } from '@poltura/theme-sdk/elements';
import { CatalogHero } from '../components/catalog-hero';

export const hero = overrideSection('hero', CatalogHero);
```

```tsx title="src/blocks/contact-hero.tsx"
import {
  overrideBlock,
  type ElementRenderProps,
  type PlatformBlockSettings,
} from '@poltura/theme-sdk/elements';

function ContactHero({ settings }: ElementRenderProps<PlatformBlockSettings<'contact-hero'>>) {
  return <h1 className='text-5xl font-semibold'>{settings.heading}</h1>;
}

export const contactHero = overrideBlock('contact-hero', ContactHero);
```

The component receives `{ settings, attrs, id }`. The platform keeps the schema, the inspector
and the data, and draws the overridden element's frame itself, so `attrs` is empty in an
override and the renderer spreads nothing. Settings arrive as stored, typed from the platform's published schema
(`ElementRenderProps<PlatformSectionSettings<'hero'>>`, `PlatformBlockSettings<'faq-item'>`),
so give optional fields a fallback. To change the settings too, `defineSection` the same type
key instead.

Checkout is the exception: Poltura draws it alone. No theme section, block or element renders
there. Its header row and summary column take only the checkout blocks (logo, trust badge,
breadcrumbs, note) and the platform's heading, text, image, icon, divider, spacer and payment
icons, and the theme's client code does not load on it. The theme's stylesheet and tokens still
apply, so a theme styles checkout with CSS on the `data-checkout` hooks of the platform markup.

The FAQ, testimonials, logo list, stats and steps sections draw their rows as blocks, so a theme
restyles the section's frame and each row on its own: override the section to draw its heading
and `<Slot />`, and the row type with `overrideBlock`. The row types are `faq-item`, `quote`,
`partner-logo`, `stat` and `step`.

```tsx title="src/sections/faq.tsx"
import {
  EditableRichText,
  Slot,
  overrideBlock,
  overrideSection,
  type ElementRenderProps,
  type PlatformBlockSettings,
  type PlatformSectionSettings,
} from '@poltura/theme-sdk/elements';

function Faq({ settings }: ElementRenderProps<PlatformSectionSettings<'faq'>>) {
  return (
    <section className='theme-container py-16'>
      <h2>{settings.heading ?? ''}</h2>
      <Slot className='divide-y' />
    </section>
  );
}

function Question({ settings }: ElementRenderProps<PlatformBlockSettings<'faq-item'>>) {
  return (
    <details open={settings.open_by_default ?? false}>
      <summary>{settings.question ?? ''}</summary>
      <EditableRichText setting='answer' />
    </details>
  );
}

export const faq = overrideSection('faq', Faq);
export const faqItem = overrideBlock('faq-item', Question);
```

Every platform section's own renderer comes from `@poltura/theme-sdk/sections` (`BentoSection`,
`FaqSection`, `HeroSection`, `TestimonialsSection` and the rest), so an override can wrap it or
delegate to it and a platform fix still reaches the theme. Product blocks do the same with the
parts in `@poltura/theme-sdk/product`: the `variant-picker` override draws `VariantPicker` with
its `classes`, and the `product-reviews` override draws `Reviews` with `className` and
`listClassName`.

```tsx title="src/sections/bento.tsx"
import { overrideSection } from '@poltura/theme-sdk/elements';
import { BentoSection } from '@poltura/theme-sdk/sections';

export const bento = overrideSection('bento', (props) => (
  <div className='theme-frame'>
    <BentoSection {...props} />
  </div>
));
```

The bento draws its grid and its optional top-sellers rail itself; its tiles are the merchant's
elements in its region, a grid of pictures, product cards and link cards by default.

## Registration [#registration]

Every element exported from a file under `src/sections/` or `src/blocks/` registers itself,
directly or re-exported. There is no registry file: the compiler builds the theme's list of
elements from those exports, and the storefront draws from it.

- One element per file, and the file name is its type: `src/sections/quote-wall.tsx` exports
  `quoteWall`, `src/blocks/product-title.tsx` exports `productTitle`. Name the export after the
  type in camelCase.
- Blocks that only live inside one section (`wall-quote` above, the mega menu's `menuColumn` and
  `promoCard`) stay in that section's file and are exported from it. A block another section also
  accepts gets its own file in `src/blocks`.
- Helpers that are not elements belong in `src/components` or `src/lib`.

The compiler and the code editor report these as issues:

| Issue                                                                                                          | Severity |
| -------------------------------------------------------------------------------------------------------------- | -------- |
| Two elements register the same type                                                                            | error    |
| The file name does not match the type                                                                          | error    |
| A block shares its section's file but another section accepts it too                                           | error    |
| A `defineSection`, `defineBlock` or override is not exported, or lives outside `src/sections` and `src/blocks` | error    |
| A definition is wrapped (`w(defineSection(...))`) or called through a namespace (`E.defineSection(...)`)       | error    |
| An override names a type no provider defines                                                                   | warning  |

Theme entries shadow platform, plugin and app entries of the same key.

## Element providers [#element-providers]

A theme is one of four element providers. The other three are the platform, Poltura's core
plugins and installed apps. They all follow the same rules:

- **Definition:** every provider uses the same `defineSection` and `defineBlock` objects and the
  same `field.*` builders.
- **Extraction:** the same static reader reads every provider. No provider code runs.
- **Catalogue:** the platform merges the providers into one catalogue, and everything reads it:
  the Customizer, save and publish checks, translation, the code editor's layout JSON Schema and
  the storefront.

Each element carries its `provider` (`{ kind, id }`). The picker groups by it: **This theme** for
theme elements, **Apps** for app elements, and by category for platform and plugin elements.

| Provider | Where it is defined                                           | Type keys                                                 | Gated by                                          |
| -------- | ------------------------------------------------------------- | --------------------------------------------------------- | ------------------------------------------------- |
| platform | `@poltura/theme-elements` primitives, platform sections       | bare, reserved                                            | never                                             |
| plugin   | a Poltura core plugin                                         | bare; unique across the platform and every plugin         | the plugin's own on and off switch                |
| app      | the app's source                                              | `app:<id>/<name>`; public apps are keyed per installation | installed and enabled                             |
| theme    | the theme's TSX                                               | bare                                                      | never                                             |

Precedence runs platform, then plugin, then app, then theme:

- A theme may redefine a platform or plugin type. Its schema then replaces theirs.
- A theme may only restyle an app type, with `overrideBlock` / `overrideSection`.
- Any other collision is an error, and the first definition stays.

When a gated provider is off, its elements disappear from the picker and from every `accepts`
list, and they render nothing on the live page. Saved layouts that use them still pass
validation, so turning the provider back on restores every placement. The Customizer and the
storefront read the same on and off state from the store.

## Checks [#checks]

The theme check (`poltura theme check <dir>`, run by the code editor on every save, by publish
and by the theme build) reports, with positions:

- non-static definitions, unknown icons, categories or templates, unknown `accepts` types and
  duplicate type keys;
- a section without `component` that no platform renderer covers;
- `usage`: a component (and the same-file functions it calls) reading a setting its own
  definition does not declare is an error; a declared setting it never reads is a warning;
- `children`: every child type a section or block accepts must be drawn by its component:
  through `<Slot />` for children saved without a region, a `<Slot name>` that accepts the
  type, or the platform renderer. A type drawn only
  while a setting equals one value (`source === 'blocks'`) counts as undrawn, because a block a
  merchant adds in any other state would never show. Files the change touched report errors;
  the rest of the theme reports warnings;
- `accepted-without-component`: an accepted theme block without a `component`; an error in
  files the change touched and a warning elsewhere;
- `checkout-locked`: checkout is platform-only. A theme that defines or overrides the `checkout`
  section, any block the checkout section owns (`checkout-brand`, `checkout-trust-badge`,
  `checkout-breadcrumbs`, `checkout-express`, `checkout-address`, `checkout-shipping`,
  `checkout-payment`, `checkout-summary`, `checkout-note`) or the cart's `cart-checkout` button
  is an error, and publish and the theme build refuse it. The storefront ignores such a
  component and draws Poltura's own. A theme styles checkout with CSS and tokens only;
- `slots`: a declared slot never rendered with `<Slot name>` (`missing-slot`), a static
  declared without a `slot` in a component that draws no default or dynamic `<Slot>`
  (`static-not-rendered`), and a region rendered `as='ul'` or
  `as='ol'` that accepts blocks not framed as `li` (`frame-mismatch`);
- layout files whose settings or style fall outside their element's schema, or that cross a
  [limit](#limits);
- `media/*`: pictures must be a library file (`media://<id>`) or a file the theme ships
  (`asset://<path under assets/>`, or `asset('<path>')` in code). A remote address
  (`media/no-remote-url`) is a warning in the CLI and the code editor and an error when a
  theme is installed; `javascript:`, `data:` and `file:` addresses (`media/unsafe-url`), paths
  nothing serves such as `/images/hero.jpg` (`media/unresolvable-path`), a missing asset
  (`media/unknown-asset`) and `media://` in code (`media/ref-in-code`) are errors. A picture
  without alt text (`media/image-alt`), an image over its budget and an unknown library file
  (`media/unknown-media`, code editor and install only) are warnings; an asset over its cap or a font that is
  not woff2 (`media/asset-size`) is an error; an asset nothing uses (`media/unused-asset`) is a
  note. [Theme assets and media](/themes/theme-assets#the-theme-check-media-rules) has the
  full table.

## Next [#next]

- [Server rendering](/themes/server-rendering): Rules for code that renders on the server.
- [Chrome stable classes](/themes/chrome-classes): Restyle the platform's brand, cart and chat parts from CSS.
- [elements reference](/themes/sdk/reference/elements): Every export of @poltura/theme-sdk/elements.
