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.
| Block | What it wraps |
|---|---|
studioHead | Extra <link> / <meta> in <head>, after the portal's own |
shellHeader | The whole <header class="p-hdr"> — logo, search, collections, cart, user menu |
shellNav | The whole <nav class="p-rail"> navigation rail |
studioContent | The page body. Data-pool screens mount the React app here |
shellFooter | The footer inside the body column |
studioScripts | Scripts at the end of <body>, after the portal's own |
bodyClass | Extra 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 overridingshellHeaderorshellNavcan, by removing the elements it looks for. - A near-identical copy lives in
shared/studio/script.html.twig, included by the legacylayout/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:
| Id | Goes missing → |
|---|---|
pe-shell-header | Header never gains is-scrolled, so it keeps its transparent fill over content |
pe-rail | Nothing below works — it is the element the others toggle |
pe-burger | No way to open the navigation drawer on mobile |
pe-scrim | Drawer opens and cannot be dismissed by tapping outside it |
pe-rail-cap | Rail cannot be collapsed on desktop |
pe-usr | User menu never opens; it is the element that carries is-open |
pe-usr-btn | Same, 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-openon<body>while the drawer is open, andis-open/is-closedon 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:
| Mount | Feature |
|---|---|
#pe-search-root | Portal-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.
Bricks can carry React islands too: [data-portal-content-island="image-carousel"] is the image
carousel brick. A page with a content island is always loaded in full, never swapped in by
client-side navigation, so such a brick must stay off client_routing.swap_safe_bricks; the router
only unmounts the island when the page is left.
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 onbody.pedeliberately: custom property substitution resolves at the declaring element, so a:rootindirection 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.