toolkit

Visual layout audit · Playbook

Layout thresholds — the numbers, and why they are those numbers

Every value here is defined once in THRESHOLDS at the top of scripts/decidueye.mjs and emitted verbatim in each run’s decidueye.json. This file is the reasoning; the code is the value. If the two disagree, the code is wrong, not this page — but they are meant to move together.

One bias runs through all of it: a false positive here is worse than a miss. A composition finding cannot be proved from a number the way an overflow can, so a reviewer decides whether it matters — and a check that fires on every centred site trains people to close the report. Every threshold is set where a human looking at the section would agree something is off, not where the measurement first becomes non-zero.


Where the checks run

Value Why
desktopMinWidth 1000px Blank space, heading wrap and alignment grade at desktop only. A four-line heading in a 390px column is the phone, not the page, and a stacked mobile layout has no columns to be unbalanced. Viewport-specific defects are conkeldurr’s job; these are not.
sectionMinHeight 200px Below this a section is a rule, a strip or a badge row. Its “blank fraction” is padding.
sectionMinArea 120,000px² The same guard on the other axis — a tall thin rail is not a composition.
inkRectCap 900 rects Above this a section is dense by definition. The blank check is skipped rather than run on a truncated measurement, so the cap can never manufacture emptiness.

Two categories are excluded outright:

  • Site chrome (header, footer, nav, and anything inside them). A masthead and a footer are sparse by design. Grading their emptiness reports every site on the internet.
  • A section that paints its own backdrop (background-image on the section). A full-bleed hero is not empty ground; it is the picture. Nothing under a backdrop is graded for blankness.

(a) Blank area

The single most important decision in this file: blank space is measured inside the page’s content column, never inside the full-bleed section box.

A section on a centred site is a 1920px box wrapping an 1184px measure: ~40% gutter, by design, on every section of every such page. Grading the box reports the whole page and means nothing. So the column is derived from the page itself — the median left and right ink edge across its graded sections. Median, not min or max, so one full-bleed band or one narrow rail cannot redefine the column for everything else. Under three usable sections there is no median worth having and these checks do not run at all.

Value Why
contentSpanMin 0.65 A section whose ink fills under 65% of the page’s own content column has an empty rail beside it for its full height — the “whole side column empty beside the numbered list” defect. Two-thirds is where emptiness stops reading as an indent and starts reading as a missing column. Reported instead of that section’s raw blank numbers: two findings for one hole is noise.
blankFractionMax 0.62 Share of a section’s content column carrying no ink at all. A generously spaced editorial section lands around 0.5; past 0.62 there is more nothing than something.
blankRectViewportPct 8% One unbroken empty rectangle, as a share of the viewport — roughly 500×330 on a 1920×1080 screen. Smaller than that and it reads as spacing.
blankRectSectionPct 0.18 And as a share of its own section. Both gates: viewport-relative alone reports the narrow gutter between two columns on any long section, section-relative alone reports a small section’s padding. Adding this second gate cut a clean reference page from seven blank findings to five without losing a real one.
blankGridCols 48 The rasterisation grid. Coarse enough that leading between lines is not a hole, fine enough that a 500px gap is. Rows are derived so cells stay square.

What counts as ink: line boxes of real text (via a Range, so glyph extent, not the element’s padding box), replaced media (img, svg, video, canvas, iframe, picture), form controls, any painted background-image, a background-color differing from the section’s own, and any real border. Each rectangle is first clipped to every hidden-overflow ancestor’s clip rect — a collapsed accordion’s box runs far past what is painted of it, and counting it fills a hole that is really there.


Generated content is ink. A ::before / ::after with text or a painted background counts — its box is estimated from the computed offsets and size when they are set, otherwise from the run of its text at its font size. A chapter numeral or a stage number drawn that way keeps its rail from reading as empty.

(b) Image fit

Value Why
aspectRatioTolerance 1.6× Rendered box aspect against the image’s own, either direction, for fits that crop (cover, fill, none). 1.6× discards roughly 38% of the picture — where a portrait in a wide slot stops being a crop and becomes a different photograph. The live case behind it: a 1584×672 landscape in a 560×700 box, 2.95× off, 66% cropped away, leaving hair and an ear.
thumbnailFloorPx 120px A content photograph rendered under this in both axes. FAIL, not REVIEW: nobody chooses to publish a photograph at postage-stamp size, so this is a stylesheet accident rather than a judgement call.
thumbnailNaturalMin 240px …and only when the source is at least this big in both axes, which is what separates a photograph from an icon. Combined with the exclusions below, this is the narrowest FAIL in the skill.
upscaleFactor 1.5× Rendered wider than the source by half again. Below that the softness is invisible on a normal display; above it, it is the thing people mean by “the image looks blurry”. SVGs are exempt.
emptyBoxPx 2px An <img> box at or under 2px in either axis, or a complete image with naturalWidth === 0. FAIL — an image element showing nothing.

Excluded from the thumbnail floor: anything with alt="" or role="presentation", anything whose class or filename says icon / logo / avatar / badge / sprite / arrow / chevron / mark / seal / emblem / crest / credential / accredit / award / partner, anything whose alt text reads as a logo or a credential (“logo”, “certified”, “accredited”, “member of”, “board-certified”, “fellow of”, “society”, “association”, “award”), any image in a strip — three or more images under one box all rendered under the floor, which is a trust-mark row or a press-logo row whatever the files are called — and anything inside a nav, header, footer or button. Those are all legitimately tiny.

