Use this skill when working on the shell of a page in a Symfony application built on the c975L ecosystem with c975l/site-bundle — the layout, its Twig blocks, the theme tokens, the error pages, the email layouts or the footer components. Covers what a template must set, which block to override, where a design token belongs and what a Content-Security-Policy nonce forbids. Triggers on: layout.html.twig, bodyClass, heading, summarySocialNetwork, theme, themes/site.css, ScaffoldThemeTest, flashes, Scroll:Buttons, backTop, pullDown, --navbar-height, --reading-max-width, --title-color, --label-color, --primary-ink, --bottom-bar-height, error404, emails/fullLayout, emailUnsubscribe, EmailLayoutProvider, EmailLayoutTemplateProvider, layout_no_spam, layout_hello, layout_closing, layout_sent_by, email_template_body, site-owner, url-privacy-policy, HostedBy, MadeBy, Preconnect, theme_variables_css, absolute_urls, ui.management_stylesheet, getManagementStylesheets, block-thumbs, alternates, hreflang, page_alternates, page_title, page_summary.
php artisan boost:add-skill c975l/site-bundle
Save this content to: .claude/skills/c975l-site-layout/SKILL.md
# c975L SiteBundle — layout, theme and emails
> The shell every page of a c975L site renders into: the base layout and its Twig blocks, the admin-editable theme, the error templates and the branded email layouts.
**Package:** `c975l/site-bundle` · **Namespace:** `c975L\SiteBundle\` · **Twig namespace:** `@c975LSite` · **Translation domain:** `site`
**Key source paths** (relative to the package root):
`templates/layout.html.twig`, `templates/components/General/`, `templates/Exception/`, `templates/emails/`, `src/Service/EmailLayoutProvider.php`, `sass/`, `scaffold/assets/styles/themes/site.css`, `config/configs.json`
**Related skills:** `c975l-site-pages`, `c975l-site-menus`, `c975l-site-seo`, `c975l-site-assets` in this same package. The theme compiler, the block system and the fonts are in `c975l/core-bundle`.
## The layout contract
`c975l:scaffold:install` puts `templates/layout.html.twig` in the app — shipped by ConfigBundle, so the
scaffolded login pages have a shell whether or not this bundle is installed:
```twig
{% extends ['@c975LSite/layout.html.twig', '@c975LUi/layout.html.twig'] %}
```
Twig takes the first template of the list that exists: full layout with this bundle, minimal shell
without. **The file is the app's from then on.** Replacing it with your own markup makes it the parent
of the database pages too (`@c975LSite/pages/page.html.twig` extends `layout.html.twig`), so it must
hold up that end of the contract: define a `container` block, and read the `title` and `robots`
variables. A replacement defining neither renders every page empty, with no error at all.
**This bundle's layout is a child of core-bundle's, not a copy of it.** `@c975LUi/layout.html.twig` is
the single source for the whole `<head>`, the body skeleton and the SEO cascade; `@c975LSite/layout.html.twig`
extends it and adds only what having Pages, menus and a navbar brings — four block overrides (`header`,
`container`, `footer`, `navigation`) and four variables handed over to the parent before it renders:
```twig
{% set ogImageMedia = page.ogImage %} {# opens the parent's share-image cascade #}
{% set headingDisplayed = page.isTitleDisplayed %}
{% set bodyClasses = bodyClasses|default('') ~ 'navbar-fixed ' %} {# appended, never assigned #}
{% set bodyControllers = (bodyControllers|default('') ~ ' basic')|trim %}
```
A child layout may set variables outside a block — Twig compiles its body before the parent's — but
never output. The two body variables are **appended, never assigned**: an app layout extending this one
runs its own `set` first, and assigning would drop what it declared. **Anything concerning the `<head>`
belongs to core-bundle's layout**, where both shells read it.
**A satellite bundle never ships a page layout.** Its templates extend `layout.html.twig` by name, no
`@` prefix, and the app's file resolves it.
## What a page template sets
```twig
{% set title = 'My page title' %}
{% set summarySocialNetwork = 'A short summary for social networks.' %}
```
`summarySocialNetwork` feeds both `<meta name="description">` and `og:description`. A page setting
neither can state them from the back office instead, through the `UrlMetadata` row CoreBundle holds for
its url — the layout only reads that row for what the template left unsaid.
An `alternates` variable — `hreflang => absolute url` — is what the layout writes its
`<link rel="alternate" hreflang>` tags from, and nothing is emitted when it is empty. A template
rendering a database page sets it from `page_alternates(page)`, and reads the two texts above through
`page_title(page)` / `page_summary(page)` so they come out in the language being served (see
`c975l-site-pages` and `c975l-site-seo`).
## Twig blocks of the layout
`head`, `meta`, `title`, `stylesheets`, `preconnect`, `fontPreload`, `body`, `bodyClass`, `header`,
`navigation`, `main`, `heading`, `flashes`, `container`, `content`, `share`, `navigationBottom`,
`footer`, `javascripts`, `importmap` — all of them core-bundle's except `navigation`.
`title` is the `<title>` tag and `heading` the `<h1>` printed above the content. **A template
suppressing its own heading overrides `heading`, never `title`** — the second empties the browser tab
and every share instead.
```twig
{% block share %}{{ parent() }}{# added content #}{% endblock %}
{% block share %}{% endblock %} {# disabled #}
```
`flashes` is the one block carrying a guard, and the guard is **inside** it: the bag is only read for a
visitor carrying the session cookie, or for a request that started the session itself. Reading
`app.flashes` is enough to open a session — and a connection to its store — so an unguarded read costs
one on every anonymous page and every crawler hit, for flashes that cannot exist. **An override takes
the guard over with the block**: filled with something else than flashes it renders for everyone, and
filled with another read of `app.flashes` it has to ask `ui_can_hold_flash()` itself. Core-bundle owns
that function and its test suite locks the condition.
Only `success`, `info`, `warning` and `danger` carry a tint. The block maps the labels other bundles
emit onto them — `error` onto `danger`, `notice` onto `info` — and any other label onto `info`, an
untinted `alert-*` printing black ink on the dark page's own background.
Overriding `header` with more than the navigation inside drops the sticky navbar the stylesheet
arranges through that `<header>` (see `c975l-site-menus`).
`<body>` is a flex column at least as tall as the viewport, `<main>` taking the free space, so the
footer holds the bottom of a short page. A site-wide band contributed by another bundle renders
outside `<main>`, pulled in by core-bundle's layout with an include that keeps that bundle optional:
```twig
{{ include('@c975LSocial/shareButtons/default.html.twig', ignore_missing: true) }}
```
A direct call to that bundle's Twig function would resolve at compile time and break the layout of a
site not installing it. **Any bundle contributing a site-wide band follows this convention, its
template path being the public contract.**
## Content-Security-Policy
Core-bundle's layout asks for a `style` nonce as well as a `script` one, which makes `'unsafe-inline'` inert for
`style-src` on every public page: **a `style=""` attribute in one of your templates is dropped by the
browser.** Move those declarations to a class. Styles written from JavaScript are unaffected, the CSSOM
not being subject to `style-src`. If `nelmio/security-bundle` is installed, `CookieNonceGenerator`
decorates its nonce generator to keep the nonce stable across a visit, so Turbo re-executing a fetched
page's `<script>` is not blocked.
## Theme
Colors, fonts and light/dark mode are **admin-editable config keys declared by UiBundle** (`theme-color-*`,
`theme-font-family-*`, `theme-mode`), compiled into `--c975l-*` custom properties. Do not restate them
in CSS.
What a design owns is the shapes, in `assets/styles/themes/*.css`, copied into the app by
`c975l:scaffold:install` and owned by it from then on. **One file per bundle, named after it** —
`ui.css` for everything the block layer reads, `site.css` for this site's chrome (navbar, footer,
`--scroll-offset`, `--frame-background`), one more per satellite that reads tokens of its own. Every
token ships commented out at its default: uncomment a line to take it over, leave it and it keeps
following the bundle.
Tokens worth knowing, all in `site.css`:
| Token | Default | Retunes |
| --- | --- | --- |
| `--navbar-padding-y` / `--navbar-content-height` | `8px` / `44px` | the bar's height; `--navbar-height` is the two together |
| `--navbar-width` / `--navbar-margin-x`, `--footer-width` / `--footer-margin-x` | full viewport | whether the bars bleed past `--body-max-width` |
| `--footer-margin-top` | `--section-space` | the gap above the footer band |
| `--font-size-body` | `1rem` | the size copy is read at; headings are sized against the root and stay out |
| `--title-color` | `var(--primary-ink)` | the ink of every heading, without repainting the brand |
| `--link-color` / `--link-hover-color` | `var(--primary-ink)` / `var(--link-color)` | a link at rest, and on hover |
| `--reading-max-width` | `min(75ch, 90vw)` | the measure body copy is laid out on |
| `--footer-items-direction` / `--footer-items-justify` | column | the footer's layout, unless an admin picks a Display style |
| `--footer-grid-columns` / `--footer-grid-rows` | `1.6fr repeat(3, 1fr)` / `auto auto` | the tracks of the *Columns* display style, read from 768px up |
**Brand-colored text reads `--primary-ink`, never `--primary`.** UiBundle's token splits the brand's
two roles: `--primary` is the **fill** a button, a `primary` flat and the footer band need dark, while
`--primary-ink` is the same color read **against** the page — a heading, a link, an outline, a rule,
a focus ring. It defaults to `--primary`, so a light page is unchanged; `sass/_theme-dark.scss`
lightens that one token and every ink follows, a deep brand blue reading at 1.43:1 on the `#121212`
page. `DarkThemeTextTokensTest` locks it, and `PrimaryInkRoleTest` scans `sass/` for any ink property
still reading `--primary`.
**Declare a token on `:root, [data-theme]`, never on `:root` alone.** A `var()` inside a custom
property's value is substituted on the element carrying the declaration: on `:root` only, every derived
token is computed once against the root palette and descends already resolved, so a scope opened
further down cannot repaint it. `ThemeScopeTest` locks the selector.
Stay out of the theme file: colors and fonts (the admin's), the per-variant section tokens UiBundle
mixes inside its own `.section--bg-*` rules (declaring them in `:root` collapses the three variants),
and the tokens JavaScript writes at runtime. `--bottom-bar-height` stays out too, for a reason of its own: a
bundle fixing a bar to the bottom of the viewport (ShopBundle's basket bar) declares it on `<body>` with that
bar's height, and UiBundle's scroll buttons step over it by reading it — a value in `:root` would raise them on
every site, bar or not.
`App\Service\ThemeStylesheetProvider`, also scaffolded, contributes the whole `themes/` directory to
UiBundle's stylesheet registry, so the files are concatenated into the single `bundles/build/site.css`
rather than costing one request each. **Never import a stylesheet from `assets/app.js`**: AssetMapper
addresses a CSS entry by a `data:application/javascript,` URL, which the site's own CSP blocks, taking
the whole entrypoint down with it.
Two registries, not one: `ui.stylesheet` builds the sheet the public pages link, `ui.management_stylesheet`
the one the back office links. This bundle's `StylesheetProvider` feeds both — its `styles.min.css` to the
first, and `block-thumbs.min.css`, the silhouettes of its block kinds shown by the visual block picker, to
the second. A sheet only the admin screens need belongs in the second alone, rather than on every page of
every site.
## Error templates
`error`, `error401`, `error403`, `error404`, `error410`, `error500` under `@c975LSite/Exception/`:
```twig
{% extends 'layout.html.twig' %}
{% block content %}{% include '@c975LSite/Exception/error404.html.twig' %}{% endblock %}
{% block share %}{% endblock %}
```
The layout defaults `robots` to `noindex, follow` on any page carrying a `status_code`, so no error
template can forget it.
## Emails
Templates under `@c975LSite/emails/`: `layout.html.twig`, `fullLayout.html.twig`, `header.html.twig`
and `footer.html.twig` (which render the `email-header` / `email-footer` menus), and
`emailTemplateLayout.html.twig`. CSS is inlined by `twig/cssinliner-extra`, the compiled theme being
appended through `theme_variables_css()` so the admin's colors win the cascade.
`fullLayout` wraps the whole document in `absolute_urls(config('site-url'))` (core-bundle's filter,
`^1.17.5`), so every root-relative `src` and `href` an email holds names the host it hangs from — a
mailbox has nothing to resolve `/medias/…` against. It is the one place to do it, since every other
email template extends this one; an unset `site-url` leaves the paths untouched.
`fullLayout`'s own copy is four `EmailTemplate` rows — `layout_no_spam`, `layout_hello`,
`layout_closing`, `layout_sent_by` — declared by `EmailLayoutTemplateProvider` and seeded by
`c975l:ui:email-templates:ensure`, **one row per declared language**, read through
`email_template_body('layout_hello', {}, locale)`. Each is an `html` block, so a line break or a bold
word survives the send. The last two take a `{{ site }}` placeholder. They were the `email-text-*`
config entries until 02/09/2026.
What the footer owes the law is not that copy: the identification comes from `site-owner`, followed by
a link to the page `url-privacy-policy` names, both skipped while their setting is empty, and both
rendered *below* the `email-footer` menu an admin builds. `{% block emailUnsubscribe %}` is where a
prospection email places its own way out (article L34-5 CPCE); a transactional one leaves it empty.
`EmailLayoutProvider` implements UiBundle's `EmailLayoutProviderInterface`, so any `EmailTemplate` sent
anywhere in the ecosystem comes out in this bundle's branded layout — and in UiBundle's plain fallback
on a site not installing it. `wrap(string $bodyHtml, ?string $locale = null)` receives the language the
body was resolved in and hands it to the template, which passes it to each `email_template_body()` call:
without it the four sentences followed whichever row the database returned first. **Do not write an email layout in a satellite bundle**; send with
`wrapLayout: true` and this provider answers.
## Footer components
```twig
<twig:c975LSite:General:HostedBy/>
<twig:c975LSite:General:MadeBy/>
```
Both read `site-hosted-by-*` / `site-made-by-*` from the config, and what they show comes from
`display-hosted-by` / `display-made-by` (`none`, `logo`, `name`, `logo-name`) through ConfigBundle's
`credits_mode()`. `MadeBy` words its label through `made_by_label()`, reading `made-by-wording`
(`made` → "Réalisé par", `powered` → "Propulsé par" for a site only running the system).
`site-preconnect` is a JSON array of external origins to preconnect to, read by core-bundle's layout,
which adds the Matomo origin to it on its own.
Matomo, the cookie banner and the scroll buttons **belong to UiBundle, and its layout renders them** —
`<twig:c975LUi:Analytics:Matomo/>`, `<twig:c975LUi:Cookie:Consent/>` and
`<twig:c975LUi:Scroll:Buttons/>`. Rendered by the layout and not by the footer, so **a site overriding
`footer` keeps its tracking and its cookie banner**. The first two carry their own enable guard; the
buttons point at the `id="top"` set on `<body>` and at the `<span id="bottom">` closing it, so a layout
of your own keeps both anchors.
## Do not
- **Do not ship a page layout from a satellite bundle**, and do not `{% extends '@c975LSite/…' %}`
with the `@` prefix from one — extend `layout.html.twig` by name.
- **Do not replace the scaffolded layout without defining `container` and reading `title`/`robots`** —
the database pages render empty, silently.
- **Do not write a `style=""` attribute** in a template. The nonce makes it inert.
- **Do not read `app.flashes` outside the layout's guard.** The read opens a session for every visitor.
- **Do not import a stylesheet from `assets/app.js`.** CSP blocks the whole entrypoint.
- **Do not declare a theme token on `:root` alone.** Use `:root, [data-theme]`.
- **Do not set colors or fonts in a theme file** — they belong to the admin, in the config screen.
- **Do not paint text, an outline or a rule with `var(--primary)`.** Read `var(--primary-ink)`, which
is what dark mode lightens; `--primary` is the fill a surface needs.
- **Do not call another bundle's site-wide band function from the layout.** Include its template with
`ignore_missing`.
- **Do not write a `<head>` tag in this bundle's layout.** Core-bundle's is the only one, and a copy
here drifts from it without a word.
- **Do not override `title` to hide a page heading** — it is the `<title>` tag. Override `heading`.
- **Do not hardcode the email copy.** The four `layout_*` templates are the site's own, one row per language.
- **Do not make an email's paths absolute template by template.** `fullLayout` already does it for all.
- **Do not read a `color-mix()` token in an email stylesheet.** No mail client resolves it and the
inliner drops the declaration — state the value, as `sass/_email-footer.scss` does for `--label-color`.
The shell every page of a c975L site renders into: the base layout and its Twig blocks, the admin-editable theme, the error templates and the branded email layouts.
Package: c975l/site-bundle · Namespace: c975L\SiteBundle\ · Twig namespace: @c975LSite · Translation domain: site
Key source paths (relative to the package root):
templates/layout.html.twig, templates/components/General/, templates/Exception/, templates/emails/, src/Service/EmailLayoutProvider.php, sass/, scaffold/assets/styles/themes/site.css, config/configs.json
Related skills: c975l-site-pages, c975l-site-menus, c975l-site-seo, c975l-site-assets in this same package. The theme compiler, the block system and the fonts are in c975l/core-bundle.
c975l:scaffold:install puts templates/layout.html.twig in the app — shipped by ConfigBundle, so the
scaffolded login pages have a shell whether or not this bundle is installed:
{% extends ['@c975LSite/layout.html.twig', '@c975LUi/layout.html.twig'] %}
Twig takes the first template of the list that exists: full layout with this bundle, minimal shell
without. The file is the app's from then on. Replacing it with your own markup makes it the parent
of the database pages too (@c975LSite/pages/page.html.twig extends layout.html.twig), so it must
hold up that end of the contract: define a container block, and read the title and robots
variables. A replacement defining neither renders every page empty, with no error at all.
This bundle's layout is a child of core-bundle's, not a copy of it. @c975LUi/layout.html.twig is
the single source for the whole <head>, the body skeleton and the SEO cascade; @c975LSite/layout.html.twig
extends it and adds only what having Pages, menus and a navbar brings — four block overrides (header,
container, footer, navigation) and four variables handed over to the parent before it renders:
{% set ogImageMedia = page.ogImage %} {# opens the parent's share-image cascade #}
{% set headingDisplayed = page.isTitleDisplayed %}
{% set bodyClasses = bodyClasses|default('') ~ 'navbar-fixed ' %} {# appended, never assigned #}
{% set bodyControllers = (bodyControllers|default('') ~ ' basic')|trim %}
A child layout may set variables outside a block — Twig compiles its body before the parent's — but
never output. The two body variables are appended, never assigned: an app layout extending this one
runs its own set first, and assigning would drop what it declared. Anything concerning the <head>
belongs to core-bundle's layout, where both shells read it.
A satellite bundle never ships a page layout. Its templates extend layout.html.twig by name, no
@ prefix, and the app's file resolves it.
{% set title = 'My page title' %}
{% set summarySocialNetwork = 'A short summary for social networks.' %}
summarySocialNetwork feeds both <meta name="description"> and og:description. A page setting
neither can state them from the back office instead, through the UrlMetadata row CoreBundle holds for
its url — the layout only reads that row for what the template left unsaid.
An alternates variable — hreflang => absolute url — is what the layout writes its
<link rel="alternate" hreflang> tags from, and nothing is emitted when it is empty. A template
rendering a database page sets it from page_alternates(page), and reads the two texts above through
page_title(page) / page_summary(page) so they come out in the language being served (see
c975l-site-pages and c975l-site-seo).
head, meta, title, stylesheets, preconnect, fontPreload, body, bodyClass, header,
navigation, main, heading, flashes, container, content, share, navigationBottom,
footer, javascripts, importmap — all of them core-bundle's except navigation.
title is the <title> tag and heading the <h1> printed above the content. A template
suppressing its own heading overrides heading, never title — the second empties the browser tab
and every share instead.
{% block share %}{{ parent() }}{# added content #}{% endblock %}
{% block share %}{% endblock %} {# disabled #}
flashes is the one block carrying a guard, and the guard is inside it: the bag is only read for a
visitor carrying the session cookie, or for a request that started the session itself. Reading
app.flashes is enough to open a session — and a connection to its store — so an unguarded read costs
one on every anonymous page and every crawler hit, for flashes that cannot exist. An override takes
the guard over with the block: filled with something else than flashes it renders for everyone, and
filled with another read of app.flashes it has to ask ui_can_hold_flash() itself. Core-bundle owns
that function and its test suite locks the condition.
Only success, info, warning and danger carry a tint. The block maps the labels other bundles
emit onto them — error onto danger, notice onto info — and any other label onto info, an
untinted alert-* printing black ink on the dark page's own background.
Overriding header with more than the navigation inside drops the sticky navbar the stylesheet
arranges through that <header> (see c975l-site-menus).
<body> is a flex column at least as tall as the viewport, <main> taking the free space, so the
footer holds the bottom of a short page. A site-wide band contributed by another bundle renders
outside <main>, pulled in by core-bundle's layout with an include that keeps that bundle optional:
{{ include('@c975LSocial/shareButtons/default.html.twig', ignore_missing: true) }}
A direct call to that bundle's Twig function would resolve at compile time and break the layout of a site not installing it. Any bundle contributing a site-wide band follows this convention, its template path being the public contract.
Core-bundle's layout asks for a style nonce as well as a script one, which makes 'unsafe-inline' inert for
style-src on every public page: a style="" attribute in one of your templates is dropped by the
browser. Move those declarations to a class. Styles written from JavaScript are unaffected, the CSSOM
not being subject to style-src. If nelmio/security-bundle is installed, CookieNonceGenerator
decorates its nonce generator to keep the nonce stable across a visit, so Turbo re-executing a fetched
page's <script> is not blocked.
Colors, fonts and light/dark mode are admin-editable config keys declared by UiBundle (theme-color-*,
theme-font-family-*, theme-mode), compiled into --c975l-* custom properties. Do not restate them
in CSS.
What a design owns is the shapes, in assets/styles/themes/*.css, copied into the app by
c975l:scaffold:install and owned by it from then on. One file per bundle, named after it —
ui.css for everything the block layer reads, site.css for this site's chrome (navbar, footer,
--scroll-offset, --frame-background), one more per satellite that reads tokens of its own. Every
token ships commented out at its default: uncomment a line to take it over, leave it and it keeps
following the bundle.
Tokens worth knowing, all in site.css:
| Token | Default | Retunes |
|---|---|---|
--navbar-padding-y / --navbar-content-height |
8px / 44px |
the bar's height; --navbar-height is the two together |
--navbar-width / --navbar-margin-x, --footer-width / --footer-margin-x |
full viewport | whether the bars bleed past --body-max-width |
--footer-margin-top |
--section-space |
the gap above the footer band |
--font-size-body |
1rem |
the size copy is read at; headings are sized against the root and stay out |
--title-color |
var(--primary-ink) |
the ink of every heading, without repainting the brand |
--link-color / --link-hover-color |
var(--primary-ink) / var(--link-color) |
a link at rest, and on hover |
--reading-max-width |
min(75ch, 90vw) |
the measure body copy is laid out on |
--footer-items-direction / --footer-items-justify |
column | the footer's layout, unless an admin picks a Display style |
--footer-grid-columns / --footer-grid-rows |
1.6fr repeat(3, 1fr) / auto auto |
the tracks of the Columns display style, read from 768px up |
Brand-colored text reads --primary-ink, never --primary. UiBundle's token splits the brand's
two roles: --primary is the fill a button, a primary flat and the footer band need dark, while
--primary-ink is the same color read against the page — a heading, a link, an outline, a rule,
a focus ring. It defaults to --primary, so a light page is unchanged; sass/_theme-dark.scss
lightens that one token and every ink follows, a deep brand blue reading at 1.43:1 on the #121212
page. DarkThemeTextTokensTest locks it, and PrimaryInkRoleTest scans sass/ for any ink property
still reading --primary.
Declare a token on :root, [data-theme], never on :root alone. A var() inside a custom
property's value is substituted on the element carrying the declaration: on :root only, every derived
token is computed once against the root palette and descends already resolved, so a scope opened
further down cannot repaint it. ThemeScopeTest locks the selector.
Stay out of the theme file: colors and fonts (the admin's), the per-variant section tokens UiBundle
mixes inside its own .section--bg-* rules (declaring them in :root collapses the three variants),
and the tokens JavaScript writes at runtime. --bottom-bar-height stays out too, for a reason of its own: a
bundle fixing a bar to the bottom of the viewport (ShopBundle's basket bar) declares it on <body> with that
bar's height, and UiBundle's scroll buttons step over it by reading it — a value in :root would raise them on
every site, bar or not.
App\Service\ThemeStylesheetProvider, also scaffolded, contributes the whole themes/ directory to
UiBundle's stylesheet registry, so the files are concatenated into the single bundles/build/site.css
rather than costing one request each. Never import a stylesheet from assets/app.js: AssetMapper
addresses a CSS entry by a data:application/javascript, URL, which the site's own CSP blocks, taking
the whole entrypoint down with it.
Two registries, not one: ui.stylesheet builds the sheet the public pages link, ui.management_stylesheet
the one the back office links. This bundle's StylesheetProvider feeds both — its styles.min.css to the
first, and block-thumbs.min.css, the silhouettes of its block kinds shown by the visual block picker, to
the second. A sheet only the admin screens need belongs in the second alone, rather than on every page of
every site.
error, error401, error403, error404, error410, error500 under @c975LSite/Exception/:
{% extends 'layout.html.twig' %}
{% block content %}{% include '@c975LSite/Exception/error404.html.twig' %}{% endblock %}
{% block share %}{% endblock %}
The layout defaults robots to noindex, follow on any page carrying a status_code, so no error
template can forget it.
Templates under @c975LSite/emails/: layout.html.twig, fullLayout.html.twig, header.html.twig
and footer.html.twig (which render the email-header / email-footer menus), and
emailTemplateLayout.html.twig. CSS is inlined by twig/cssinliner-extra, the compiled theme being
appended through theme_variables_css() so the admin's colors win the cascade.
fullLayout wraps the whole document in absolute_urls(config('site-url')) (core-bundle's filter,
^1.17.5), so every root-relative src and href an email holds names the host it hangs from — a
mailbox has nothing to resolve /medias/… against. It is the one place to do it, since every other
email template extends this one; an unset site-url leaves the paths untouched.
fullLayout's own copy is four EmailTemplate rows — layout_no_spam, layout_hello,
layout_closing, layout_sent_by — declared by EmailLayoutTemplateProvider and seeded by
c975l:ui:email-templates:ensure, one row per declared language, read through
email_template_body('layout_hello', {}, locale). Each is an html block, so a line break or a bold
word survives the send. The last two take a {{ site }} placeholder. They were the email-text-*
config entries until 02/09/2026.
What the footer owes the law is not that copy: the identification comes from site-owner, followed by
a link to the page url-privacy-policy names, both skipped while their setting is empty, and both
rendered below the email-footer menu an admin builds. {% block emailUnsubscribe %} is where a
prospection email places its own way out (article L34-5 CPCE); a transactional one leaves it empty.
EmailLayoutProvider implements UiBundle's EmailLayoutProviderInterface, so any EmailTemplate sent
anywhere in the ecosystem comes out in this bundle's branded layout — and in UiBundle's plain fallback
on a site not installing it. wrap(string $bodyHtml, ?string $locale = null) receives the language the
body was resolved in and hands it to the template, which passes it to each email_template_body() call:
without it the four sentences followed whichever row the database returned first. Do not write an email layout in a satellite bundle; send with
wrapLayout: true and this provider answers.
<twig:c975LSite:General:HostedBy/>
<twig:c975LSite:General:MadeBy/>
Both read site-hosted-by-* / site-made-by-* from the config, and what they show comes from
display-hosted-by / display-made-by (none, logo, name, logo-name) through ConfigBundle's
credits_mode(). MadeBy words its label through made_by_label(), reading made-by-wording
(made → "Réalisé par", powered → "Propulsé par" for a site only running the system).
site-preconnect is a JSON array of external origins to preconnect to, read by core-bundle's layout,
which adds the Matomo origin to it on its own.
Matomo, the cookie banner and the scroll buttons belong to UiBundle, and its layout renders them —
<twig:c975LUi:Analytics:Matomo/>, <twig:c975LUi:Cookie:Consent/> and
<twig:c975LUi:Scroll:Buttons/>. Rendered by the layout and not by the footer, so a site overriding
footer keeps its tracking and its cookie banner. The first two carry their own enable guard; the
buttons point at the id="top" set on <body> and at the <span id="bottom"> closing it, so a layout
of your own keeps both anchors.
{% extends '@c975LSite/…' %}
with the @ prefix from one — extend layout.html.twig by name.container and reading title/robots —
the database pages render empty, silently.style="" attribute in a template. The nonce makes it inert.app.flashes outside the layout's guard. The read opens a session for every visitor.assets/app.js. CSP blocks the whole entrypoint.:root alone. Use :root, [data-theme].var(--primary). Read var(--primary-ink), which
is what dark mode lightens; --primary is the fill a surface needs.ignore_missing.<head> tag in this bundle's layout. Core-bundle's is the only one, and a copy
here drifts from it without a word.title to hide a page heading — it is the <title> tag. Override heading.layout_* templates are the site's own, one row per language.fullLayout already does it for all.color-mix() token in an email stylesheet. No mail client resolves it and the
inliner drops the declaration — state the value, as sass/_email-footer.scss does for --label-color.How can I help you explore Laravel packages today?