Offer 98 icons in a dialog and style a button icon under Design

The icon set grows from 33 to 98 drawings in seven categories (arrows,
contact, commerce, files and media, people and security, house and trade,
general), including truck, cart, euro, camera, tools and solar. icons.js is
the single source; npm run build:icons writes the Twig drawings and the
validator list, and a test keeps them equal.

Choosing an icon no longer fills the panel with tiles: the panel shows the
current icon with "Change", which opens a modal with category tabs and named
tiles, keyboard navigable, closing with Escape. Button, icon and icon card
share it.

A new style group "Symbol" targets the icon inside a button per device and
on hover: colour, background, size, padding, radius, rotation, plus the gap
to the text. Schema v26 adds hover motion (slide, reveal) and an icon-only
button whose label stays as the accessible name and keeps the line height.
ADR 109.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5.5 committed 2026-10-07 10:01:18 +02:00
1 parent 6af2e2aead
commit 08998de533
20 files changed
+658 -228

No files matched your search

+19
View File
@@ -0,0 +1,19 @@
// Schreibt den Symbolsatz aus public/assets/icons.js in die Twig-Vorlage der Website und in die
// erlaubte Liste des Validators. Eine Quelle, drei Abnehmer; tests/icons.test.mjs prüft das Ergebnis.
// Aufruf: node scripts/build-icons.mjs
import { readFile, writeFile } from 'node:fs/promises';
import { ICON_PATHS } from '../public/assets/icons.js';
const twigFile = new URL('../templates/blocks/icon.html.twig', import.meta.url);
const twig = await readFile(twigFile, 'utf8');
const entries = Object.entries(ICON_PATHS).map(([name, path]) => ` ${/-/.test(name) ? `'${name}'` : name}: '${path}'`).join(',\n');
const nextTwig = twig.replace(/\{% set paths = \{\n[\s\S]*?\n\} %\}/, `{% set paths = {\n${entries}\n} %}`);
if (nextTwig === twig && !twig.includes(entries)) throw new Error('Die Symbolliste in icon.html.twig wurde nicht gefunden.');
await writeFile(twigFile, nextTwig);
const validatorFile = new URL('../src/Content/DocumentValidator.php', import.meta.url);
const validator = await readFile(validatorFile, 'utf8');
const list = Object.keys(ICON_PATHS).map(name => `'${name}'`).join(', ');
const nextValidator = validator.replace(/public const ICONS = \[[^\]]*\];/, `public const ICONS = [${list}];`);
await writeFile(validatorFile, nextValidator);
console.log(`${Object.keys(ICON_PATHS).length} Symbole geschrieben.`);
+116 -54
View File
@@ -1,6 +1,8 @@
// Prüft die Symbolwahl am Button im echten Editor: Kachel wählen, Stellung umschalten, kein Symbol,
// Tastatur, Rückgängig, Speichern und Neuladen - auf einer isolierten Kopie der Pilotdaten.
// Prüft das Symbol am Button im echten Editor auf einer isolierten Kopie der Pilotdaten: Auswahl-
// fenster mit Kategorien, Tastatur, Stellung, Bewegung, nur Symbol, Farbe und Größe unter Design,
// Rückgängig, Speichern und Neuladen. Screenshots unter reports/button-icons/.
import { chromium } from 'playwright';
import AxeBuilder from '@axe-core/playwright';
import assert from 'node:assert/strict';
import { execFileSync, spawn } from 'node:child_process';
import { mkdir, writeFile } from 'node:fs/promises';
@@ -38,80 +40,140 @@ try {
await page.getByRole('button', { name: 'Anmelden', exact: true }).click(); await page.waitForURL(origin + '/admin');
const api = `${origin}/admin/api/pages/${fixture.homeId}`;
const data = await (await context.request.get(api)).json();
const startVersion = data.document.schemaVersion;
data.document.blocks.unshift({ id: 'qa-icons', type: 'section', children: [
{ id: 'qa-icon-button', type: 'button', props: { text: 'Jetzt anrufen', href: 'tel:+4972296992826' } },
] });
assert.equal((await context.request.post(api + '/draft', { headers: { 'X-CSRF-Token': data.csrfToken }, data: { document: data.document, expectedVersion: data.version } })).status(), 200);
// Was die Vorschau im Button zeigt: Reihenfolge von Text und Symbol und die Zeichnung.
const preview = () => page.evaluate(() => {
const el = document.querySelector('#preview')?.contentDocument?.getElementById('qa-icon-button');
if (!el) return null;
return { first: el.firstElementChild?.className ?? null, text: el.textContent.trim(), path: el.querySelector('svg path')?.getAttribute('d') ?? null, firstNode: el.firstChild?.nodeType };
});
const inPreview = fn => page.evaluate(fn);
const waitPreview = async expected => page.waitForFunction(expected => {
const el = document.querySelector('#preview')?.contentDocument?.getElementById('qa-icon-button');
if (!el) return false;
const path = el.querySelector('svg path')?.getAttribute('d') ?? null;
const iconFirst = el.firstChild?.nodeType === 1;
return (expected.path === null ? path === null : path?.startsWith(expected.path)) && (expected.first === undefined || iconFirst === expected.first);
const path = el.querySelector('.button-icon svg path')?.getAttribute('d') ?? null;
const classes = el.className;
return (expected.path === undefined || (expected.path === null ? path === null : path?.startsWith(expected.path)))
&& (expected.first === undefined || ((el.firstChild?.nodeType === 1 && el.firstChild.classList.contains('button-icon')) === expected.first))
&& (expected.has ?? []).every(name => classes.includes(name))
&& (expected.not ?? []).every(name => !classes.includes(name));
}, expected);
await page.goto(`${origin}/admin/pages/${fixture.homeId}`);
await selectBlock('qa-icon-button');
const picker = page.locator('.icon-picker');
await picker.waitFor();
// Ohne Wahl: der bisherige Pfeil ist markiert und steht in der Vorschau hinter dem Text.
assert.equal(await picker.locator('[aria-pressed="true"]').getAttribute('data-icon'), 'arrow-up-right');
const field = page.locator('.icon-field');
await field.waitFor();
assert.equal(await field.locator('.icon-field-name').textContent(), 'Pfeil schräg');
assert.equal(await page.locator('.icon-choice').count(), 0, 'Im Panel steht keine Kachelwand.');
await waitPreview({ path: 'M6.5 17.5', first: false });
assert.equal(await picker.locator('.icon-tile').count(), 25, 'Kein Symbol plus 24 Symbole in drei Gruppen');
assert.deepEqual(await picker.locator('.icon-group-title').allTextContents(), ['Pfeile', 'Kontakt', 'Allgemein']);
assert.equal(await page.locator('#property-icon').count(), 0, 'Keine Auswahlliste mehr');
await page.locator('.properties-panel, #properties, aside').first().screenshot({ path: `${report}/vorher.png` }).catch(() => {});
checks.push('ohne Wahl: Pfeil ↗ markiert und hinter dem Text, Kacheln statt Liste');
await page.locator('.properties, #properties, aside').first().screenshot({ path: `${report}/panel-vorher.png` }).catch(() => {});
checks.push('Panel: nur das gewählte Symbol (Pfeil schräg) mit „Ändern …“');
await picker.getByRole('button', { name: 'Telefon', exact: true }).click();
// Das Fenster: Kategorien links, Symbole mit Namen, geöffnet bei der Kategorie des Pfeils.
await page.getByRole('button', { name: /Symbol ändern, aktuell: Pfeil schräg/ }).click();
const dialog = page.getByRole('dialog', { name: 'Symbol wählen' });
await dialog.waitFor();
assert.equal(await dialog.getByRole('tab').count(), 7);
assert.equal(await dialog.getByRole('tab', { selected: true }).textContent(), 'Pfeile');
assert.equal(await page.evaluate(() => document.activeElement?.dataset.icon), 'arrow-up-right', 'Fokus auf dem gewählten Symbol');
const axe = await new AxeBuilder({ page }).include('.icon-dialog').analyze();
assert.deepEqual(axe.violations.map(v => v.id), [], 'axe im Auswahlfenster');
await dialog.getByRole('tab', { name: 'Haus & Handwerk' }).click();
await dialog.screenshot({ path: `${report}/fenster.png` });
// Escape schließt ohne Änderung, der Fokus geht zurück zum Knopf.
await page.keyboard.press('Escape');
await dialog.waitFor({ state: 'detached' });
assert.match(await page.evaluate(() => document.activeElement?.getAttribute('aria-label') ?? ''), /Symbol ändern/);
await waitPreview({ path: 'M6.5 17.5' });
checks.push('Fenster: 7 Kategorien, Fokus auf dem Gewählten, axe ohne Befund, Escape ändert nichts');
// Tastatur: Kategorie mit Pfeiltaste wechseln, Symbol wählen.
await page.getByRole('button', { name: /Symbol ändern/ }).click();
await dialog.getByRole('tab', { name: 'Pfeile' }).focus();
await page.keyboard.press('ArrowDown');
assert.equal(await dialog.getByRole('tab', { selected: true }).textContent(), 'Kontakt & Ort');
await dialog.getByRole('button', { name: 'Telefon', exact: true }).click();
await dialog.waitFor({ state: 'detached' });
await waitPreview({ path: 'M6 3h4', first: false });
assert.equal(await page.locator('.icon-tile[data-icon="phone"]').getAttribute('aria-pressed'), 'true');
assert.equal(await page.evaluate(() => document.activeElement?.dataset.icon), 'phone', 'Fokus bleibt auf der gewählten Kachel');
assert.equal(await field.locator('.icon-field-name').textContent(), 'Telefon');
checks.push('Telefon über die Kategorie „Kontakt & Ort“ gewählt');
await page.getByRole('button', { name: 'Vor dem Text', exact: true }).click();
await waitPreview({ path: 'M6 3h4', first: true });
checks.push('Telefon gewählt, dann vor den Text gestellt - Vorschau folgt');
await waitPreview({ first: true, has: ['icon-start'] });
await page.getByRole('button', { name: 'Rückt', exact: true }).click();
await waitPreview({ has: ['icon-motion-slide'] });
await page.getByRole('button', { name: 'Erscheint', exact: true }).click();
await waitPreview({ has: ['icon-motion-reveal'], not: ['icon-motion-slide'] });
await page.locator('#undo').click(); await waitPreview({ has: ['icon-motion-slide'] });
await page.locator('#redo').click(); await waitPreview({ has: ['icon-motion-reveal'] });
checks.push('Stellung und Bewegung, Rückgängig und Wiederholen');
// Tastatur: Pfeiltasten wandern durch die Kacheln, Enter wählt.
await page.locator('.icon-tile[data-icon="phone"]').focus();
await page.keyboard.press('ArrowRight');
assert.equal(await page.evaluate(() => document.activeElement?.dataset.icon), 'mail');
await page.keyboard.press('Enter');
await waitPreview({ path: 'M3 6h18', first: true });
checks.push('Pfeiltaste und Enter wählen E-Mail');
await page.getByLabel('Nur das Symbol zeigen').check();
await waitPreview({ has: ['icon-only'] });
const label = await inPreview(() => document.querySelector('#preview').contentDocument.querySelector('#qa-icon-button .button-label')?.textContent);
assert.equal(label, 'Jetzt anrufen', 'Beschriftung bleibt für Vorlesesoftware');
await page.locator('.properties, #properties, aside').first().screenshot({ path: `${report}/panel-nachher.png` }).catch(() => {});
checks.push('Nur Symbol: Beschriftung unsichtbar im Link');
await page.locator('#undo').click(); await waitPreview({ path: 'M6 3h4', first: true });
await page.locator('#redo').click(); await waitPreview({ path: 'M3 6h18', first: true });
checks.push('Rückgängig und Wiederholen');
await page.getByRole('button', { name: 'Kein Symbol', exact: true }).click();
await waitPreview({ path: null });
assert.equal(await page.getByRole('button', { name: 'Vor dem Text', exact: true }).count(), 0, 'Ohne Symbol keine Stellung');
assert.equal((await preview()).text, 'Jetzt anrufen');
await page.locator('#undo').click(); await waitPreview({ path: 'M3 6h18', first: true });
await picker.getByRole('button', { name: 'Pfeil nach rechts', exact: true }).click();
await waitPreview({ path: 'M4 12h15.5', first: true });
await page.locator('.icon-picker').screenshot({ path: `${report}/auswahl.png` });
await page.locator('.icon-position').screenshot({ path: `${report}/stellung.png` });
checks.push('kein Symbol, dann Pfeil nach rechts');
// Design: Gruppe „Symbol“ mit Farbe, Fläche, Größe, Drehung.
await page.getByRole('button', { name: 'Farbe, Größe und Fläche des Symbols → Design' }).click();
const group = page.locator('.design-group[data-group="icon"]');
await group.waitFor();
assert.equal(await group.evaluate(el => el.open), true);
for (const name of ['Symbolfarbe', 'Fläche hinter dem Symbol', 'Symbolgröße', 'Innenabstand der Fläche', 'Ecken der Fläche', 'Drehung', 'Abstand zum Text']) {
assert.ok(await group.getByText(name, { exact: true }).count() > 0, `${name} fehlt in der Gruppe Symbol`);
}
await group.locator('#style-iconSize').fill('24');
await group.locator('#style-iconSize').press('Enter');
await page.waitForFunction(() => {
const doc = document.querySelector('#preview')?.contentDocument, svg = doc?.querySelector('#qa-icon-button .button-icon svg');
return svg && Math.round(svg.getBoundingClientRect().width) === 24;
});
await group.screenshot({ path: `${report}/design-symbol.png` });
checks.push('Design → Symbol: alle sieben Werte da, Größe 24 px wirkt in der Vorschau');
await page.locator('#save').click(); await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor();
const stored = (await (await context.request.get(api)).json()).document;
assert.equal(stored.schemaVersion, 25, `vorher v${startVersion}`);
assert.deepEqual(stored.blocks[0].children[0].props, { text: 'Jetzt anrufen', href: 'tel:+4972296992826', icon: 'arrow-right', iconPosition: 'start' });
const button = stored.blocks[0].children[0];
assert.equal(stored.schemaVersion, 26);
assert.deepEqual(button.props, { text: 'Jetzt anrufen', href: 'tel:+4972296992826', icon: 'phone', iconPosition: 'start', iconMotion: 'reveal', iconOnly: true });
assert.equal(button.style.desktop.iconSize, '24px');
await page.reload(); await selectBlock('qa-icon-button');
await page.locator('.icon-picker').waitFor();
assert.equal(await page.locator('.icon-tile[aria-pressed="true"]').getAttribute('data-icon'), 'arrow-right');
assert.equal(await page.locator('.icon-position [aria-pressed="true"]').getAttribute('data-position'), 'start');
await waitPreview({ path: 'M4 12h15.5', first: true });
checks.push('gespeichert als Schema v25, nach Neuladen unverändert');
await page.locator('.icon-field').waitFor();
assert.equal(await page.locator('.icon-field-name').textContent(), 'Telefon');
assert.equal(await page.getByLabel('Nur das Symbol zeigen').isChecked(), true);
checks.push('gespeichert als Schema v26 mit Größe unter Design, nach Neuladen gleich');
// So sieht es aus: Pfeil in einer runden Fläche, die sich beim Überfahren dreht und umfärbt,
// daneben ein Button nur aus Symbol.
const look = (await (await context.request.get(api)).json());
look.document.blocks[0].children = [
{ id: 'qa-look-a', type: 'button', props: { text: 'Projekt anfragen', href: '/kontakt', icon: 'arrow-right', iconMotion: 'slide' }, style: { desktop: { iconColor: '#ffffff', iconBackgroundColor: '#b71c22', iconPadding: '6px', iconRadius: '999px', iconSize: '16px', hover: { iconBackgroundColor: '#1e2a20', iconRotate: '-45deg' } } } },
{ id: 'qa-look-b', type: 'button', props: { text: 'Anrufen', href: 'tel:+4972296992826', icon: 'phone', iconOnly: true } },
{ id: 'qa-look-c', type: 'button', props: { text: 'Prospekt laden', href: '/', icon: 'download', iconPosition: 'start' } },
];
assert.equal((await context.request.post(api + '/draft', { headers: { 'X-CSRF-Token': look.csrfToken }, data: { document: look.document, expectedVersion: look.version } })).status(), 200);
await page.reload();
const frame = page.frameLocator('#preview');
await frame.locator('#qa-look-a .button-icon').waitFor();
const section = frame.locator('#qa-icons');
await section.screenshot({ path: `${report}/aussehen-normal.png` });
const styles = await frame.locator('#qa-look-a .button-icon').evaluate(el => { const s = getComputedStyle(el); return { bg: s.backgroundColor, radius: s.borderRadius, color: s.color }; });
assert.deepEqual(styles, { bg: 'rgb(183, 28, 34)', radius: '999px', color: 'rgb(255, 255, 255)' });
await frame.locator('#qa-look-a').hover();
await page.waitForTimeout(400);
await section.screenshot({ path: `${report}/aussehen-hover.png` });
assert.equal(await frame.locator('#qa-look-a .button-icon').evaluate(el => getComputedStyle(el).rotate), '-45deg');
const only = await frame.locator('#qa-look-b').evaluate(el => ({ name: el.textContent.trim(), w: Math.round(el.getBoundingClientRect().width), h: Math.round(el.getBoundingClientRect().height) }));
assert.equal(only.name, 'Anrufen');
assert.ok(Math.abs(only.w - only.h) <= 2, `Nur-Symbol-Button ist nicht quadratisch: ${only.w}x${only.h}`);
checks.push('Aussehen: farbige runde Fläche, Drehung beim Überfahren, quadratischer Nur-Symbol-Button, Symbol vor dem Text');
await selectBlock('qa-look-c');
// Kein Symbol: Nur-Symbol fällt weg, die Einstellungen verschwinden.
await page.getByRole('button', { name: /Symbol ändern/ }).click();
await dialog.getByRole('button', { name: 'Kein Symbol', exact: true }).click();
await page.waitForFunction(() => { const el = document.querySelector('#preview')?.contentDocument?.getElementById('qa-look-c'); return el && !el.querySelector('.button-icon'); });
assert.equal(await page.getByRole('button', { name: 'Vor dem Text', exact: true }).count(), 0);
checks.push('Kein Symbol entfernt auch „nur Symbol“');
assert.deepEqual(errors, []);
await writeFile(`${report}/report.json`, JSON.stringify({ origin, checks }, null, 2));