Agent Skillsnubjs/nub › visual-review

visual-review

GitHub

通过浏览器脚本检测UI遮挡、裁剪及对齐问题,结合视觉审查确保布局正确。

.claude/skills/visual-review/SKILL.md nubjs/nub

Trigger Scenarios

验证UI/布局/样式变更 检查z-index或遮挡bug

Install

npx skills add nubjs/nub --skill visual-review -g -y
More Options

Non-standard path

npx skills add https://github.com/nubjs/nub/tree/main/.claude/skills/visual-review -g -y

Use without installing

npx skills use nubjs/nub@visual-review

指定 Agent (Claude Code)

npx skills add nubjs/nub --skill visual-review -a claude-code -g -y

安装 repo 全部 skill

npx skills add nubjs/nub --all -g -y

预览 repo 内 skill

npx skills add nubjs/nub --list

SKILL.md

Frontmatter
{
    "name": "visual-review",
    "metadata": {
        "internal": true
    },
    "description": "Verify UI\/layout\/styling changes are correct by computing occlusion, clipping, and alignment from the browser's resolved paint order via the chrome-devtools MCP `evaluate_script` tool — instead of eyeballing a flat screenshot. Invoke BEFORE declaring any UI, site, or styling\/layout change correct. Screenshots have no depth buffer, so z-index\/occlusion\/clip bugs are exactly where \"just look at it\" fails; the `evaluate_script` routines below turn those fuzzy visual judgments into deterministic measurements — including optical center-of-mass, which measures the glyph ink's true visual center so differently-sized labels can be aligned by more than eye."
}

Visual review — compute occlusion, don't perceive it

A flat PNG has no depth buffer and no stacking-context model, so layering and clipping bugs (z-index, overflow: hidden, fixed/sticky overlays) are exactly where eyeballing fails. The browser already resolved the paint order; query it with evaluate_script (chrome-devtools MCP).

Run both passes. Geometry catches occlusion the eye misses; the eye catches font-metric and color issues geometry misses.

Optical ≠ mathematical

A layout can be geometrically consistent and still look wrong. Never declare a spacing/alignment/centering change correct from measurements alone — change it, screenshot it, look, and adjust by eye. Expect to nudge against the math when:

  • Rounded caps (pills, rounded-full) eat edge space — content at px-3.5 from a rounded end looks tighter than against a square edge, so a pill with symmetric padding and a leading icon looks lopsided; the text-adjacent cap needs more padding (keep symmetric px on the pill, add pr-*/pl-* per button on its text side).
  • Icon ink ≠ icon box — a w-4 icon whose glyph is 14px and visually light leaves dead space, inflating the perceived gap beyond the measured flex gap.
  • Optical ≠ geometric centering — a glyph can be box-centered and ride visually high/low because the font's ink sits asymmetrically in the em.

Geometry decides occlusion/clipping; the eye decides balance/scale/centering.


The evaluate_script routines

Replace 'SELECTOR' with a real CSS selector before running.

1 — Occlusion (the non-negotiable check)

Reports what fraction of the element is actually visible, and names anything covering it.

(selector => {
  const el = document.querySelector(selector);
  if (!el) return { error: 'not found' };
  const r = el.getBoundingClientRect();
  if (r.width === 0 || r.height === 0) return { error: 'zero-size box' };
  const N = 5;                       // 5×5 = 25 sample points across the box
  let visible = 0; const coveredBy = new Set();
  for (let i = 0; i < N; i++) for (let j = 0; j < N; j++) {
    const x = r.left + (i + 0.5) / N * r.width;
    const y = r.top  + (j + 0.5) / N * r.height;
    const top = document.elementFromPoint(x, y);   // topmost painted element here
    if (top === el || el.contains(top)) visible++;
    else if (top) coveredBy.add(top.tagName.toLowerCase() +
                                (top.id ? '#' + top.id : '') +
                                (top.className ? '.' + String(top.className).split(' ')[0] : ''));
  }
  return { coverage: visible / (N * N), coveredBy: [...coveredBy] };
})('SELECTOR')

