diff --git a/.superpowers/sdd/2026-09-04-portfolio-content-seo-media-implementation/task-6-report.md b/.superpowers/sdd/2026-09-04-portfolio-content-seo-media-implementation/task-6-report.md index 3a06012..d596b88 100644 --- a/.superpowers/sdd/2026-09-04-portfolio-content-seo-media-implementation/task-6-report.md +++ b/.superpowers/sdd/2026-09-04-portfolio-content-seo-media-implementation/task-6-report.md @@ -208,3 +208,102 @@ tests/unit/metadata.test.ts ``` No generated build output, private artifact, temporary inspection harness, screenshot, or unrelated file is staged. + +## Review round 1 — constrained chart prose and print image surfaces + +Status: **DONE** + +Review round 1 identified two remaining rendered-layout problems without changing the approved media, content, reference closure, or Task 7 boundary: + +1. The chart-pair description list inherited the general two-column `dt`/`dd` layout even though each desktop chart card is narrow. +2. Print made the figure surface white but allowed the desktop chart image's absolute positioning, forced `!important` dimensions, and dark screen background to remain active. + +### RED evidence + +The strengthened browser regression inspects the actual Opinion chart figures at a 1440px viewport. Before the scoped CSS fixes it reported: + +```text +minimum chart-description dd width: 81.671875px (required >= 160px) +maximum chart-description height: 768.703125px (required < 600px) +print chart-image background: not white +maximum rendered/intrinsic aspect error: 2.315647 (required <= 0.02) +``` + +The width and height assertions exercise the inner definition values rather than only the outer full-width `
`. The print assertion loads both chart assets before enabling print emulation, then compares each rendered box with the intrinsic dimensions declared by the approved manifest-backed image element. This prevents an unloaded lazy image from collapsing to its border box and producing a false aspect result. + +### Minimal implementation + +- Only a chart description inside a desktop `chart-pair` switches to one definition-list column. General and single-media description layouts remain unchanged; the existing narrow-layout rule remains unchanged. +- Only print chart-pair figures leave the screen grid treatment. Their image/video element becomes statically positioned, removes the screen `!important` inline/block sizes with equally explicit print overrides, retains intrinsic aspect under the print maximums, and uses a white image surface. +- Screen image/caption alignment, chart order, exact value table, mobile stacking, and portrait print containment are unchanged. + +### GREEN metrics and visual evidence + +The focused GREEN run passed both regressions. A separate temporary real-media capture harness (removed after use) recorded: + +```text +desktop Opinion article: 608 × 1947.484375 CSS px +minimum chart-description dd width: 177.671875px +maximum chart-description height: 371.953125px + +print polarization chart: 586 × 457 CSS px +print polarization aspect error: 0.0009667 +print final-distribution chart: 573 × 457 CSS px +print final-distribution aspect error: 0.0008891 +print chart-image backgrounds: rgb(255, 255, 255) + +print medication portraits: 453.125 × 804 CSS px each +print medication object-fit: contain +``` + +Inspected repaired artifacts: + +```text +/tmp/portfolio-task-6-visual/review-1-after-desktop-opinion.png +/tmp/portfolio-task-6-visual/review-1-after-print-opinion.png +/tmp/portfolio-task-6-visual/review-1-after-print-medication.png +``` + +The desktop Opinion descriptions now read in normal phrase-width lines without mid-word character-column wrapping. The print charts are centered at useful size on white surfaces with no dark letterboxes. Medication's three portrait images remain uncropped, contained, ordered, and readable in print. No further crop, hierarchy, caption, contrast, or overflow defect was found in these reviewed states, and no visual baseline changed. + +### Review-round verification + +```text +npx playwright test tests/browser/media-policy.spec.ts \ + --project=desktop-chromium --workers=1 --grep "desktop chart|print project" +# 2/2 passed + +npm run verify:approved-assets +# passed; exact 15-ID closure retained + +npm run test:unit -- tests/integration/public-content.test.ts \ + tests/unit/ProjectMedia.test.ts tests/unit/content-schemas.test.ts \ + tests/integration/materialize-empty-asset-set.test.mjs \ + tests/unit/entry-client.test.ts tests/unit/metadata.test.ts \ + tests/integration/prerender.test.ts +# 7 files passed; 69/69 tests passed + +npm run typecheck +# passed + +npm run build:preview +# verify:approved-assets passed +# typecheck passed +# 49 files passed; 652/652 unit/integration tests passed +# client/SSR builds, prerender, artifact verification, and transfer budgets passed + +npx playwright test tests/browser/media-policy.spec.ts \ + tests/browser/media-failure.spec.ts tests/browser/responsive-layout.spec.ts \ + tests/browser/accessibility-reflow.spec.ts tests/browser/visual-regression.spec.ts \ + --project=desktop-chromium --project=mobile-chromium --workers=2 +# 64 passed; 22 intentional project/mode skips; 0 failed +``` + +The Chromium commands used the normal approved out-of-sandbox launch path. Review round 1's separate commit contains only: + +```text +src/styles/components.css +src/styles/print.css +tests/browser/media-policy.spec.ts +.superpowers/sdd/2026-09-04-portfolio-content-seo-media-implementation/task-6-report.md +``` diff --git a/src/styles/components.css b/src/styles/components.css index 5fe2aef..0d7a738 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -751,6 +751,7 @@ .project-media-gallery[data-media-layout="chart-pair"] > .project-media-gallery__item > .project-media > .chart-description { grid-column: 1; grid-row: 3; + grid-template-columns: minmax(0, 1fr); } .project-media-gallery[data-media-layout="chart-pair"] > .project-media-gallery__item > .project-media > .chart-values { diff --git a/src/styles/print.css b/src/styles/print.css index 8571505..52072b0 100644 --- a/src/styles/print.css +++ b/src/styles/print.css @@ -127,6 +127,20 @@ object-fit: contain; } + .project-media-gallery[data-media-layout='chart-pair'] > .project-media-gallery__item > .project-media { + display: block; + } + + .project-media-gallery[data-media-layout='chart-pair'] > .project-media-gallery__item > .project-media > :is(img, video) { + position: static; + inset: auto; + inline-size: auto !important; + max-inline-size: 100% !important; + block-size: auto !important; + max-block-size: min(9in, calc(100vh - 6rem)) !important; + background: #fff !important; + } + a { color: inherit; text-decoration: underline; diff --git a/tests/browser/media-policy.spec.ts b/tests/browser/media-policy.spec.ts index 5e0039c..e627160 100644 --- a/tests/browser/media-policy.spec.ts +++ b/tests/browser/media-policy.spec.ts @@ -77,49 +77,70 @@ test('failed project images leave titles, captions, chart explanations, and alt await expect(page.locator('[data-project-id="currency-converter"] img')).toHaveAttribute('alt', /conversion interface/iu); }); -test('desktop chart descriptions and exact values occupy sequential full-width rows', async ({ page, distUrl }, testInfo) => { +test('desktop chart descriptions keep readable value columns and exact values in sequential full-width rows', async ({ page, distUrl }, testInfo) => { test.skip(testInfo.project.name !== 'desktop-chromium', 'Desktop chart grid geometry is verified once.'); await page.setViewportSize({ width: 1440, height: 900 }); await page.addInitScript(() => localStorage.setItem('portfolio.motion.maximum-quality', 'static')); await page.goto(distUrl, { waitUntil: 'networkidle' }); - const chart = page.locator('[data-project-id="opinion-dynamics-simulation"] .project-media--chart').first(); - const geometry = await chart.evaluate((figure) => { + const charts = page.locator('[data-project-id="opinion-dynamics-simulation"] .project-media--chart'); + const geometry = await charts.evaluateAll((figures) => figures.map((figure) => { const description = figure.querySelector('.chart-description')!; - const table = figure.querySelector('.chart-values')!; + const table = figure.querySelector('.chart-values'); + const values = [...description.querySelectorAll('dd')]; const figureRect = figure.getBoundingClientRect(); const descriptionStyle = getComputedStyle(description); - const tableStyle = getComputedStyle(table); + const tableStyle = table === null ? null : getComputedStyle(table); return { descriptionColumn: descriptionStyle.gridColumnStart, descriptionRow: descriptionStyle.gridRowStart, - tableColumn: tableStyle.gridColumnStart, - tableRow: tableStyle.gridRowStart, + tableColumn: tableStyle?.gridColumnStart ?? null, + tableRow: tableStyle?.gridRowStart ?? null, descriptionWidthRatio: description.getBoundingClientRect().width / figureRect.width, - tableWidthRatio: table.getBoundingClientRect().width / figureRect.width, + tableWidthRatio: table === null ? null : table.getBoundingClientRect().width / figureRect.width, + minimumValueWidth: Math.min(...values.map((value) => value.getBoundingClientRect().width)), + descriptionHeight: description.getBoundingClientRect().height, }; - }); + })); const opinionHeight = await page.locator('[data-project-id="opinion-dynamics-simulation"]') .evaluate((article) => article.getBoundingClientRect().height); - expect(geometry).toEqual({ + expect(geometry[0]).toEqual({ descriptionColumn: '1', descriptionRow: '3', tableColumn: '1', tableRow: '4', descriptionWidthRatio: expect.any(Number), tableWidthRatio: expect.any(Number), + minimumValueWidth: expect.any(Number), + descriptionHeight: expect.any(Number), }); - expect(geometry.descriptionWidthRatio).toBeGreaterThanOrEqual(0.8); - expect(geometry.tableWidthRatio).toBeGreaterThanOrEqual(0.8); + expect(geometry[1]).toMatchObject({ + descriptionColumn: '1', + descriptionRow: '3', + tableColumn: null, + tableRow: null, + }); + expect(geometry.every(({ descriptionWidthRatio }) => descriptionWidthRatio >= 0.8)).toBe(true); + expect(geometry[0]!.tableWidthRatio).toBeGreaterThanOrEqual(0.8); + expect.soft( + Math.min(...geometry.map(({ minimumValueWidth }) => minimumValueWidth)), + JSON.stringify(geometry), + ).toBeGreaterThanOrEqual(160); + expect.soft(Math.max(...geometry.map(({ descriptionHeight }) => descriptionHeight))).toBeLessThan(600); expect(opinionHeight).toBeLessThan(3_000); }); test('print project captions and evidence text retain readable contrast', async ({ page, distUrl }, testInfo) => { test.skip(testInfo.project.name !== 'desktop-chromium', 'Print contrast is verified once.'); await page.setViewportSize({ width: 1440, height: 900 }); - await page.emulateMedia({ media: 'print' }); await page.goto(distUrl, { waitUntil: 'networkidle' }); + const opinionCharts = page.locator('[data-project-id="opinion-dynamics-simulation"] .project-media--chart img'); + await opinionCharts.first().scrollIntoViewIfNeeded(); + await expect.poll(() => opinionCharts.evaluateAll((images) => images.every((image) => ( + (image as HTMLImageElement).complete && (image as HTMLImageElement).naturalWidth > 0 + )))).toBe(true); + await page.emulateMedia({ media: 'print' }); const result = await page.locator('.project-media').evaluateAll((figures) => { const channel = (value: number): number => { @@ -139,12 +160,34 @@ test('print project captions and evidence text retain readable contrast', async return [...figure.querySelectorAll('figcaption, .media-description, .chart-description, .chart-values')] .map((element) => contrast(getComputedStyle(element).color, background)); }); + const chartImages = figures + .filter((figure) => figure.matches('.project-media--chart')) + .map((figure) => figure.querySelector('img')!); return { backgrounds: [...new Set(figures.map((figure) => getComputedStyle(figure).backgroundColor))], minimumContrast: Math.min(...samples), + chartImages: chartImages.map((image) => { + const rendered = image.getBoundingClientRect(); + const renderedAspect = rendered.width / rendered.height; + const intrinsicAspect = Number(image.getAttribute('width')) / Number(image.getAttribute('height')); + return { + background: getComputedStyle(image).backgroundColor, + renderedWidth: rendered.width, + renderedHeight: rendered.height, + renderedAspect, + intrinsicAspect, + aspectError: Math.abs(renderedAspect - intrinsicAspect) / intrinsicAspect, + }; + }), }; }); expect(result.backgrounds).toEqual(['rgb(255, 255, 255)']); expect(result.minimumContrast).toBeGreaterThanOrEqual(4.5); + expect(result.chartImages).toHaveLength(2); + expect.soft(result.chartImages.every(({ background }) => background === 'rgb(255, 255, 255)')).toBe(true); + expect.soft( + Math.max(...result.chartImages.map(({ aspectError }) => aspectError)), + JSON.stringify(result.chartImages), + ).toBeLessThanOrEqual(0.02); });