From armor
Detects unnecessary layout shifts (CLS/jank) in web UIs by instrumenting Chrome with PerformanceObserver during real interactions. Use for UI QA on shifts, jumpiness, Core Web Vitals.
npx claudepluginhub markacianfrani/armor --plugin armorThis skill is limited to using the following tools:
Most layout-shift bugs are invisible to the eye in real-time but obvious in slow-motion video. The cheaper alternative is instrumentation: Chrome already emits a `layout-shift` performance entry every time something on the page moves, and the `chrome-devtools` MCP can drive the page while you read those entries.
Mandates invoking relevant skills via tools before any response in coding sessions. Covers access, priorities, and adaptations for Claude Code, Copilot CLI, Gemini CLI.
Share bugs, ideas, or general feedback.
Most layout-shift bugs are invisible to the eye in real-time but obvious in slow-motion video. The cheaper alternative is instrumentation: Chrome already emits a layout-shift performance entry every time something on the page moves, and the chrome-devtools MCP can drive the page while you read those entries.
This skill turns that into a repeatable QA loop:
PerformanceObserver({type: 'layout-shift'}) so every shift gets logged with its value, sources, and per-node bounding-rect deltas.count: 0 result on its own doesn't prove anything; it could mean nothing shifted or the observer wasn't watching. The A/B step is what makes the result trustworthy.Once you've run the recipe and have shifts to interpret, every shift you see falls into one of four causes. Use this taxonomy to map an observed shift to a fix.
1. State-driven content swap in document flow. Some piece of state — input value, toggle, fetch result, hover, focus — controls what content occupies a layout slot, and the content for state A has different intrinsic dimensions than the content for state B. When the state flips, neighbors reflow. Component shape doesn't matter (search results, accordion body, tab panel, validation message, typing indicator are all the same bug). The fix is to reserve space: pin the slot's dimensions, or render an equivalent-sized placeholder for the "absent" state.
2. Animation on a layout-affecting property. A CSS transition or keyframe whose target property is in the box model — width, height, padding, margin, top, left, inset, border-width, font-size — emits one shift entry per animation frame and reflows neighbors on each frame. The presence of multiple small shift entries clustered in a 100–300ms window is the signature. The fix is to animate transform or opacity instead, or remove the transition.
3. Async content arrival into an unsized slot. Anything that lands after first paint and pushes already-painted neighbors: images and videos without explicit width/height/aspect-ratio, web fonts swapping to different metrics, lazy-hydrated components, fetched data filling a slot whose loading state had different dimensions. The pattern is "the layout was decided when this was absent, and now it's present." The fix is to make the slot's dimensions independent of the content arriving — explicit media dimensions, font-display tuning, skeletons sized to match real data.
4. Viewport-dependent reflow. The scrolling root or the viewport changes. Most common: page grows tall enough to need a vertical scrollbar, the scrollbar appears, takes ~15px of width, every centered element yanks horizontally. Signature: shift entries with non-zero dx and zero dy. Fix: scrollbar-gutter: stable on the html element so the gutter is always reserved. Also in this category: window resize crossing a container-query breakpoint, orientation change, dynamic viewport units recalculating.
The unifying property across all four: a node that was already painted ends up occupying a different amount of layout space than it did a moment ago. If the change under review can do that, it can shift.
Read this end to end before starting; the steps depend on each other.
The chrome-devtools MCP needs the page to actually load. If the route is behind auth and you don't have a session, either log in once interactively, or temporarily relax the guard for the route under test (and restore it before commit). Either way: you can't QA a 302-to-login.
The MCP tools you'll use are deferred — load them via ToolSearch with this query:
select:mcp__chrome-devtools__list_pages,mcp__chrome-devtools__new_page,mcp__chrome-devtools__navigate_page,mcp__chrome-devtools__select_page,mcp__chrome-devtools__take_snapshot,mcp__chrome-devtools__fill,mcp__chrome-devtools__click,mcp__chrome-devtools__wait_for,mcp__chrome-devtools__evaluate_script,mcp__chrome-devtools__performance_start_trace,mcp__chrome-devtools__performance_stop_trace
navigate_page accepts an initScript that runs before any of the page's own scripts on the next navigation. That's the right place to attach the observer so you don't miss shifts that happen during initial render.
The observer should:
layout-shift entry into a global array (e.g. window.__shifts).value, hadRecentInput, startTime, and the sources array. For each source capture a node identifier (nodeName + className is enough) plus previousRect and currentRect (x, y, width, height).buffered: true on first attach so initial-render shifts aren't missed.window that returns { count, cumulativeValue, entries }.After the page loads, optionally read and reset the array to separate "initial-load" shifts from "interaction-driven" shifts.
Use the same flow a real user would — take_snapshot to find element uids, then fill, click, wait_for to make things happen. Keep the flow tight; you only need long enough for the suspect interaction to complete.
A few useful patterns:
fill the input, wait_for the result text, then read shifts.click the trigger, wait_for the revealed content, then read shifts.If you need to reset between scenarios, just clear window.__shifts = [] via evaluate_script.
Via evaluate_script, return a digest of the captured shifts. For each entry, compute dx and dy per source as currentRect - previousRect (rounded to integer pixels) and drop sources missing either rect.
The dx / dy per source are the ground truth — which node moved, in which direction, by how many pixels. The cumulative value scalar is for CLS scoring; the per-source deltas are for diagnosis.
This is the step most engineers skip and the reason results often aren't trustworthy.
A count: 0 reading proves nothing on its own. It could mean:
To rule out (2) and (3), do one round with the bug deliberately re-introduced:
count: 0.Now count: 0 is meaningful — you've shown the observer can see shifts in this DOM, on this flow, and your fix is what's making them stop.
If you relaxed an auth guard, dev-only stress-test affordance, etc., put it back. Lint + build before declaring done.
initScript only runs on the next navigation, not on reload. If you navigate_page with type: 'reload', the script attached to the previous load is gone and window.__shifts / window.__shiftSummary are undefined. Either re-navigate (type: 'url') with the initScript again, or re-inject the observer via evaluate_script after the reload settles. After re-injection, use buffered: false on the observer — buffered: true would surface buffered shifts from the previous observer, which you've already accounted for.
hadRecentInput: true does not mean "ignore". That flag exists so the official CLS metric excludes shifts the user "asked for" by interacting in the last 500ms. But content shifting because the user typed a key — when the keystroke didn't logically demand layout motion — is still jank. The flag is for scoring, not for human judgment. Read the deltas; trust your eyes.
Animations show up as many small entries, not one big one. A transition: padding 200ms ease-out will emit 5–10 separate layout-shift entries (one per animated frame), each with a small value (e.g. 0.0005). The cumulative might still be small, but count >= 5 with all sources pointing at the same node and consistent dy direction is a strong tell that something is animating its layout-affecting property. The fix is almost always "animate transform instead" or "snap to final state instantly".
Scrollbar reflows look like horizontal shifts. When a page that previously fit in the viewport grows tall enough to overflow, the vertical scrollbar appears and steals ~15px of width. Centered content gets yanked horizontally — so you'll see entries with dy: 0, dx: -7 (centered chrome shifting half the scrollbar width). The fix is scrollbar-gutter: stable on the html element so the gutter is always reserved.
You're observing a single document. Iframes, web components with shadow DOM, and cross-origin embeds need their own observers. If the suspect content is in an iframe, attach the observer inside that iframe.
When you tell the user what you found, lead with the deltas, not the scalar:
"Typing in the search box emitted 7 layout-shift entries (cumulative 0.0031). All sources point at
H1.home__title,P.home__subtitle,DIV.home__searchbar,SECTION.home__results— each shiftingdy: -3 to -4px in sync. Pattern matches an animatedpaddingtransition on the parent, frame by frame. Suggest switching totransformor removing the transition."
That's actionable. "CLS = 0.003" by itself isn't.