---
name: nova-pagebuilder-modeling
description: Model editor-friendly block systems for tibi-admin-nova. Covers pagebuilder structure, block schemas, preview, drillDown, dependsOn, containerProps.layout, and the required alignment between admin config, frontend block registry, and SSR.
---

# nova-pagebuilder-modeling

## When to use this skill

Use this skill when:

- Building a flexible pagebuilder for pages, landing pages, reusable sections, or site settings
- Designing nested `object[]` schemas for blocks in Nova
- Deciding how editors should create, scan, reorder, and edit blocks
- Translating website requirements into maintainable block types
- Refactoring a block system that is technically valid but editor-hostile

## Goal

The goal is not just to make blocks storable. The goal is to model a block system that is:

- understandable for editors
- safe to extend over time
- easy to preview in Nova
- aligned with frontend rendering and SSR
- structured enough that an LLM can add new blocks without inventing ad-hoc patterns

## Source of truth

Use these sources when designing or reviewing the schema:

- `tibi-admin-nova/types/admin.d.ts`
- `tibi-admin-nova/docs/collection-config.md`
- `api/collections/content.yml`
- `frontend/src/blocks/`
- `frontend/src/blocks/BlockRenderer.svelte`
- `types/global.d.ts`

Do not model pagebuilder structures from memory when current Nova types are available.

## Core mental model

In this starter family, a pagebuilder is usually an `object[]` field where each array item represents one block. Each block needs three layers to stay coherent:

1. **Data model** in collection YAML
2. **Render component** in `frontend/src/blocks/`
3. **Type and registry alignment** in TypeScript and `BlockRenderer.svelte`

If one of these layers is missing, the system is incomplete.

## Design rules

### 1. Prefer a small block vocabulary with strong reuse

Do not create a new block type for every tiny content variation.

Prefer:

- `hero`
- `richText`
- `imageText`
- `cta`
- `featureGrid`
- `faq`
- `logos`
- `testimonials`

Avoid block libraries that mirror every page one-to-one. That produces brittle schemas and weak editor UX.

### 2. Every block must be recognizable in lists

Editors should understand an entry without opening each block.

Use current Nova preview capabilities on block objects:

```yaml
meta:
    preview:
        label: headline
        secondary: type
        badge: variant
```

If a block has no single identifying field, use a preview `eval` that combines multiple fields.

### 3. Large blocks should open in drill-down editing

If a block contains many fields, nested objects, or repeated items, prefer drill-down editing instead of forcing everything into one long inline form.

Use `drillDown` when the inline view becomes noisy or error-prone.

### 4. Use `dependsOn` to keep block forms focused

Conditional fields are essential in block schemas.

Use `dependsOn` when:

- a field is only relevant for one `variant`
- a CTA only appears when `showCta` is true
- media settings depend on layout choice
- a nested group only matters for one block subtype

Do not dump every optional field into the same visible form.

### 5. Use `containerProps.layout` to model editor flow

Block editing should reflect visual and editorial grouping, not raw storage order.

Use `containerProps.layout` to:

- put related fields side by side
- separate content from appearance controls
- reduce scroll depth
- keep critical fields in the first viewport

### 6. Keep the block model SSR-safe

If a block is page-critical, it must render correctly in SSR too.

That means:

- the block data must come through the same content-loading path as the page
- the Svelte block component must be importable by the SSR bundle
- the renderer must not rely on browser-only APIs during initial render

### 7. Model for migrations, not just first delivery

Blocks evolve. Design schemas so fields can be added without breaking every existing entry.

Prefer additive changes and explicit defaults over brittle implicit assumptions.

## Recommended modeling workflow

### Step 1: Start from editorial jobs, not component names

Define what editors need to do:

- create a page hero
- add structured intro content
- place testimonials
- create CTA sections
- insert FAQs
- reuse site-wide sections

Then derive block types from these jobs.

### Step 2: Decide which data belongs at page level and which belongs inside blocks

Keep page-level fields for concerns that apply to the whole page, such as:

- path
- language
- SEO
- publication
- translation linking

Keep block-level fields for modular content slices.

### Step 3: Define the block array schema

Typical pagebuilder field:

```yaml
- name: blocks
  type: object[]
  meta:
      label: { de: "Blöcke", en: "Blocks" }
      widget: pagebuilder
      pagebuilder:
          blockTypeField: type
      preview:
          label: headline
          secondary: type
          badge: variant
      drillDown: true
  subFields:
      - name: type
        type: string
        meta:
            widget: select
            choices:
                - value: hero
                  label: Hero
                - value: richText
                  label: Rich text
                - value: featureGrid
                  label: Feature grid
      - name: headline
        type: string
      - name: variant
        type: string
        meta:
            dependsOn:
                eval: "$parent.type === 'hero'"
```

