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
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ jobs:
- target: x86_64-pc-windows-msvc
os: windows-latest
- target: aarch64-pc-windows-msvc
os: windows-latest
os: windows-11-arm
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v6
Expand Down
124 changes: 124 additions & 0 deletions crates/plumb-mcp/rule-docs/a11y-touch-target.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# a11y/touch-target

**Status:** active

**Default severity:** `warning`

## What it checks

For every interactive node in the snapshot, the rule reads the
rendered bounding rect (`Rect`) and compares it to the configured
minimum target size:

- `width ≥ a11y.touch_target.min_width_px`
- `height ≥ a11y.touch_target.min_height_px`

A node fires a violation when *either* axis is below its threshold.
Defaults are 24×24 CSS pixels — the minimum required by
[WCAG 2.5.8 *Target Size (Minimum)*](https://www.w3.org/WAI/WCAG22/Understanding/target-size-minimum.html).

A node is treated as interactive when:

- `tag` is `button`, `select`, or `textarea`; or
- `tag` is `a` and the node has an `href` attribute (per the HTML
spec, a bare `<a>` with no `href` is non-interactive); or
- `tag` is `input` with a button-shaped `type` (`button`, `submit`,
`reset`, `image`, `checkbox`, `radio`); or
- the node carries `role="button"`.

The rule MUST skip a node when:

- it is not interactive by the rules above;
- its `Rect` is `None` (off-screen, hidden, or not yet laid out);
- both `min_width_px` and `min_height_px` are `0` (the rule is a
no-op in that case).

At most one violation is emitted per offending node per viewport.
The violation's `metadata` records the rendered and minimum sizes for
formatter use.

## Why it matters

Tiny tap targets are unreachable for users with motor impairments and
miserable on touchscreens. WCAG 2.5.8 sets 24×24 CSS pixels as the
floor. Plumb checks rendered geometry — the visible hit area — rather
than the CSS the author wrote, because a `padding: 12px` button can
end up smaller than expected once flex squeeze or text-shrink kicks
in.

The fix is emitted at `confidence: low` — Plumb can't know whether to
adjust `min-width`, `padding`, or the surrounding layout. The
description names the target dimensions; a human picks the change.

## Example violation

```json
{
"rule_id": "a11y/touch-target",
"severity": "warning",
"message": "`html > body > button:nth-child(2)` is 16×16px; WCAG 2.5.8 wants at least 24×24px for interactive targets.",
"selector": "html > body > button:nth-child(2)",
"viewport": "desktop",
"rect": { "x": 0, "y": 40, "width": 16, "height": 16 },
"dom_order": 3,
"fix": {
"kind": {
"kind": "description",
"text": "Enlarge the hit area to at least 24×24px (CSS pixels). Padding or `min-width` / `min-height` typically does the trick without changing the visual size."
},
"description": "Bring `html > body > button:nth-child(2)` up to the minimum touch-target size (24×24px).",
"confidence": "low"
},
"doc_url": "https://plumb.aramhammoudeh.com/rules/a11y-touch-target",
"metadata": {
"rendered_width_px": 16,
"rendered_height_px": 16,
"min_width_px": 24,
"min_height_px": 24
}
}
```

## Configuration

`a11y.touch_target` carries the two thresholds. Both default to 24.

```toml
[a11y.touch_target]
min_width_px = 24
min_height_px = 24
```

Bump the thresholds for an iOS-aligned 44×44 target:

```toml
[a11y.touch_target]
min_width_px = 44
min_height_px = 44
```

Either knob set to `0` disables that axis. Setting both to `0`
disables the rule.

## Suppression

Disable the rule for an entire run:

```toml
[rules."a11y/touch-target"]
enabled = false
```

Bump or lower the severity:

```toml
[rules."a11y/touch-target"]
severity = "error"
```

Per-element suppression follows the standard `RuleOverride` model.

## See also

- [WCAG 2.5.8 *Target Size (Minimum)*](https://www.w3.org/WAI/WCAG22/Understanding/target-size-minimum.html).
- PRD §11.7 — accessibility rules.
95 changes: 95 additions & 0 deletions crates/plumb-mcp/rule-docs/baseline-rhythm.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# baseline/rhythm

**Status:** active

**Default severity:** `warning`

## What it checks

For every text-bearing element (`p`, `span`, `h1`--`h6`, `a`, `li`,
`label`, `button`, `input`, `textarea`, `select`, and others) with a
`font-size` computed style and a bounding rect, the rule approximates
the element's typographic baseline position:

1. Parses `font-size` from computed styles.
2. Computes cap-height: uses `rhythm.cap_height_fallback_px` when set,
otherwise `font_size * 0.7` (typical Latin cap-height ratio).
3. Parses `line-height` (falls back to `font_size * 1.2` when missing
or `normal`).
4. Calculates `baseline_y = rect.y + half_leading + cap_height`, where
`half_leading = (line_height - font_size) / 2`.
5. Checks distance from `baseline_y` to the nearest multiple of
`rhythm.base_line_px`. If distance exceeds `rhythm.tolerance_px`,
a violation is emitted.

The rule is a no-op when `rhythm.base_line_px` is `0`.

## Why it matters

Vertical rhythm aligns text baselines across a page to a shared grid.
When baselines drift off-grid, adjacent columns of text sit at
different vertical offsets and the layout looks uneven. Catching these
misalignments at lint time is faster than manual visual QA.

## Example violation

```json
{
"rule_id": "baseline/rhythm",
"severity": "warning",
"message": "`html > body > p:nth-child(2)` baseline at 20.2px is 3.8px off the 24px rhythm grid.",
"selector": "html > body > p:nth-child(2)",
"viewport": "desktop",
"dom_order": 3,
"fix": {
"kind": {
"kind": "description",
"text": "Adjust line-height or margin-top so the baseline aligns to the nearest 24px grid line (24px)."
},
"description": "Shift baseline from 20.2px to 24px to restore vertical rhythm.",
"confidence": "low"
},
"doc_url": "https://plumb.aramhammoudeh.com/rules/baseline-rhythm",
"metadata": {
"baseline_y": 20.2,
"nearest_grid_y": 24.0,
"distance_px": 3.8
}
}
```

## Configuration

Three knobs under `[rhythm]` in `plumb.toml`:

```toml
[rhythm]
base_line_px = 24 # grid interval; 0 disables the rule
tolerance_px = 2 # how far off-grid before firing
cap_height_fallback_px = 0 # explicit cap-height; 0 = estimate from font-size
```

Setting `base_line_px = 0` disables the rule entirely (no violations
emitted regardless of element positions).

## Suppression

Disable the rule for an entire run:

```toml
[rules."baseline/rhythm"]
enabled = false
```

Bump or lower the severity:

```toml
[rules."baseline/rhythm"]
severity = "error"
```

## See also

- [`spacing/grid-conformance`](./spacing-grid-conformance.md) — the
horizontal spacing-grid sibling.
- PRD §11.3 — vertical rhythm and baseline alignment.
131 changes: 131 additions & 0 deletions crates/plumb-mcp/rule-docs/color-contrast-aa.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
# color/contrast-aa

**Status:** active

**Default severity:** `warning`

## What it checks

For every node in the snapshot, the rule reads these computed styles:

- `color`
- `font-size`
- `font-weight` (optional; only needed for the bold large-text cutoff)
- `background-color` on the node and its ancestors

The rule parses the foreground color, resolves the node's effective
background by compositing `background-color` layers up the DOM ancestor
chain, then computes the WCAG contrast ratio from relative luminance.
If the foreground itself has alpha, it is composited over the resolved
background before the ratio is measured.

WCAG 2.1 AA uses two floors:

- normal text: `4.5:1`
- large text: `3.0:1`

Plumb classifies a node as large text when its computed `font-size` is
at least `24px`, or at least `18.667px` with computed `font-weight >= 700`.
That matches WCAG's `18pt` regular / `14pt` bold thresholds in CSS pixels.

The rule MUST skip a node when:

- `color` is missing, transparent, or not parseable as `rgb(...)`,
`rgba(...)`, `#rgb`, `#rrggbb`, `#rgba`, or `#rrggbbaa`;
- `font-size` is missing, not parseable as a pixel value, or not
strictly positive;
- the background chain contains unsupported color syntax only, in which
case the rule falls back to `#ffffff`, the User Agent default.

At most one violation is emitted per node per viewport.

## Why it matters

Contrast failures are hard to spot in a token audit because the problem
is relational: a text color can be valid on one surface and unreadable
on another. WCAG AA is the baseline accessibility contract for body copy
and large headings, and the large-text carveout matters because a ratio
that fails 16px body text may still be readable at 24px.

Using the nearest composited background keeps the rule grounded in what
the user actually sees. A muted foreground over a white card is a
different accessibility outcome from the same foreground over a dark
panel.

## Example violation

```json
{
"rule_id": "color/contrast-aa",
"severity": "warning",
"message": "`html > body > div:nth-child(2)` has contrast ratio 4.478:1; WCAG 2.1 AA requires at least 4.500:1 for normal text.",
"selector": "html > body > div:nth-child(2)",
"viewport": "desktop",
"dom_order": 3,
"fix": {
"kind": {
"kind": "description",
"text": "Increase the foreground/background contrast to at least 4.500:1 for this normal text."
},
"description": "Raise `html > body > div:nth-child(2)` to the WCAG 2.1 AA contrast floor.",
"confidence": "low"
},
"doc_url": "https://plumb.aramhammoudeh.com/rules/color-contrast-aa",
"metadata": {
"contrast_ratio": 4.478,
"required_ratio": 4.5,
"font_size_px": 16.0,
"large_text": false,
"foreground_color": "rgb(119, 119, 119)"
}
}
```

The `metadata` block carries the measured ratio, the active floor, and
the size-class inputs so downstream tools can explain why the node was
treated as normal or large text.

## Configuration

The rule has no required config. Its default behavior is fixed WCAG 2.1
AA: `4.5:1` for normal text and `3.0:1` for large text.

`a11y.min_contrast_ratio`, when set, acts as a stricter global floor.
It can raise the threshold above the AA defaults; it does not lower them.

```toml
[a11y]
min_contrast_ratio = 7.0
```

That example raises both normal and large text to `7.0:1`.

## Suppression

Disable the rule for an entire run:

```toml
[rules."color/contrast-aa"]
enabled = false
```

Bump or lower the severity:

```toml
[rules."color/contrast-aa"]
severity = "error"
```

`RuleOverride` accepts both `enabled` (default `true`) and an optional
`severity` of `info`, `warning`, or `error`. Severity remapping is
applied at the formatter layer.

## See also

- [`color/palette-conformance`](./color-palette-conformance.md) — checks
whether the colors themselves are on the configured palette.
- [`type/scale-conformance`](./type-scale-conformance.md) — keeps text
size on the system scale.
- [`a11y/touch-target`](./a11y-touch-target.md) — the other shipped
accessibility rule.
- PRD §11.2 — built-in rule catalog.
Loading
Loading