Skip to content

Repository files navigation

mqgap

Find viewport widths your CSS breakpoints miss or double-cover, fractional pixels included.

The problem

Breakpoints are written by hand as pairs like max-width: 767px and min-width: 768px. At 125% or 150% browser zoom the layout viewport is measured in fractions of a CSS pixel, so it can be 767.2px wide, and neither rule matches: the page falls through to whatever the base styles say. Mixing em and px, legacy max-width with range syntax (width < 768px), and orientation or height queries makes these gaps harder to see by eye. CSS linters check that a query is well formed. None of them tell you which viewports fall through every rule in a breakpoint family, or which viewports get two rules that disagree.

How it works

mqgap is a small pipeline of exact computations. Nothing in it uses floating point.

1. Parse. Each @media prelude is parsed into an AST following the Media Queries Level 4 grammar: media types, only and not, and/or/not conditions (mixing and and or without parentheses is rejected, as the spec requires), comma lists, legacy min-/max- features, and range syntax including the chained form 400px <= width < 800px. The features modelled are width, height, aspect-ratio, orientation, resolution and -webkit-*-device-pixel-ratio. Error handling follows the spec. A query that breaks the grammar becomes not all without affecting its siblings in the list. An unknown feature or an invalid value, such as (min-width: 600), evaluates to unknown, and not unknown is still unknown, so it still does not match.

2. Normalise to boxes. A query becomes a union of boxes. A box is a product of four intervals over width × height × aspect-ratio × resolution. Every endpoint is an exact rational (a bigint numerator and denominator) that records whether it is open or closed, so max-width: 767px is (0, 767] and width < 768px is (0, 768). em and rem convert at a configurable base (16px by default). in, cm, mm, Q, pt and pc convert exactly, and so do dpi and dpcm. orientation: portrait is the half-plane height >= width, which becomes the interval aspect-ratio <= 1 on the third axis. The three-valued logic is carried as a pair of regions per condition: where it is true and where it is false. and, or and not then map to intersection, union and swapping the pair.

The interval arithmetic has intersection, complement, subtraction (a box minus a box splits into at most eight disjoint boxes, peeling one axis at a time), and a simplifier that merges boxes differing on one axis. The aspect-ratio axis is not independent of width and height, so emptiness is decided exactly by projection. The set of widths of a box is W ∩ H·R, and the product and quotient of positive intervals are intervals whose ends are attained exactly when both factors' ends are.

3. Families. A small zero-dependency tokenizer walks the stylesheet: comments, strings, nested @media (conditions intersect), @supports and @layer (kept as part of the family key), and skipped blocks such as @keyframes. Declarations under @media are grouped by (selector, property). A selector list .a, .b contributes to both families.

4. Holes. For a family with covered region C, a hole is a viewport outside C that has covered viewports on both sides along width, along height or along resolution. With max-width: 767px and min-width: 768px the sliver in between is a hole. With only min-width: 768px, the narrow viewports are a deliberate fallback to base styles and are not reported. "Has a covered point to its left" is itself a box: a box (W, H, R, S) has a point at the same height and resolution with smaller width exactly when w > inf W, w/h > inf R, h ∈ H ∩ W/R and s ∈ S. Holes are therefore complement(C) ∩ ((left(C) ∩ right(C)) ∪ (below(C) ∩ above(C)) ∪ (lowerRes(C) ∩ higherRes(C))), computed exactly with no sampling.

5. Conflicts. Two rules in a family whose regions overlap, that set different values (after whitespace normalisation) with the same !important-ness. They share a selector, so they share specificity, and source order silently decides.

6. Reachability. A browser at device pixel ratio d lays out a viewport that is a whole number of device pixels, so its CSS width and height are multiples of 1/d. For each --dpr value mqgap looks for such a lattice point inside the hole (and checks d against the resolution interval). When the aspect-ratio cut does not bind, the two axes are independent and this is two integer-in-interval checks. Otherwise it scans candidate heights outward from 900px.

Worked example: 767px / 768px

@media (max-width: 767px) { .nav { display: none; } }
@media (min-width: 768px) { .nav { display: flex; } }
  • Rule 1 is the box width ∈ (0, 767]. Rule 2 is width ∈ [768, ∞).
  • Covered: (0, 767] ∪ [768, ∞). Uncovered: (767, 768), open at both ends because both 767 and 768 are matched.
  • Left of rule 1 is width > 0 and right of rule 2 is width < ∞, so every uncovered point is bordered on both sides: the hole is 767px < width < 768px.
  • Witness: the midpoint, 767.5px wide, at a default height of 900px.
  • dpr 1: the multiples of 1 in (767, 768) are none, so not reachable. dpr 1.25: multiples of 0.8 include 767.2 (959 device pixels), so reachable. dpr 1.5: multiples of 2/3 include 2302/3 ≈ 767.333, so reachable. dpr 2: 767.5, so reachable.