The exact shape can vary, but the pattern stays the same: block type first, then a previewable and conditionally focused schema.

### Step 3a: Build and wire the block registry

In current Tibi/Nova projects, the pagebuilder registry is typically not implicit. Nova loads it from the admin bundle via `meta.pagebuilder.blockRegistry.file`.

The concrete chain is:

1. define the registry in `frontend/src/admin.ts`
2. export it as `blockRegistry`
3. build the admin bundle with `yarn build`
4. point the collection field or collection meta to the built module

The concrete file names vary by project, but the pattern is the same: registry code lives in the admin bundle and collection config points to the built admin asset.

Typical starter pattern:

```ts
const blockRegistry = {
    hero: {
        label: "Hero",
        render(container, row, context) {
            return {
                update(nextRow, nextContext) {},
                destroy() {},
            }
        },
    },
}

export { blockRegistry }
```

And the collection wiring:

```yaml
meta:
    widget: pagebuilder
    pagebuilder:
        blockTypeField: type
        blockRegistry:
            file: /_/assets/dist/admin.mjs?v=${ADMIN_ASSET_VERSION}
```

Important constraints for this setup:

- the registry module must be part of the admin bundle, not a random standalone file outside the build pipeline
- the exported registry keys must match the block type values stored in the collection
- after registry changes, run `yarn build` so `frontend/dist/admin.mjs` is regenerated
- if the registry file path in YAML and the built admin asset diverge, Nova can still render the schema but the pagebuilder preview/picker loses its real block definitions
- in Nova pagebuilder preview, file fields are already normalized by the admin backend to absolute `http(s)://...` URLs when appropriate; preview code must not prepend `apiBase`, `projectBase`, or other frontend URL helpers when the value is already absolute
- Nova may also pass preview rows with hydrated `_lookup` data for FK-like fields; the registry/block preview should consume that data directly instead of trying to re-fetch or manually hydrate references inside the admin preview
- For medialib-based images, prefer the same shared frontend widget used on the public site rather than preview-only URL logic. The widget/helper must honor `apiBaseOverride` so filter URLs, placeholders, and medialib files keep working inside admin preview.
- Nova's `render(container, row, context)` provides API path information. **`context.projectBase`** contains the full project-specific API base including namespace. `context.apiBase` may only contain the generic `/api/` root and is often not sufficient for project-scoped collection endpoints. Blocks that load collection data in preview should therefore use the project-specific base when one is available:

    ```ts
    import { apiBaseOverride } from "./lib/store"
    import { get } from "svelte/store"
    const prev = get(apiBaseOverride)
    if (context?.projectBase) apiBaseOverride.set(String(context.projectBase))
    // mount(BlockRenderer, ...)
    // in destroy(): apiBaseOverride.set(prev)
    ```

When a pagebuilder block renders images from medialib, prefer this pattern:

1. request lookup-resolved medialib entries in the data load
2. pass the resolved entry into the shared image widget
3. let that widget decide filter sizing from explicit `filter` or `minWidth`
4. rely on `apiBaseOverride` / `context.projectBase` for admin-safe URL resolution

Do not create a second, preview-only image rendering path that diverges from the public frontend. That usually causes broken placeholders, wrong filter URLs, or SSR/admin mismatches later.

Use collection-level `meta.pagebuilder.blockRegistry.file` when several pagebuilder fields share the same registry. Override at field level only when one field genuinely needs a different registry.

### Step 4: Map each block type to a frontend component

Every allowed `type` value in the schema must be handled in `BlockRenderer.svelte`.

Do not leave “temporary” admin-only block types without a renderer unless they are truly non-public and intentionally excluded.

## Frontend preparation requirements

For this starter, pagebuilder work is only half done when the collection schema exists. The frontend must be prepared explicitly so block-based rendering stays maintainable.

### 1. Keep one clear renderer boundary

`frontend/src/blocks/BlockRenderer.svelte` should remain the central registry that maps `block.type` to concrete Svelte components.

That means:

- every public block type in the schema gets one renderer branch
- unknown block handling stays explicit
- block selection logic stays centralized instead of being scattered across many unrelated files

Do not distribute block-type branching across the app shell, page components, and nested helpers at the same time.

### 2. Use a stable component contract

Each block component should receive the block object in a consistent way.

In this starter, the default contract is:

```svelte
<script lang="ts">
  let { block }: { block: ContentBlockEntry } = $props()
</script>
```

This matters because the block system becomes much easier to extend when every component follows the same top-level prop contract.

If a block needs additional derived data, derive it inside the component or in a small helper, but do not invent a different top-level prop API for every block.

### 3. Keep `ContentBlockEntry` aligned with real frontend usage