coverage === 1 → no occlusion. coverage < 1 with a coveredBy entry that is not an ancestor/descendant → occlusion bug; the array names the covering element. No z-index reasoning required — elementFromPoint returns the browser's resolved paint order.

2 — Ancestor overflow / clip

(selector => {
  const el = document.querySelector(selector);
  const r = el.getBoundingClientRect();
  for (let p = el.parentElement; p; p = p.parentElement) {
    const o = getComputedStyle(p).overflow;
    if (o === 'visible') continue;
    const pr = p.getBoundingClientRect();
    if (r.left < pr.left || r.top < pr.top || r.right > pr.right || r.bottom > pr.bottom)
      return { clippedBy: p.tagName + (p.id ? '#' + p.id : ''),
               overflow: o, target: r, clip: pr };
  }
  return { clipped: false };
})('SELECTOR')

clipped: false is clean. Anything else → the element is cropped by that ancestor.

3 — Alignment and spacing

([a, b] => {
  const A = document.querySelector(a).getBoundingClientRect();
  const B = document.querySelector(b).getBoundingClientRect();
  return {
    leftAligned: Math.abs(A.left - B.left),       // px delta; ~0 = aligned
    centerXdelta: Math.abs((A.left+A.right)/2 - (B.left+B.right)/2),
    gap: B.top - A.bottom,                          // vertical spacing between them
  };
})(['SEL_A', 'SEL_B'])

State verdicts in px: "left-edge delta 2px (clean)", "gap 28px vs expected 24px."

getBoundingClientRect centers the line box, not the visible ink. Fine at the same font-size; for elements at different font-sizes that must look centered together, use §5.

4 — Viewport and off-screen

(selector => {
  const r = document.querySelector(selector).getBoundingClientRect();
  return {
    inViewport: r.top >= 0 && r.left >= 0 && r.bottom <= innerHeight && r.right <= innerWidth,
    rect: r,
    viewport: { w: innerWidth, h: innerHeight },
  };
})('SELECTOR')

5 — Optical center of mass (different font-sizes)

Measures the alpha-weighted centroid of the actual glyph ink by rasterizing each label's computed font to a canvas. Implementation lives beside this skill in optical-center.js; inline it into one evaluate_script call.

// after inlining optical-center.js in the same evaluate_script:
opticalCenter(['.wordmark', 'a[href="/docs"]', 'a[href="/blog"]'])
//  → results:[{selector, comY, deltaFromAnchor}],  cssHint:[{selector, nudge}]
//    deltaFromAnchor ~0 = optically aligned; cssHint gives the ready-to-paste translate.
  • One call does measure + fix + verify. { apply: true } nudges each non-anchor toward the anchor, re-measures the post-nudge DOM, and iterates until converged, returning { before, after, appliedTranslateY }. Don't hand-derive nudges — transcribe appliedTranslateY to CSS.
  • { overlay: true } paints the analysis onto the page (anchor solid-green, others dashed-red, px deltas labelled) so the next screenshot self-documents.
  • Anchor choice matters — a filled pill/badge is optically centered by its BOX, not its caps ink; anchor to a bare-glyph sibling.
  • Auto-handled: the baseline probe uses vertical-align:baseline, which flex/grid ignore, so the tool descends from a flex <a> to the inline element hosting the text.
  • Scope: exact for a single line of plain text. Letter-spacing, text-shadow, -webkit-text-stroke, gradient text, and raster content aren't in the font render — screenshot the element's clip box and centroid the real pixels instead, or trust the eye.

Keep it to ONE tool call. To avoid re-inlining ~6KB, define it once via navigate_page's initScript so window.opticalCenter exists in every later evaluate_script.

Portability

optical-center.js is dependency-free, JSON-in/JSON-out, no closure over outer scope, so it rides on any evaluate primitive. Pattern is always: inject once (defines window.opticalCenter) → call it.

// chrome-devtools MCP — inline in one evaluate_script, or persist via initScript:
navigate_page({ url, initScript: <contents of optical-center.js> })
evaluate_script(`() => window.opticalCenter(['.wm','a[href="/docs"]'], { overlay:true })`)

