From bcecfad4388bb4ad625764dc2c3432433a4d31e1 Mon Sep 17 00:00:00 2001 From: Jessica Deen Date: Mon, 17 Aug 2026 18:44:21 -0700 Subject: [PATCH] Fix stray line in the Contributions icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Contributions stat used a hand-edited copy of the `package-16` octicon that had been mangled. Its final subpath — M2.5 13.677v-2.3L6.5 13.8v2.3Z was a leftover sliver running to y=16.1, past the bottom of the 16x16 viewBox, so it rendered as a stray line poking out from under the cube. The same edit had truncated the cube's right edge and dropped its bottom-right face, and shifted the left edge to x=0.378 while the right stopped at x=15, leaving the glyph visibly off-center. Measured geometry before: [0.378, 0.156 -> 15.000, 16.100] after: [1.000, 0.156 -> 15.000, 15.844] Restore the upstream Primer `package-16` path, which is the cube this design already intended. The icon keeps its `stack` name, so no call sites change and nothing else about the card moves. Artwork that escapes its viewBox gets silently clipped, which is why this shipped unnoticed, so the new tests measure the real rendered geometry of every icon on the card rather than eyeballing it: one asserts no icon strays outside the box it declares, the other pins the Contributions cube to a single path, horizontally symmetric and within bounds. Both fail against the old path. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3a633ff9-3c7e-4499-825f-af5baab48b1c --- src/components/ui/Icon.tsx | 2 +- tests/icon-geometry.spec.ts | 167 ++++++++++++++++++++++++++++++++++++ 2 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 tests/icon-geometry.spec.ts diff --git a/src/components/ui/Icon.tsx b/src/components/ui/Icon.tsx index 27d077f..26e0c8c 100644 --- a/src/components/ui/Icon.tsx +++ b/src/components/ui/Icon.tsx @@ -248,7 +248,7 @@ export function Icon({ aria-label={label} role={label ? 'img' : undefined} > - + ); diff --git a/tests/icon-geometry.spec.ts b/tests/icon-geometry.spec.ts new file mode 100644 index 0000000..0bfec42 --- /dev/null +++ b/tests/icon-geometry.spec.ts @@ -0,0 +1,167 @@ +/** + * Icon geometry regression tests. + * + * The Contributions icon shipped with a hand-edited `package-16` path whose final + * subpath ("M2.5 13.677v-2.3L6.5 13.8v2.3Z") was a leftover sliver extending to + * y=16.1 — past the bottom of the 16x16 viewBox, so it rendered as a stray line + * poking out of the cube. + * + * Artwork that escapes its own viewBox is silently clipped by the renderer, which + * makes it easy to ship and hard to spot in review. These tests measure the real + * geometry of every icon the card renders and fail when any of it falls outside + * the box it declares. + */ +import { expect, test, type Page } from '@playwright/test'; + +const avatarDataUrl = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII='; + +async function mockGitHubProfile(page: Page) { + await page.route('https://api.github.com/users/octocat', async (route) => { + await route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ + login: 'octocat', + name: 'The Octocat', + avatar_url: avatarDataUrl, + html_url: 'https://github.com/octocat', + followers: 42, + public_repos: 8, + bio: 'GitHub mascot', + created_at: '2011-01-25T18:44:36Z', + }), + }); + }); + + await page.route( + 'https://api.github.com/users/octocat/repos?per_page=100&sort=updated', + async (route) => { + await route.fulfill({ + contentType: 'application/json', + body: JSON.stringify([ + { stargazers_count: 10, forks_count: 2, language: 'TypeScript' }, + ]), + }); + } + ); + + await page.route('https://github.com/octocat.contribs', async (route) => { + await route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ + total_contributions: 123, + weeks: [{ contribution_days: [{ count: 1 }] }], + }), + }); + }); +} + +async function openDevemonCard(page: Page) { + await page.goto('/', { waitUntil: 'networkidle' }); + const usernameInput = page.getByLabel('GitHub Username'); + await usernameInput.fill('octocat'); + await page.getByRole('button', { name: /generate/i }).click(); + await expect(page.getByText('@octocat').first()).toBeVisible(); + await page.getByRole('tab', { name: /devémon/i }).click(); + await expect(page.locator('[data-devemon-card="true"]')).toBeVisible(); +} + +/** + * Measure each rendered icon's true path geometry against the viewBox it declares. + */ +async function measureIcons(page: Page) { + return page.evaluate(() => { + const card = document.querySelector('[data-devemon-card="true"]')!; + + return [...card.querySelectorAll('svg')].map((svg) => { + const [vx, vy, vw, vh] = (svg.getAttribute('viewBox') ?? '0 0 16 16') + .split(/\s+/) + .map(Number); + + let box: { x: number; y: number; mx: number; my: number } | null = null; + for (const path of svg.querySelectorAll('path')) { + const b = path.getBBox(); + box = box + ? { + x: Math.min(box.x, b.x), + y: Math.min(box.y, b.y), + mx: Math.max(box.mx, b.x + b.width), + my: Math.max(box.my, b.y + b.height), + } + : { x: b.x, y: b.y, mx: b.x + b.width, my: b.y + b.height }; + } + + return { + label: svg.getAttribute('aria-label') ?? '(unlabelled)', + viewBox: { x: vx, y: vy, mx: vx + vw, my: vy + vh }, + box, + }; + }); + }); +} + +test('card icons stay inside their viewBox', async ({ page }) => { + await mockGitHubProfile(page); + await openDevemonCard(page); + + const icons = await measureIcons(page); + expect(icons.length).toBeGreaterThan(0); + + // Sub-pixel slack: stroke joins can round a hair past the edge legitimately. + const tolerance = 0.02; + + const escaping = icons.filter( + (icon) => + icon.box !== null && + (icon.box.x < icon.viewBox.x - tolerance || + icon.box.y < icon.viewBox.y - tolerance || + icon.box.mx > icon.viewBox.mx + tolerance || + icon.box.my > icon.viewBox.my + tolerance) + ); + + expect( + escaping.map( + (i) => + `${i.label}: [${i.box!.x.toFixed(2)}, ${i.box!.y.toFixed(2)} -> ` + + `${i.box!.mx.toFixed(2)}, ${i.box!.my.toFixed(2)}] outside ` + + `[${i.viewBox.x}, ${i.viewBox.y} -> ${i.viewBox.mx}, ${i.viewBox.my}]` + ) + ).toEqual([]); +}); + +test('contributions icon is a closed, centered cube', async ({ page }) => { + await mockGitHubProfile(page); + await openDevemonCard(page); + + const contributions = await page.evaluate(() => { + const svg = document + .querySelector('[data-devemon-card="true"]')! + .querySelector('svg[aria-label="Contributions"]'); + if (!svg) return null; + + const paths = [...svg.querySelectorAll('path')]; + const b = paths[0].getBBox(); + + return { + pathCount: paths.length, + x: b.x, + y: b.y, + mx: b.x + b.width, + my: b.y + b.height, + }; + }); + + expect(contributions).not.toBeNull(); + + // A single path draws the whole cube; a stray leftover subpath is what broke it. + expect(contributions!.pathCount).toBe(1); + + // The cube is horizontally symmetric within the 16x16 box. The broken version + // started at x=0.378 on the left but stopped at x=15 on the right. + const leftGap = contributions!.x; + const rightGap = 16 - contributions!.mx; + expect(Math.abs(leftGap - rightGap)).toBeLessThan(0.05); + + // ...and it must not hang below the box, which is what produced the stray line. + expect(contributions!.my).toBeLessThanOrEqual(16); +});