The frontend preparation is incomplete until `types/global.d.ts` can express the fields the block components actually read.

Whenever a new block type or field is added, verify alignment between:

- collection YAML subfields
- `ContentBlockEntry`
- the block component implementation
- `BlockRenderer.svelte`

If a component reads fields that are only implicit or typed as vague leftovers, the pagebuilder is not ready for reliable future extension.

### 4. Plan lookup data together with the block model

If blocks reference media or foreign entities, the frontend must be prepared to receive the resolved lookup data through the page-loading path.

For this starter, that usually means checking the lookup strings used when loading content in `App.svelte`.

For Nova admin previews, treat the incoming row differently from a raw frontend API payload:

- `_lookup` may already be hydrated by the admin preview pipeline
- file/image values may already be absolute URLs

Do not add preview logic that blindly rewrites file URLs or assumes it still has to hydrate foreign references before rendering the admin pagebuilder preview.

Do not add a block that depends on:

- media lookups
- referenced collections
- nested foreign references

without also updating the content-loading layer so the renderer receives the required `_lookup` data.

### 5. Treat SSR compatibility as part of frontend preparation

A pagebuilder block is not frontend-ready if it only works after hydration.

Every public block should render safely during SSR:

- no unconditional `window`/`document` usage at module top level
- browser-only behavior guarded inside `typeof window !== "undefined"`
- meaningful initial markup without waiting for client-only effects

If a block absolutely requires browser APIs, keep the browser-only part small and ensure the surrounding block still renders a stable SSR shell.

### 6. Unknown block handling should help development without hiding errors

`BlockRenderer.svelte` should make unknown block types visible enough during development that schema/frontend drift is caught early.

For this starter, the current renderer already has a development-side unknown-block fallback. Keep a mechanism like that in place when the demo renderer is refactored.

Do not silently swallow unknown block types in a way that makes editor-created content disappear with no signal.

### 7. Keep block components presentation-focused

Pagebuilder block components should mostly render data, not own cross-page application logic.

Prefer:

- block-local formatting and small derived values
- presentational composition
- small helper components inside `frontend/src/blocks/`

Avoid pushing these concerns into block components unless there is a strong reason:

- route loading
- global app state orchestration
- unrelated API fetching
- page-level navigation concerns

### 7a. Admin pagebuilder preview: CSS custom properties in shadow DOM

The Nova pagebuilder renders block previews in an isolated DOM context (shadow DOM or detached subtree). Tailwind 4's `@theme` directive generates CSS custom properties on `:root`, but these do **not** cascade into shadow DOM contexts.

**Consequence:** Block previews in the admin can have wrong colors (light text instead of dark, missing brand colors) because `var(--color-ink)` resolves to nothing.

**Fix:** Add a `:host` selector in the project's CSS file that redeclares the theme variables for the shadow DOM context. Also set a hardcoded `color` fallback on `[data-admin-preview]` since the Nova preview container has this attribute.

```css
:host,
[data-admin-preview] {
    --color-ink: #2c3e45;
    /* … all theme color variables … */
    font-family: "Inter Tight", system-ui, sans-serif;
    color: #2c3e45; /* hardcoded fallback, not var() */
}
```

Verify by checking admin pagebuilder block preview after any CSS theme changes.

### 7b. Admin pagebuilder preview: API calls from dynamic blocks

Blocks that load data via API (e.g. `CategoryGridBlock` using `getCachedEntries`) need the correct API base URL in the admin preview. The Nova pagebuilder's `render()` callback receives a `context` object with optional `apiBase` and `namespace`, but not all versions provide these.

The `admin.mjs` is loaded by the pagebuilder via dynamic `import()` — the URL is resolved relative to the admin page, NOT relative to the API. So `import.meta.url` contains the admin's page URL (e.g. `/_/assets/dist/admin.mjs`), not the tibi-server's API URL. Regex extraction from `import.meta.url` for the pattern `/api/_/{namespace}/` does NOT work for this reason.

**Reliable approach:** use multiple fallbacks in `admin.ts`, with the admin hostname pattern as the most robust:

1. `context.apiBase` (from Nova when available)
2. `context.namespace` (from Nova)
3. `import.meta.url` regex (works when admin serves admin.mjs through its own API proxy)
4. **Hostname extraction**: admin URL is `{project}-tibiadmin.{domain}` → extract project name
5. DOM scan: find any element with `src`/`href` containing `/api/_/{namespace}/`

