Theme authoring — instructions for Claude

You are working on a GSID CMS theme: a Twig template tree + a Tailwind v4 CSS source file, packaged as a git tag and deployed by the cms-public-worker pod. Examples are the other theme repositories under ceems/ on Gitea (svbeautique, pmumelanie, purefragrance, kringlooptilburgnoord, ertveldetruckparts, gsid, …).

Read this file first when the user asks you to do anything in a theme repo.

Always-read references

These are the other pages of this handbook (the ceems/doc repository). Read what's relevant to the task, and don't summarise from training data: the docs are the source of truth.

When the task involves… Read
Repo layout, sandbox boundary, deploys getting-started.md
Calling any Twig helper twig-helpers.md
Editable content blocks (field('name', 'type')) field-types.md
Contact / quote / newsletter forms forms.md
Carousel / lightbox / slideshow JS+CSS per-page-modules.md
Playing an uploaded video (inline or header background) twig-helpers.md → cms_video()
styles.src.css, @source, the deploy compile tailwind-and-styles.md
Backend modules (catalog/customer/cart/checkout) helpers twig-helpers.md → module helpers + operators/modules/
Landing-page templates under templates/landing/ operators/modules/landingpages.md
An FAQ accordion, or the /faq page templates twig-helpers.md → FAQ module + operators/modules/faq.md
Patterns to avoid do-and-dont.md

If the task spans more than one of these, read each before writing.

Hard rules — do not break

  1. Stay inside the Twig sandbox. Tags, filters, functions, and entity methods are allowlisted in cms-core/src/Theme/ThemeSandboxPolicy.php. Reach for something that isn't allowlisted → SecurityError at render time. The docs list what's available; if the user wants something new, stop and tell them it requires a cms-core release, don't try to work around it.

  2. No PHP files in a theme. Themes are template + CSS only. If you find yourself wanting to write a service, a listener, an entity — that's a backend change, not a theme change.

  3. No npm, no package.json, no node_modules/, no bundler config. Tailwind ships as a single binary (cms-public/bin/tailwindcss) used at deploy time. Don't add a JS toolchain.

  4. No import of third-party JS libraries in theme JS. A theme that ships app.js may use plain ES module syntax + browser APIs only. If a library is needed it goes in cms-public/public/assets/ as a per-page frontend module — that's a backend change too.

  5. Never edit cms-core/, cms-public/, cms-api/, or cms-admin/ from a theme task. Those are separate repos with their own release cycles. If a theme-side need points at one of those, surface it to the user — don't sneak a fix in.

  6. Never commit the compiled styles.css. The deploy worker produces it. Theme repos are source-only. Add styles.css to .gitignore if it isn't already.

  7. Never edit _shared/ partials by copying them into the theme. If _shared/_partials/seo.html.twig doesn't fit, mirror the path in the theme (templates/_partials/seo.html.twig) — the @website namespace wins. Don't ship a fork of _shared/base.html.twig. The <html> tag lives there and is not overridable by design: it already carries lang and dir for the rendered locale, so an RTL site needs no fork and no direction hack. See multi-locale.md → Right-to-left locales.

  8. No inline on*= handlers, ever. No onclick=, onchange=, onsubmit=. A nonce can be put on a <script> tag and never on an attribute, so a single inline handler is what keeps 'unsafe-inline' alive in the site's script policy. cms-core's shared base carries delegated listeners for the common cases:

    <select data-submit-on-change>    submits its own form
    <select data-go-on-change>        navigates to the chosen value
    <form data-confirm="Sure?">       asks before it posts
    <button data-toggle="#panel">     toggles `hidden` on the target
                                      (and maintains aria-expanded)
    

    Anything else goes in the theme's own app.js as an addEventListener. A <script> tag you write in a template is nonced automatically; one you build out of a variable is not, and will be blocked once the policy tightens. See operators/security.md → Content-Security-Policy.

  9. Migrations are not your concern. A theme task should never touch migrations/ anywhere. If a feature needs a schema change, that's a cms-core release.

Sandbox-error checklist

When a render fails with Twig\Sandbox\SecurityError, walk this list before assuming the framework is broken:

  • Are you calling a function not in twig-helpers.md? Almost certainly the cause. Find the equivalent allowed helper or escalate to a cms-core change.
  • Did you call something.foo on an entity? Property access only works for arrays + stdClass. Use the getter (something.getFoo() or something.foo if the getter exists with that exact name).
  • Did you call a filter that isn't in ThemeSandboxPolicy::FILTERS? inline_css, markdown_to_html, convert_encoding are common ones that aren't there.
  • Did you use a tag that isn't in ThemeSandboxPolicy::TAGS? embed, use, verbatim are blocked.
  • Did the user toggle a backend module on but you're missing a helper? Check that the contributor for that module is registered for the active website (the operator-facing operators/modules/ covers each).

When in doubt, read ThemeSandboxPolicy.php directly — it's in cms-core/src/Theme/.

Editor-safety patterns