At a 1.25 ratio (Windows display scaling at 125%, or 125% browser zoom on a 1x screen), a 959-device-pixel window gets neither rule.

Install and usage

Requires Node.js 20 or newer. No runtime dependencies.

From a clone of this repository:

npm install          # also compiles TypeScript to dist/
node dist/src/cli.js examples/zoom.css
npm test             # rebuilds, then runs the suite

npm link puts mqgap on your PATH if you want to run it by name.

mqgap <file.css>... [--dpr 1,1.25,1.5,2] [--em 16] [--media screen] [--json]

Real output for examples/zoom.css:

$ node dist/src/cli.js examples/zoom.css
examples/zoom.css: 5 families checked, 3 holes, 1 conflict

.nav { display }
  line 5: @media (max-width: 767px) -> none
  line 10: @media (min-width: 768px) -> flex
  hole: 767px < width < 768px
    witness 767.5x900
    reachable at dpr 1.25 (767.2x900), dpr 1.5 (2302/3 (~767.333)x900), dpr 2 (767.5x900)
    not reachable at dpr 1
    falls back to "flex" from line 2

.menu-button { display }
  line 6: @media (max-width: 767px) -> block
  line 11: @media (min-width: 768px) -> none
  hole: 767px < width < 768px
    witness 767.5x900
    reachable at dpr 1.25 (767.2x900), dpr 1.5 (2302/3 (~767.333)x900), dpr 2 (767.5x900)
    not reachable at dpr 1
    falls back to the property's inherited or initial value

.grid { grid-template-columns }
  line 16: @media screen and (max-width: 47.9375em) -> 1fr
  line 20: @media screen and (min-width: 48em) -> repeat(12, 1fr)
  hole: 767px < width < 768px
    witness 767.5x900
    reachable at dpr 1.25 (767.2x900), dpr 1.5 (2302/3 (~767.333)x900), dpr 2 (767.5x900)
    not reachable at dpr 1
    falls back to the property's inherited or initial value

.card { padding }
  line 34: @media (min-width: 600px) -> 16px
  line 38: @media (max-width: 900px) -> 8px
  conflict: 600px <= width <= 900px
    line 34 sets "16px", line 38 sets "8px"; line 38 wins, e.g. at 900x900

Reading it:

  • hole: is the exact region, in range syntax. < means the endpoint is excluded.
  • witness is one concrete viewport inside it (width x height in CSS px).
  • reachable at dpr … gives, for each device pixel ratio, a viewport on that ratio's pixel grid that hits the hole. not reachable means no such viewport exists; this is exact, not a sample.
  • falls back to shows the unconditional declaration that applies inside the hole, if any. For .nav the fallback happens to be harmless; for .menu-button it is not.
  • The .sidebar family (width < 1024px / width >= 1024px) is checked and not listed, because range syntax with a strict and a non-strict bound leaves no gap.
  • Media features outside the model (hover, prefers-color-scheme, …) are listed as note: lines, and those declarations are left out of their family.

Exit status is 1 when any hole is reachable at one of the --dpr values, 0 otherwise, and 2 for usage or input errors. Conflicts are reported but do not change the exit status. With --dpr 1 the same file exits 0: all three holes are sub-pixel.

--json emits the same report with exact endpoints as strings ("lo": "767", "loClosed": false) so a CI step can consume it.

Library

import { parseMediaQuery, toRegions, findHoles, findConflicts, formatBox, Rational } from 'mqgap';

const rules = ['(max-width: 767px)', '(min-width: 768px)'].map((q) => toRegions(parseMediaQuery(q)));
const [hole] = findHoles(rules, [Rational.parse('1.25')]);
formatBox(hole.box);                        // '767px < width < 768px'
hole.reachable[0].witness?.width.toString(); // '767.2'

toRegions(list, { emPx, mediaType }) returns an array of boxes. evaluate(list, viewport) is an independent direct evaluator. analyzeStylesheet(css, options) runs the whole pipeline.

Results

