Upgrade-Skill 1.3 - neuer Step 9b: die uebrig gebliebenen sys_template- Datensaetze loeschen. "Via site sets, not sys_template" liest sich wie "die Tabelle ist erledigt", aber v14 wertet sie weiter aus (TCA in cms-frontend, PageInformationFactory ruft getSysTemplateRowsByRootline). Ein Alt-Template mit clear=3 OBERHALB des Site-Roots loescht Constants und Setup, also auch das Set-TypoScript - und zwar nur fuer Code, der das FE-TypoScript im Backend mit eigener Rootline neu aufbaut, weil der Core die Rootline am Site abschneidet. Symptome sehen unverwandt aus (fehlende FormEngine-Platzhalter, leere Template-Layout-Listen, nicht erscheinende FlexForm-Sheets), Kennzeichen ist: Frontend richtig, Backend falsch. Mit den drei Pruefungen, die das Loeschen vorher absichern, und Soft-Delete statt DELETE. Design-Parity 1.9 - drei Punkte: - Vergleichsschleife: um zu belegen, dass ein Eingriff das Frontend NICHT veraendert, muss vor dem Diff das Render-Rauschen weggefiltert werden (picture-/img-Hashes, CSS-mtime), sonst weicht jede Seite ab und der Beweis faellt aus. - Neuer Abschnitt zum Backend-Formular mit Playwright: es steckt in einem iframe (und der naheliegende Frame-Filter erwischt den Hauptframe mit), innerText ist in inaktiven Tabs LEER, auf den eigenen field-item eingrenzen, Tab-Aktivierung ist unzuverlaessig, den SAVE testen - und die UI-Reichweite einer Aenderung an einer FormEngine-Renderbedingung vorher/nachher zaehlen (aus 9 Schaltern wurden unbemerkt 83). - Settings: Booleans in settings.yaml gehoeren in Anfuehrungszeichen. YAML true wird zum Constant "1", YAML false zu einem LEEREN Constant; das Frontend ueberlebt es, der Backend-Platzhalter nicht. Deploy-Skill 1.3 - die dort empfohlene Form MYSQL_PWD="$(cat …)" wird fuer Schreibzugriffe regelmaessig blockiert. Durchgegangen ist ein Skript, das das Passwort selbst aus DBPASSFILE liest, sodass nur ein Pfad in der Kommandozeile steht. Dazu: solche Skripte gezielt und idempotent auf dem aktuell gespeicherten Stand arbeiten lassen statt ein vorbereitetes Blob zurueckzuschreiben, sonst ueberfaehrt man den parallelen Save eines Redakteurs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
486 lines
41 KiB
Markdown
486 lines
41 KiB
Markdown
---
|
||
name: typo3-t3bootstrap-live-design-parity
|
||
description: "Match a TYPO3 v14 t3bootstrap-based build (local DDEV) to a reference LIVE site page by page — the frontend/design phase after a v11->v14 upgrade. Use when making an upgraded TYPO3 site look like its old/live counterpart, when doing per-page visual parity against a reference URL, when content that exists in the DB isn't rendering after a v11->v14 migration, or when customizing a t3bootstrap sitepackage's templates/SCSS/settings. Covers: the Playwright compare loop (computed style + CSS rule, and how to prove a change did NOT alter the frontend), driving the BACKEND record form/FormEngine with Playwright (it lives in an iframe; innerText is empty in inactive tabs), the settings-vs-SCSS decision incl. why settings.yaml booleans must be quoted strings, the t3b_core spacing system (spacing.yaml / tx_t3b_spacing) and why spacing must never be fixed in SCSS, recurring structural fixes (stranded colPos, missing templateLayout partials, template partialRootPaths precedence/shadowing, on-demand component CSS, unregistered CTypes), a full visual-parity checklist (layout/width, typography, colors, links/buttons, equal-sizing, icons, borders, footer, header/nav, hero carousel incl. why Bootstrap autoplay cannot be switched off via the interval), diagnosing regressions caused by an editor's SAVE (CKEditor dropping undeclared classes, FlexForm selects with no empty item flipping to "yes"), and cache-safe techniques (full-bleed bands, image-border restyle, responsive crop variants, client-side randomisation, FAL-via-DataHandler). Assumes the site already boots on v14 (see the v11->v14 upgrade skill)."
|
||
metadata:
|
||
author: Wappler
|
||
version: "1.9"
|
||
---
|
||
|
||
# TYPO3 v14 t3bootstrap — live-design parity
|
||
|
||
Make a **v14 t3bootstrap-based DDEV build** look like a **reference live site** (usually the old v11
|
||
site being replaced), **page by page**. There are two layers, in order:
|
||
1. **Structural** — the right content actually *renders* (migration artifacts often hide it).
|
||
2. **Visual parity** — typography/colors/spacing/components match the reference.
|
||
|
||
Compare with **Playwright** ([headed, in the user's visible window]); **read computed values, don't
|
||
eyeball**. Brand tokens (colors, fonts, page IDs, label text) are **site-specific** — fill the
|
||
placeholders below from the site you're matching; keep a per-project log of what you changed.
|
||
|
||
## How the v14 t3bootstrap theme works (resolve these first)
|
||
- **TypoScript via site sets**, not `sys_template`. The `PAGE` object comes from the theme set,
|
||
declared under `dependencies:` in `config/sites/<SITE_ID>/config.yaml`. (Missing → "No page
|
||
configured for type=0".) flux/vhs are gone in v14; content is `t3bs_*` container CTypes.
|
||
- **Sitepackage** `<TEMPLATE_PACKAGE>` (extension key `<TEMPLATE_EXTKEY>`), usually a fork of
|
||
`t3bootstrap/template`, often installed from a `dev-<branch>` git checkout in `vendor/`.
|
||
Content-element overrides live in `EXT:<TEMPLATE_EXTKEY>/Resources/Private/Extensions/
|
||
fluid_styled_content/{Templates,Partials,Layouts}`; per-CType TypoScript auto-imports from
|
||
`Configuration/TypoScript/Lib/ContentElement/*.typoscript`.
|
||
- **⚠ Workflow:** edits in a `dev-<branch>` vendor checkout must be **committed & pushed to the
|
||
template repo** before any `composer update <TEMPLATE_PACKAGE>` (else lost). **Code** changes
|
||
(templates/SCSS/TypoScript/tsconfig/JS in the sitepackage) deploy via `composer update`. **Content**
|
||
changes (tt_content, settings.yaml, DB) are project-local and travel via a DB dump, NOT the repo.
|
||
|
||
## Per-page compare loop
|
||
1. Open live `https://<LIVE_HOST>/<path>` and DDEV `https://<DDEV_HOST>/<path>` in Playwright.
|
||
**Cache-bust DDEV with `?cb=N`** (browser caches); `ddev exec vendor/bin/typo3 cache:flush` after
|
||
config/SCSS/template changes (a **hard** flush — also `rm -rf var/cache/*` — for template/TS path
|
||
or on-demand-CSS changes). After editing a **Fluid partial/template**, `cache:flush` alone can
|
||
still serve the old render → also clear `var/cache/code/fluid_template/*` + `var/cache/data/pages/*`.
|
||
- **⚠ `?cb=N` busts the PAGE HTML but NOT the compiled-CSS asset URL.** Playwright caches
|
||
`main.css` across navigations, so `getComputedStyle` may read **stale** styles after a recompile
|
||
(a rule "missing" from the CSSOM though it's on disk). Verify against server truth: in-page
|
||
`fetch(cssUrl,{cache:'no-store'})` and grep the rule, or `grep` the compiled file on disk; don't
|
||
trust the cached DOM. Same for server-rendered HTML — `fetch(url,{cache:'no-store'})` to confirm a
|
||
template change actually took, rather than the cached page.
|
||
- **⚠ The browser may also serve a whole cached DOCUMENT for a URL you believe is new.** Seen with a
|
||
persistent-profile browser: the DOM still showed the pre-change markup while `curl` on the same
|
||
path already returned the new one. Symptom: your fix "doesn't work" in the browser but is provably
|
||
live on the server. Cross-check with an in-page `fetch(location.pathname + '?x=' + Math.random(),
|
||
{cache:'no-store'})` and compare against the DOM before concluding anything about the fix.
|
||
- **⚠⚠ CHECK THE RESPONSE HEADERS BEFORE BELIEVING ANY "still not fixed" REPORT.**
|
||
`curl -sI <url> | grep -iE 'cache-control|expires'`. With `config.sendCacheHeaders = 1` (t3bootstrap
|
||
sets it) and **no `config.cache_period`**, TYPO3 emits its core default lifetime — `CacheLifetime
|
||
Calculator::defaultCacheTimeout = 365 * 86400`, i.e. **`max-age` of ONE YEAR** — to browsers and,
|
||
since the response carries no `private`, to shared proxies. A `max-age` already handed out
|
||
**cannot be revoked**: `cache:flush` clears the SERVER cache only, that client simply stops asking.
|
||
Deploys and editorial changes then never reach returning visitors, and anything rendered INTO the
|
||
document comes back stale with it — including inline JS config (e.g. `ws_slider` writes its whole
|
||
configuration as `let options_slider_<uid> = {...}` in the page), so a long-fixed bug reproduces
|
||
perfectly. Tell-tale: people on YOUR network see every fix, people on the CUSTOMER's network see
|
||
none. Fix at the source — `config.cache_period = 3600` (caps the server cache AND the emitted
|
||
max-age), or `sendCacheHeaders = 0` to stop instructing the client at all. Note the lifetimes
|
||
already issued cannot be recalled: those users need one hard reload. Assets are fine either way
|
||
(TYPO3 appends `?<mtime>` to CSS/JS URLs).
|
||
- **⚠⚠ START EVERY Playwright INVESTIGATION BY PROVING THE DOCUMENT IS FRESH.** The stale copy
|
||
misleads in BOTH directions, and the false-negative is the dangerous one: a reported bug is
|
||
**not reproducible** because the cached document carries an OLDER, working configuration. Real
|
||
case: "the slider doesn't page endlessly" — the browser's cached HTML had `loop:1` (looped fine
|
||
under arrow clicks, real drags, frame-by-frame translate sampling — all "proof" that there was no
|
||
bug), while a cache-bypassing fetch of the same URL returned `loop:false`. Everything rendered
|
||
INTO the document lies with it: inline JS config, slide COUNT, and content ELEMENT ORDER (the
|
||
stale copy showed the slider above the heading, the current page below it — nearly filed as a
|
||
second defect).
|
||
First action, before touching the DOM: `browser_evaluate` →
|
||
`fetch(location.href, {cache:'reload'})`, then grep the answer for the settings the report is
|
||
about (`loop:`, `autoplay:`, slide/frame counts) and compare with the DOM. Cheap, one call, and
|
||
it decides whether anything you measure afterwards means something. `curl` is NOT a substitute
|
||
behind HTTP basic auth (staging) — it returns 401 while the browser holds the credentials.
|
||
Test behaviour only after that fetch and the DOM agree.
|
||
- **To prove a change did NOT alter the frontend, diff the HTML — but normalise the per-render noise
|
||
first.** TYPO3 emits values that differ on every render, so a raw `diff` reports every page as
|
||
changed and the proof evaporates: `id="picture-<hash>"` / `id="img-<hash>"` on responsive images
|
||
(regenerated per render), and the `?<mtime>` on `main.css`/`fonts.css` after any `cache:flush`
|
||
recompile. Filter those, then diff:
|
||
```
|
||
norm() { sed 's|/typo3temp/assets/css/[^"]*|CSS|g; s|picture-[0-9a-f]*|P|g; s|img-[0-9a-f]*|I|g'; }
|
||
diff <(norm < before.html) <(norm < after.html) && echo "identisch"
|
||
```
|
||
Capture the "before" over **several** pages (a content page, the home page, one with news teasers)
|
||
before touching anything — after the change it is too late. This is the evidence that turns "I
|
||
think nothing broke" into "4 pages byte-identical".
|
||
2. With `browser_evaluate`, capture the real element's **computed style AND its explicit CSS rule**
|
||
(getComputedStyle + walk `document.styleSheets` for the selector). Don't assume inheritance.
|
||
3. Diff vs DDEV → decide **setting vs SCSS** (below) → apply → flush → re-screenshot → verify.
|
||
|
||
## Driving the BACKEND with Playwright (FormEngine)
|
||
Sooner or later the question is "what does the editor actually see in this field?" — the record form is
|
||
a different beast from the frontend, and four things silently produce wrong measurements. Each of these
|
||
cost a wasted measurement round:
|
||
- **⚠ The record form lives in an `iframe`.** `document.querySelectorAll(...)` on the main document
|
||
returns **0 fields** and looks like an empty form. Use `page.frames()`. And beware the obvious
|
||
selector: `page.frames().find(f => f.url().includes('record/edit'))` matches the **main** frame too,
|
||
because the outer URL contains that path — pick `f => f !== page.mainFrame()` instead.
|
||
- **⚠ `innerText` returns EMPTY for anything in an inactive FormEngine tab.** FlexForm sheets are tabs,
|
||
so every field outside the active one reads as blank — which looks exactly like "no label / no hint
|
||
rendered". Use **`textContent`**, which ignores visibility. (`offsetParent === null` likewise only
|
||
means "its tab isn't open", not "hidden".)
|
||
- **Scope to the field's own `.t3js-formengine-field-item`.** Walking up to a parent to find some text
|
||
pulls in the neighbouring fields' hints and produces confident nonsense (a hint from the field *above*
|
||
attributed to this one).
|
||
- **Activating a tab is unreliable.** `getByRole('tab', {name})` timed out and a JS `.click()` on
|
||
`[role="tab"]` did nothing on v14. Don't fight it: read via `textContent` regardless of the active
|
||
tab. Buttons work normally — `button[name="_savedok"]` is Save.
|
||
- **Test the SAVE, not just the render**, whenever the concern is "does the stored configuration
|
||
survive?" Press Save in the real form, then check the DB and the frontend. That is the only test that
|
||
reproduces what an editor does, and it is where FlexForm bugs actually surface.
|
||
- **⚠ Count the affected widgets BEFORE and AFTER changing a FormEngine element's render condition.**
|
||
These blocks often render more than they appear to — a placeholder block can also carry the nullable
|
||
"Set element specific value" toggle, so a change to *when* it renders silently adds or removes that
|
||
control. A one-line fix of mine turned 9 toggles into **83** (and the naive variant would have removed
|
||
all 9); only counting `.t3js-form-field-eval-null-placeholder-checkbox` in the real form before and
|
||
after revealed it. Then choose the variant that keeps the previous footprint.
|
||
|
||
## When the report is a rendering artefact, not a defect
|
||
Some "it looks broken" reports are sub-pixel rasterisation: a hairline that washes out, a circle whose
|
||
cap renders as a straight edge, an outline that vanishes. They are **display-dependent**, which is why
|
||
one person sees them and another does not, and why they come and go with the zoom level.
|
||
|
||
- **First ask whether the reference site does the same.** If live emits the identical asset at the
|
||
identical size, it is not a regression — say so before spending a day on it.
|
||
- **A single zoom level proves nothing.** An element's box lands on a fractional device-pixel position
|
||
that depends on zoom AND on where it sits in the page. A change that looks like a fix at 100 % can
|
||
be measurably worse at 120 %. Always sweep several zoom levels **and** several sub-pixel offsets.
|
||
- **Measure the whole sweep in ONE call instead of a screenshot per variant.** Build each variant as an
|
||
SVG data-URL, `drawImage` it into a canvas at the exact device size and offset, and read the edge
|
||
rows with `getImageData`. Same rasteriser as the page, no screenshot round-trips:
|
||
```js
|
||
ctx.drawImage(img, 0, offset, size, size); // size = base * zoom, offset = 0 … 0.8
|
||
const d = ctx.getImageData(0, 0, n, n).data; // then count alpha>128 per row
|
||
```
|
||
Compare the top and bottom edge rows; a large difference is the asymmetry the user is seeing.
|
||
- **Know when to stop and say so.** Geometry often cannot fix it: shrinking a radius just moves the
|
||
bad zoom levels, thickening an inner ring changes nothing because the OUTER edge does the
|
||
rasterising, and making the icon bigger scales the artefact with it. Report the measurements and let
|
||
the customer decide between living with it and redrawing the asset — that is a design decision, not
|
||
a bug fix. Do not ship a change that your own numbers say is not an improvement.
|
||
|
||
## Settings vs SCSS decision
|
||
- **⚠ Write booleans in `settings.yaml` as QUOTED STRINGS, not YAML booleans.** Site settings arrive in
|
||
TypoScript as constants, and the conversion is lossy: YAML `true` becomes the constant **`"1"`** and
|
||
YAML `false` becomes an **EMPTY** constant. The frontend usually survives that (a falsy check, or an
|
||
extension mapping `'true'`/`'false'` to real booleans), so it looks fine and the bug hides in the
|
||
backend: an option field whose items are the strings `true`/`false` can no longer match the constant,
|
||
so its "Default: …" hint shows `Default: "1"` — or, for the empty case, matches the FIRST item and
|
||
claims `Default: "Default"`. With `'true'` / `'false'` the mapping works and the hint reads
|
||
`(Default: "Yes"/"No")`, with identical frontend output. Applies to any `plugin.tx_*` value you set
|
||
through `settings.yaml`, not just declared theme settings.
|
||
- **Setting** (`config/sites/<SITE_ID>/settings.yaml`) for anything mapped in the theme's
|
||
`Configuration/TypoScript/Plugin/Setup/tx_wsscss.typoscript`: `design.color.{primary,secondary,
|
||
body-color,headings-color,header-bg,page-bg,footer-bg,footer-color,footer-headline-color,…}`,
|
||
`design.font.{font-family-base,headings-font-family,font-size-base,h1..h6-font-size}`, plus
|
||
`header.logo`, `footer.logo`, `meta.favicon`. These are injected as SCSS vars, so **editing
|
||
`_variables.scss` for colors/fonts has NO effect** — settings win.
|
||
- **SCSS** (a custom partial `@import`ed **last** in `base-layout.scss`; sizes in **rem**) for
|
||
everything with no setting: utility classes, component tweaks, link/hover colors, icon/logo sizing,
|
||
equal-sizing.
|
||
- **Webfonts:** no setting → add the Google-Fonts URL to `page.includeCSS` in the theme's
|
||
`HTML/Page/head.typoscript`; a font-downloader ext localizes it.
|
||
- **Vanilla-JS components:** register in `page.includeJSFooter` (theme `head.typoscript`), file under
|
||
`Resources/Public/JavaScript/components/`.
|
||
|
||
---
|
||
|
||
## Spacing (margin/padding) — a THIRD place, and NEVER your SCSS partial
|
||
`t3b_core` ships an **editor-facing** spacing system. Writing a spacing fix into your custom SCSS
|
||
silently disables it for that element — this is the single easiest way to break the backend field
|
||
without any error appearing.
|
||
|
||
**Where spacing is resolved**, weakest → strongest:
|
||
1. `EXT:t3b_core/Configuration/Yaml/Spacing.yaml` — the steps (`none/xs/s/m/l/xl/xxl`, rem values)
|
||
and global defaults (typically `padding_top`/`padding_bottom` = `l`).
|
||
2. `config/sites/<SITE_ID>/spacing.yaml` — per site, itself a cascade:
|
||
`defaults:` → `first:` / `last:` (position in the column) → `ctypes.<CType>:` →
|
||
`containers.<containerCType>:` (each of the latter two may carry its own `defaults/first/last`).
|
||
Field keys: `padding_{top,right,bottom,left}`, `margin_{top,bottom}`. Values: a step key, or a
|
||
per-breakpoint map. There is also a backend module that writes this file.
|
||
3. `tt_content.tx_t3b_spacing` — per element, TCA `type: json`, rendered as the "Spacing/Abstände"
|
||
box-model palette: `{"padding_top":"none"}` or `{"padding_top":{"xs":"none","md":"l"}}`.
|
||
|
||
All of it ends up as `t3b-{p,m}{t,r,b,l}-<step>` utility classes on the frame element.
|
||
|
||
- **THE TRAP.** A rule such as
|
||
`.page-content > .frame-type-header:first-child + .frame { padding-top: 0 }` has specificity
|
||
(0,4,0) and beats the utility class `.t3b-pt-l` (0,1,0). The editor then picks a spacing step and
|
||
**nothing happens** — no warning, the field simply appears broken. Confirm by simulating an editor:
|
||
swap the class in the browser to the strongest step and read the computed value; if it stays put,
|
||
a CSS rule is winning. → Express spacing in `spacing.yaml` (defaults) or `tx_t3b_spacing`
|
||
(exceptions), never in the SCSS partial.
|
||
- **Positional rules leak into containers.** `first`/`last` is computed per
|
||
`(pid, colPos, language, tx_container_parent)`, so a `ctypes.<X>.first` rule ALSO matches elements
|
||
that are first inside a *container column*. Measure the blast radius before writing the rule:
|
||
`SELECT parent.CType, COUNT(*) FROM tt_content c JOIN tt_content parent ON parent.uid=c.tx_container_parent
|
||
WHERE c.CType='<X>' AND c.deleted=0 AND c.tx_container_parent>0 GROUP BY parent.CType;`
|
||
Neutralise with `containers.<containerCType>.first:` re-asserting the extension defaults (harmless
|
||
for other CTypes, since it only restates what they already inherit).
|
||
- **The system has no notion of "has a background".** The same frame padding is dead whitespace on a
|
||
plain element but becomes the *inner padding of a band* once the element carries a `bg-*` class —
|
||
so one positional default cannot serve both, and requirements like "less gap under the page title"
|
||
and "more gap inside the grey box" only *look* contradictory. Keep the default, and set the
|
||
exception per element (`{"padding_top":"none"}`) — it stays visible and editable in the backend.
|
||
Find the elements needing it (first element after a page-title header, no band class):
|
||
`… WHERE c1.CType='header' AND c1.colPos=0 AND c2.element_classes NOT LIKE '%bg-%' …` — do this
|
||
before estimating effort, the count is usually far smaller than it looks.
|
||
- **When SCSS IS still correct:** the spacing system works per content ELEMENT, never per column. A
|
||
two-column element where one column must stay flush (a background-media image) while the other
|
||
needs an inset can only be solved in CSS — target the inner column (e.g. `.ce-bodytext`), not the
|
||
frame, so you are not overriding anything the editor could have set.
|
||
- **Verify from the emitted markup, not the rendering:**
|
||
`curl -s "<url>?cb=$RANDOM" | grep -o 't3b-pt-[a-z]*'`. `spacing.yaml` is read at render time and
|
||
the result is page-cached → `cache:flush` after editing it, and mind the browser's cached
|
||
`main.css` (see the cache section).
|
||
|
||
**General principle this is an instance of:** when a theme offers an editor-facing control for
|
||
something, configure *that* control; a CSS override of it is invisible to the editor, outranks their
|
||
input, and turns a self-service setting into a support ticket.
|
||
|
||
---
|
||
|
||
## When an editor's SAVE is the cause
|
||
Once a site is in the hands of editors, a whole class of "it broke and nobody changed anything" reports
|
||
appears. The editor is the **trigger, not the cause**: opening a record and pressing Save makes TYPO3
|
||
rewrite parts of it, and whatever was never expressible in the backend gets normalised away.
|
||
|
||
**Diagnose in this order** — it is much faster than reading CSS:
|
||
1. Compare the record's `tstamp` with the date the problem was first noticed. A match points at a save.
|
||
2. Diff the record's stored value against what you expect (a `pi_flexform` that grew from 6 to 165
|
||
fields, a `bodytext` that lost its classes). The size alone is usually the giveaway.
|
||
3. Only then look at rendering.
|
||
|
||
**Known mechanisms:**
|
||
- **CKEditor drops every class not declared in the RTE preset.** Hand-written classes in `bodytext`
|
||
survive only until someone opens that element and saves. No error, no warning. Check
|
||
`editor.config.style.definitions` in the preset YAML for the class; if it's absent, that's your cause.
|
||
**Fix:** declare the project's classes there (group them with a common name prefix so editors can tell
|
||
them from the framework's), and — where the markup allows it — derive the styling from **structure**
|
||
instead of classes (e.g. `:nth-child(odd|even)` for a strict number/label alternation), so the look
|
||
cannot be lost at all. Audit the blast radius before assuming it's one element:
|
||
`SELECT bodytext FROM tt_content WHERE deleted=0 AND bodytext LIKE '%class=%'` → extract the classes →
|
||
compare against the preset's list. Classes with no CSS anywhere are dead migration residue; ignore those.
|
||
- **A FlexForm `select` with no empty item stores its FIRST item on save.** If the options are just
|
||
"yes"/"no", every untouched field silently becomes **yes**. One save can therefore switch on dozens
|
||
of features nobody chose — including ones that break rendering outright. Symptom: a slider/gallery
|
||
that worked for months misbehaves right after an editorial edit, and its stored flexform is suddenly
|
||
huge. **Fix at the source:** give those selects an empty first item (a `selectSingleWithTypoScriptPlaceholder`
|
||
renderType is *designed* for that empty state — it then shows the TypoScript default as placeholder).
|
||
Then clean the affected records; the schema fix alone does not repair stored data.
|
||
- **⚠ The same fix creates a SECOND, quieter failure when a FlexForm declares one setting in TWO
|
||
sheets.** Giving the selects an empty first item stops the "everything becomes yes" flood, but a save
|
||
still MATERIALISES every sheet — now with empty values. If a "quick settings" sheet (`sDEF`) and a
|
||
per-topic sheet both carry the same `<field index="settings.x">`, `convertFlexFormContentToArray()`
|
||
flattens all sheets into one array and the **later sheet wins**, so the empty duplicate wipes the
|
||
value the editor set in `sDEF`. It is then dropped by the `mergeRecursiveWithOverrule(…,
|
||
includeEmptyValues: false)` against TypoScript and the record silently falls back to the extension
|
||
defaults. Measured on a ws_slider element: `settings.loop = 1` and `autoplay.delay = 5000` stored in
|
||
`sDEF`, rendered as `loop:false` and `delay:3000` (the constants) — every one of the six configured
|
||
options neutralised, and the record's `tstamp` was the save minutes earlier.
|
||
**Diagnose with one command** — duplicates are visible in the raw XML:
|
||
`grep -o 'index="settings\.[^"]*"' <flexform> | sort | uniq -c | awk '$1>1'`.
|
||
**Do not "fix" it by re-cleaning the record** (that is what the previous round did — the next save
|
||
undoes it). Put the value where a save cannot reach: a **site setting / TypoScript constant**, which
|
||
the empty flexform value then falls back to, so the element is save-proof. Report the duplicate
|
||
declaration upstream as the actual defect.
|
||
- **Your own CSS outranking an editor-facing field** — see the spacing section above.
|
||
|
||
**Two rules that follow:**
|
||
- **After adding anything editor-facing, ask what happens when they press Save without touching it.**
|
||
That single question would have caught all three cases above.
|
||
- **Give the customer a place, not a person.** When the question is "where can I set this?", the good
|
||
answer is a declared site setting with a label and a category, visible in the backend — not a value
|
||
you maintain in a YAML they never see. Declare it in the sitepackage's
|
||
`Configuration/Sets/<Set>/settings.definitions.yaml` (`type`, `default`, `label`, `description`,
|
||
`category`), wire it into the template via `{$your.setting}`, and *verify it is registered*: a small
|
||
CLI bootstrap printing `$site->getSettings()->get('your.setting')` plus the definition from the set
|
||
registry proves both the value and the backend visibility.
|
||
|
||
---
|
||
|
||
## Layer 1 — Structural fixes (content that won't render). Do these FIRST.
|
||
These are v11→v14 migration artifacts, not design; they recur across many pages.
|
||
|
||
- **⚠ Site-set page TSconfig does NOT reach records in storage folders beside the site root.** A site
|
||
set's `Configuration/Sets/<Set>/page.tsconfig` only applies to pages in that site's ROOTLINE.
|
||
Storage folders for news, addresses, jobs are frequently SIBLINGS of the site root under a common
|
||
parent, not descendants — so TCEFORM you put in the set never reaches the editing form of those
|
||
records, while it visibly works on normal pages. Symptom: your TCA/TCEFORM change "does nothing"
|
||
for exactly one record type. **Check the storage folder's pid, not a content page:**
|
||
`BackendUtility::getPagesTSconfig(<storagePid>)` in a CLI bootstrap, and compare with a site page.
|
||
**Fix:** move it to the extension's **global** `Configuration/page.tsconfig`, which TYPO3 loads for
|
||
every page. Load order then decides who wins — your sitepackage must load AFTER the extension you
|
||
are overriding (`vendor/bin/typo3 extension:list` to confirm), which it normally does.
|
||
- **The crop variant OFFERED to the editor may not be the one RENDERED.** Extensions ship global
|
||
page TSconfig that **replaces** the crop variants of a field with the single ratio their own layout
|
||
wants. If your templates request a different variant, an editor can crop all day with no effect and
|
||
the image is emitted uncropped. Trace BOTH ends before touching anything: which variant the template
|
||
asks for (often a setting such as `settings.imageFormat`, with a fallback for the literal value
|
||
`default`), and which variants the form offers at that record's pid. They must be the same name.
|
||
|
||
- **Stranded `colPos=1` (the #1 recurring bug).** v11 content sitting at `colPos 1` (or 2/3) is not
|
||
rendered by the v14 backend layout → the page looks half-empty though the content is in the DB.
|
||
Find it site-wide:
|
||
`SELECT c.pid,p.title,COUNT(*) FROM tt_content c JOIN pages p ON p.uid=c.pid WHERE c.colPos=1 AND
|
||
c.hidden=0 AND c.deleted=0 AND p.doktype=1 GROUP BY c.pid;` (also check colPos NOT IN the known
|
||
container values 2110xx). **Fix:** `UPDATE tt_content SET colPos=0, sorting=sorting+<OFFSET> WHERE
|
||
pid=<pid> AND colPos=1 AND deleted=0` — offset so moved rows sort AFTER the colPos-0 intro. Add a
|
||
band class via `element_classes` where the live section has a background.
|
||
- **Missing templateLayout partial** — "Partial Layouts/List/<X> not found" (news) / "List/<X> not
|
||
found" (address). A v11 custom `templateLayout` (e.g. `List`, `Jobs`, `Lightbox`) has no v14
|
||
partial. **Fix:** port the partial into the sitepackage's news/address `partialRootPaths` dir AND
|
||
register the layout in page TSconfig (`tx_news.templateLayouts { X = X }` /
|
||
`tx_address.templateLayouts`). Empty-list "no results" message can be suppressed by overriding the
|
||
list template for that layout.
|
||
- **Template partial PRECEDENCE / shadowing.** Fluid searches `partialRootPaths` **highest-index
|
||
first**. Extensions register at different indices (e.g. `t3bootstrap_news` at `.13`, your
|
||
sitepackage at `.12`) → the higher one **shadows** yours, so your override is dead code (symptom:
|
||
your template edits don't show; a partial you didn't write renders). **Fix:** register the
|
||
sitepackage's paths at a **higher index** (e.g. `.20`), and **base your override on the partial
|
||
that actually renders** (diff it against the reference — don't rewrite from a different base).
|
||
- **On-demand component CSS loads AFTER main.css.** `scssComponents:require key="…"` (accordion,
|
||
pagination, carousel, …) injects its stylesheet after your compiled `main.css`, so
|
||
**equal-specificity overrides lose**. Use higher specificity or `!important`, or a direct target
|
||
(e.g. recolour an accordion chevron via `::after { background-image: … }`, not the CSS var).
|
||
- **Unregistered custom CType → FE fatal** (empty-`templateName` `InvalidTemplateResourceException`).
|
||
Re-register the CType for v14: TCA override (`addRecordType`/`addTCAcolumns`, guard with
|
||
`defined('TYPO3')`), `tt_content.<ctype> =< lib.contentElement` + `templateName` in
|
||
`Lib/ContentElement/*.typoscript`, and the FSC template/partial. If the old site used the native
|
||
t3b_core rendering (e.g. its `Icon.html` icon-tile partial that parses `path;class;identifier`),
|
||
reuse that instead of porting old code.
|
||
- **Content ≠ template.** If a page's content differs from the reference, first check whether it's an
|
||
**editor-managed content divergence** (the dump predates live edits) rather than a template bug —
|
||
don't "fix" the template to reproduce editor content.
|
||
|
||
## Layer 2 — Visual-parity checklist (run ALL for each page/section)
|
||
- **Layout / width:** full-bleed section background (viewport width) with content contained (~container
|
||
width). `full_width=1` wrongly makes the *content* full-width too → keep `full_width=0` and bleed
|
||
only the bg in SCSS: `.<band>{box-shadow:0 0 0 100vw <color>;clip-path:inset(0 -100vw)}`.
|
||
- **Typography (rem):** font family + base size/line-height; heading sizes; **card/teaser titles**
|
||
(v14 often defaults bigger/bolder than the old design); teaser text size/weight. The theme's base
|
||
rule is `h1..h6{font-weight:500}`; live is often **400** → override globally in your SCSS (equal
|
||
specificity, appended later → wins). **Page-title spacing:** every frame carries `t3b-pt-l`/`t3b-pb-l`
|
||
(~24px) padding, so a page title gets doubled gaps above/below vs live's ce-header-margin model.
|
||
Scope to `.page-main .page-content > .frame-type-header:first-child`: `margin-top` for the gap ABOVE
|
||
(breadcrumb→title); `padding-bottom:0` + `& + .frame{padding-top:0}` for the gap BELOW.
|
||
- **Colors** (settings first): primary/headings/nav/links, body text, section-band bg, footer bg +
|
||
labels. Read the element's *explicit* rule (inheritance assumptions bite).
|
||
- **Links & buttons:** underline on/off (match live); base + **exact hover** colors (links→accent,
|
||
buttons→accent bg — read the `:hover` rule). Match the button **base** style (live `.btn-primary`
|
||
is often an *outline* look: white bg + brand border/text, not filled). **Buttons are frequently
|
||
square — no border-radius** on some brands (global `.btn{border-radius:0}`). A "read more"/CTA may
|
||
be a plain link + leading chevron (`::before "\203A"`) on live vs a styled button in v14.
|
||
- **Repeated items (cards/teasers/logos/icon tiles):** live usually forces **equal height AND width**
|
||
across the row (`height:100%` in an equal-height row; images `object-fit:cover` + fixed height;
|
||
logo boxes equal size). DDEV often renders ragged — measure siblings, don't assume.
|
||
- **⚠ A FIXED HEIGHT on a cropped image box drifts in aspect ratio across breakpoints.** The height
|
||
stays put while the column width grows, so the same box is e.g. 1.58 on desktop and 1.82 on a
|
||
phone — and then **no single crop variant can fit both**, which reads as "the crop is off" on one
|
||
device. Use `aspect-ratio: <w> / <h>; height: auto` instead so the box keeps ONE ratio everywhere,
|
||
then one crop variant is enough. Measure live's box before choosing the ratio; it usually already
|
||
holds a constant one.
|
||
- **⚠ Wrapped labels under tiles: reserve the line box, or the grid rhythm breaks.** In a wrapping
|
||
Bootstrap row each flex line sizes itself from its tallest item, so one two-line label makes that
|
||
whole row taller and the icon-to-icon pitch varies. Give the label `min-height: <maxLines> ×
|
||
line-height` **and zero the label `<p>`'s bottom margin** — otherwise that margin is added on
|
||
exactly the tiles that actually reach the maximum, which are the ones you were trying to level.
|
||
Derive `maxLines` per breakpoint by measuring, and **check the narrowest device (320px), not just
|
||
360** — the extra column narrowness there routinely needs one line more.
|
||
- **⚠ `:has()` cannot be nested inside `:has()` — the whole rule is silently discarded.** A selector
|
||
like `.a:has(.x):has(+ .a:has(.x))` is invalid CSS, so the browser drops it entirely and you get
|
||
no error anywhere. Very easy to write when chaining "frame that is followed by another frame"
|
||
rules. If a rule seems to have no effect, check the computed value on the element first; a
|
||
property that never appears at all points at an invalid selector, not at specificity.
|
||
- **background-media hero image** (textmedia with `asBackground`, usually empty text — e.g. section
|
||
landing/"Auftraggeber" pages): the image is a CSS background on a grid column sitting on a grey band.
|
||
Live renders it **flush + container-width**; DDEV insets it (frame `t3b-pt-l/pb-l` padding + a
|
||
`.bg-lightgray` full-bleed). Scope `.frame-type-textmedia:has(.bg-column.col-type-media)`: zero the
|
||
frame padding, drop the full-bleed and move the grey onto `> .container`, media col `padding-left:0`,
|
||
inner `> div{height:100%; background-size:cover}` (+ a `min-height` so an empty-text row still shows).
|
||
- **Images / borders:** the `imageborder` field renders Bootstrap's padded grey `.img-thumbnail`;
|
||
restyle to the brand look (`border-color:<c>;border-radius:0;padding:0;background:transparent`).
|
||
Migrated `imageborder`/border flags may be **inverted or on the wrong elements** — verify against
|
||
live and fix in the DB. Divider (`hr.ce-div`) style, footer top border, header separator, decorative
|
||
brand SVG shapes (stripes/polygons) — port from the old partial.
|
||
- **Icons:** match the live pixel size (icon box + svg in rem); center (`text-align:center` +
|
||
`margin-inline:auto`); hover → accent.
|
||
- **Structural extras (new-but-not-old):** remove v14 extras the old site lacks (empty
|
||
`.card-footer`/`news-meta`, wrappers, unwanted pagination) — **remove from the DOM** (`<f:if>`), not
|
||
`display:none`. Note Bootstrap utilities (`d-none`/`d-sm-block`) are `!important` → out-specify or
|
||
remove the markup.
|
||
- **Footer:** columns via `footer.contentPageId`; meta nav via `navigation.footer.enabled` +
|
||
`folderPageId`; disable credits (`footer.creditsEnabled:false` + gate the Copyright partial). The
|
||
`.layout-full .page-footer` rule may out-specify a plain `.page-footer` — match its specificity.
|
||
Footer link columns may be migrated as **RTE `frame-type-text`** (not menu elements) → target the
|
||
text links, excluding `mailto:`/`tel:`, when adding chevrons.
|
||
- **Header / navigation:** meta menu = `navigation.meta.enabled` + `folderPageId`; nav alignment/logo
|
||
= `header.logoPosition`. Top-level shortcut items may need to be **toggle-only** (`href="#"`) if the
|
||
live nav doesn't navigate on them. `navigation.main.menuType` is GLOBAL — a per-item mega/dropdown
|
||
mix + the mega's grid/width/padding needs SCSS work (smartmenus writes inline styles on open →
|
||
`!important`). Uppercase mega headers: `text-transform:none` to get title-case. A **persistent green/
|
||
highlighted background** on the current-page item + section header in the mega/dropdowns is
|
||
`--bs-dropdown-link-active-bg` — set it `transparent` (scope to the mega) to remove the "it stays
|
||
highlighted" effect while keeping `--bs-dropdown-link-hover-bg` for transient hover.
|
||
- **Hero / carousel (`hero-item`):** the ext injects its own `Hero.html` at a fixed `page.5`
|
||
partialRootPaths index → re-assert your partial at a HIGHER index in `page.typoscript`, or your
|
||
edits don't show. `.carousel-fade` = crossfade vs `.slide` = slide. The `.carousel-indicators` strip
|
||
is a full-width high-z-index bar that steals hover from content beneath — reposition/shrink or
|
||
`pointer-events:none`. Contain-vs-full-bleed per design.
|
||
- **Switching autoplay OFF cannot be done via the interval.** Bootstrap 5's `_updateInterval()` reads
|
||
the interval from the **active slide**, not the container; slides normally carry none → `NaN` →
|
||
it falls back to `defaultInterval`, and `setInterval(fn, false)` means **0 ms**. Measured on 5.3.8:
|
||
`data-bs-interval="false"` produced *3 slide changes in 2 seconds*. An empty interval is worse
|
||
still — that's the "interval type null" JS crash. **Omit `data-bs-ride` instead**: `_maybeEnableCycle()`
|
||
returns immediately, manual controls keep working (measured: 0 changes in 12 s). Keep the interval
|
||
value stored so re-enabling works.
|
||
- Beware the interaction with a **randomised slide order**: with autoplay off, a visitor who doesn't
|
||
click sees exactly one random slide out of N. Raise it before implementing — it may or may not be
|
||
what "manual only" was meant to achieve.
|
||
- **Translucent overlays — paint ONE shape:** a translucent box + a separate translucent shape beside
|
||
it can never be seamless (hairline gap or doubled-alpha line). Merge into one element with one
|
||
`clip-path` polygon + one `rgba()`.
|
||
- **i18n:** `vendor/bin/typo3 language:update` (composer mode ships no packs → English fallback).
|
||
- **Verify:** flush + screenshot with `?cb=N`; confirm by reading computed values.
|
||
|
||
---
|
||
|
||
## Cache-safe & content techniques
|
||
- **Randomise/shuffle on every reload → client-side JS.** The FE page is cached, so a server-side
|
||
shuffle (`ORDER BY RAND`) freezes one order per cache lifetime. Do it in the browser. To avoid a
|
||
flash, run an **inline `<script>` right after the markup** (before Bootstrap's DOMContentLoaded
|
||
auto-init) rather than a footer component. **Check CSP first** (`curl -D-` for a
|
||
`content-security-policy` header) — if enforced, inline scripts need a nonce; else prefer an external
|
||
component. (Same technique for a shuffled team/address grid.)
|
||
- **Responsive per-device image cropping (hero bg):** the bg renders via
|
||
`t3bc:backgroundImage useBootstrapCropVariants=true`, which reads a per-breakpoint cropVariant NAME
|
||
from `settings.breakpoints` (`xs/sm→s576`, `md→s768`, `lg→s992`, `xl→s1200`, `xxl→s1400`). To let
|
||
editors crop per device, add cropVariants with **those exact keys** (page TSconfig
|
||
`TCEFORM.tt_content.<falField>.config.overrideChildTca.columns.crop.config.cropVariants` — page
|
||
TSconfig avoids TCA load-order issues). Base recommended aspect ratios on the element's **measured**
|
||
rendered proportions per breakpoint (measure with Playwright + `browser_resize`), keep a "Frei"
|
||
(free) option.
|
||
- **Bulk-create content elements + FAL references** via a bootstrapped CLI PHP script (SystemEnviron-
|
||
mentBuilder + Bootstrap::init + CommandLineUserAuthentication + DataHandler). **Gotcha:** creating a
|
||
`type=file` FAL relation via DataHandler datamap creates the `sys_file_reference` but leaves
|
||
`uid_foreign=0` (child not wired to parent) and the parent counter 0 → after `process_datamap()`,
|
||
read `$dh->substNEWwithIDs[...]` and directly `UPDATE` the ref's `uid_foreign` + set the parent
|
||
file-count column = 1, then `referenceindex:update`. Import images with `$storage->addFile(…,
|
||
DuplicationBehavior::REPLACE)` (or `$storage->getFile('/path')` to index an already-copied file).
|
||
- **Single-element clone WITHOUT DataHandler** (simplest for "add one slide/CE like an existing one"):
|
||
clone the sibling `tt_content` row and its `sys_file_reference` via `ConnectionPool` (unset `uid`,
|
||
repoint the ref's `uid_local`/`uid_foreign`) — the copied file-count column stays correct, so it
|
||
sidesteps the `uid_foreign=0` gotcha entirely.
|
||
- **RTE field rendered escaped by a custom template → literal `<p>` on the page.** Symptom: after an
|
||
editor edits+saves an element (e.g. a hero slide — even just its title), its rich-text field shows
|
||
literal `<p>…</p>` in the FE. Cause: the field is **RTE** (`enableRichtext=true` in TCA — e.g.
|
||
`tx_heroitem.bodytext` in `t3bootstrap/hero-item`), so the backend's RTE→DB transform wraps the value
|
||
in `<p>…</p>` on every save, but the custom Fluid partial renders it as ESCAPED plain text
|
||
(`<p>{…bodytext -> f:format.nl2br()}</p>` or a bare `{…}`) → the tags are escaped and shown. FIX =
|
||
render it as the RTE HTML it is: `{record.data.<field> -> f:format.html()}` (lib.parseFunc_RTE), and
|
||
drop the manual `<p>`/`nl2br`. This renders editor-saved `<p>` correctly AND wraps raw
|
||
(script-imported) text in a paragraph — so no per-slide data cleanup. (A bulk-import script that
|
||
stores RAW text into an RTE field hides this until the first editor save — don't rely on that;
|
||
fix the template.) **General rule:** whenever a custom template renders a field, check the field's TCA
|
||
`enableRichtext` — if true, use `f:format.html`, never bare `{field}` or `f:format.nl2br` (both
|
||
escape). Core/t3b_core templates already do this (e.g. image caption `text_visible`, news `teaser`),
|
||
so the risk is only in your own/overridden partials — sweep them for `nl2br`/bare RTE-field output.
|
||
- **Applying customer copy across DDEV+staging:** key the UPDATE on a stable identifier (e.g. the bg
|
||
**image filename** via `tt_content→sys_file_reference→sys_file`), since row uids differ per env.
|
||
|
||
## Deploy the design changes
|
||
- **Code** (templates/SCSS/TS/tsconfig/JS): commit + push the sitepackage branch, then on the target
|
||
run `composer update <TEMPLATE_PACKAGE>` (NOT plain `composer install` — that keeps the stale locked
|
||
ref) + hard cache flush so SCSS recompiles. ⚠ ws_scss writes the compiled CSS in a **`<theme>/`
|
||
subdir** (`public/typo3temp/assets/css/<theme>/main.css`), so `rm css/*.css` (non-recursive) MISSES
|
||
it and ships a stale `main.css` — remove `css/<theme>/main.css` (or the whole `css/` tree). It
|
||
recompiles on the first frontend hit (behind basic-auth, that's the user's browser reload).
|
||
- **Content** (tt_content/settings/DB): travels via a DB dump or replay the same DB edits; keep them
|
||
env-agnostic (e.g. key UPDATEs on stable identifiers like file names, since uids differ per env).
|