> ## Documentation Index
> Fetch the complete documentation index at: https://docs.member.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Auth brand panel

> Author the login/register/payments brand panel through the page tree.

The brand panel is the aside band next to the login, register, and payments forms — the DS's gradient-glow panel with your logo on it. It is entirely **opt-in**: if you never author it, visitors see exactly what they see today — the hub's auth logo (or a text fallback) centered on the default glow-on-primary panel, nothing else. Authoring a `brand-panel` region adds content and a background *below* that fixed logo, on the same panel.

This is a page-tree region, not a settings blob. It rides the same draft → publish pipeline as any other page: **team-owner only**, via the admin tree-write door.

<Note>
  This guide documents the shipped renderer contract (`preparePanelRegion`, `src/lib/auth/prepare-panel-region.ts` in mio-hub) exactly. A couple of details in earlier design notes for this feature didn't survive to the shipped code unchanged — each is called out inline where it matters.
</Note>

## The region contract

Auth pages (`login`, `register`, `payments`) are real `Page` rows with the same `tree_draft` / `tree_published` columns as every other page — write and publish them through the [normal page-tree API](/guides/pages-page-builder#draft-tree-authoring).

The brand panel is a **root child of that page's tree**, tagged `settings.slot: "brand-panel"`:

```json theme={null}
{
  "root": {
    "id": "root",
    "kind": "stack",
    "settings": {},
    "children": [
      {
        "id": "brand-panel",
        "kind": "stack",
        "settings": { "slot": "brand-panel", "side": "right" },
        "children": []
      }
    ]
  }
}
```

The renderer finds it with a **direct scan of `root.children`** — not a deep search. A `settings.slot: "brand-panel"` node nested anywhere *other* than directly under the root is never picked up; if two root children both carry the tag, the first one wins and the rest are ignored. One region per page.

Everything on the region node's own `settings` is **chrome** (`side`, `logoSize`, `surface` — see below); everything under its `children` is **content**, filtered through the kind allowlist.

## Chrome

Authored directly on the region node's `settings`:

| Setting    | Values                                        | Default    | Notes                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| ---------- | --------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `side`     | `"left"` \| `"right"`                         | `"right"`  | Flips which side of the form the panel renders on. Any value other than the literal string `"left"` resolves to `"right"` — there's no third state.                                                                                                                                                                                                                                                                                                                                                                       |
| `logoSize` | `"normal"` \| `"hero"` \| a number `16`–`320` | `"normal"` | Scales the **fixed** logo chrome above your content (see [The fixed logo](#the-fixed-logo-not-authorable-here) below) — it does not affect content nodes. `"hero"` renders it noticeably larger, matching the `logo` primitive's own hero step. A number is applied as an exact pixel height via inline style, identical at every breakpoint (not responsive like the two enum steps). Any other value — an unrecognized string, a non-finite number, or a number outside `16`–`320` — silently falls back to `"normal"`. |

<Note>
  An earlier design note for this feature also specified a `background: "primary" | "gradient"` chrome enum. It was dropped before shipping — background is authored as the region's `settings.surface` instead (see [Background and surface](#background-and-surface)), the same field a page-builder section carries.
</Note>

### The fixed logo (not authorable here)

Regardless of what you author in the region, the panel *always* renders a logo chrome above your content, using a three-tier fallback: `branding.auth_logo_url` → `branding.logo_url` → the hub name as text → nothing. This is fixed shell chrome, not a tree node — there is deliberately no `logo` kind in the content allowlist, so a `logo` node authored in the region renders nothing. If you need to hide the fixed logo, that isn't supported today.

## Content: the kind allowlist

Only six kinds render inside the region. Everything else — including every other kind in the full 43-kind node model — is silently dropped. This is a curation boundary, not the anon-safety boundary (anon safety is the existing prune + per-primitive sanitization, unchanged).

| Kind        | Notes                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `stack`     | Container — the only way to nest content (e.g. the canonical bullet list below). Its own `children` are recursively filtered through the same allowlist.                                                                                                                                                                                                                                                                                                                                                   |
| `headline`  | `level` is clamped to the integer range `2`–`6`; anything else — `1`, non-integer, out of range, `NaN`, missing — falls back to `2`. **There is no `level: 1` in the panel**: the clamp lower-bounds at `2`, so an authored `1` is rewritten to `2` at sanitize time, not merely visually demoted by a wrapper. `align` and `weight` pass through untouched. `highlight`, `highlightStyle`, and `highlightUnderline` are stripped (see [Why some settings are stripped](#why-some-settings-are-stripped)). |
| `text`      | `align`, `size`, `weight`, `clamp`, `marginBottom`, `italic`, `alignMobile`, and `variant` all pass through untouched — including `variant: "bullet"`, the canonical list-row treatment (below), and `variant: "eyebrow"`. `muted` passes through too (it's an opacity modifier, not a color). `tone`, `highlight`, and `highlightTone` are stripped.                                                                                                                                                      |
| `image`     | `value` is the image URL. A non-string `value` (a malformed payload) is dropped rather than rendered as a broken `<img src="[object Object]">`. See [Images: use durable URLs](#images-use-durable-urls) for what URLs actually work here.                                                                                                                                                                                                                                                                 |
| `divider`   | Carries only `settings.spacing`. No content of its own — the closing rule in the canonical bullet-list shape.                                                                                                                                                                                                                                                                                                                                                                                              |
| `countdown` | `value` is an ISO-8601 deadline string. An absent or unparseable value renders nothing — the leaf itself degrades gracefully, so the sanitizer does nothing special for it. This is the countdown half of MIO-3315; the paired `video` kind was deferred and is **not** in the allowlist.                                                                                                                                                                                                                  |

`text`/`headline` are the only kinds with an **empty-value drop**: a node whose `value` is missing, non-string, or trims to an empty string is dropped entirely, exactly like a non-allowlisted kind — not rendered as a content-less row. This exists because a blank `text`/`headline` node still paints its own chrome (a bullet row's top hairline + padding, or an empty block) with nothing in it; the historical symptom was two authored bullets producing four hairlines instead of three, with a phantom empty row sandwiched in. `image` and `countdown` are explicitly **not** covered by this — an empty `image.value` is a different failure mode (the non-string guard above), and `countdown` already renders nothing gracefully on its own. `divider`/`stack` have no `value` to be empty.

### Why some settings are stripped

The panel forces its own on-surface ink onto every leaf via a CSS variable remap — but that remap only covers classes that resolve `--foreground`. A `text`/`headline` setting that forces a *different*, non-`--foreground` color class (`tone: "primary"` → `text-primary`, a highlight wash → a primary-tinted background) escapes the remap and can render invisibly (primary text on the primary panel fill). So the sanitizer strips exactly those color-bearing keys — `tone`, `highlight`, `highlightTone` on `text`; `highlight`, `highlightStyle`, `highlightUnderline` on `headline` — while keeping every layout/typography-only setting (`align`, `size`, `weight`, `level`, `clamp`, `marginBottom`, `italic`, `alignMobile`, `variant`, `muted`). `variant: "eyebrow"`'s own default color survives because it's a `color-mix()` derived from `--foreground`, so it self-adapts through the same remap.

## The canonical bullet list

The "You'll learn how to:" list shape — a headline followed by rows with a hairline above each — is built like this, and only this way:

```json theme={null}
{
  "id": "bp-bullet-list",
  "kind": "stack",
  "settings": { "gap": 0 },
  "children": [
    { "id": "bp-bullet-1", "kind": "text", "settings": { "variant": "bullet", "marginBottom": 0, "align": "left" }, "value": "Build a signature offer members say yes to" },
    { "id": "bp-bullet-2", "kind": "text", "settings": { "variant": "bullet", "marginBottom": 0, "align": "left" }, "value": "Price your programs with confidence" },
    { "id": "bp-bullet-list-divider", "kind": "divider", "settings": { "spacing": 0 } }
  ]
}
```

**Why this exact shape:**

* `variant: "bullet"` rows each render their *own* top hairline. Stacking bullet rows one after another reproduces a between-item rule for free — row 2's top hairline *is* the rule between row 1 and row 2. There is no "list" kind and no way to interleave a `divider` between rows; the rows supply their own rules.
* The nested stack is `gap: 0` — the rows are meant to pack tight against each other, unlike normal stack spacing.
* Exactly **one** closing `divider` with `settings.spacing: 0` goes at the end, as the *last* child of the same nested stack. It rides flush against the last row's own bottom padding, giving the closing rule the same spacing as every hairline above it. No divider goes anywhere else in the list.
* Getting any of this wrong doesn't error — it just looks wrong (uneven gaps, a missing or doubled closing rule, or — if you leave a blank row in the middle — a phantom extra hairline that the empty-value drop above won't catch unless the `value` is actually empty).

<Note>
  The outer region itself (the node tagged `slot: "brand-panel"`) conventionally authors `settings.gap: 6` — matching the 24px rhythm between the logo, headline, list, and footnote blocks. This is **not** a `preparePanelRegion` default: it's just what every current tree-building tool authors. An authored region with no `gap` set falls through to the `stack` primitive's own 16px default. The nested bullet-list stack keeps `gap: 0` regardless of what the outer region's gap is set to.
</Note>

## Background and surface

The panel's background is not a chrome enum — it's the region root's own `settings.surface`, the **same `TemplateSurface` field** a page-builder section or a `Stack` column carries. It's extracted from the region root, then stripped out of the content that actually renders (so it paints the panel itself, not a nested box inside it) — inner/nested nodes keep full surface freedom of their own, same as a section's descendant column or card.

```json theme={null}
"settings": {
  "slot": "brand-panel",
  "surface": {
    "background": { "type": "gradient-glow", "glowTint": true },
    "ink": "auto"
  }
}
```

`surface` is read tolerantly — any object-shaped value passes through unvalidated; each field either resolves correctly or safely no-ops. **Omitting `surface` entirely** (not authoring the key at all) is different from authoring `"surface": {}`: omitting it renders the pre-existing default — an opaque primary fill with the DS's radial glow layer and AA-clamped on-primary ink, byte-identical to a hub that has never touched this feature. An authored-but-empty `{}` resolves through the ordinary empty-surface path instead — no background paint, no ink override, following the page theme.

### Background variants

`background.type` is one of:

| `type`          | Renders                                                                                                                                       |
| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `none`          | No override                                                                                                                                   |
| `color`         | A solid theme token (`primary`, `secondary`, `muted`, `accent`, `background`)                                                                 |
| `custom-color`  | A solid inline hex                                                                                                                            |
| `tint`          | A subtle wash — `tone: "neutral"` (default) or `tone: "primary"`; optional `fade: "down"` for a top-to-bottom fade instead of flat            |
| `image`         | `url` + `blur`/`scrim` — see below                                                                                                            |
| `gradient`      | Configured by the **sibling** `gradient` setting on the same surface object (not nested inside `background`)                                  |
| `gradient-glow` | The DS's default look — a wide radial glow rising from the top edge. `glowTint` (boolean), `glowStrength` (`1`\|`2`), `glowSpread` (`1`\|`2`) |
| `gradient-tint` | A soft horizontal wash using the same gradient stops as `gradient`, at a fixed lower strength                                                 |

### Image: `scrim` and `blur`

```json theme={null}
"background": { "type": "image", "url": "https://…", "blur": true, "scrim": false }
```

* `blur` (default `false`) applies a CSS blur directly to the image.
* `scrim` (default `true`) is the always-on secondary-tint darkening layer over the image. Setting it to `false` renders the raw image with no darkening at all.

### `ink` and the scrim-off rule

```json theme={null}
"surface": { "background": { "type": "image", "url": "https://…", "scrim": false }, "ink": "light" }
```

`ink` (`"auto"` default, `"light"`, or `"dark"`) manually overrides the resolved text/foreground contrast for **any** background variant, not just images — a general escape hatch for when auto-derived contrast picks the wrong pole, or has nothing to derive from.

That last case is exactly what `scrim: false` creates: contrast is normally derived *from the scrim's own composited color* — with no scrim, there's no color to sample against an arbitrary photo. So **`scrim: false` skips contrast auto-derivation entirely**, and `ink` left at its own `"auto"` default falls back to forced light/white ink rather than silently inheriting whatever an ancestor happens to have. The rule of thumb: turn off the scrim, you own the ink — set `ink` explicitly on a scrim-less image surface.

## Sanitizer behavior

`preparePanelRegion` never throws, no matter what's in the tree — a malformed brand panel degrades to the pre-feature default (no region content, `side: "right"`, no surface), never a broken page. What it drops, silently:

* **Any kind outside the six-kind allowlist**, and its entire subtree — if the dropped node is a container, its children are never even visited, so the whole branch disappears. A non-allowlisted node nested *inside* an otherwise-kept `stack` only drops that one child (and whatever's under it); its siblings are unaffected.
* **`text`/`headline` nodes with an empty/missing/non-string `value`** — see [above](#content-the-kind-allowlist).
* **`image` nodes with a non-string `value`** — the URL is left unset rather than passed through broken.
* **`dataSource`, `repeat`, `template`, and any other key on the raw node** that isn't `id`, `kind`, `settings`, `children`, or `value` — the sanitized node is rebuilt from scratch from just those five, never spread from the input, so nothing else survives regardless of what's authored. There is no data-binding or repeat support inside this region.
* **Garbage of every kind** — non-object nodes, missing/non-string `kind`, an out-of-range `headline.level` (even something like `1e21`, which would otherwise produce an invalid `h1e+21` tag and crash SSR), a malformed `surface.background.url` — all degrade to a safe default rather than propagating an error.

In development, dropped kinds are logged to the console (`[preparePanelRegion] dropped non-allowlisted brand-panel kind(s): …`); nothing is logged in production, and the drop is otherwise invisible to the visitor — the page just renders without that node.

## `branding.auth_logo_url`

The fixed logo chrome's top-tier source is `branding.auth_logo_url` — a plain key on the hub's branding JSONB, validated exactly like every other `*_url` branding key: an absolute `https://` URL with no embedded credentials, no percent-encoded host, no control characters. `data:` URIs and relative paths are rejected.

```bash theme={null}
mio hubs update hub_abc123 --branding-json '{"auth_logo_url":"https://cdn.example.com/login-logo.svg"}'
```

**`custom_login_logo_url` is a deprecated alias.** The renderer reads `auth_logo_url` first and falls back to `custom_login_logo_url` only when the canonical key is absent — so a hub whose branding was set under the old key still renders correctly during the transition. Write `auth_logo_url` going forward; don't write both.

### Managed upload: the `auth_logo` branding role

`auth_logo` is a managed branding role, the same file-upload-and-attach flow `logo` / `favicon` / `social_image` already use — create the file, upload the bytes to S3, finalize, then attach with `role: "auth_logo"`. Attaching resolves a durable, hub-branding-CDN-backed URL that overlays `branding.auth_logo_url` at read time; the stored `hubs.branding` JSONB itself is never mutated by the attach.

```text theme={null}
POST /api/v1/teams/{team_id}/files                        create file row + presigned S3 PUT URL
PUT  <upload_url from the create response>                 upload the raw bytes to S3
POST /api/v1/teams/{team_id}/files/{file_id}/finalize      confirm upload, PENDING → READY
POST /api/v1/teams/{team_id}/hub-branding-attachments      attach media_id to the hub, role: "auth_logo"
```

```json theme={null}
// POST /api/v1/teams/{team_id}/hub-branding-attachments
{
  "data": {
    "type": "attachments",
    "attributes": {
      "media_id": "media_abc123",
      "target_type": "hub_branding",
      "target_id": "hub_abc123",
      "role": "auth_logo",
      "position": 0
    }
  }
}
```

* **Raster only — SVG is rejected.** Attaching goes through the same eligibility gate the `logo`/`favicon`/`social_image` roles use, shared between the copy-on-attach publisher and the read-time resolver so the two paths can't drift: the media must be `asset_kind: "image"` with a MIME type in the raster allowlist (`image/png`, `image/jpeg`, `image/jpg`, `image/webp`, `image/gif`, `image/x-icon`, `image/vnd.microsoft.icon`). An SVG upload attaches successfully (the attachment row is created), but is never copied into the public bucket — `auth_logo_url` doesn't pick it up, and the resolver falls back to whatever static branding value (or nothing) was there before.
* **Replace-on-attach.** At most one active `auth_logo` attachment exists per hub at a time — attaching a new file for the same (hub, role) deletes the prior attachment in the same transaction before inserting the new one, so re-uploading swaps the logo instead of accumulating rows. The new attachment carries a fresh `media_id`, which naturally busts any downstream cache of the old URL.
* **Durable, not signed.** The resolved `auth_logo_url` is read live from the attachment row (never cached into the branding JSONB) and points at the durable branding-CDN path — it doesn't expire the way a signed `variants` URL would.

<Note>
  The mechanics above are identical to `logo`/`favicon`/`social_image` — only the role string (`"auth_logo"`) changes. If you'd rather point `auth_logo_url` at a URL you already control instead of uploading through this flow, writing it directly via `--branding-json` (above) still works — the direct-write path only validates URL shape, not raster-vs-vector, so an SVG URL set that way is accepted (unlike a managed `auth_logo` upload, which rejects SVG at attach time).
</Note>

## Images: use durable URLs

The `image` kind's `value` is embedded directly as an `<img src>` on an **anonymous, unauthenticated** page — anyone hitting `/login` or `/register` sees it, logged in or not. Don't use a signed `variants` URL (it expires); use a [durable image URL](/guides/media-workflow#durable-image-urls-for-page-trees), which never expires and 302-redirects to a freshly-signed variant per request:

```bash theme={null}
mio media files durable-url file_hero --hub hub_abc123 --preset large-1440 --publish
```

The `--publish` flag (or a separate `mio media hub-media publish --visibility public`) is required — an unpublished file's durable URL 404s for anonymous visitors, which on an auth page means every visitor, always. Append `?hub_id=` if you construct the URL by hand instead of using the CLI.

## Publish

Writing the region into the draft tree doesn't change what visitors see by itself — the auth pages render from the **published** tree, exactly like every other page:

```bash theme={null}
PAGE_ID=$(mio pages list --hub hub_abc123 --type login -o plain --jq '.[0].id')
V=$(mio pages tree get "$PAGE_ID" --hub hub_abc123 --jq .draft_version)
mio pages tree set "$PAGE_ID" --hub hub_abc123 --if-match "$V" --file brand-panel-tree.json
mio pages publish "$PAGE_ID" --hub hub_abc123 --if-match "$((V + 1))"
```

Publishing bakes an anon artifact the same way any other page does. The anon render endpoint (`GET /api/v1/hubs/{slug}/pages/{page_type}`) carries the resulting tree on an additive `tree` attribute, alongside the legacy `branding`/`sections` fields — sourced from the fresh anon-tree artifact when available, or live-pruned from the published tree otherwise, `null` when the page has no published tree at all. That's what `preparePanelRegion` reads on the live login/register/payments page.

<Note>
  **`tree` is served only when the page's `privacy` is `public`.** Auth pages (`login`/`register`/`payments`) default to `privacy: "public"` on create, so this is normally invisible — but privacy is an explicit, editable field, and an admin can still set one of these pages to `members`. In that case the endpoint still returns `200` with `branding`/`sections`/`settings` populated as usual, but `tree` comes back `null` — same shape as "never published," no separate error and no disclosure of which case occurred. If your brand panel isn't showing up on an otherwise-published auth page, check the page's `privacy` first.
</Note>

<Note>
  **The tree is also filtered through the catalog `anonSafe` boundary, not served as-is.** The `sections` half of this response has always dropped any section whose `type` isn't in the anon-safe allowlist; the `tree` half mirrors that exact same boundary onto the node tree — a node whose resolved section type (or catalog template) isn't anon-safe is dropped **structurally, along with its whole subtree**, before the response is built, so the two halves of the response can never disagree about what an anonymous visitor may see. This runs server-side regardless of the brand-panel region specifically — it's why a `brand-panel` region built only from the [six allowlisted content kinds](#content-the-kind-allowlist) above renders correctly for anon visitors: every one of those kinds resolves to an anon-safe type. A node carrying a non-page `dataSource` (e.g. a live query) is dropped the same way, defence-in-depth.
</Note>

## Full example

The complete, valid admin-API tree body for a `page-register` (or `page-login`) brand panel — headline, canonical bullet list, a countdown, and a bold footnote, on a gradient-glow surface with the panel flipped to the left:

```json theme={null}
{
  "root": {
    "id": "root",
    "kind": "stack",
    "settings": {},
    "children": [
      {
        "id": "brand-panel",
        "kind": "stack",
        "settings": {
          "slot": "brand-panel",
          "side": "left",
          "logoSize": "normal",
          "gap": 6,
          "surface": {
            "background": { "type": "gradient-glow", "glowTint": true },
            "ink": "auto"
          }
        },
        "children": [
          {
            "id": "bp-headline",
            "kind": "headline",
            "settings": { "level": 2, "align": "center" },
            "value": "You'll learn how to:"
          },
          {
            "id": "bp-bullet-list",
            "kind": "stack",
            "settings": { "gap": 0 },
            "children": [
              {
                "id": "bp-bullet-1",
                "kind": "text",
                "settings": { "variant": "bullet", "marginBottom": 0, "align": "left" },
                "value": "Build a signature offer members say yes to"
              },
              {
                "id": "bp-bullet-2",
                "kind": "text",
                "settings": { "variant": "bullet", "marginBottom": 0, "align": "left" },
                "value": "Price your programs with confidence"
              },
              {
                "id": "bp-bullet-list-divider",
                "kind": "divider",
                "settings": { "spacing": 0 }
              }
            ]
          },
          {
            "id": "bp-countdown",
            "kind": "countdown",
            "settings": {},
            "value": "2026-09-01T00:00:00Z"
          },
          {
            "id": "bp-footnote",
            "kind": "text",
            "settings": { "weight": 700, "align": "left" },
            "value": "No credit card required to get started."
          }
        ]
      }
    ]
  }
}
```

Wrap this exactly as shown for `pages tree set` — the top-level `root` key, `root.children` as an array. Every node needs its own `id`; the ids above (`bp-*`) are illustrative, but keep them unique within the tree.
