a11y_dom/tiers.rs
1//! Fähigkeits-Tiers.
2//!
3//! Die drei Oberflächen unterscheiden sich nicht darin, wie sie dieselben Daten
4//! darstellen, sondern darin, **welche Daten es überhaupt gibt**:
5//!
6//! | | statisches HTML | Chrome via CDP | In-Page WASM |
7//! |---|---|---|---|
8//! | Struktur ([`Document`]) | ✓ | ✓ | ✓ |
9//! | [`Semantics`] | berechnet | nativ | berechnet |
10//! | [`Rendering`] | — | ✓ | ✓ |
11//! | [`Interaction`] | — | ✓ | ✓ |
12//!
13//! Ein einziges flaches Trait dafür wäre voller `Option`, die je nach Host
14//! `None` liefern — Regeln würden stillschweigend nicht laufen. Stattdessen
15//! deklariert jede Regel ihren Tier, jeder Host implementiert die Tiers, die er
16//! bedienen kann, und ein nicht erfüllter Tier wird zu `UNTESTED` statt zu
17//! Schweigen.
18//!
19//! [`Document`]: crate::Document
20
21use crate::tree::Document;
22
23/// Welche Datenschicht eine Regel braucht.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
25pub enum Tier {
26 /// Tags, Attribute, Text, Hierarchie. Immer verfügbar.
27 Structure,
28 /// Rolle und Accessible Name.
29 Semantics,
30 /// Berechnete Stile und Geometrie.
31 Rendering,
32 /// Fokus, Ereignisse, veränderlicher DOM.
33 Interaction,
34}
35
36impl Tier {
37 pub fn as_str(self) -> &'static str {
38 match self {
39 Tier::Structure => "structure",
40 Tier::Semantics => "semantics",
41 Tier::Rendering => "rendering",
42 Tier::Interaction => "interaction",
43 }
44 }
45}
46
47/// Was ein Host liefern kann. [`Tier::Structure`] ist immer dabei — ohne
48/// Baum gäbe es nichts zu prüfen.
49#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
50pub struct Caps {
51 pub semantics: bool,
52 pub rendering: bool,
53 pub interaction: bool,
54}
55
56impl Caps {
57 /// Nur Struktur — der statische Fall.
58 pub const STRUCTURE_ONLY: Caps = Caps {
59 semantics: false,
60 rendering: false,
61 interaction: false,
62 };
63
64 pub fn has(self, tier: Tier) -> bool {
65 match tier {
66 Tier::Structure => true,
67 Tier::Semantics => self.semantics,
68 Tier::Rendering => self.rendering,
69 Tier::Interaction => self.interaction,
70 }
71 }
72
73 pub fn with_semantics(mut self) -> Self {
74 self.semantics = true;
75 self
76 }
77
78 pub fn with_rendering(mut self) -> Self {
79 self.rendering = true;
80 self
81 }
82
83 pub fn with_interaction(mut self) -> Self {
84 self.interaction = true;
85 self
86 }
87}
88
89/// Woher ein Accessible Name stammt. Nur der native Accessibility-Tree kennt
90/// das; berechnende Hosts lassen es bei `None`.
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub enum NameSource {
93 AriaLabel,
94 AriaLabelledBy,
95 Label,
96 Title,
97 Alt,
98 Placeholder,
99 Contents,
100 Value,
101}
102
103/// **Tier 2** — Rolle und Accessible Name.
104///
105/// auditmysite liefert beides nativ aus dem Accessibility-Tree des Browsers,
106/// inklusive [`NameSource`] und `is_ignored`. astro-post-audit und LiveAudit
107/// berechnen es über das `accname`-Crate; dort bleibt `name_source` `None`.
108/// Die Lebenszeit des Knotens ist an die Ausleihe des Dokuments gekoppelt
109/// (`&'n self`, `Self::N<'n>`). Ohne diese Kopplung könnte ein Host, der seinen
110/// Baum nur *ausleiht* und daneben abgeleitete Daten hält — etwa einen
111/// vorberechneten ID-Index —, das Trait gar nicht erfüllen: `Self: 'n` wäre
112/// nicht herleitbar.
113pub trait Semantics: Document {
114 fn role<'n>(&'n self, node: Self::N<'n>) -> Option<String>;
115
116 fn accessible_name<'n>(&'n self, node: Self::N<'n>) -> Option<String>;
117
118 fn name_source<'n>(&'n self, _node: Self::N<'n>) -> Option<NameSource> {
119 None
120 }
121
122 /// Ob der Accessibility-Tree diesen Knoten auslässt — etwa wegen
123 /// `aria-hidden`, `display: none` oder weil er rein präsentational ist.
124 fn is_ignored<'n>(&'n self, _node: Self::N<'n>) -> bool {
125 false
126 }
127}
128
129/// Ein Rechteck in CSS-Pixeln, Ursprung links oben im Dokument.
130#[derive(Debug, Clone, Copy, PartialEq)]
131pub struct Rect {
132 pub x: f32,
133 pub y: f32,
134 pub width: f32,
135 pub height: f32,
136}
137
138impl Rect {
139 pub fn area(&self) -> f32 {
140 self.width * self.height
141 }
142
143 pub fn is_empty(&self) -> bool {
144 self.width <= 0.0 || self.height <= 0.0
145 }
146}
147
148/// sRGB mit Alpha, Kanäle 0–255.
149#[derive(Debug, Clone, Copy, PartialEq, Eq)]
150pub struct Color {
151 pub r: u8,
152 pub g: u8,
153 pub b: u8,
154 pub a: u8,
155}
156
157/// Die berechneten Stilwerte, die Accessibility-Regeln tatsächlich brauchen.
158/// Bewusst keine vollständige CSSOM-Abbildung.
159#[derive(Debug, Clone, PartialEq)]
160pub struct ComputedStyle {
161 pub color: Option<Color>,
162 /// Die *effektive* Hintergrundfarbe — der Host löst Transparenz über die
163 /// Vorfahren auf. Eine reine `background-color`-Ablesung pro Knoten wäre
164 /// für Kontrastprüfungen unbrauchbar.
165 pub background_color: Option<Color>,
166 pub font_size_px: Option<f32>,
167 pub font_weight: Option<u16>,
168 pub display: Option<String>,
169 pub visibility: Option<String>,
170}
171
172/// Layout-Angaben für heuristische Prüfungen — was eine Regel braucht, um eine
173/// Barriere zu *vermuten*, nicht um sie zu belegen. Die Regeln darauf melden
174/// deshalb `REVIEW`, nie `FAIL`.
175///
176/// Alles hier kostet den Host zusätzliche Arbeit; er liefert es über
177/// [`Rendering::layout`], und wer es nicht liefert, bekommt diese Heuristiken
178/// nicht.
179#[derive(Debug, Clone, Default, PartialEq)]
180pub struct Layout {
181 /// Ein Flex-Container mit `flex-direction: row-reverse` oder
182 /// `column-reverse`: Die Leserichtung weicht von der Quellreihenfolge ab.
183 pub flex_reversed: bool,
184 /// Berechnetes `order`. Ungleich 0 verschiebt das Element gegenüber der
185 /// Quellreihenfolge.
186 pub order: i32,
187 /// Berechnetes `min-width` in CSS-Pixeln, `0` ohne Angabe.
188 pub min_width_px: f32,
189 /// Berechnetes `cursor: pointer` — das Element sieht anklickbar aus.
190 pub cursor_pointer: bool,
191 /// Auf dem Element läuft eine Animation mit unendlich vielen Wiederholungen.
192 pub infinite_animation: bool,
193 /// Das Element liegt im sichtbaren Bereich, aber seine Mitte wird von
194 /// einem fixierten oder klebenden fremden Element überdeckt.
195 pub obscured: bool,
196 /// Ein fixiertes oder klebendes Element am oberen Rand, das tiefer reicht
197 /// als `scroll-padding-top`: Was beim Rückwärts-Tabben oben ausgerichtet
198 /// wird, kann ganz darunter verschwinden.
199 pub hides_focus: bool,
200 /// Ob der Fokus sichtbar wird: `Some(true)`, wenn sich beim Fokussieren
201 /// ein Stil ändert, der als Indikator taugt; `None`, wenn nicht gemessen —
202 /// Fokussieren ändert den Zustand der Seite und ist deshalb ein eigener,
203 /// ausdrücklicher Durchgang.
204 pub focus_visible: Option<bool>,
205}
206
207/// **Tier 3** — berechnete Stile und Geometrie.
208///
209/// Statische HTML-Analyse kann das nicht; Kontrast-, Target-Size- und
210/// Reflow-Regeln melden dort `UNTESTED`.
211pub trait Rendering: Document {
212 fn computed_style<'n>(&'n self, node: Self::N<'n>) -> Option<ComputedStyle>;
213
214 fn bounds<'n>(&'n self, node: Self::N<'n>) -> Option<Rect>;
215
216 /// Ob der Knoten tatsächlich sichtbar gerendert wird — nicht dasselbe wie
217 /// „steht im Markup".
218 fn is_rendered<'n>(&'n self, node: Self::N<'n>) -> bool {
219 self.bounds(node).is_some_and(|b| !b.is_empty())
220 }
221
222 /// Layout-Angaben für die heuristischen Regeln. Vorgabe `None`: Ein Host,
223 /// der sie nicht erhebt, bekommt diese Regeln nicht.
224 fn layout<'n>(&'n self, _node: Self::N<'n>) -> Option<Layout> {
225 None
226 }
227}
228
229/// **Tier 4** — Fokus, Ereignisse, veränderlicher DOM.
230///
231/// In-Page ist das billig und genau, weil der Prüfer *in* der Seite sitzt und
232/// Ereignisse real auslösen kann. Über CDP geht es auch, kostet aber einen
233/// Roundtrip je Schritt.
234pub trait Interaction: Document {
235 /// Die tatsächliche Tabreihenfolge, in der Reihenfolge des Durchlaufs.
236 fn tab_order(&self) -> Vec<crate::NodeId>;
237
238 /// Ob der Knoten bei Fokus einen sichtbaren Indikator zeigt.
239 fn has_visible_focus<'n>(&'n self, _node: Self::N<'n>) -> Option<bool> {
240 None
241 }
242}