Skip to content

Commit 6940d04

Browse files
ifahimrezaclaude
andauthored
feat: accessibility lint rules — text-contrast, missing-alt-text, heading-order (#29)
Closed-loop scope (#27): three a11y judgments in the free rule set, feeding lint-page today and verify-page next. All three feature-detect the companion Saddle_Lint_Style_Accessor and stay silent on accessors that haven't caught up (proven against a base-only legacy accessor in the tests). - text-contrast: generalizes button-contrast to all text. Effective background = the node's own, else the nearest painted ancestor (walks up like a browser paints). WCAG AA thresholds: 4.5:1 normal, 3:1 large (headings, or >=24px) - rendered weight is unknowable from the tree, so the rule under-flags rather than cries wolf. Buttons stay button-contrast's finding; unknown backgrounds are never guessed at. - missing-alt-text: content images with missing/empty alt; the accessor decides what counts as content (covers stay decorative -> never nagged). Warn, not error - the tree can't prove an image is meaningful. - heading-order: skipped levels on the way down (h2->h4) and duplicate h1s; upward moves are new sections and always fine; the first heading is never judged against an invisible predecessor. Every rule pinned both ways (fires on the bad fixture, silent on the clean one). Free suite 271 green; Pro suite 119 green against the un-upgraded Divi accessor (the skip path). phpcs clean. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent caf7efe commit 6940d04

6 files changed

Lines changed: 407 additions & 0 deletions

File tree

includes/lint/class-saddle-lint.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,11 @@ private static function default_rules() {
8282
new Saddle_Lint_Rule_Unaligned_Buttons(),
8383
new Saddle_Lint_Rule_Section_Padding(),
8484
new Saddle_Lint_Rule_Featured_Plan(),
85+
// Accessibility (closed-loop scope); these need the companion
86+
// style accessor and stay silent on accessors without it.
87+
new Saddle_Lint_Rule_Text_Contrast(),
88+
new Saddle_Lint_Rule_Missing_Alt(),
89+
new Saddle_Lint_Rule_Heading_Order(),
8590
);
8691
}
8792

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
<?php
2+
/**
3+
* Lint rule: broken heading hierarchy.
4+
*
5+
* @package Saddle
6+
*/
7+
8+
defined( 'ABSPATH' ) || exit;
9+
10+
/**
11+
* Two heading-structure breaks screen-reader users actually hit: a level
12+
* skipped on the way down (h2 → h4 orphans the missing h3 in the outline),
13+
* and more than one h1 (the page loses its single top landmark). Moving UP
14+
* levels (h4 → h2, a new section) is always fine. The first heading is never
15+
* judged against an invisible predecessor — the page title usually lives
16+
* outside the content tree.
17+
*/
18+
class Saddle_Lint_Rule_Heading_Order extends Saddle_Lint_Rule {
19+
20+
/**
21+
* Rule id.
22+
*
23+
* @return string
24+
*/
25+
public function id() {
26+
return 'heading-order';
27+
}
28+
29+
/**
30+
* Flag skipped heading levels and duplicate h1s, in document order.
31+
*
32+
* @param array[] $nodes Flat node list.
33+
* @param Saddle_Lint_Accessor $accessor Builder accessor.
34+
* @return array[]
35+
*/
36+
public function check( array $nodes, Saddle_Lint_Accessor $accessor ) {
37+
if ( ! $accessor instanceof Saddle_Lint_Style_Accessor ) {
38+
return array();
39+
}
40+
41+
$violations = array();
42+
$previous = null;
43+
$h1_seen = false;
44+
45+
foreach ( $nodes as $node ) {
46+
$level = $accessor->heading_level( $node['block'] );
47+
if ( null === $level ) {
48+
continue;
49+
}
50+
51+
if ( 1 === $level ) {
52+
if ( $h1_seen ) {
53+
$violations[] = $this->violation(
54+
$node['address'],
55+
self::SEVERITY_WARN,
56+
__( 'More than one h1 on the page — assistive tech expects a single top-level heading.', 'saddle' ),
57+
__( 'Keep one h1 and demote the others to h2.', 'saddle' )
58+
);
59+
}
60+
$h1_seen = true;
61+
}
62+
63+
if ( null !== $previous && $level > $previous + 1 ) {
64+
$violations[] = $this->violation(
65+
$node['address'],
66+
self::SEVERITY_WARN,
67+
sprintf(
68+
/* translators: 1: previous heading level, 2: this heading level. */
69+
__( 'Heading level jumps from h%1$d to h%2$d — the skipped level breaks the outline for screen readers.', 'saddle' ),
70+
$previous,
71+
$level
72+
),
73+
sprintf(
74+
/* translators: %d: expected next heading level. */
75+
__( 'Use h%d here, or restructure so no level is skipped.', 'saddle' ),
76+
$previous + 1
77+
)
78+
);
79+
}
80+
81+
$previous = $level;
82+
}
83+
return $violations;
84+
}
85+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<?php
2+
/**
3+
* Lint rule: content image without alt text.
4+
*
5+
* @package Saddle
6+
*/
7+
8+
defined( 'ABSPATH' ) || exit;
9+
10+
/**
11+
* A content image with missing or empty alt text. The accessor decides what
12+
* counts as a content image — decorative background media (covers, section
13+
* backgrounds) returns null and is never nagged about, and alt bound to
14+
* dynamic content counts as satisfied. Severity is warn, not error: the tree
15+
* can't prove the image is meaningful rather than decorative.
16+
*/
17+
class Saddle_Lint_Rule_Missing_Alt extends Saddle_Lint_Rule {
18+
19+
/**
20+
* Rule id.
21+
*
22+
* @return string
23+
*/
24+
public function id() {
25+
return 'missing-alt-text';
26+
}
27+
28+
/**
29+
* Flag content images whose alt is empty.
30+
*
31+
* @param array[] $nodes Flat node list.
32+
* @param Saddle_Lint_Accessor $accessor Builder accessor.
33+
* @return array[]
34+
*/
35+
public function check( array $nodes, Saddle_Lint_Accessor $accessor ) {
36+
if ( ! $accessor instanceof Saddle_Lint_Style_Accessor ) {
37+
return array();
38+
}
39+
40+
$violations = array();
41+
foreach ( $nodes as $node ) {
42+
if ( '' !== $accessor->image_alt( $node['block'] ) ) {
43+
continue;
44+
}
45+
$violations[] = $this->violation(
46+
$node['address'],
47+
self::SEVERITY_WARN,
48+
__( 'Image has no alt text — screen readers announce nothing useful for it.', 'saddle' ),
49+
__( 'Describe what the image shows in a short alt text. Leave alt empty only when the image is purely decorative.', 'saddle' )
50+
);
51+
}
52+
return $violations;
53+
}
54+
}
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
<?php
2+
/**
3+
* Lint rule: text contrast below WCAG AA.
4+
*
5+
* @package Saddle
6+
*/
7+
8+
defined( 'ABSPATH' ) || exit;
9+
10+
/**
11+
* Any text whose color fails WCAG AA against its effective background — the
12+
* node's own background, or the nearest ancestor's when the node paints none.
13+
* Generalizes button-contrast to all text; buttons stay that rule's job so a
14+
* bad button is flagged once, not twice.
15+
*
16+
* Thresholds follow WCAG AA: 4.5:1 for normal text, 3:1 for large text.
17+
* Headings and text at ≥24px count as large — rendered weight is unknowable
18+
* from the tree, so the rule under-flags rather than over-flags. Needs the
19+
* companion style accessor for the large-text call; without it the rule
20+
* stays silent (skip, never guess).
21+
*/
22+
class Saddle_Lint_Rule_Text_Contrast extends Saddle_Lint_Rule {
23+
24+
/**
25+
* WCAG AA minimum for normal text.
26+
*/
27+
const MINIMUM = 4.5;
28+
29+
/**
30+
* WCAG AA minimum for large text (≥24px, or any heading).
31+
*/
32+
const MINIMUM_LARGE = 3.0;
33+
34+
/**
35+
* Font size, in px, from which text counts as large.
36+
*/
37+
const LARGE_PX = 24;
38+
39+
/**
40+
* Rule id.
41+
*
42+
* @return string
43+
*/
44+
public function id() {
45+
return 'text-contrast';
46+
}
47+
48+
/**
49+
* Flag text whose contrast against its effective background is below AA.
50+
*
51+
* @param array[] $nodes Flat node list.
52+
* @param Saddle_Lint_Accessor $accessor Builder accessor.
53+
* @return array[]
54+
*/
55+
public function check( array $nodes, Saddle_Lint_Accessor $accessor ) {
56+
if ( ! $accessor instanceof Saddle_Lint_Style_Accessor ) {
57+
return array();
58+
}
59+
60+
$by_address = array();
61+
foreach ( $nodes as $node ) {
62+
$by_address[ $node['address'] ] = $node;
63+
}
64+
65+
$violations = array();
66+
foreach ( $nodes as $node ) {
67+
// Buttons are button-contrast's job — one finding per problem.
68+
if ( $accessor->is_button( $node['block'] ) ) {
69+
continue;
70+
}
71+
72+
$text = $accessor->text_color( $node['block'] );
73+
if ( null === $text ) {
74+
continue;
75+
}
76+
77+
$background = $this->effective_background( $node, $by_address, $accessor );
78+
$ratio = Saddle_Lint_Color::contrast( (string) $text, (string) $background );
79+
if ( null === $ratio ) {
80+
continue;
81+
}
82+
83+
$minimum = $this->is_large( $node, $accessor ) ? self::MINIMUM_LARGE : self::MINIMUM;
84+
if ( $ratio >= $minimum ) {
85+
continue;
86+
}
87+
88+
$violations[] = $this->violation(
89+
$node['address'],
90+
self::SEVERITY_ERROR,
91+
sprintf(
92+
/* translators: 1: text color, 2: background color, 3: contrast ratio, 4: required minimum. */
93+
__( 'Text %1$s on background %2$s has a contrast of %3$s:1 — below the WCAG AA minimum of %4$s:1.', 'saddle' ),
94+
$text,
95+
$background,
96+
number_format_i18n( $ratio, 2 ),
97+
number_format_i18n( $minimum, 1 )
98+
),
99+
__( 'Darken the text or lighten the background (or vice versa) so the pair reaches WCAG AA.', 'saddle' )
100+
);
101+
}
102+
return $violations;
103+
}
104+
105+
/**
106+
* The background this node's text actually sits on: its own, or the
107+
* nearest ancestor's. Null when nothing up the chain resolves — an
108+
* unknown page background is never guessed at.
109+
*
110+
* @param array $node Node entry.
111+
* @param array[] $by_address Address → node map.
112+
* @param Saddle_Lint_Accessor $accessor Builder accessor.
113+
* @return string|null
114+
*/
115+
private function effective_background( array $node, array $by_address, Saddle_Lint_Accessor $accessor ) {
116+
$current = $node;
117+
while ( $current ) {
118+
$background = $accessor->background_color( $current['block'] );
119+
if ( null !== $background ) {
120+
return $background;
121+
}
122+
$parent = $current['parent'];
123+
$current = null !== $parent && isset( $by_address[ $parent ] ) ? $by_address[ $parent ] : null;
124+
}
125+
return null;
126+
}
127+
128+
/**
129+
* Whether the node's text counts as WCAG "large": any heading, or a font
130+
* size of at least 24px. Only px sizes are compared — rem/em/clamp depend
131+
* on context the tree can't know, so they fall back to the normal-text
132+
* threshold (the stricter, safer direction... for the ratio; for the
133+
* threshold choice the heading call is the under-flagging one).
134+
*
135+
* @param array $node Node entry.
136+
* @param Saddle_Lint_Style_Accessor $accessor Style accessor.
137+
* @return bool
138+
*/
139+
private function is_large( array $node, Saddle_Lint_Style_Accessor $accessor ) {
140+
if ( null !== $accessor->heading_level( $node['block'] ) ) {
141+
return true;
142+
}
143+
$size = $accessor->font_size( $node['block'] );
144+
if ( is_string( $size ) && preg_match( '/^(\d+(?:\.\d+)?)px$/', trim( $size ), $m ) ) {
145+
return (float) $m[1] >= self::LARGE_PX;
146+
}
147+
return false;
148+
}
149+
}

saddle.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@
4949
require_once SADDLE_DIR . 'includes/lint/rules/class-rule-unaligned-buttons.php';
5050
require_once SADDLE_DIR . 'includes/lint/rules/class-rule-section-padding.php';
5151
require_once SADDLE_DIR . 'includes/lint/rules/class-rule-featured-plan.php';
52+
require_once SADDLE_DIR . 'includes/lint/rules/class-rule-text-contrast.php';
53+
require_once SADDLE_DIR . 'includes/lint/rules/class-rule-missing-alt.php';
54+
require_once SADDLE_DIR . 'includes/lint/rules/class-rule-heading-order.php';
5255
require_once SADDLE_DIR . 'includes/class-saddle-capabilities.php';
5356
require_once SADDLE_DIR . 'includes/class-saddle-approval.php';
5457
require_once SADDLE_DIR . 'includes/class-saddle-context.php';

0 commit comments

Comments
 (0)