Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions packages/app/e2e/icon-viewbox-fit.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/**
* icon-viewbox-fit.spec.ts @smoke
*
* Static compliance check: every glyph in packages/ui/src/components/icon.tsx
* must render inside the `0 0 20 20` viewBox. The chrome icon registry stores
* each glyph as an inner `<g transform=...>` that re-positions a traced path
* into the shared 20x20 canvas; a mis-fit transform leaves part of the glyph
* outside the viewport, and the svg's UA `overflow: hidden` then clips it on
* screen.
*
* Originally added with the `read-file` refit — that glyph extended to y≈22.1
* (clipped at the bottom). This test renders each icon, calls the browser's
* native `getBBox`, and asserts the bounding box stays within `[0, 20]` so a
* future port that overshoots a side is caught before it ships.
*
* No app server / opencode backend required: the spec only uses
* `page.setContent` to evaluate SVG geometry in chromium.
*/
import { icons } from "@opencode-ai/ui/icon"
import { test, expect } from "./fixtures"

// 1-unit margin is the design contract noted at the top of icon.tsx; we lock
// the harder 0-20 bound so the test fails only on real clipping, not on
// glyphs that hug the keyshape edge.
const VIEWBOX_LOW = 0
const VIEWBOX_HIGH = 20
const EPSILON = 0.05

test("@smoke every chrome icon fits inside the 0..20 viewBox", async ({ page }) => {
const names = Object.keys(icons)
expect(names.length, "icon registry should expose at least one glyph").toBeGreaterThan(0)

await page.setContent('<!doctype html><html><body><div id="stage"></div></body></html>')

const measurements = await page.evaluate((iconMap) => {
const ns = "http://www.w3.org/2000/svg"
const stage = document.getElementById("stage")!
const results: Array<{ name: string; x: number; y: number; w: number; h: number } | { name: string; error: string }> = []
for (const [name, inner] of Object.entries(iconMap)) {
const svg = document.createElementNS(ns, "svg")
svg.setAttribute("viewBox", "0 0 20 20")
svg.setAttribute("width", "200")
svg.setAttribute("height", "200")
svg.innerHTML = inner
stage.appendChild(svg)
try {
const bbox = svg.getBBox()
results.push({ name, x: bbox.x, y: bbox.y, w: bbox.width, h: bbox.height })
} catch (e) {
results.push({ name, error: String(e) })
}
stage.removeChild(svg)
}
return results
}, icons)

const overflow = measurements
.map((entry) => {
if ("error" in entry) {
return `${entry.name}: getBBox threw ${entry.error}`
}
const x1 = entry.x + entry.w
const y1 = entry.y + entry.h
const sides: string[] = []
if (entry.x < VIEWBOX_LOW - EPSILON) sides.push(`left ${entry.x.toFixed(2)}`)
if (entry.y < VIEWBOX_LOW - EPSILON) sides.push(`top ${entry.y.toFixed(2)}`)
if (x1 > VIEWBOX_HIGH + EPSILON) sides.push(`right ${x1.toFixed(2)}`)
if (y1 > VIEWBOX_HIGH + EPSILON) sides.push(`bottom ${y1.toFixed(2)}`)
if (!sides.length) return null
return `${entry.name}: ${sides.join(", ")} (bbox x:[${entry.x.toFixed(2)},${x1.toFixed(2)}] y:[${entry.y.toFixed(2)},${y1.toFixed(2)}])`
})
.filter((line): line is string => line !== null)

expect(overflow, `icons overflowing the 0..20 viewBox:\n${overflow.join("\n")}`).toEqual([])
})
104 changes: 104 additions & 0 deletions packages/app/e2e/snap/icon-viewbox.snap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/**
* icon-viewbox.snap.ts
*
* Visual audit for the chrome icon registry's viewBox fit. The registry stores
* each glyph as an inner `<g transform=...>` that re-positions a traced path
* into a shared `0 0 20 20` canvas; a transform that overshoots leaves part of
* the glyph outside the viewport and the svg's UA `overflow: hidden` clips it.
*
* The grid shows `read-file` rendered with both its pre-fix and post-fix
* transform, plus its v4 batchmates `skill` and `thinking` for reference. A
* red dashed outline marks the `0..20` viewBox edge so any overshoot is
* immediately visible — `read-file (before)` extends past the bottom edge,
* the other three sit cleanly inside with ~1-unit margin.
*
* Static-data snap: no app shell, no opencode backend. The test mounts a
* self-contained HTML stage via `page.setContent` and screenshots a locator.
*/
import { icons } from "@opencode-ai/ui/icon"
import { test } from "../fixtures"
import { composeGrid, snapOutputPath, type Shot } from "./_compose"