CSS background images with background-size: cover are graded on the same aspect rule as <img>. The hero-crop defect lives on a background as often as on an element, and the source’s natural size only exists once the file is decoded — so the measurement decodes it, with a 4-second cap per image.


(c) Sibling consistency

Value Why
gridMinChildren 3 Two boxes side by side are a layout. Three or more of the same element are a set, and a set is what can be inconsistent.
siblingHeightRatio 1.25× Tallest over shortest in one visual row.
siblingHeightSpreadPx 24px And an absolute spread, so a 40px chip beside a 50px one is not a finding. Both always: a ratio alone flags trivia at small sizes, an absolute alone flags nothing on tall cards.
siblingWidthRatio 1.15× Tighter than heights: cards commonly grow with their content vertically, almost never horizontally.
siblingWidthSpreadPx 16px The same absolute companion.
gapSpreadPx 8px Largest minus smallest gap between children in one row. Below 8px it is sub-pixel rounding on a fractional track.

Width variance is graded only where equal columns were declared — a display: grid container. A flex row of chips, tags or footer links is meant to hug its own text, and grading that reports every tag cloud on the web. Height variance is graded in both, because a short card in a row of tall ones is wrong either way. A row also has to be a real repeated tile set: every child the same element type, each at least 60×24px.


(d) Wrap

Value Why
headingMaxLines 4 An h1/h2 at desktop. Three lines is a long headline; four is a headline in a column that is too narrow for it, and it is the point where a reader stops seeing one phrase. Counted from line boxes, so it is the real wrap, not a character estimate.
wrapImbalanceRatio One column’s text lines against the column beside it.
wrapImbalanceMinLines 6 …with an absolute floor, so 1 line against 3 is not a finding.
mediaColumnShare 0.25 A column this much covered by media is not “a column of text” and is excluded from the comparison. Text beside a map or a portrait is always longer in lines; reporting that is reporting the layout. Where the heights genuinely diverge, column-imbalance below is the check that fires.

(e) Column balance

Value Why
columnHeightRatio 0.4 Shorter column height over taller, in a two-column row. Under 40% the short column has visibly run out, and the ground beneath it is the defect people describe as “the plate only fills a third of the column”.
columnMinHeight 240px …and only when the taller column is at least this tall, so a 60px-vs-20px pair of labels is not a column imbalance.
emptyColumnBlankFraction 0.92 A column carrying essentially no ink at all, beside one that does. Distinct from the imbalance above: this is the aside that rendered nothing, not the aside that rendered something short.

A “two-column row” is a flex or grid container with exactly two visible children whose tops are within 40px of each other, each at least 100px wide, together spanning at least 70% of the container. Anything looser starts pairing a heading with the body under it.


(f) Overlap

Value Why
overlapMinArea 400px² Absolute floor — a 20×20 intersection is a rounding artefact.
overlapMinRatio 0.35 …and 35% of the smaller box, so a large block grazing a small one does not count. Both conditions.
stackedPairIoU 0.9 Two images hanging off one box (siblings, or each in its own pane under a shared parent) whose boxes coincide this closely are one picture shown through another — a before/after slider, a crossfade, a hover swap. Not reported at all.
groundCoverage 0.85 An image covering this much of its nearest positioned or clipping ancestor is that box’s ground. Text over a ground image is a full-bleed hero or a banner, so it is filed as Text over image (REVIEW — is there a scrim, is the copy legible?) rather than an overlap FAIL. Text over an image placed beside it is still the collision.

The candidate set is deliberately narrow, because this is the skill’s other FAIL:

  • Only text runs and images. Decorative absolutely-positioned ground under content is the normal case on every modern site; text on text, or text on a picture, is the defect.
  • One candidate per line box, not per element. An element’s bounding box is the union of its line fragments, so a wrapped inline spans the full measure and “contains” every sibling on its first line, and a block’s box carries its leading, so two consecutive paragraphs read as a 16px collision. Both were live false positives before line boxes replaced element boxes.
  • Nothing inside a fixed or sticky ancestor (a sticky header legitimately overlays content), nothing inside a hidden drawer, modal, lightbox, popup or off-canvas shell, and nothing clipped to nothing by an ancestor’s overflow.

(g) Alignment

Value Why
alignmentTolerancePx 4px Left edges within 4px are the same edge. Sub-pixel layout, borders and italic side bearings all move a measured left edge by a point or two.
alignmentMinHeadings 3 Fewer than three headings is not a pattern that can be inconsistent with itself.
alignmentMaxEdges 2 Distinct left edges allowed across a page’s h2s. Two is generous on purpose: a design that deliberately alternates a left rail and a right rail is legitimate and lands on exactly two. Three or more is drift.

Centred and right-aligned headings are excluded — their left edge varies with their own text length by definition — as are headings out of normal flow. The companion heading-offset check compares each heading against the first left-aligned paragraph or list item beneath it in its own section: the case where a heading and its own text column disagree, which no page-wide edge count can see.


The vision pass

--verify crops each measured section out of the desktop capture and puts one fixed rubric to a vision model: subject cropped, unintended empty space, misaligned, text/image mismatch, or ok. It has no threshold, because it is not a measurement.

It is advisory by construction: its answers are only ever REVIEW, never FAIL, and a run without GEMINI_API_KEY is SKIPPED for that step with the key named while every measured check runs unchanged. A model’s opinion about a picture is a second reader, not a gate. Capped at 14 crops per page and at twice the viewport height per crop.