fix: refine chart evidence layouts

This commit is contained in:
2026-09-05 00:22:02 +03:00
parent 2fce0cedee
commit 0f6349507b
4 changed files with 170 additions and 13 deletions

View File

@@ -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 `<dl>`. 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
```

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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<HTMLElement>('.chart-description')!;
const table = figure.querySelector<HTMLElement>('.chart-values')!;
const table = figure.querySelector<HTMLElement>('.chart-values');
const values = [...description.querySelectorAll<HTMLElement>('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<HTMLElement>('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<HTMLImageElement>('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);
});