Skip to main content
Version: 2026.3

Studio Chrome Contract

Studio (new) frontend. See Frontend Variant for which frontend a portal runs, and how to preview the other one.

The customer portal's chrome — header, navigation rail, footer — is server-rendered Twig, while the page content is a React app mounted into it. That makes the chrome the one part of the new frontend you customise the ordinary Symfony way, by overriding a template.

This page is the contract for doing that: which blocks exist, and what the chrome expects to still be there afterwards.

If you want to change the content of a data-pool screen rather than the chrome around it, this is the wrong seam — that is the React app, and it is extended through the plugin SDK.

The blocks​

@PimcorePortalEngine/layout/studio.html.twig exposes seven blocks. Override them the standard Symfony way.

BlockWhat it wraps
studioHeadExtra <link> / <meta> in <head>, after the portal's own
shellHeaderThe whole <header class="p-hdr"> — logo, search, collections, cart, user menu
shellNavThe whole <nav class="p-rail"> navigation rail
studioContentThe page body. Data-pool screens mount the React app here
shellFooterThe footer inside the body column
studioScriptsScripts at the end of <body>, after the portal's own
bodyClassExtra classes on <body> (which always carries pe)

The element-id contract​

This is the part that will catch you out. The chrome's behaviour is a small vanilla-JS IIFE inline in layout/studio.html.twig itself, and it finds its elements by id. It is not a framework and it does not fail loudly: if an id is missing, getElementById returns null, the listener is never attached, and the control simply does nothing. Nothing is logged, and the page looks fine.

Two details worth knowing before you override anything:

  • The script sits outside studioScripts, just above it. Overriding that block without {{ parent() }} therefore does not remove the chrome behaviour — only overriding shellHeader or shellNav can, by removing the elements it looks for.
  • A near-identical copy lives in shared/studio/script.html.twig, included by the legacy layout/default.html.twig. Changing one does not change the other; make sure you are looking at the layout your portal actually renders.

So overriding shellHeader or shellNav means reproducing these:

IdGoes missing →
pe-shell-headerHeader never gains is-scrolled, so it keeps its transparent fill over content
pe-railNothing below works — it is the element the others toggle
pe-burgerNo way to open the navigation drawer on mobile
pe-scrimDrawer opens and cannot be dismissed by tapping outside it
pe-rail-capRail cannot be collapsed on desktop
pe-usrUser menu never opens; it is the element that carries is-open
pe-usr-btnSame, and aria-expanded is never updated

Two more, less obvious:

  • Rail links need the class p-rail-it. The drawer closes itself when one is clicked on mobile, and that handler is bound by class, not by tag.
  • The script toggles pe--drawer-open on <body> while the drawer is open, and is-open / is-closed on the rail. Custom CSS should key off those rather than inventing its own state.

React mount points in the chrome​

The header is server-rendered but carries React islands. Remove one and you remove the feature — again with no error, because the app only mounts what it finds:

MountFeature
#pe-search-rootPortal-wide search in the header
[data-portal-chrome-island="cart"]Download-cart flyout (enhances the server-rendered link)
[data-portal-chrome-island="tasks"]Background-task notifications

data-portal-chrome-island is the general mechanism and takes a kind; #pe-search-root predates it and is matched by id. If you are adding a mount of your own, follow the attribute.

Theming without overriding anything​

Before overriding a template, check whether the style settings already cover it. The portal derives its whole palette from four per-portal inputs — brandPrimary, brandFill, brandInk, brandAccent — into --p-* custom properties emitted on body.pe.

Two rules if you write CSS against them:

  • Consume the tokens, do not recompute the colours. Everything but those four inputs is derived with color-mix(in oklab, …); hand-picking a derived value puts your override out of step the moment an input changes.
  • Do not redefine --p-* on :root. They are emitted on body.pe deliberately: custom property substitution resolves at the declaring element, so a :root indirection freezes the kit's default and your value never applies.

Worked example​

Add a link to the header without touching anything else:

{# templates/bundles/PimcorePortalEngineBundle/layout/studio.html.twig #}
{% extends '@!PimcorePortalEngine/layout/studio.html.twig' %}

{% block shellHeader %}
{{ parent() }}
<a class="p-hdr-ic" href="/en/help">{{ 'portal-engine.navbar.help'|trans }}</a>
{% endblock %}

{{ parent() }} is doing the real work here: it keeps every id above intact. Prefer extending a block over replacing it, and when you must replace one, copy the original markup first and edit it down — that way the ids come along by default instead of by memory.