Frontend¶
Templates, CSS, admin UI conventions, translations, and HTML sanitization.
Template layout¶
Templates live in templates/ and mirror the controller structure:
templates/
├── base.html.twig ← Public site base layout
├── admin/
│ └── base.html.twig ← Admin section base layout
├── events/
│ ├── index.html.twig ← Full page
│ ├── details.html.twig ← Full page
│ └── _event_card.html.twig ← Fragment (underscore prefix = reusable partial)
├── _components/ ← Reusable snippets across multiple pages
│ └── warning_box.html.twig
└── cms/
└── blocks/ ← CMS content-block renderers
Naming conventions:
- snake_case for all template files
- Prefix fragments (partials) with _ — e.g. _event_card.html.twig
- templates/_components/ for snippets included in multiple different pages
- templates/{page}/_partials/ for sub-sections specific to one page
Key blocks in base.html.twig to override in child templates:
| Block | Purpose |
|---|---|
title |
<title> content |
content |
Main page content |
stylesheets |
Extra <link> tags |
javascripts |
Extra <script> tags |
Twig namespaces¶
| Namespace | Resolves to |
|---|---|
| (no prefix) | templates/ |
@PluginName/ |
plugins/plugin-name/templates/ |
Plugin templates always use their own namespace:
{# In a plugin template: #}
{% extends '@MyPlugin/base.html.twig' %}
{# Core template including a plugin partial: #}
{{ plugin.getEventTile(event.id)|raw }}
Bulma CSS¶
The entire UI uses Bulma — no other CSS framework. Keep styling minimal.
Use these elements:
{# Layout #}
<div class="container">
<div class="columns">
<div class="column is-6">
{# Tables #}
<table class="table is-fullwidth">
{# Forms #}
<div class="field">
<div class="control">
<input class="input" type="text">
<div class="select">
<textarea class="textarea">
{# Buttons — only for form submits #}
<button class="button is-primary">Save</button>
<a href="..." class="button is-light">Cancel</a>
Avoid: box, card, notification, message, hero, section, tag (for status),
buttons group wrapper. These add visual weight without benefit.
Icons: Font Awesome via <span class="icon"><i class="fa fa-edit"></i></span>.
Admin UI conventions¶
The admin interface follows a minimal design philosophy. See the full spec in design guidelines.
Table lists¶
The standard pattern for list pages:
<div class="container">
<table id="filteredTable" class="table is-fullwidth">
<thead>
<tr>
<th>Name</th>
<th>Status</th>
{# Create action in last header column #}
<th>
<a href="{{ path('app_admin_entity_create') }}">
<span class="icon"><i class="fa fa-plus"></i></span>
</a>
</th>
</tr>
</thead>
<tbody>
{% for item in items %}
<tr class="{{ item.canceled ? 'has-background-danger-light' : '' }}">
<td>{{ item.name }}</td>
<td>{{ item.active ? 'Active' : 'Inactive' }}</td> {# plain text, not tags #}
<td>
<a href="{{ path('app_admin_entity_edit', {'id': item.id}) }}">
<span class="icon"><i class="fa fa-edit"></i></span>
</a>
<a href="{{ path('app_admin_entity_delete', {'id': item.id}) }}">
<span class="icon"><i class="fa fa-trash"></i></span>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
Standard action icons:
| Icon | FA class | Use for |
|---|---|---|
| Edit | fa-edit |
Navigate to edit form |
| Delete | fa-trash |
Navigate to delete confirmation |
| Create | fa-plus |
In table header only |
| Cancel | fa-ban has-text-danger |
Destructive state change |
| Approve | fa-check has-text-success |
Positive state change |
Row colour coding¶
Use light background variants to indicate row state:
| Class | When to use |
|---|---|
has-background-danger-light |
Canceled / inactive |
has-background-info-light |
Recurring / special |
has-background-warning-light |
Featured / next |
Delete warning boxes¶
Prefer the reusable warning box component over onclick="confirm()" dialogs:
{% include '_components/warning_box.html.twig' with {
'id': 'entity-delete-warning',
'title': 'Delete Entity',
'content': '<p>This will permanently delete the entity and all related data.</p>',
'buttonLabel': 'Yes, Delete',
'buttonUrl': path('app_admin_entity_delete', {'id': entity.id}),
'type': 'danger'
} %}
Translations¶
Translation keys use snake_case and are scoped by concept:
{# In templates #}
{{ 'event.title.label'|trans }}
{{ 'action.save'|trans }}
{{ 'user.email.placeholder'|trans }}
Translation files live in translations/:
YAML format — quote values that contain special characters:
event.title.label: 'Event title'
event.rsvp.confirm: 'Are you sure you want to RSVP?'
action.save: 'Save'
Workflow for adding new keys:
- Add the key and English value to
translations/messages.en.yaml - Run
just translationExtractto scan all templates for new keys - Run
/fill-translationsin Claude Code to fill missing DE/CN keys
HTML sanitization¶
CMS page content can contain HTML entered by group organizers (ROLE_FOUNDER). Never use
|raw in CMS templates — use |sanitize_html instead:
{# ✅ Safe — strips disallowed tags #}
{{ block.content|sanitize_html('cms.content') }}
{# ❌ Dangerous — XSS risk #}
{{ block.content|raw }}
The allowlist is configured in config/packages/html_sanitizer.yaml. The cms.content
profile allows common formatting tags (<p>, <a>, <strong>, <ul>, <img>, etc.)
while stripping scripts and event attributes.
For non-CMS content (descriptions entered through validated Symfony forms), standard Twig
auto-escaping is sufficient — no |raw or |sanitize_html needed.
JavaScript¶
JavaScript is progressive enhancement only. The site must be fully functional without JS.
{# ✅ Works with and without JS #}
<a href="{{ path('app_event_toggle_rsvp', {event: event.id}) }}"
class="button is-primary"
data-action="ajax-toggle-rsvp">
RSVP
</a>
{# Without JS: full page navigation to controller and back
With JS: AJAX intercepts, updates UI in place #}
{# ❌ Broken without JS #}
<button onclick="submitRsvp({{ event.id }})">RSVP</button>
No inline <script> blocks in templates. Use data-action attributes and external JS files.