// Playwright (Node):
await page.addInitScript({ path: 'optical-center.js' });     // window.opticalCenter on every doc
const r = await page.evaluate(([t,o]) => window.opticalCenter(t,o),
                              [['.wm','a[href="/docs"]'], { apply:true }]);

// Puppeteer:
await page.evaluateOnNewDocument(fs.readFileSync('optical-center.js','utf8'));
const r = await page.evaluate((t,o) => window.opticalCenter(t,o),
                              ['.wm','a[href="/docs"]'], { overlay:true });

// Selenium / WebDriver (any language):
driver.execute_script(open('optical-center.js').read())       // define it once
r = driver.execute_script("return window.opticalCenter(arguments[0], arguments[1])",
                          ['.wm', 'a[href="/docs"]'], { 'apply': True })

// DevTools console / bookmarklet: paste the file, then call opticalCenter([...]).

Don't break these invariants when editing the file: no import/export/require in the injected source, args stay plain JSON, the return stays JSON-serializable (never a DOM node), and it keeps defining a single global.


Icon-beside-text alignment — measure each glyph's INK

The most-repeated visual defect: an icon, glyph, emoji, badge, or counter beside text, riding high or low. items-center centers the glyph's BOX on the flex line; the eye aligns INK, and those never coincide — a digit has no descender so its ink rides high, an SVG's ink sits wherever its path falls in its viewBox, and an emoji is a bitmap whose metrics ignore your font size.

One shared nudge cannot fix a cluster. Measure each glyph, correct each glyph. Illustrative divergence for a real count-badge cluster (11.5px text, 12px icons):

glyph ink height offset from the digit's ink center correction
filled octicon 10.8px 1.16px low translateY(-0.1em)
lucide stroke glyph 9.0px 1.29px low translateY(-0.112em)
emoji 16.0px 0.26px — sub-pixel none; a nudge only blurs the bitmap
  • Express the correction in em, never px, then prove it by doubling the cluster's font size and re-measuring; the residual must stay near zero.
  • Leave sub-pixel offsets alone. Below ~0.3px you are under the device grid.
  • Re-measure after correcting. The residual should read ~0.01px.
// Baseline: an empty zero-size inline-block's bottom margin edge IS the baseline of the INLINE context
// it sits in. THE TRAP: a badge holder is usually inline-FLEX, so a probe appended there becomes a FLEX
// ITEM and reports the flex line's center — inflating a real 1.2px error into a plausible 3.5px one.
// Wrap the text node in its OWN inline span, probe INSIDE.
const baselineOfTextNode = (node) => {
  const span = document.createElement("span")
  node.parentNode.insertBefore(span, node); span.appendChild(node)
  const probe = document.createElement("span")
  probe.style.cssText = "display:inline-block;width:0;height:0;padding:0;margin:0;border:0"
  span.appendChild(probe)
  const baseline = probe.getBoundingClientRect().bottom
  const cs = getComputedStyle(span)
  const font = `${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize} / ${cs.lineHeight} ${cs.fontFamily}`
  probe.remove(); span.parentNode.insertBefore(node, span); span.remove()
  return { baseline, font }
}
const inkOfText = (text, font, baseline) => {
  const c = document.createElement("canvas").getContext("2d"); c.font = font
  const m = c.measureText(text)
  return { top: baseline - m.actualBoundingBoxAscent, bottom: baseline + m.actualBoundingBoxDescent }
}
// An SVG geometry element's getBoundingClientRect IS its ink box (stroke included). Union EVERY child —
// a multi-path icon's ink is all of it, not the first path.
const inkOfSvg = (svg) => {
  const rects = [...svg.querySelectorAll("path,rect,circle,ellipse,polyline,polygon,line")]
    .map((g) => g.getBoundingClientRect())
  return { top: Math.min(...rects.map((r) => r.top)), bottom: Math.max(...rects.map((r) => r.bottom)) }
}
// → returns textInkCenter - glyphInkCenter. NEGATIVE = glyph sits BELOW the text and must be LIFTED.