// Pre-fix transform — captured here for the historical comparison; do NOT
// mirror future icon.tsx changes here. This is the "before" state that the
// snap exists to prevent regressing back to.
const READ_FILE_BEFORE_TRANSFORM = "translate(-5.7316 36.8284) scale(0.011675 -0.011675)"

function withTransform(glyph: string, transform: string): string {
return glyph.replace(/^<g[^>]*>/, `<g transform="${transform}">`)
}

test.use({ viewport: { width: 920, height: 380 }, deviceScaleFactor: 2 })

test("snap: icon viewBox fit (read-file before/after, skill/thinking reference)", async ({ page }) => {
const readFileNow = icons["read-file"]
const readFileBefore = withTransform(readFileNow, READ_FILE_BEFORE_TRANSFORM)
const skill = icons["skill"]
const thinking = icons["thinking"]

const cell = (label: string, inner: string, badge?: string) => `
<div class="cell">
<div class="canvas">
<svg viewBox="0 0 20 20" width="220" height="220" aria-hidden="true">${inner}</svg>
<div class="edge" aria-hidden="true"></div>
${badge ? `<div class="badge">${badge}</div>` : ""}
</div>
<div class="lbl">${label}</div>
</div>`

const html = `<!doctype html><html><head><style>
html, body { margin: 0; background: #f8f8f8; }
body {
font: 13px -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", sans-serif;
color: #333;
padding: 28px;
}
[data-snap-stage] {
display: flex;
flex-direction: column;
gap: 18px;
align-items: flex-start;
}
.row { display: flex; gap: 24px; }
.cell { display: flex; flex-direction: column; align-items: center; gap: 8px; }
.canvas { position: relative; width: 220px; height: 220px; background: #fff; }
.canvas svg { display: block; color: #2f2f2f; }
.edge {
position: absolute; inset: 0;
border: 1px dashed #d33;
pointer-events: none;
}
.badge {
position: absolute; top: 6px; right: 6px;
padding: 2px 6px;
background: #d33; color: #fff;
font-size: 11px; font-weight: 600;
border-radius: 3px;
}
.lbl { font-size: 12px; color: #555; letter-spacing: 0.2px; }
.heading { font-size: 12px; color: #888; letter-spacing: 0.4px; text-transform: uppercase; margin-bottom: -4px; }
</style></head><body>
<div data-snap-stage>
<div class="heading">read-file · before vs after</div>
<div class="row">
${cell("before (overshoots bottom)", readFileBefore, "clipped")}
${cell("after (centered, 1u margin)", readFileNow)}
</div>
<div class="heading">batchmates for reference</div>
<div class="row">
${cell("skill", skill)}
${cell("thinking", thinking)}
</div>
</div>
</body></html>`

await page.setContent(html)
const stage = page.locator("[data-snap-stage]")
await stage.waitFor({ state: "visible", timeout: 10_000 })
// Give the browser one frame to layout the four svg canvases.
await page.waitForFunction(() => document.querySelectorAll("svg").length === 4)
const buf = await stage.screenshot()

const shots: Shot[] = [{ name: "icon-viewbox", buf }]
await composeGrid(shots, snapOutputPath("icon-viewbox"), { cols: 1 })
})
Loading