There is no benchmark; mqgap makes no performance claim. Correctness is what the test suite (npm test, 37 tests, under a second) checks:

  • Parser conformance: 60 queries covering range syntax, chained ranges, not precedence, only, units, orientation, resolution, unknown features and the MQ4 error-handling examples, each compared with its expected normalised boxes. Another 16 grammar-breaking queries must parse as invalid and match nothing.
  • Semantic property test: 500 randomly generated query lists (including invalid ones, unknown features and nested not), each checked at 40 viewports with fractional widths, exact width = height diagonals and exact aspect-ratio boundaries. A direct evaluator walks the AST using the spec's matching rules and shares no code with the region arithmetic. The two must agree at all 20,000 points.
  • Hole soundness: 150 random families. Every reported witness, and every per-dpr lattice witness, is re-checked with the direct evaluator and must match no rule. Lattice witnesses must be whole device pixels.
  • Hole completeness: 24 random breakpoint-style families, sampled on a 1/8px grid over 112 x 112 points. Every unmatched grid point with matched grid points on both sides of its row or column must lie in a reported hole, and no matched point may. Deleting the height-direction term from the hole finder makes this test fail with a counterexample.
  • Region algebra: union, intersection, difference and complement of random boxes (including aspect-ratio cuts) are checked pointwise on a half-pixel grid.
  • Named edge cases: 767/768 (reachable at 1.25, 1.5 and 2, not at 1); 47.9375em/48em at 16px and at 20px; width < 768px/width >= 768px (no hole); a 767.5/768 hole that is unreachable at 1, 1.25, 1.5 and 2 but reachable at 4; overlapping identical min-widths (no conflict); not screen and (min-width: 600px); portrait vs landscape tiling the plane, and portrait vs min-aspect-ratio: 1/1 conflicting exactly on the diagonal.

Design notes

The spec for this tool suggested cutting aspect-ratio and orientation half-planes into staircases of axis-aligned boxes over a bounded domain. I did not do that. A staircase is wrong near the diagonal, and near a boundary is exactly where this tool has to be right: a query like (orientation: portrait) against (min-aspect-ratio: 1/1) overlaps only on the line width = height, and no finite staircase represents a line. Instead each box carries a third interval on width/height. Intersection stays a per-axis operation, complement still splits into a bounded number of boxes, and the property test can demand exact agreement at points on the diagonal. The cost is that the axes are no longer independent. Emptiness, witnesses and "has a neighbour to the left" all need the projection formulas above, not a per-axis check, and lattice search with a binding aspect-ratio cut is a scan, not a formula.

The second decision was what counts as a hole. "Unmatched" alone is useless: every family built from min-width rules leaves small screens unmatched on purpose. "Unmatched and bounded" is not well defined once height and aspect ratio enter. "Unmatched, with matched viewports on both sides along some axis" is easy to explain, and it can be computed exactly as a region, not sampled. It also has a checkable grid analogue, which is what the completeness test uses. It will flag a deliberate 300px gap between max-width: 300px and min-width: 600px. I accept that, because the output shows the exact interval and the fallback value, and a reviewer can tell a 300px gap from a 1px one at a glance.

Limitations

  • Only width, height, aspect-ratio, orientation and resolution (plus -webkit-*-device-pixel-ratio) are modelled. Declarations whose queries use other features (hover, prefers-*, color, device-width, …) are skipped and listed, not analysed.
  • Families are keyed by exact selector text. .a .b and .a .b are merged, but .nav and nav.nav are not recognised as overlapping, and no cascade from other selectors is considered. Conflicts are reported between rules with the same selector only.
  • @container, @scope conditions, @supports conditions and CSS nesting are not evaluated. Nested style rules (&:hover { … }) are skipped. @supports and @layer only separate families.
  • Values are compared as whitespace-normalised text, so 0 and 0px count as different values and produce a conflict.
  • resolution: infinite and degenerate ratios such as 16/0 are treated as unknown.
  • The viewport domain is strictly positive, so boolean-context queries like (width) always match.
  • Reachability assumes both viewport dimensions are whole device pixels at the given ratio. When an aspect-ratio cut couples width and height, the lattice search examines at most 20,000 candidate heights. A hole that is a very thin wedge spanning more than that could be reported unreachable when a lattice point exists far from 900px.
  • Holes are reported as boxes after merging. A region that is not a single box, for example an L-shape, is reported as several holes.
  • No source maps, no @import following, and no SCSS/Less: run it on compiled CSS.

License

MIT. See LICENSE.

About

Find viewport widths your CSS breakpoints miss or double-cover, fractional pixels included

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages