Ein Kommentar zum inzwischen sieben Jahre alten Beitrag OXID6 Child-Themes auf Basis von WAVE-Parent erstellen, habe ich zum Anlass genommen das Theme-Thema auf den aktuellen Stand zu bringen. OXID7 hat inzwischen Minor-Version 5 (Stand August 2026) erreicht, also allerhöchste Zeit hier nachzuziehen.
Was hat sich in den sieben Jahren geändert:
- APEX hat Wave und Flow als Basis in OXID7 abgelöst
- Das neue Theme basiert grundlegend auf Twig und löst somit die alte Template-Engine Smarty ab
- Im Basis-Theme steckt Bootstrap 5 (in Wave noch BS4)
- jQueryUi ist endlich weg
- Vite als moderne Build-Engine hat Grunt abgelöst
- Das Theme ist grundsätzlich barrierefrei aufgebaut
Da sich der grundsätzliche Weg der herangehensweise nicht geändert hat, folge ich einfach noch einmal dem Weg aus dem älteren Beitrag. D.h. als erstes das APEX-Theme lokal pullen und analysieren. Die drei wichtigen Dateien sind composer.json, package.json und vite.config.js sowie der Ordner build.
APEX: composer.json
Gegenüber der alten composer.json aus dem wave-theme ist ein require-Package hinzugekommen: die twig-component von OXID. Das ist aber nur Formsache. Die component steckt auch offiziell in dem OXID-metapackage-composer-repo.
APEX: package.json
In der neuen package.json fällt sofort auf, das die Anzahl der devDependencies deutlich geschrumpft sind. Hintergrund ist, das ein großer Teil der einzelnen Grunt-Komponenten logischerweise nicht mehr gebraucht werden. Die Arbeit muss aber dennoch getan werden. Das passiert in den vite-Scripten, dazu weiter unten dann mehr.
APEX: vite.config.js
Diese Datei ist Neuland. Sie wirkt erst einmal kompakter und übersichtlicher als die alte Gruntfile.js. Der Grund liegt im Grundprinzip: Grunt als Taskrunner beschreibt Schritt für Schritt, welche Dateien wohin kopiert, konkateniert, kompiliert und minifiziert werden. Vite ist dagegen ein Bundler. Du gibst nur noch die Entry-Points an, alles Weitere findet Vite über die import-Anweisungen im Code selbst. Die Quelle der Wahrheit wandert also aus der Config in den Quellcode – was vorher eine Liste in einem concat- oder copy-Task war, ist jetzt ein import in der Entry-Datei. So ist jedenfalls die Idee.
Denn beim Lesen kommt einem dann doch alles vertraut vor. Und das ist der eigentlich interessante Punkt an dieser Datei: Fast jede Option darin ist dazu da, einen Vite-Default zurückzudrehen. Das ist gut und schlecht zugleich.
Der viteStaticCopy-Block ist im Kern der alte Grunt-copy-Task. Bootstrap und die beiden Fonts werden weiterhin 1:1 ins out-Verzeichnis kopiert, statt über einen import in den Build zu wandern. Und wenn man einmal genauer hinsieht, zeigt sich auch, was man dabei verliert: Der Copy-Task holt sowohl bootstrap.min.js als auch bootstrap.bundle.min.js ins Theme – eingebunden wird in den Templates aber ausschließlich die Bundle-Variante. Rund 60 KB liegen also ungenutzt im out-Verzeichnis. Ein Modul-Graph hätte das nie durchgelassen, weil dort nur landet, was auch tatsächlich importiert wird. Ähnlich bei den JS-Entries: Die drei Zoom-Skripte auf der Detailseite beginnen alle mit demselben DOMContentLoaded-Gerüst und wiederholen zeichengleiche Berechnungen – ein gemeinsames Helper-Modul wäre der naheliegende Schritt, aber genau dazu lädt eine Architektur aus 16 unabhängigen Entry-Points nicht ein.
Unterm Strich benutzt Apex von Vite im Wesentlichen die Asset-Pipeline: SCSS kompilieren, JS transformieren, minifizieren, an einen festen Ort schreiben. Der Modul-Graph existiert, wird aber kaum genutzt – ganze drei import-Anweisungen finden sich im gesamten build/js-Verzeichnis. Die Asset-Auflösung für Fonts und Bilder, das Aufteilen in gemeinsame Chunks, der Dev-Server mit HMR: all das bleibt außen vor. Das Ergebnis ist erstaunlich nah an dem, was die alte Gruntfile.js produziert hat, nur eben mit einem moderneren, schnelleren und deutlich besser gewarteten Werkzeug darunter. Dass in der package.json noch matchdep steht – ein Paket, dessen einziger Zweck das Nachladen von Grunt-Tasks war – passt ins Bild.
Und trotzdem finde ich das die richtige Entscheidung. Nach so vielen Jahren Grunt geht es beim Technologiewechsel erst einmal darum, das Werkzeug zu tauschen, ohne das Ergebnis zu verändern – die Templates, die Pfade und die Erwartungen der Child-Themes bleiben, wie sie sind. Der Schritt darüber hinaus wäre ein zweiter, und der geht nur zusammen mit den Templates: echte Imports statt Copy-Tasks, Fonts und Bilder über den Modul-Graphen auflösen lassen, gemeinsamen Code in Chunks auslagern und über die manifest.json einbinden. Wo diese Grenze genau liegt, sieht man an einer Kleinigkeit: Mehrere Templates erzeugen Inline-JavaScript mit new bootstrap.Modal(…). Solange Twig JavaScript ausspuckt, muss bootstrap global erreichbar sein – ein ES-Modul kann es nicht sein. Der Modul-Graph endet also dort, wo PHP anfängt, JavaScript zu schreiben. Und genau diese Grenze zu verschieben ist einer der interessantesten Freiheitsgrade, die ein eigenes Child-Theme mitbringt. Wir bleiben in diesem Beitrag bei der konservativen Variante – aber es lohnt sich, im Kopf zu behalten, dass hier noch Luft ist.
Das eigene Child-Theme
Auch hier ist wieder die Basis ein eigenes Repo für das zukünftige Child-Theme.
Aus dem APEX-Theme kopiere ich:
- build-Ordner
- composer.json
- package.json
- vite.config.js
- theme.php
CHILD: composer.json
In der composer.json name, description und assets-directory anpassen. Bei der Gelegenheit aus dem blacklist-filter das Grunt-Artefakt löschen.
CHILD: package.json
Auch hier können name und description angepasst werden. Desweiteren entfernt Ihr das Grunt-Artefakt „matchdep“. Im aktuellen b-7.5-Branch des Themes, steckt vite in Version 5 drin. Das kann problemlos im eigenen theme auch auf die aktuelle Version 7 hochgezogen werden.
Dann erfolgt die erste Installation mit: npm install
CHILD: vite.config.js
Sucht einmal nach „apex„. Ihr werdet das in der outDir Zeile finden. Passt diese an, danach erfolgt der erste Probebau mit npm run build
CHILD: theme.php
Das Config-Array in der theme.php bekommt erst einmal grundlegend ein paar neue Keys und Values:
$aTheme = [
'id' => 'fancychild',
'title' => 'Fancy Child',
'description' => 'Child-Theme based on APEX - Bootstrap 5 TWIG Theme',
'thumbnail' => 'fancychild.svg',
'version' => '1.0.0',
'author' => 'the fantastic four',
'parentTheme' => 'apex'
'parentVersion' => '3.1.0'
];
Auch in OXID7 gilt noch die Regel, das ein Child-Theme die Einstellungen des Parent-Themes erben !kann! Darum entfernen wir im Child-Theme erst einmal den settings-Bereich.
Jetzt ist das Theme für die erste Aktivierung bereit. D.h. commited und pushed hier einmal und bindet das Theme in die root-composer.json Eures Shops ein. Nach der Installation via composer müsst Ihr das Theme noch aktivieren. Das geht entweder über den „alten“ Weg im OXID-Admin im Bereich Themes oder über den OXID-Console:
vendor/bin/oe-console o:t:a fancychild
In den Theme-Einstellungen des neuen Child-Themes sieht man erst einmal keine Einstellungen. Die werden vom Parent geerbt. D.h. sie werden im Moment auch im Parent-Theme (Apex) gepflegt. Wer das gern ändern möchte, wirft einen Blick in die Datenbank in die Tabellen oxconfig und oxconfigdisplay. In beiden Tabellen findet man die typischen Configs des themes zu erkennen an den Einträgen „theme:apex“ in der Spalte oxmodule bzw. oxcfgmodule. Die müssen einfach für das Child-Theme zur Verfügung gestellt werden. Hilfreich ist folgendes SQL:
SET @parent := 'theme:apex';
SET @child := 'theme:fancychild';
SET @shopId := 1;
-- values
INSERT INTO oxconfig (OXID, OXSHOPID, OXMODULE, OXVARNAME, OXVARTYPE, OXVARVALUE)
SELECT
MD5(CONCAT(@child, '#', p.OXSHOPID, '#', p.OXVARNAME)),
p.OXSHOPID,
@child,
p.OXVARNAME,
p.OXVARTYPE,
p.OXVARVALUE
FROM oxconfig p
LEFT JOIN oxconfig ex
ON ex.OXMODULE = @child
AND ex.OXSHOPID = p.OXSHOPID
AND ex.OXVARNAME = p.OXVARNAME
WHERE p.OXMODULE = @parent
AND p.OXSHOPID = @shopId
AND ex.OXID IS NULL;
-- display/grouping
INSERT INTO oxconfigdisplay (OXID, OXCFGMODULE, OXCFGVARNAME, OXGROUPING, OXVARCONSTRAINT, OXPOS)
SELECT
MD5(CONCAT(@child, '#', p.OXCFGVARNAME)),
@child,
p.OXCFGVARNAME,
p.OXGROUPING,
p.OXVARCONSTRAINT,
p.OXPOS
FROM oxconfigdisplay p
LEFT JOIN oxconfigdisplay ex
ON ex.OXCFGMODULE = @child
AND ex.OXCFGVARNAME = p.OXCFGVARNAME
WHERE p.OXCFGMODULE = @parent
AND ex.OXID IS NULL;Anschließend hat auch Dein Child eigene vom Parent unabhängige Config-Einträge im Admin. Etwas Gutes kommt in OXID7.6: Dann ist das Thema Config in der Datenbank auch erledigt. Nach den Modulen werden auch die Theme-Optionen in yaml-Files unter var/configuation abgelegt.
CHILD: Templates
Auch hier gilt wie in OXID6 das Vererbungsprinzip. D.h. angenommen Ihr wollt ein Template des Parents in Eurem Child-Theme anpassen, dann kopiert Euch die Datei und packt sie exakt in die gleiche Ordner-Struktur in Eurer Child-Theme wie im Parent. Damit gewinnt Euer Template.
Einige Dinge funktionieren in OXID7 jetzt anders. In OXID6 konnte man z.B. ein Template eines Moduls einfach kopieren und in die gleiche Ordnerstruktur die im Modul definiert war, einfach im Child-Theme ablegen und anpassen. Das Child-Theme-Template hat so in der Hierachie gewonnen. Das geht nun nicht mehr. Jetzt müssen Anpassungen an Modul-Templates auch durch Template-Extensions in Modulen erledigt werden. Übrigens können auch Theme-Template-Anpassungen durch Module erfolgen. Beide Techniken sind identisch und ausführlich hier, in dem etwas versteckten Tutorial von OXID erklärt.
CHILD: Sprachdateien
Bei den Sprachdateien hat sich an der Funktionsweise nichts geändert. Auch hier gilt, die Sprachdateien im Child-Theme haben das letzte Wort und können sämtliche Translations aus dem Parent-Theme, dem Shop und der Module überschreiben. Legt also wie gewohnt für jede Eurer Sprachen ein Verzeichnis an (z.B. de, en) und platziert dort eine *lang.php Datei. Schaut Euch die lang.php-Files aus dem parent an und stellt nach dem Muster ein $aLang-Array in Euren Files bereit.
CHILD: Dokumentation
Zum guten Ton gehört es immer noch, das Ihr zwei Dateien in Euer Repo packt in dem Ihr das Theme und Eure Änderungen beschreibt:
- README.md
- CHANGELOG.md
therealworld/apexdevelop-theme – ein Child-Theme, das die Lücken des Parents schließt
Damit man nicht nur über Child-Themes liest, sondern eines anschauen kann, habe ich unser Entwicklungs-Theme öffentlich gemacht:
therealworld/apexdevelop-theme. Es ist genau nach dem oben beschriebenen Muster gebaut – parentTheme => ‚apex‘, eigenes theme.php, eigener Build – und es zeigt zugleich, wo das Apex-Parent-Theme Dinge zwar angefangen, aber nicht zu Ende gebracht hat.
Dass an einigen Stellen noch „Buchsuite“ im Paket steht, hat einen Grund: apexdevelop ist bei uns kein Selbstzweck, sondern das Basis-Theme, aus dem die konkreten Shop-Themes für Verlage und Buchhandlungen abgeleitet werden. Was hier drinsteckt, hat sich also über eine Reihe echter Projekte hinweg als tragfähig erwiesen – und ist genau deshalb generisch genug, um es öffentlich zu zeigen.
Drei Punkte davon sind einen genaueren Blick wert.
Font-Preload: aus der Absicht generiert statt von Hand gepflegt
Apex bringt Preloads mit – hartcodiert im tpl/layout/base.html.twig:
Das ist der halbe Weg. Die Liste steht als Text im Template, und niemand gleicht sie mit dem ab, was das Stylesheet tatsächlich anfordert. Wird eine Schrift ausgetauscht oder in einer neuen Paketversion anders benannt, bleibt ein toter Preload zurück – und der Browser lädt anschließend brav die richtige Datei ein zweites Mal.
Erschwerend kommt hinzu, dass Apex beide Faces mit font-display: optional deklariert. optional gibt der Schrift nur ein sehr kurzes Fenster; verpasst sie es, rendert der Browser die Fallback-Schrift – und tauscht sie für den Rest des Besuchs nicht mehr aus. Ohne funktionierenden Preload ist die Webschrift beim ersten Besuch also schlicht nicht da. Und Apex lädt an dieser Stelle ein Roboto-Regular.woff2 von 63 KiB: den vollen Familien-Build mit allen Schriftsystemen, obwohl der Shop nur Latin
rendert.
In apexdevelop ist das umgedreht. Die Absicht steht in einer Konfigurationsdatei, nicht im Markup – und sie benennt ein Face, keine Datei:
// build/font-preload.config.js
export default {
preload: [
{
family: 'Oswald',
weight: 600,
style: 'normal',
reason: 'headings ($headings-font-family)',
},
{
family: 'Roboto',
weight: 400,
style: 'normal',
reason: 'body copy ($font-family-base)',
},
],
};Warum ein Face und kein Dateiname? Weil die woff2-Dateien aus npm-Paketen kommen (@fontsource/*) und ihre Namen sich mit jedem Versionssprung ändern können. Das Face bleibt dasselbe.
Der Build-Schritt npm run build:preload löst jeden Eintrag auf und schreibt daraus das Twig-Fragment tpl/layout/_font-preload.html.twig, das im Block des Parents eingehängt wird:
{% block base_preload_fonts %}
{% include "layout/_font-preload.html.twig" ignore missing %}
{% endblock %}Das ignore missing ist Absicht: ein ohne Build ausgeliefertes Theme rendert dann ohne Preload weiter, statt eine Exception zu werfen.
Dazu kommt das Naheliegende, das Apex ausgelassen hat: die Latin-Subsets von @fontsource. Aus 63 KiB Roboto werden 21 KiB. Bei font-display: optional entscheidet genau dieser Unterschied darüber, ob die Schrift ihr Fenster überhaupt erreicht.
Ein Detail, über das man leicht stolpert und das deshalb als Kommentar im generierten Fragment steht: crossorigin ist auch bei Same-Origin Pflicht. @font-face lädt grundsätzlich im CORS-Modus; ein Preload ohne crossorigin landet in einem anderen Cache-Eintrag, und die Datei wird zweimal geladen.
Font-Check: der Build prüft, was das Stylesheet wirklich sagt
Ein Preload, den niemand nachprüft, ist eine Behauptung. Und eine unbrauchbare Behauptung ist hier schlechter als gar keine: ein toter Preload kostet einen Request und produziert eine Browser-Warnung, ein fehlender ist einfach unsichtbar. Diese Asymmetrie ist der Grund, warum Drift den Build brechen muss, statt leise gemeldet zu werden.
Der Generator liest deshalb nicht die SCSS-Quellen, sondern die kompilierte styles.min.css – sie ist die einzige ehrliche Quelle, denn sie ist das, was der Browser sieht. Er parst sie mit PostCSS und zieht aus jeder @font-face-Regel Familie, Gewichtsbereich (auch Variable-Font-Ranges wie 300 700), Style, font-display, unicode-range, die woff2-URL sowie die Information, ob es sich um ein metrisches Fallback-Face handelt.
Diese Befunde brechen den Build:
- Ein konfiguriertes Face ist im Stylesheet nicht deklariert – Schrift entfernt oder umbenannt?
- Ein Face ist mehrdeutig, mehrere Faces matchen (typisch bei Subset-Faces) – dann muss die Konfiguration per covers: ‚ä‘ sagen, welches Subset gemeint ist
- Das Face hat keine woff2-Quelle – ein Legacy-Format zu preloaden lohnt nicht
- Das Face liegt cross-origin
- Die Datei wird vom Stylesheet referenziert, liegt aber nicht auf der Platte
Diese Befunde werden gewarnt — und die Liste ist im Grunde eine Sammlung der Fallen, in die man bei Webfonts tritt:
- Ungültige unicode-range. Der Klassiker ist ein gequoteter Wert: der Browser verwirft die Deklaration, das Face deckt damit U+0-10FFFF – und Subset-Faces beschatten sich gegenseitig, ohne dass irgendwo etwas kaputt aussieht.
- Dieselbe Familie/Gewicht/Style mehrfach ohne wirksame unicode-range. Die letzte Regel gewinnt schlicht, und jeder fehlende Glyph kostet einen zusätzlichen Roundtrip.
- Icon-Font ohne font-display: block. Icon-Glyphen haben keine Fallback-Schrift, zu der gewechselt werden könnte – bei swap sieht der Nutzer bis zum Eintreffen Ersatzkästchen. Geprüft wird das über das ganze Stylesheet, nicht nur über die Preload-Liste.
- Kein metrisch angepasstes Fallback-Face zur preloadeten Schrift. Genau die Hälfte der Arbeit, die sonst vergessen wird: der Preload verkürzt den Wechsel, das Layout springt trotzdem. Icon-Familien sind ausgenommen – für ein Icon-Set lässt sich kein Fallback kalibrieren, und eine Warnung, die man immer ignorieren muss, bringt allen bei, Warnungen zu
ignorieren. - woff2 über 40 KiB. Ein Latin-Subset liegt bei 10–30 KiB; darüber trägt die Datei mit hoher Wahrscheinlichkeit jedes Schriftsystem mit, das die Familie ausliefert.
- Über 100 KiB Preload insgesamt. Jedes Byte hier konkurriert mit Stylesheet und Skripten um die ersten Roundtrips; ab einem Punkt kostet ein Preload mehr, als er einbringt.
Nebenbei löst der Generator auch das Pfadproblem, das man sonst richtig raten muss: Das Stylesheet referenziert Schriften relativ zu out/src/css/, getResourceUrl() löst gegen out/src/ auf. Die preloadete URL muss aber byte-identisch zu der sein, die das
Stylesheet anfordert – sonst wird die Datei zweimal geladen.
Das Schöne daran: Lässt man diesen Check gegen das unveränderte Apex-Setup laufen, meldet er dessen beide offene Punkte von selbst – die 63 KiB große, nicht subsettete Roboto-Datei und die fehlenden metrischen Fallback-Faces. Wir haben die Lücken nicht gesucht, der Build hat sie benannt.
Die andere Hälfte: metrische Fallbacks
Denn der zweite Punkt, den Apex gar nicht adressiert, sind eben jene Fallback-Faces. Das Parent-Theme deklariert keine; der Browser rendert also bis zum Eintreffen der Schrift in Arial – mit anderer Laufweite und anderen vertikalen Metriken. Gemessen auf unserer Entwicklungsinstanz:
Werden beide Schriften blockiert, verschiebt sich das Dokument auf der Startseite um 92 px, auf einer Kategorie-Liste um 180 px.
apexdevelop deklariert deshalb zu jeder Webschrift ein Gegenstück:
@font-face {
font-family: 'Roboto Fallback';
src: local('Arial'), local('Helvetica'), local('Liberation Sans');
font-weight: 400;
font-style: normal;
size-adjust: 99.3%;
ascent-override: 93.7%;
descent-override: 24.2%;
line-gap-override: 0%;
}… und trägt es in die Font-Stacks ein: $font-family-base: ‚Roboto‘, ‚Roboto Fallback‘, system-ui, ….
Der Effekt ist bei optional ein anderer als bei swap, und das ist wichtig zu verstehen: Es springt hinterher nichts, denn eine optional-Schrift, die ihr Fenster verpasst, wird gar nicht mehr eingesetzt. Was die Fallback-Faces kaufen, ist, dass der erste Besuch – der in der Fallback-Schrift rendert – bereits so aussieht wie das beabsichtigte Layout.
Zwei Feinheiten beim Kalibrieren, weil sie sonst Stunden kosten:
size-adjust ist ein Mittelwert über eine Buchstabenmischung, die Messtexte sind also Teil des Ergebnisses und müssen stabil bleiben. Und gemessen wird gegen Arial Regular – src: local(„Arial“) lädt immer Arial Regular, und der Browser rendert es ohne synthetisches Fetten in der Gewichtung, die das Face behauptet. Die Überschreibungen skalieren mit size-adjust, also override = Zielwert% / size-adjust.
ZURB Ink 1 → Foundation for Emails 2
Der dritte Punkt betrifft die Mails. Apex – und ebenso Wave – bauen ihre HTML-Mails auf Zurb Ink 1, ausgeliefert als
Inline-style-block im tpl/email/html/header.html.twig:
/**********************************************
Ink v1.0.5 - Copyright 2013 ZURB Inc *
**********************************************/Ein CSS-Stand von 2013, hunderte Zeilen fest verdrahtet im Template, mit hartcodierten Farben und Größen. Wer sein Theme umfärbt, färbt seine Mails nicht mit – man müsste den Block anfassen und pflegen.
apexdevelop ersetzt diesen Inline-Block durch ein Stylesheet, das aus Foundation for Emails 2 – Inks gepflegtem Nachfolger – gebaut und dabei mit den Theme-Variablen gefüttert wird. Die Mails folgen damit dem Theme,
statt Werte zu wiederholen. Die Importreihenfolge in style-email.scss ist der Kern der Sache:
- Theme-Variablen zuerst, damit sie gegen die Defaults gewinnen
- email/defaults — füllt jeden Namen, den die geteilten Partials erwarten
- die Zurb-Overrides, die Theme-Werte auf Foundations Settings abbilden
- foundation-emails, das diese Settings konsumiert
- eine Ink-1-Kompatibilitätsschicht für die Klassen, die die Apex-Templates weiterhin verwenden
- die geteilten Partials
Kompiliert wird das nicht von Vite, sondern von einem eigenen Schritt (npm run build:email) nach tpl/email/html/email.min.css – Vite würde die relativen url(../fonts/…)-Referenzen umschreiben.
Drei Dinge, die dabei aufgefallen sind und die den Aufwand rechtfertigen:
- Eine Brücke zwischen den Bootstrap-Generationen. Die geteilten Mail-Partials stammen aus einem Wave-Theme (Bootstrap 4), Apex ist Bootstrap 5. Statt die Partials pro Theme umzuschreiben, deklariert email/_defaults.scss jeden erwarteten Namen mit !default und leitet ihn aus den Theme-eigenen Variablen ab – ein Theme, das etwas anderes will, definiert die Variable vorher und gewinnt. Genau hier lauerte ein hübscher Fehler: Waves $brand-secondary meint eine zweite Markenfarbe
(eine dunkle), Bootstrap 5s $secondary ist ein UI-Grau – manche Themes setzen es sogar auf Weiß. Da die Partials $brand-secondary als Textfarbe für p, td verwenden, ergab die naheliegende Zuordnung weiße Schrift auf weißem Grund. - Selektoren, die nie gegriffen haben. Ink 1 formatierte den Footer über .footer.wrapper, also beide Klassen auf einem Element. Die lagen aber schon in Ink 1 auf verschiedenen Elementen – die Regel hat nie gematcht, der graue Footer kam in Wahrheit aus einem hartcodierten bgcolor-Attribut im Template. In der Foundation-2-Struktur ist die Zelle ein th.columns; korrigiert wird das in einer eigenen Datei, damit die geteilten Partials über alle Themes hinweg identisch bleiben.
- Eine Größenskala in px, nicht in rem. Mehrere Mail-Clients – allen voran Outlook Desktop – ignorieren die Root-Schriftgröße; eine rem-basierte Skala rendert dort in ihrer eigenen Annahme. Foundation for Emails arbeitet selbst durchgängig in px. Dazu kommt: Die Web-Skala ist in einer Mail zu laut. Ein h1 mit 2,5 rem = 40 px füllt eine 580 px breite Mail vollständig aus.
Und nun Happy THEMEing …