```ts
const prevApiBase = get(apiBaseOverride)
let ns: string | null = null

if (context?.apiBase) {
    apiBaseOverride.set(String(context.apiBase))
} else {
    if (context?.namespace) ns = String(context.namespace)
    if (!ns) {
        try {
            ns = ((import.meta as any).url || "").match(/\/api\/_\/([^/]+)\//)?.[1]
        } catch {}
    }
    // Most reliable: admin hostname is always {namespace}-tibiadmin.{domain}
    if (!ns && typeof window !== "undefined") {
        const h = window.location.hostname.match(/^(.+?)-tibiadmin\./)
        if (h) ns = h[1]
    }
    // Fallback: scan DOM for API references
    if (!ns && typeof document !== "undefined") {
        const el = document.querySelector('[src*="/api/_/"], [href*="/api/_/"]')
        if (el) {
            const a = el.getAttribute("src") || el.getAttribute("href") || ""
            ns = a.match(/\/api\/_\/([^/]+)\//)?.[1] || null
        }
    }
    if (ns) apiBaseOverride.set(`/api/_/${ns}/`)
}
```

Set the `apiBaseOverride` store BEFORE mounting the block component so API calls inside `$effect` use the correct base.

The Nova pagebuilder renders block previews in an isolated DOM context (shadow DOM or detached subtree). Tailwind 4's `@theme` directive generates CSS custom properties on `:root`, but these do **not** cascade into shadow DOM contexts.

**Consequence:** Block previews in the admin can have wrong colors (light text instead of dark, missing brand colors) because `var(--color-ink)` resolves to nothing.

**Fix:** Add a `:host` selector in the project's CSS file that redeclares the theme variables for the shadow DOM context. Also set a hardcoded `color` fallback on `[data-admin-preview]` since the Nova preview container has this attribute.

```css
:host,
[data-admin-preview] {
    --color-ink: #2c3e45;
    --color-ink-2: #3a4d56;
    /* … all theme color variables … */
    font-family: "Inter Tight", system-ui, sans-serif;
    color: #2c3e45; /* hardcoded fallback, not var() */
}
```

Verify by checking admin pagebuilder block preview after any CSS theme changes.

### 8. Prepare for styling consistency across blocks

A block system works better when blocks share a few stable layout conventions.

Examples:

- container width choices
- vertical spacing conventions
- anchor/id behavior
- CTA shape and link handling
- media aspect ratio conventions

Do not let every new block invent its own spacing, width, and link semantics from scratch unless the design system really requires it.

## When a block is actually ready in the frontend

A new pagebuilder block should only be considered integrated when all of these are true:

1. The schema contains the block type and required subfields.
2. `ContentBlockEntry` expresses the fields used by the block.
3. A dedicated Svelte block component exists in `frontend/src/blocks/`.
4. `BlockRenderer.svelte` routes the block type to that component.
5. Any required lookup data is loaded by the app content-loading path.
6. The block renders acceptably in SSR and browser navigation.
7. Unknown or stale block types remain debuggable.

### Step 5: Keep types aligned

Update project types when the block model changes.

In this starter family, block schemas usually affect:

- `types/global.d.ts`
- Svelte component props
- block renderer branching

If TypeScript cannot express the new block shape, the schema work is incomplete.

## Practical block design patterns

### Hero block

Use for top-of-page messaging. Keep the editor form short and obvious.

Typical fields:

- eyebrow
- headline
- subline
- image
- cta
- variant

Use `dependsOn` for variant-specific media and CTA settings.

### Rich text block

Use for long-form body content. Avoid mixing it with too many presentational toggles.

Typical fields:

- headline
- body
- maxWidth

### Feature grid block

Use nested repeatable objects for feature items, but make the parent block previewable.

Typical fields:

- headline
- items[]
- columns
- variant

For `items[]`, add its own preview so editors can scan the nested list.

### Reusable section reference

If the same content must appear on many pages, consider a dedicated collection plus foreign reference instead of copy-pasting large pagebuilder blocks.

Use foreign previews so editors understand the referenced entity before opening it.

## Anti-patterns

- One block type per page template fragment with no reuse
- Giant catch-all block with dozens of unrelated optional fields
- No preview on nested objects
- No drill-down for large objects
- Using array order as the only meaning without labels or previews
- Frontend blocks that exist without matching collection schema
- Collection schema values that have no renderer

## Verification checklist

After adding or changing pagebuilder blocks, verify all of these:

1. Editors can identify blocks quickly in Nova.
2. The block form hides irrelevant fields.
3. Reordering works without losing meaning.
4. `BlockRenderer.svelte` handles every public block type.
5. SSR renders the affected page correctly.
6. `yarn validate` stays clean.

## What an LLM should inspect first

When asked to extend a pagebuilder on this starter, inspect in this order:

1. `api/collections/content.yml`
2. `frontend/src/blocks/BlockRenderer.svelte`
3. existing files in `frontend/src/blocks/`
4. `types/global.d.ts`
5. `tibi-admin-nova/types/admin.d.ts`

This order prevents schema-only or frontend-only changes.