Distrust a suspiciously large reading. A 3.5px error on an 11.5px font is ~30% — a broken instrument, not a misalignment. If the number claims a gross error and the picture shows a subtle one, fix the instrument.

Mirror the real product before inventing a treatment. If the thing exists in a product the user knows (GitHub, Linear, an existing component), drive the real one headless and read its computed values rather than designing from taste. Corollary: color belongs to an item's own state, not to its links.


7-step checklist

Run for any change to site/ or other rendered UI. Steps 3–4 are what a screenshot review cannot do.

  1. You are the first reviewer of your own screenshot. Capturing evidence is not reviewing it. Read the shot back and hunt for what is WRONG — a glyph riding high, one mark heavier than its neighbours, a collision at a narrow width. Capture at a scale where the detail is judgeable. Never hand the user a screenshot you have not personally critiqued.
  2. Screenshottake_screenshot, full page + tight crop around the changed element.
  3. Consolelist_console_messages. A 200 alongside a thrown error is still a broken page.
  4. Occlusion pass — §1 on the changed element AND neighbors near fixed/sticky/absolute/overlay elements. coverage < 1 with a non-ancestor cover → flag it.
  5. Clip pass — §2 on the changed element.
  6. Alignment/spacing pass — §3 for anything that should align or sit at a fixed gap. For labels at different font-sizes, §5. For any icon/glyph/emoji beside text, the ink measurement above, per glyph, then re-measure the residual.
  7. Viewport pass — §4.
  8. State the verdict in measurements. "coverage 1.0, clip: false, left-edge delta 0px" — never a bare "looks great."

If chrome-devtools MCP is unavailable

Say so explicitly. Reason about stacking from the CSS (position, z-index, overflow, paint order), but label it inference, not measurement. Do not silently claim visual verification you couldn't do.

Version History

  • 46280a5 Current 2026-08-03 01:43
  • f966b97 2026-07-31 00:06

    新增按字形墨量测量例程,用于优化图标旁文本的对齐;明确em非px规则及flex基线探测bug;强调截图捕获不等于审查。

  • 4fd083f 2026-07-19 12:03
  • 44d0dd0 2026-07-05 11:03

Same Skill Collection

.agents/skills/linux-vm-test/SKILL.md
.agents/skills/windows-vm-test/SKILL.md
.claude/skills/audit-thread/SKILL.md
.claude/skills/download-stats/SKILL.md
.claude/skills/git-archaeology/SKILL.md
.claude/skills/md-toc/SKILL.md
.claude/skills/plan-thread/SKILL.md
.claude/skills/pm-perf-tracing/SKILL.md
.claude/skills/soak/SKILL.md
.claude/skills/todo/SKILL.md
skills/nub/SKILL.md
.agents/skills/agent-browser/SKILL.md
.claude/skills/ad-hoc-test/SKILL.md
.claude/skills/address-issue/SKILL.md
.claude/skills/aube-bump/SKILL.md
.claude/skills/aube-sync/SKILL.md
.claude/skills/ci-adhoc-test/SKILL.md
.claude/skills/ci-watch/SKILL.md
.claude/skills/cpu-reduction/SKILL.md
.claude/skills/dev-loop/SKILL.md
.claude/skills/impact-analysis/SKILL.md
.claude/skills/implementation-thread/SKILL.md
.claude/skills/prose-writing/SKILL.md
.claude/skills/release/SKILL.md
.claude/skills/remote-build/SKILL.md
.claude/skills/rust-build-hygiene/SKILL.md
.claude/skills/rust-build/SKILL.md
.claude/skills/worktree/SKILL.md

Metadata

Files
0
Version
46280a5
Hash
df63bd80
Indexed
2026-07-05 11:03

Home - Wiki
Copyright © 2011-2026 iteam. Current version is 2.155.2. UTC+08:00, 2026-08-03 05:00
浙ICP备14020137号-1 $Map of visitor$