diff --git a/README.md b/README.md index d82bf6e..7d6d165 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,8 @@ Das Passwort wird verdeckt zweimal abgefragt (14–72 Bytes). Bestehende Benutze Unter `http://127.0.0.1:8080/admin` eine Seite auswählen. Links Blöcke hinzufügen/auswählen, in der Mitte die Vorschau, rechts Eigenschaften. Die Auswahl ist zwischen Strukturbaum und Vorschau synchron. Sektion, Zeile, Spalte, Überschrift, Text, Bild, Button und Galerie sind vorhanden. Bilder können aus der Mediathek oder über vorhandene lokale/HTTPS-Adressen eingebunden werden. +**Bild prozentual verkleinern (ab 1.7.2):** Bild auswählen → Inhalt → **Bildbreite (%)**. Regler oder Zahlenfeld setzen 1–100 % der verfügbaren Breite in Sektion/Spalte. Desktop, Tablet und Mobil können eigene Werte erhalten; kleinere Geräte übernehmen zunächst den Wert vom größeren Gerät. „Breite zurücksetzen“ entfernt den eigenen Wert und stellt die Vererbung beziehungsweise automatische Breite wieder her. Originalbreite/-höhe beschreiben weiterhin die Datei. Der Regler bearbeitet denselben Wert wie Design → Größe → Breite; eine dort gesetzte maximale Breite bleibt wirksam. Prüfung mit kopierten Kant-IT-Daten: `npm run test:image-size`, Bericht und Screenshots unter `reports/image-size/`. + Blöcke per Drag-and-drop verschieben: auf einen Container ziehen, um hineinzuverschieben; auf einen Text-/Bild-/Linkblock ziehen, um davor einzufügen. Alternativ unten am Strukturbaum die Schaltflächen für Auf/Ab, Verschachteln und Herausheben verwenden. Duplizieren vergibt neue Block-IDs und wandelt eine kopierte H1 in H2 um. Löschen lässt sich rückgängig machen. Jede speicherbare Seite benötigt genau eine H1. Undo/Redo gilt für die lokale Bearbeitung, `Strg+S` speichert den Entwurf. `Strg+Z`/`Strg+Umschalt+Z` gelten außerhalb der Texteingaben für das Dokument; Texteingaben behalten ihr natives Undo. Erst speichern, dann separat veröffentlichen. Die Bestätigungsdialoge unterstützen Escape und Fokus-Rückkehr. Reine Redakteure können nicht veröffentlichen. diff --git a/VERSION b/VERSION index 943f9cb..f8a696c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.7.1 +1.7.2 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index bafb492..fccb865 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2,6 +2,12 @@ Stand: 14.09.2026. Arbeitstitel KantSites; Produktname offen. +## ADR 080 – Prozentuale Bildbreite direkt bei den Bildoptionen (14.09.2026) + +Der Inhaltsbereich eines Bildes erhält oben einen Prozentregler und ein Zahlenfeld für 1–100 % der verfügbaren Containerbreite. Beide schreiben ausschließlich `style..width` über die bestehende `setStyle`-Transaktion. Die generische Breite unter Design und die direkte Bildsteuerung sind dadurch immer derselbe Wert. Originalmaße, Quelle, Varianten, Alternativtext und Bildunterschrift bleiben unverändert. Vorhandene maximale Breiten werden erhalten und im Hinweis genannt; Werte in anderen Einheiten bleiben sichtbar, bis bewusst eine Prozentbreite gesetzt wird. Keine neue Style-Eigenschaft, keine neue Schema- oder Datenbankmigration, keine Änderung am öffentlichen Renderer oder öffentlichen JavaScript. + +Die Steuerung verwendet das aktive Vorschaugerät und zeigt die Herkunft des Werts. Zurücksetzen entfernt nur die Breite dieses Geräts und stellt die bestehende Desktop→Tablet→Mobil-Vererbung wieder her. Eingaben aktualisieren die Vorschau, ohne das Feld zu ersetzen. Eine Ziehbewegung ist ein Undo-Schritt; Zahlenwerte außerhalb des Bereichs werden mit verständlicher Meldung abgelehnt und nicht gespeichert. Tastatur, Undo/Redo, Speichern/Laden und prozentuale Ausgabe ohne JavaScript werden am realen TeamViewer-Bild aus einer isolierten Kopie der Pilotdaten geprüft. + ## ADR 079 – Livesuche mit gemeinsamen HTML-Ergebnissen (14.09.2026) Seiten, Beiträge und Mediathek verwenden dieselben authentifizierten GET-Routen für vollständige Ansichten und AJAX. Der Header `X-Requested-With: XMLHttpRequest` fordert JSON mit einem serverseitig gerenderten Ergebnisfragment, Gesamtzahl und normalisierten Filtern an. Twig bleibt die einzige Darstellung für Listen, Berechtigungen, CSRF-Felder, leere Ergebnisse und Pagination. Suchformular sowie Anlegen/Hochladen bleiben außerhalb des austauschbaren Bereichs; vorhandene Formulare funktionieren weiter ohne JavaScript. Keine Schema- oder Inhaltsmigration. diff --git a/docs/STATUS.md b/docs/STATUS.md index 031f232..af98e0c 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,5 +1,13 @@ # Projektstatus +## 1.7.2 – Bildbreite direkt in Prozent einstellen (14.09.2026) + +**Umgesetzt:** Beim ausgewählten Bild steht im Reiter Inhalt oben „Bildbreite (%)“ mit Regler, Zahlenfeld und Zurücksetzen. 1–100 % beziehen sich auf die verfügbare Breite der Sektion/Spalte. Desktop, Tablet und Mobil verwenden die vorhandene Vererbung; Herkunft und bestehende maximale Breiten sind sichtbar. Beide Bildsteuerungen bearbeiten denselben gespeicherten Breitenwert wie Design → Größe. Originalmaße, Medienvarianten, Alt-Text und Bildunterschrift bleiben erhalten. Ungültige Zahlen verändern den gespeicherten Wert nicht, eine Ziehbewegung bleibt ein Undo-Schritt. Der veraltete Hinweis auf eine erst künftig verfügbare Mediathek wurde berichtigt. Keine neue Schema-/Datenbankmigration, keine Änderung am öffentlichen Renderer oder öffentlichen Paket. + +**Geprüft:** `npm run test:image-size` auf einer isolierten Kant-IT-Kopie mit dem TeamViewer-Logo: Regler mit echter Mausbewegung und Tastatur, Zahlenfeld, Fokus, ungültiger Wert, Undo/Redo, responsive Vererbung/Reset, Speichern/Laden, unveränderte Bilddaten und Abgleich mit Design → Größe. Veröffentlichte Ausgabe ohne JavaScript bei 1600/900/390 px: 60/75/30 % und unverzerrtes Seitenverhältnis gemessen. Neue Steuerung auf Desktop/Mobil ohne Überlauf und axe-Verstöße, Screenshots visuell geprüft (`reports/image-size/`). Vollständige Editor-Browserprüfung erfolgreich, einschließlich Speichern/Veröffentlichen, Konflikten, Mediathek und Desktop-/Mobil-Barrierefreiheit (`reports/editor/`). 30 JavaScript-Tests und 35 PHP-Tests zu Styles/Bildern mit 67 Assertions erfolgreich. Alle Schreibtests in eigenen Datenbankkopien. + +**Offen:** Geprüfte Fassung als 1.7.2 veröffentlichen. Kundenhostings wurden nicht verändert. Da nur die Editorsteuerung vorhandener Style-Werte geändert wurde, keine erneuten öffentlichen Lighthouse-Messungen; die Prozentdarstellung wurde im gemeinsamen Renderer direkt überprüft. + ## 1.7.1 – Livesuche in der Verwaltung (14.09.2026) **Umgesetzt:** Seiten und Beiträge suchen beim Tippen per AJAX; Status, 20/50 Einträge und Pagination aktualisieren nur die Ergebnisliste. URL, Browser-Zurück/Vor, Eingabefokus und Rückkehrfilter beim Speichern bleiben zusammenhängend. Die Mediathek durchsucht Titel und Alternativtext über alle Seiten, kombiniert mit Archivfilter. Bestehende Suchen in Menüs, übergeordneten Seiten, Editor-Werkzeugkasten/Struktur/Eigenschaften und Bildauswahl reagieren bereits beim Tippen. Kein Enter nötig; ohne JavaScript bleibt „Anwenden“ erhalten. Alte Antworten werden verworfen, Fehler erhalten Treffer und Eingaben, Wiederholen ist direkt möglich. Eine während der Suche begonnene Bearbeitung hält den Austausch an. SQLite-Suche erkennt Groß-/Kleinschreibung auch bei Umlauten. Keine Datenmigration und kein zusätzliches öffentliches JavaScript. diff --git a/package.json b/package.json index e3e69b5..0196c3e 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "test:links": "node scripts/link-check.mjs", "audit": "node scripts/audit.mjs", "test:admin": "node scripts/admin-quality-check.mjs", - "test:search": "node scripts/live-search-check.mjs" + "test:search": "node scripts/live-search-check.mjs", + "test:image-size": "node scripts/image-size-check.mjs" }, "devDependencies": { "@axe-core/playwright": "^4.10.0", diff --git a/public/assets/editor.css b/public/assets/editor.css index 987c3f4..bcce2f8 100644 --- a/public/assets/editor.css +++ b/public/assets/editor.css @@ -311,3 +311,6 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)} @media(max-width:560px){.left-panel,.right-panel{width:100%;height:620px;max-height:620px}.canvas-toolbar{padding-inline:8px}.canvas-label{display:none}} .canvas-toolbar{flex-wrap:wrap;gap:8px}.canvas-toolbar #preview-motion{white-space:nowrap;min-height:36px} +.image-width-control{padding:14px;margin-bottom:20px;border:1px solid var(--line);border-radius:10px;background:#f5f8f1} +.image-width-control>label{margin-top:0}.image-width-inputs{display:grid;grid-template-columns:minmax(0,1fr) 76px;align-items:center;gap:12px} +.image-width-inputs input[type=range]{width:100%;min-width:0;margin:0;padding:0;accent-color:var(--accent);min-height:36px}.image-width-control .field-help{margin:8px 0}.image-width-control .text-button{margin-top:4px}.image-width-error{color:#a62424} diff --git a/public/assets/editor.js b/public/assets/editor.js index a3cc607..06a1672 100644 --- a/public/assets/editor.js +++ b/public/assets/editor.js @@ -333,15 +333,16 @@ function renderProperties() { } if (block.type === 'button') field('Linkziel', 'href', block.props.href, { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse.' }); if (block.type === 'image') { + renderImageWidth(block, panel); field('Beim Klick', 'click', block.props?.click || (block.props?.href ? 'link' : 'keine'), { choices: { keine: 'Nichts tun', vergroessern: 'Bild vergrößern', link: 'Zu einer Adresse führen' }, help: 'Vergrößern öffnet das Bild über der Seite. Ohne JavaScript öffnet derselbe Klick die große Bilddatei – die Seite bleibt also in jedem Fall benutzbar.' }); if ((block.props?.click || (block.props?.href ? 'link' : 'keine')) === 'link') field('Linkziel', 'href', block.props.href || '', { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse. Für ein Logo im Kopfbereich meist /.' }); const picker = document.createElement('button'); picker.className = 'secondary'; picker.textContent = 'Bild aus Mediathek wählen'; picker.addEventListener('click', () => chooseMedia(false)); panel.append(picker); - field('Bildadresse', 'src', block.props.src, { help: 'Vorhandene lokale Datei oder HTTPS-Adresse. Eine Mediathek folgt später.' }); + field('Bildadresse', 'src', block.props.src, { help: 'Lokale Datei oder HTTPS-Adresse. Über die Mediathek wird die passende Bildvariante automatisch gewählt.' }); field('Dekoratives Bild', 'decorative', block.props.decorative, { checkbox: true }); if (!block.props.decorative) field('Alternativtext', 'alt', block.props.alt, { multiline: true }); field('Originalbreite (px)', 'width', block.props.width, { number: true }); - field('Originalhöhe (px)', 'height', block.props.height, { number: true }); + field('Originalhöhe (px)', 'height', block.props.height, { number: true, help: 'Abmessungen der Bilddatei. Die Anzeigegröße stellst du oben unter Bildbreite ein.' }); field('Bildunterschrift', 'caption', block.props.caption || '', { multiline: true, maxLength: 500 }); } if (block.type === 'gallery') { @@ -397,6 +398,48 @@ function renderAdvanced(block, panel) { } } +function renderImageWidth(block, panel) { + const group = document.createElement('div'); group.className = 'image-width-control'; + const label = document.createElement('label'); label.htmlFor = 'image-width-percent'; label.textContent = 'Bildbreite (%)'; + const row = document.createElement('div'); row.className = 'image-width-inputs'; + const slider = document.createElement('input'); slider.type = 'range'; slider.setAttribute('aria-label', 'Bildbreite Schieberegler'); + const exact = document.createElement('input'); exact.type = 'number'; exact.id = label.htmlFor; + for (const input of [slider, exact]) { input.min = '1'; input.max = '100'; input.step = '0.1'; input.setAttribute('aria-describedby', 'image-width-help image-width-state'); } + const state = document.createElement('p'); state.id = 'image-width-state'; state.className = 'field-help'; + const help = document.createElement('p'); help.id = 'image-width-help'; help.className = 'field-help'; + help.textContent = 'Anteil an der verfügbaren Breite der Sektion oder Spalte. Die Originalmaße der Datei bleiben erhalten.'; + const error = document.createElement('p'); error.className = 'field-help image-width-error'; error.setAttribute('role', 'alert'); error.hidden = true; + error.textContent = 'Bitte 1 bis 100 eingeben. Die bisherige Breite bleibt bis dahin erhalten.'; + const reset = document.createElement('button'); reset.type = 'button'; reset.id = 'image-width-reset'; reset.className = 'text-button'; reset.textContent = 'Breite zurücksetzen'; + const sync = (keepInput = false) => { + const current = locate(history.document.blocks, block.id)?.block; + const { value, source } = effectiveStyle(current, device, 'width'); + const percent = typeof value === 'string' && /^\d+(?:\.\d+)?%$/.test(value) ? parseFloat(value) : null; + if (!keepInput) exact.value = percent ?? ''; + exact.placeholder = value && percent === null ? value : '100'; + slider.value = percent ?? 100; + slider.setAttribute('aria-valuetext', percent !== null ? `${percent} Prozent` : `Aktuell ${value || 'automatisch'}. Prozentwert wählen.`); + state.textContent = `${devices[device]} · ${source === device ? 'Eigener Wert' : source ? `Von ${devices[source]} übernommen` : 'Automatische Breite'}${value ? ` (${value})` : ''}`; + const maximum = effectiveStyle(current, device, 'maxWidth').value; + if (maximum && maximum !== 'none') state.textContent += `. Maximale Breite: ${maximum} (Design → Größe).`; + }; + const apply = (value, keepInput = false) => { + error.hidden = true; exact.removeAttribute('aria-invalid'); + history.change(doc => setStyle(doc, block.id, device, 'width', value), `image-width:${block.id}:${device}`); + controls(); schedulePreview(); sync(keepInput); + }; + slider.addEventListener('input', () => apply(`${slider.value}%`)); + exact.addEventListener('input', () => { + if (exact.validity.badInput || (exact.value !== '' && !exact.checkValidity())) { error.hidden = false; exact.setAttribute('aria-invalid', 'true'); return; } + apply(exact.value === '' ? '' : `${Number(exact.value)}%`, true); + }); + // A whole drag or keyboard edit is one undo step, independent of preview requests. + slider.addEventListener('change', () => { history.mergeKey = null; }); + exact.addEventListener('blur', () => { history.mergeKey = null; if (error.hidden) sync(); }); + reset.addEventListener('click', () => { history.mergeKey = null; apply(''); history.mergeKey = null; }); + row.append(slider, exact); group.append(label, row, state, help, error, reset); panel.append(group); sync(); +} + function renderDesign(block, panel) { if (designSchema && designPanel) { designPanel(panel); diff --git a/scripts/image-size-check.mjs b/scripts/image-size-check.mjs new file mode 100644 index 0000000..ec58aa6 --- /dev/null +++ b/scripts/image-size-check.mjs @@ -0,0 +1,124 @@ +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'; +import { randomBytes } from 'node:crypto'; +import net from 'node:net'; +import { mediaProps } from '../public/assets/editor-model.js'; + +const php = process.env.PHP_BINARY || 'php'; +const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true })); +const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory }; +execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env }); +const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve)); +const port = probe.address().port; await new Promise(resolve => probe.close(resolve)); +const origin = `http://127.0.0.1:${port}`; +const server = spawn(php, ['-d', 'memory_limit=256M', '-S', `127.0.0.1:${port}`, '-t', 'public', 'bin/dev-router.php'], { windowsHide: true, stdio: 'ignore', env: { ...env, SITE_ORIGIN: origin } }); +const report = 'reports/image-size'; await mkdir(report, { recursive: true }); +let browser, page; +const checks = [], errors = []; +try { + for (let attempt = 0; attempt < 60; attempt++) { + try { if ((await fetch(origin)).ok) break; } catch {} + if (attempt === 59) throw new Error('Pilot test server did not start'); + await new Promise(resolve => setTimeout(resolve, 200)); + } + browser = await chromium.launch(); + const context = await browser.newContext({ viewport: { width: 1600, height: 1050 } }); + page = await context.newPage(); page.setDefaultTimeout(15000); page.on('pageerror', error => errors.push(error.message)); + await page.goto(origin + '/admin/login'); + await page.getByLabel('Benutzername').fill(fixture.username); await page.getByLabel('Passwort', { exact: true }).fill(fixture.password); + 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 media = (await (await context.request.get(origin + '/admin/api/media')).json()).items; + const chosen = media.find(item => /teamviewer/i.test(item.title)) || media.find(item => item.variants.at(-1).width >= 800); + assert.ok(chosen); const original = mediaProps(chosen); original.alt ||= chosen.title; + data.document.blocks.unshift({ id: 'qa-image-size-section', type: 'section', layout: { width: 'full', tone: 'paper' }, style: { desktop: { maxWidth: '800px' } }, children: [{ id: 'qa-image-size', type: 'image', props: original }] }); + const created = await context.request.post(api + '/draft', { headers: { 'X-CSRF-Token': data.csrfToken }, data: { document: data.document, expectedVersion: data.version } }); + assert.equal(created.status(), 200); + await page.goto(`${origin}/admin/pages/${fixture.homeId}`); + const select = async () => { await page.locator('#structure [data-block-id="qa-image-size"]').click(); await page.locator('[data-panel="content"]').click(); }; + await select(); + const exact = () => page.getByLabel('Bildbreite (%)', { exact: true }); + const slider = () => page.getByRole('slider', { name: 'Bildbreite Schieberegler' }); + const width = async percent => page.waitForFunction(percent => { + const doc = document.querySelector('#preview')?.contentDocument; + const figure = doc?.getElementById('qa-image-size'); if (!figure) return false; + const parent = figure.parentElement; const style = doc.defaultView.getComputedStyle(parent); + const available = parent.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight); + return Math.abs(figure.getBoundingClientRect().width / available * 100 - percent) < .1; + }, percent); + const saved = async () => { await page.locator('#save').click(); await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor(); }; + const changeDevice = async device => page.locator(`[data-viewport="${device}"]`).click(); + assert.equal(await exact().inputValue(), ''); + await exact().fill('50'); await width(50); + assert.equal(await exact().evaluate(input => input === document.activeElement), true); + await exact().blur(); + await slider().focus(); await slider().press('ArrowRight'); await width(50.1); + await page.locator('#undo').click(); await width(50); await page.locator('#redo').click(); await width(50.1); + const box = await slider().boundingBox(); + await page.mouse.move(box.x + box.width * .2, box.y + box.height / 2); await page.mouse.down(); + await page.mouse.move(box.x + box.width * .6, box.y + box.height / 2, { steps: 8 }); await page.mouse.up(); + const dragged = Number(await exact().inputValue()); assert.ok(dragged > 50.1); await width(dragged); + await page.locator('#undo').click(); await width(50.1); + await page.locator('#redo').click(); await width(dragged); + await exact().fill('0'); assert.equal(await exact().getAttribute('aria-invalid'), 'true'); await width(dragged); + await exact().fill('50'); await width(50); await exact().blur(); + checks.push('number, keyboard and drag update preview; one undo per gesture; invalid values do not change the document'); + + await changeDevice('tablet'); assert.equal(await exact().inputValue(), '50'); + assert.match(await page.locator('#image-width-state').innerText(), /Von Desktop übernommen/); + await exact().fill('75'); await width(75); await exact().blur(); + await changeDevice('mobile'); assert.equal(await exact().inputValue(), '75'); + await exact().fill('30'); await width(30); await exact().blur(); + await page.locator('#image-width-reset').click(); await width(75); + assert.match(await page.locator('#image-width-state').innerText(), /Von Tablet übernommen/); + await page.locator('#undo').click(); await width(30); + await changeDevice('desktop'); assert.equal(await exact().inputValue(), '50'); await width(50); + await saved(); await page.reload(); await select(); assert.equal(await exact().inputValue(), '50'); await width(50); + const stored = await (await context.request.get(api)).json(); + const image = stored.document.blocks[0].children[0]; + assert.deepEqual(image.style, { desktop: { width: '50%' }, tablet: { width: '75%' }, mobile: { width: '30%' } }); + assert.deepEqual(image.props, original, 'original dimensions, media variants, caption and alt text are unchanged'); + checks.push('device inheritance, reset, undo, save/reload and unchanged image metadata'); + + await page.locator('[data-panel="design"]').click(); + const size = page.locator('.design-group').filter({ has: page.locator('summary').filter({ hasText: /^Größe$/ }) }); + if (!(await size.getAttribute('open'))) await size.locator('summary').click(); + assert.equal(await page.locator('#style-width').inputValue(), '50'); + await page.locator('#style-width').fill('60'); await page.locator('#style-width').blur(); + await page.locator('[data-panel="content"]').click(); assert.equal(await exact().inputValue(), '60'); await width(60); + await page.screenshot({ path: `${report}/editor-desktop.png`, fullPage: true }); + for (const viewport of [1600, 390]) { + await page.setViewportSize({ width: viewport, height: 1050 }); + await exact().scrollIntoViewIfNeeded(); + const results = await new AxeBuilder({ page }).include('.image-width-control').analyze(); + assert.deepEqual(results.violations.map(v => ({ id: v.id, nodes: v.nodes.map(n => n.target) })), []); + assert.equal(await page.locator('.image-width-control').evaluate(el => el.scrollWidth <= el.clientWidth), true); + await page.locator('.image-width-control').screenshot({ path: `${report}/control-${viewport}.png` }); + } + await page.setViewportSize({ width: 1600, height: 1050 }); await saved(); + await page.locator('#publish').click(); await page.locator('#confirm-publish').click(); + await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor(); + const plain = await browser.newContext({ javaScriptEnabled: false }); const publicPage = await plain.newPage(); + for (const [viewport, percent] of [[1600, 60], [900, 75], [390, 30]]) { + await publicPage.setViewportSize({ width: viewport, height: 900 }); await publicPage.goto(origin); + const geometry = await publicPage.locator('#qa-image-size').evaluate(async figure => { + const img = figure.querySelector('img'); await img.decode(); + const parent = figure.parentElement, style = getComputedStyle(parent); + return { figure: figure.getBoundingClientRect().width, available: parent.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight), width: img.getBoundingClientRect().width, height: img.getBoundingClientRect().height }; + }); + assert.ok(Math.abs(geometry.figure / geometry.available * 100 - percent) < .1); + assert.ok(Math.abs(geometry.width / geometry.height - original.width / original.height) < .02, 'image aspect ratio is preserved'); + await publicPage.locator('#qa-image-size-section').screenshot({ path: `${report}/published-${viewport}.png` }); + } + await plain.close(); checks.push('shared Design width; desktop/mobile accessible controls; published responsive percentages and aspect ratio without JavaScript'); + assert.deepEqual(errors, []); + await writeFile(`${report}/result.json`, JSON.stringify({ checks, passed: true }, null, 2)); + console.log('Image size checks passed:', checks.length, 'groups.'); +} catch (error) { + if (page) await page.screenshot({ path: `${report}/failure.png`, fullPage: true }).catch(() => {}); + throw error; +} finally { await browser?.close(); server.kill(); }