The inline editor in cms-admin renders the theme via cms-api with data-cms-field-name markers, and it shows only the page's {% block body %}, not the whole page; the designer preview does the same. Both render the page template whole, as the public site does, and take the body block out of it, so a top-level {% import %} works there too. Four rules that catch everyone at least once:

  1. Use field() for everything editable. Reaching past it to read page.getFields()['x'].content|raw works on the public site but makes the field invisible to the editor. If you need to control the outer tag, use type 'text' — it renders content with no wrapper but still emits editor markers in editor mode. See field-types.md → text.

  2. Don't gate field wrappers behind {% if x is defined %} only. The editor needs the empty wrapper to be hoverable. Widen with or cms_is_editing():

    {% if banner.src is defined or cms_is_editing() %}
        <div class="banner-wrapper">{{ field('banner', 'image') }}</div>
    {% endif %}
    

    See twig-helpers.md → cms_is_editing().

  3. Don't put <script> tags inside fields. The editor's iframe strips behavioural JS. If a field needs JS to look right in public mode, that's a per-page frontend module (per-page-modules.md), not inline <script>.

  4. Import macros in every template that calls them, partials included. An {% import %} alias is local to the template that imports it: it does not reach an {% include %}d partial. On the public site strict_variables is off, so a partial calling an alias it did not import does not fail; every label it prints comes out as an empty string, with no error and no log line. Import in the partial itself:

    {# _partials/footer.html.twig #}
    {% import '@website/_macros.html.twig' as site %}
    ...
    

    (A top-level import in a page template is fine in the editor and the designer preview: both render the template whole. They used to render the body block alone, where a top-level import never ran and its alias was a hard 500.)

    For site-wide data (phone, address, socials) don't use macros at all: that is cms_company() (twig-helpers.md).

When the user says "make a new page"

Two parts. Both are needed:

  1. Template at templates/pages/<name>.html.twig. Extends the theme's base.html.twig, defines a body block, calls field() for every editable block. Follow the existing theme's conventions — section structure, container widths, font usage, card patterns. If the theme has default.html.twig, that's the safe starting point to copy from.

  2. A row in the admin — but the operator does this in the SPA; you don't create database rows from a theme task. Tell the user to: open Pages → New, fill slug + title + locale, set template = '<name>', save, edit the fields. Don't try to script it.

If the user wants the page available immediately without going through the admin, that's a sign the page should be in the theme as a non-CMS page (rare — usually privacy/terms boilerplate). Even then, it has to land via the page system to get a URL — there's no "static page" route. Push back if it doesn't fit the model.

When the user says "the styling broke"

Walk this in order:

  1. Did Tailwind compile? Check the compiled styles.css exists in the deployed version dir (/data/cms/themes/<code>/<version>/).
  2. Did @source glob the right paths? A wrong relative path = empty utility set. See tailwind-and-styles.md → @source.
  3. Did Website.themeVersion get bumped? An old version is still activated until the operator clicks "Activate" on the new row in the Theme tab.
  4. Browser cache? Hard reload before assuming the deploy is broken.
  5. CSP blocking inline styles? Look at the browser console.

Don't cache:clear Symfony — the failure is almost never a Symfony cache miss in theme work.

When you need to test locally

# In the theme repo:
<ceems checkout>/apps/public/bin/tailwindcss -i styles.src.css -o styles.css --watch

The local cluster is --context local. The cms-public pod mounts the NFS theme dir; if you copy your theme files into /data/cms/themes/<code>/<version>/ on the local node, the public site picks them up (Twig is in auto_reload mode in dev).

For testing without going through Gitea + worker dispatch:

# Compile + sync against the local pod's NFS mount.
# Adjust paths per your local cluster setup.
kubectl --context local exec -n cms cms-public-0 -c cms-public -- \
    php bin/console app:theme:compile <themeCode> <version>

Don't commit experimental changes you only made for local testing — make the test changes in the live theme repo and verify deploy flow works end to end before tagging.

Git workflow expectations

  • Each theme repo is independent. A change to svbeautique is one commit + one tag (v0.4.2 etc.) in its repo. Don't try to cross-link commits.
  • Tags drive deploys: git tag v0.4.3 && git push origin v0.4.3. The admin's Theme tab picks up the new tag and shows it in the version picker.
  • Never push without committing all the files Tailwind expects to scan. Pushing templates/pages/new.html.twig but forgetting templates/_partials/foo.html.twig (referenced via include) produces a deployed theme that throws TemplateNotFoundError on every render. Test deploy locally first.

When the task is ambiguous

Default to:

  • Read the existing theme. The repo has its own conventions — font naming, container widths, color tokens. Match them.
  • Don't introduce new patterns the theme hasn't used yet (e.g. don't add a _shared/_macros.html.twig import if the theme has been fine without one).
  • Ask the user if you're about to add a new field type, new helper category, or new top-level layout block. Those are choices with downstream consequences.

What good looks like

A theme PR (or commit) for a typical page-add task:

  • One new template file under templates/pages/.
  • Maybe a new partial under templates/_partials/ (if reusable).
  • Maybe a few new utility classes referenced in styles.src.css — added there if the theme uses @layer utilities, otherwise as arbitrary Tailwind classes inline ([data-x="y"]:bg-cream, etc.).
  • No composer.json changes, no package.json changes, no PHP, no Doctrine, no migration, no edits to other repos.

If the diff includes anything outside that list, that's a signal — either the task scope changed, or you've drifted into a backend concern that should be a separate PR in the right repo.

Things to escalate, not work around

  • "I want a new field type" → cms-core release. Read field-types.md → adding a new field type and tell the user the steps; don't fake it with embed.
  • "I want a new Twig helper" → cms-core release. Don't try to compose the behaviour from existing helpers if the user explicitly wants the helper itself.
  • "The cookie wall doesn't show on a tenant" → that's an operator config (Website.cookieSettings.enabled), check operators/index.md → General.
  • "Forms aren't sending mail" → mail-from chain. Walk operators/security.md → mail-from refusal model; the fix is admin-side, not a theme change.
  • "Add npm install <foo> to the theme" → no. Ask why; if the answer is "I need a JS library", that's a frontend module in cms-public.
  • "Add a database column to remember X per page" → no. Page metadata is fixed in cms-core; persistent per-page state needs an entity.

When in doubt about scope, ask before editing.