mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-19 01:26:29 +03:00
Five browser rules were reporting something other than what the reader sees, and a review that charges those numbers is charging noise. - `line-length` measured `rect.width / (fontSize * 0.5)`, the box's capacity. A paragraph in a 1022px column whose text stops at 571px was charged 142 characters a line it never rendered. The probe now hands back the client rects of the direct text one per line box (`direct_text_line_rects`), the characters divide between the lines in proportion to the ink each carries, and the charge needs more than one long line: the harm named is the eye tracking back to the start of the next line, which takes a column to do. - `cramped-padding` read the declared padding. A 44px control with `padding: 0 16px` and a flex-centred label has 12px of air above the label and was charged "0px vertical padding"; the measurement is now the inset between the rendered text and the inside of the border box. Its wrapper half read a text-bearing child's border box the same way, so a `<td>` that fills its table and insets its own text counted as flush; it reads the text now. - `gray-on-color` called anything under 0.85 relative luminance gray, which takes in every off-white: `#e8edf2` measures 0.84 there and 0.93 as lightness. Gray is now low chroma at the lightness the ink actually sits at (saturation, which is chroma normalized for lightness) and neither of the two neutral inks a coloured surface carries. The contrast check beside it is untouched, and the recorded vectors still pass. - `ai-color-palette` charged every hue between 160° and 200° on a dark ground as neon, which lit one ordinary teal accent 18 places on a page with nothing wrong with it. A gradient in a tell hue is still the pattern on its own; flat neon ink on near-black waits for a second tell hue to turn up somewhere on the page, because one saturated accent on a dark system is an accent. - `kicker-above-heading` reported against `body`, so a charged row had nothing to point at, and it fired on eyebrows a design document documents. It names the eyebrow element now, and stands down where the repository's DESIGN.md declares the class by name — the prose's backticked class selectors travel on the design-system config the colour and radius rules already read. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LQBUunp8QttxZqihybNmtL
1090 lines
37 KiB
Rust
1090 lines
37 KiB
Rust
//! Port of `cli/engine/rules/checks.mjs` Section 3: the pure element checks
|
|
//! and their helpers. Every function keeps the JS name in its doc comment;
|
|
//! opts objects become structs whose `Option` fields mirror the JS
|
|
//! `undefined` / `null` distinctions the source relies on.
|
|
|
|
use crate::color::{
|
|
color_to_hex, contrast_ratio, get_hue, has_chroma, is_gray_ink, is_neutral_color,
|
|
relative_luminance, Rgba,
|
|
};
|
|
use crate::constants::{
|
|
BORDER_SAFE_TAGS, GENERIC_FONTS, KNOWN_SERIF_FONTS, SAFE_TAGS, WCAG_LARGE_BOLD_TEXT_PX,
|
|
WCAG_LARGE_TEXT_PX,
|
|
};
|
|
use crate::js::{
|
|
self, ci, math_max, math_round, number_to_string, parse_float, parse_int, string_to_number,
|
|
to_fixed, WS,
|
|
};
|
|
use crate::js_ext_a::{num_truthy, slice_utf16_start, split_commas_outside_parens, utf16_length};
|
|
use once_cell::sync::Lazy;
|
|
use regex::Regex;
|
|
|
|
/// The hit and option structs these checks are written against are shared;
|
|
/// re-exported so `checks::rules` stays one path.
|
|
pub use impeccable_foundation::rules::types::*;
|
|
|
|
macro_rules! re {
|
|
($name:ident, $pat:expr) => {
|
|
static $name: Lazy<Regex> = Lazy::new(|| Regex::new(&$pat).expect(stringify!($name)));
|
|
};
|
|
}
|
|
|
|
const SIDE_NAMES: [&str; 4] = ["Top", "Right", "Bottom", "Left"];
|
|
|
|
/// JS: checks.mjs#checkBorders
|
|
pub fn check_borders(
|
|
tag: &str,
|
|
widths: &Sides<f64>,
|
|
colors: &Sides<Option<&str>>,
|
|
radius: f64,
|
|
opts: &BorderOpts,
|
|
) -> Vec<RuleHit> {
|
|
let span_badge = tag == "span" && opts.badge_like;
|
|
if set_has(BORDER_SAFE_TAGS, tag) && !span_badge {
|
|
return Vec::new();
|
|
}
|
|
if opts.status_context {
|
|
return Vec::new();
|
|
}
|
|
let mut findings = Vec::new();
|
|
for i in 0..4 {
|
|
let w = widths.get(i);
|
|
if w < 1.0 || is_neutral_color(colors.get(i)) {
|
|
continue;
|
|
}
|
|
let mut max_other = f64::NEG_INFINITY;
|
|
for j in 0..4 {
|
|
if j != i {
|
|
max_other = math_max(max_other, widths.get(j));
|
|
}
|
|
}
|
|
if !(w >= 2.0 && (max_other <= 1.0 || w >= max_other * 2.0)) {
|
|
continue;
|
|
}
|
|
let sn = SIDE_NAMES[i].to_lowercase();
|
|
let is_side = i == 1 || i == 3;
|
|
let w_s = number_to_string(w);
|
|
let r_s = number_to_string(radius);
|
|
if is_side {
|
|
if span_badge {
|
|
continue;
|
|
}
|
|
if radius > 0.0 {
|
|
findings.push(RuleHit::new(
|
|
"side-tab",
|
|
format!("border-{sn}: {w_s}px + border-radius: {r_s}px"),
|
|
));
|
|
} else if w >= 3.0 {
|
|
findings.push(RuleHit::new("side-tab", format!("border-{sn}: {w_s}px")));
|
|
}
|
|
} else if radius > 0.0 && w >= 2.0 {
|
|
findings.push(RuleHit::new(
|
|
"border-accent-on-rounded",
|
|
format!("border-{sn}: {w_s}px + border-radius: {r_s}px"),
|
|
));
|
|
} else if !opts.tab_context && w >= 3.0 && w <= 12.0 {
|
|
findings.push(RuleHit::new("side-tab", format!("border-{sn}: {w_s}px")));
|
|
}
|
|
}
|
|
findings
|
|
}
|
|
|
|
re!(GRADIENT_CI, ci("gradient"));
|
|
|
|
re!(
|
|
TW_GRAY_TEXT,
|
|
format!(r"{B}text-(?:gray|slate|zinc|neutral|stone)-{D}+{B}")
|
|
);
|
|
re!(
|
|
TW_COLOR_BG,
|
|
format!(
|
|
r"{B}bg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-{D}+{B}"
|
|
)
|
|
);
|
|
re!(TW_BG_CLIP_TEXT, format!(r"{B}bg-clip-text{B}"));
|
|
|
|
/// JS: detect-text.mjs#TW_SOLID_CHROMATIC_BG_RE and checks.mjs#checkColors's
|
|
/// `colorBgMatch`, whose `\d+(?!\/)\b` skips a `bg-blue-500/10` opacity tint
|
|
/// (#707). The `regex` crate has no lookahead: `\d+` is already maximal before
|
|
/// the word boundary, so the only thing left to test is the byte after it.
|
|
pub fn find_solid_chromatic_bg(s: &str) -> Option<&str> {
|
|
let mut from = 0usize;
|
|
while let Some(m) = TW_COLOR_BG.find_at(s, from) {
|
|
if s.as_bytes().get(m.end()) != Some(&b'/') {
|
|
return Some(m.as_str());
|
|
}
|
|
from = m.start() + 1;
|
|
}
|
|
None
|
|
}
|
|
re!(TW_BG_GRADIENT_TO, format!(r"{B}bg-gradient-to-"));
|
|
re!(
|
|
TW_PURPLE_TEXT,
|
|
format!(r"{B}text-(?:purple|violet|indigo)-{D}+{B}")
|
|
);
|
|
re!(TW_TEXT_XL, format!(r"{B}text-(?:[2-9]xl){B}"));
|
|
re!(
|
|
TW_FROM_PURPLE,
|
|
format!(r"{B}from-(?:purple|violet|indigo)-{D}+{B}")
|
|
);
|
|
re!(
|
|
TW_TO_PURPLE,
|
|
format!(r"{B}to-(?:purple|violet|indigo|blue|cyan|pink|fuchsia)-{D}+{B}")
|
|
);
|
|
|
|
fn is_heading_123(tag: &str) -> bool {
|
|
matches!(tag, "h1" | "h2" | "h3")
|
|
}
|
|
|
|
/// JS: checks.mjs#checkColors
|
|
pub fn check_colors(opts: &ColorOpts) -> Vec<RuleHit> {
|
|
let tag = opts.tag.as_str();
|
|
let bg_image = opts.bg_image.as_deref().unwrap_or("");
|
|
let bg_clip = opts.bg_clip.as_deref().unwrap_or("");
|
|
if set_has(SAFE_TAGS, tag) {
|
|
let own_bg = opts
|
|
.bg_color
|
|
.map_or(false, |c| c.a.map_or(false, |a| a > 0.5));
|
|
let own_gradient = !bg_image.is_empty() && GRADIENT_CI.is_match(bg_image);
|
|
let is_styled_control =
|
|
opts.has_direct_text && (own_bg || own_gradient) && opts.font_size >= 9.0;
|
|
if !is_styled_control {
|
|
return Vec::new();
|
|
}
|
|
}
|
|
let mut findings = Vec::new();
|
|
|
|
if opts.has_direct_text && opts.text_color.is_some() && !opts.is_emoji_only {
|
|
let text_color = opts.text_color.unwrap();
|
|
let is_gradient_clipped_text = bg_clip == "text";
|
|
let bgs: Option<Vec<Rgba>> = if is_gradient_clipped_text {
|
|
None
|
|
} else if let Some(bg) = opts.effective_bg {
|
|
Some(vec![bg])
|
|
} else {
|
|
match &opts.effective_bg_stops {
|
|
Some(stops) if !stops.is_empty() => Some(stops.clone()),
|
|
_ => None,
|
|
}
|
|
};
|
|
if let Some(bgs) = bgs {
|
|
// Gray is low chroma at whatever lightness the ink sits at, and
|
|
// the surface is a colour when it has chroma of its own. The old
|
|
// pair of tests read relative luminance as if it were lightness,
|
|
// which made every off-white under 0.85 gray and charged an
|
|
// off-white nav on a teal masthead three times over (REN-404).
|
|
if is_gray_ink(&text_color) && bgs.iter().all(|b| has_chroma(Some(b), Some(40.0))) {
|
|
let bg_label = match opts.effective_bg {
|
|
Some(bg) => color_to_hex(Some(&bg)),
|
|
None => format!(
|
|
"gradient({})",
|
|
bgs.iter()
|
|
.map(|b| color_to_hex(Some(b)))
|
|
.collect::<Vec<_>>()
|
|
.join(", ")
|
|
),
|
|
};
|
|
findings.push(RuleHit::new(
|
|
"gray-on-color",
|
|
format!(
|
|
"text {} on bg {}",
|
|
color_to_hex(Some(&text_color)),
|
|
bg_label
|
|
),
|
|
));
|
|
}
|
|
|
|
let ratios: Vec<f64> = bgs.iter().map(|b| contrast_ratio(&text_color, b)).collect();
|
|
let mut worst_idx = 0usize;
|
|
for i in 1..ratios.len() {
|
|
if ratios[i] < ratios[worst_idx] {
|
|
worst_idx = i;
|
|
}
|
|
}
|
|
let ratio = ratios[worst_idx];
|
|
let is_large_text = opts.font_size >= WCAG_LARGE_TEXT_PX
|
|
|| (opts.font_size >= WCAG_LARGE_BOLD_TEXT_PX && opts.font_weight >= 700.0);
|
|
let threshold = if is_large_text { 3.0 } else { 4.5 };
|
|
if ratio < threshold {
|
|
let is_alpha_fallback_fp = !opts.detector_is_browser
|
|
&& opts.effective_bg.is_none()
|
|
&& text_color.a.map_or(false, |a| a < 1.0);
|
|
if !is_alpha_fallback_fp {
|
|
let ratio_label = if to_fixed(ratio, 1) == to_fixed(threshold, 1) {
|
|
to_fixed(ratio, 2)
|
|
} else {
|
|
to_fixed(ratio, 1)
|
|
};
|
|
findings.push(RuleHit::new(
|
|
"low-contrast",
|
|
format!(
|
|
"{}:1 (need {}:1) — text {} on {}",
|
|
ratio_label,
|
|
number_to_string(threshold),
|
|
color_to_hex(Some(&text_color)),
|
|
color_to_hex(Some(&bgs[worst_idx]))
|
|
),
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
if has_chroma(Some(&text_color), Some(50.0)) {
|
|
let hue = get_hue(Some(&text_color));
|
|
if hue >= 260.0 && hue <= 310.0 && (is_heading_123(tag) || opts.font_size >= 20.0) {
|
|
findings.push(RuleHit::new(
|
|
"ai-color-palette",
|
|
format!(
|
|
"Purple/violet text ({}) on heading",
|
|
color_to_hex(Some(&text_color))
|
|
),
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
if bg_clip == "text" && !bg_image.is_empty() && bg_image.contains("gradient") {
|
|
findings.push(RuleHit::new(
|
|
"gradient-text",
|
|
"background-clip: text + gradient".to_string(),
|
|
));
|
|
}
|
|
|
|
if let Some(class_str) = opts.class_list.as_deref().filter(|s| !s.is_empty()) {
|
|
let gray_match = TW_GRAY_TEXT.find(class_str);
|
|
let color_bg_match = find_solid_chromatic_bg(class_str);
|
|
if let (Some(g), Some(c)) = (gray_match, color_bg_match) {
|
|
findings.push(RuleHit::new(
|
|
"gray-on-color",
|
|
format!("{} on {}", g.as_str(), c),
|
|
));
|
|
}
|
|
if TW_BG_CLIP_TEXT.is_match(class_str) && TW_BG_GRADIENT_TO.is_match(class_str) {
|
|
findings.push(RuleHit::new(
|
|
"gradient-text",
|
|
"bg-clip-text + bg-gradient (Tailwind)".to_string(),
|
|
));
|
|
}
|
|
if let Some(p) = TW_PURPLE_TEXT.find(class_str) {
|
|
if is_heading_123(tag) || TW_TEXT_XL.is_match(class_str) {
|
|
findings.push(RuleHit::new(
|
|
"ai-color-palette",
|
|
format!("{} on heading", p.as_str()),
|
|
));
|
|
}
|
|
}
|
|
if TW_FROM_PURPLE.is_match(class_str) && TW_TO_PURPLE.is_match(class_str) {
|
|
findings.push(RuleHit::new(
|
|
"ai-color-palette",
|
|
"Purple/violet gradient (Tailwind)".to_string(),
|
|
));
|
|
}
|
|
}
|
|
|
|
findings
|
|
}
|
|
|
|
/// JS: checks.mjs#checkHoverContrast
|
|
pub fn check_hover_contrast(opts: &HoverContrastOpts) -> Vec<RuleHit> {
|
|
if !opts.has_direct_text || opts.is_emoji_only || opts.text_color.is_none() || opts.bg.is_none()
|
|
{
|
|
return Vec::new();
|
|
}
|
|
if set_has(SAFE_TAGS, &opts.tag) && !opts.own_bg_alpha.map_or(false, |a| a > 0.5) {
|
|
return Vec::new();
|
|
}
|
|
let text_color = opts.text_color.unwrap();
|
|
let bg = opts.bg.unwrap();
|
|
let ratio = contrast_ratio(&text_color, &bg);
|
|
let is_large_text = opts.font_size >= WCAG_LARGE_TEXT_PX
|
|
|| (opts.font_size >= WCAG_LARGE_BOLD_TEXT_PX && opts.font_weight >= 700.0);
|
|
let threshold = if is_large_text { 3.0 } else { 4.5 };
|
|
if ratio >= threshold {
|
|
return Vec::new();
|
|
}
|
|
vec![RuleHit::new(
|
|
"low-contrast",
|
|
format!(
|
|
":hover state {}:1 (need {}:1) — text {} on {}",
|
|
to_fixed(ratio, 1),
|
|
number_to_string(threshold),
|
|
color_to_hex(Some(&text_color)),
|
|
color_to_hex(Some(&bg))
|
|
),
|
|
)]
|
|
}
|
|
|
|
// ─── isCardLikeFromProps / HEADING_TAGS ─────────────────────────────────────
|
|
|
|
/// JS: checks.mjs#isCardLikeFromProps
|
|
pub fn is_card_like_from_props(
|
|
has_shadow: bool,
|
|
has_border: bool,
|
|
has_radius: bool,
|
|
has_bg: bool,
|
|
) -> bool {
|
|
if !has_shadow && !has_border {
|
|
return false;
|
|
}
|
|
has_radius || has_bg
|
|
}
|
|
|
|
/// JS: checks.mjs#checkIconTile
|
|
pub fn check_icon_tile(opts: &IconTileOpts) -> Vec<RuleHit> {
|
|
if !is_heading_tag(&opts.heading_tag) {
|
|
return Vec::new();
|
|
}
|
|
let sibling_tag = match opts.sibling_tag.as_deref() {
|
|
None | Some("") => return Vec::new(),
|
|
Some(t) => t,
|
|
};
|
|
if is_heading_tag(sibling_tag) {
|
|
return Vec::new();
|
|
}
|
|
let w = opts.sibling_width;
|
|
let h = opts.sibling_height;
|
|
if !(w >= 32.0 && w <= 128.0) {
|
|
return Vec::new();
|
|
}
|
|
if !(h >= 32.0 && h <= 128.0) {
|
|
return Vec::new();
|
|
}
|
|
let ratio = w / h;
|
|
if ratio < 0.7 || ratio > 1.4 {
|
|
return Vec::new();
|
|
}
|
|
let bg_visible = opts
|
|
.sibling_bg_color
|
|
.map_or(false, |c| c.a.map_or(false, |a| a > 0.1))
|
|
|| opts
|
|
.sibling_bg_image
|
|
.as_deref()
|
|
.map_or(false, |s| !s.is_empty() && s != "none");
|
|
let border_visible = opts.sibling_border_width > 0.0;
|
|
if !bg_visible && !border_visible {
|
|
return Vec::new();
|
|
}
|
|
if opts.sibling_border_radius >= w / 2.0 {
|
|
return Vec::new();
|
|
}
|
|
if !opts.has_icon_child {
|
|
return Vec::new();
|
|
}
|
|
if num_truthy(opts.icon_child_width) && opts.icon_child_width >= w * 0.95 {
|
|
return Vec::new();
|
|
}
|
|
if num_truthy(opts.heading_top)
|
|
&& num_truthy(opts.sibling_bottom)
|
|
&& opts.sibling_bottom > opts.heading_top + 4.0
|
|
{
|
|
return Vec::new();
|
|
}
|
|
let text = slice_utf16_start(js::trim(opts.heading_text.as_deref().unwrap_or("")), 60);
|
|
vec![RuleHit::new(
|
|
"icon-tile-stack",
|
|
format!(
|
|
"{}x{}px icon tile above {} \"{}\"",
|
|
number_to_string(math_round(w)),
|
|
number_to_string(math_round(h)),
|
|
opts.heading_tag,
|
|
text
|
|
),
|
|
)]
|
|
}
|
|
|
|
/// JS `f.trim().replace(/^['"]|['"]$/g, '')`.
|
|
fn strip_font_quotes(f: &str) -> &str {
|
|
let t = js::trim(f);
|
|
let is_quote = |b: u8| b == b'\'' || b == b'"';
|
|
let bytes = t.as_bytes();
|
|
let start = if !bytes.is_empty() && is_quote(bytes[0]) {
|
|
1
|
|
} else {
|
|
0
|
|
};
|
|
let end = if bytes.len() > start && is_quote(bytes[bytes.len() - 1]) {
|
|
bytes.len() - 1
|
|
} else {
|
|
bytes.len()
|
|
};
|
|
&t[start..end]
|
|
}
|
|
|
|
/// JS: checks.mjs#resolveSerif
|
|
pub fn resolve_serif(font_family: Option<&str>) -> SerifResolution {
|
|
let none = SerifResolution {
|
|
primary: None,
|
|
is_serif: false,
|
|
};
|
|
let ff = match font_family {
|
|
None | Some("") => return none,
|
|
Some(s) => s,
|
|
};
|
|
let tokens: Vec<String> = ff
|
|
.split(',')
|
|
.map(|f| js::to_lower_case(strip_font_quotes(f)))
|
|
.collect();
|
|
let primary = tokens
|
|
.iter()
|
|
.find(|f| !f.is_empty() && !set_has(GENERIC_FONTS, f))
|
|
.cloned();
|
|
let primary = match primary {
|
|
None => return none,
|
|
Some(p) => p,
|
|
};
|
|
if set_has(KNOWN_SERIF_FONTS, &primary) {
|
|
return SerifResolution {
|
|
primary: Some(primary),
|
|
is_serif: true,
|
|
};
|
|
}
|
|
if tokens.iter().any(|t| t == "serif") {
|
|
return SerifResolution {
|
|
primary: Some(primary),
|
|
is_serif: true,
|
|
};
|
|
}
|
|
SerifResolution {
|
|
primary: Some(primary),
|
|
is_serif: false,
|
|
}
|
|
}
|
|
|
|
/// JS: checks.mjs#checkItalicSerif
|
|
pub fn check_italic_serif(opts: &ItalicSerifOpts) -> Vec<RuleHit> {
|
|
if opts.font_style.as_deref() != Some("italic") {
|
|
return Vec::new();
|
|
}
|
|
let tag = opts.tag.as_str();
|
|
if tag != "h1" && !(tag == "h2" && opts.font_size >= 48.0) {
|
|
return Vec::new();
|
|
}
|
|
if opts.font_size < 48.0 {
|
|
return Vec::new();
|
|
}
|
|
let res = resolve_serif(opts.font_family.as_deref());
|
|
if !res.is_serif {
|
|
return Vec::new();
|
|
}
|
|
let text = slice_utf16_start(js::trim(opts.heading_text.as_deref().unwrap_or("")), 60);
|
|
vec![RuleHit::new(
|
|
"italic-serif-display",
|
|
format!(
|
|
"italic serif {} ({}) at {}px \"{}\"",
|
|
tag,
|
|
res.primary.as_deref().unwrap_or("serif"),
|
|
number_to_string(math_round(opts.font_size)),
|
|
text
|
|
),
|
|
)]
|
|
}
|
|
|
|
// ─── isAccentColor ──────────────────────────────────────────────────────────
|
|
re!(
|
|
ACCENT_RGB_STRICT,
|
|
format!(r"rgba?\({WS}*({D}+){WS}*,{WS}*({D}+){WS}*,{WS}*({D}+)")
|
|
);
|
|
re!(ACCENT_HEX, format!(r"^#([0-9a-fA-F]{{3,8}}){B}"));
|
|
re!(ACCENT_OKLCH_HEAD, format!(r"^{}\(", ci("oklch")));
|
|
re!(ACCENT_NUMS, format!(r"{D}*\.{D}+|{D}+"));
|
|
re!(
|
|
ACCENT_HSL,
|
|
format!(r"{}[aA]?\({WS}*[0-9.]+{WS}*,{WS}*([0-9.]+)%", ci("hsl"))
|
|
);
|
|
|
|
/// JS: checks.mjs#isAccentColor
|
|
pub fn is_accent_color(css_color: &str) -> bool {
|
|
if css_color.is_empty() {
|
|
return false;
|
|
}
|
|
let s = js::trim(css_color);
|
|
if let Some(m) = ACCENT_RGB_STRICT.captures(s) {
|
|
let r = string_to_number(&m[1]);
|
|
let g = string_to_number(&m[2]);
|
|
let b = string_to_number(&m[3]);
|
|
return (js::math_max3(r, g, b) - js::math_min3(r, g, b)) >= 40.0;
|
|
}
|
|
if let Some(m) = ACCENT_HEX.captures(s) {
|
|
let raw = &m[1];
|
|
let h: String = if raw.len() == 3 || raw.len() == 4 {
|
|
let doubled: String = raw.chars().flat_map(|c| [c, c]).collect();
|
|
doubled.chars().take(6).collect()
|
|
} else {
|
|
raw.chars().take(6).collect()
|
|
};
|
|
if h.len() == 6 {
|
|
let r = parse_int(&h[0..2], 16);
|
|
let g = parse_int(&h[2..4], 16);
|
|
let b = parse_int(&h[4..6], 16);
|
|
return (js::math_max3(r, g, b) - js::math_min3(r, g, b)) >= 40.0;
|
|
}
|
|
}
|
|
if ACCENT_OKLCH_HEAD.is_match(s) {
|
|
let nums: Vec<&str> = ACCENT_NUMS.find_iter(s).map(|m| m.as_str()).collect();
|
|
if nums.len() >= 2 {
|
|
let c = parse_float(nums[1]);
|
|
return !c.is_nan() && c >= 0.05;
|
|
}
|
|
}
|
|
if let Some(m) = ACCENT_HSL.captures(s) {
|
|
let sat = parse_float(&m[1]);
|
|
return !sat.is_nan() && sat >= 20.0;
|
|
}
|
|
false
|
|
}
|
|
|
|
// ─── resolveHeroHeadingSizePx ───────────────────────────────────────────────
|
|
re!(
|
|
SIMPLE_LENGTH,
|
|
format!(r"^(-?{D}*\.?{D}+){WS}*(px|rem|em|%)?$")
|
|
);
|
|
re!(CLAMP_RE, format!(r"^clamp\(({DOT}*)\)$"));
|
|
|
|
fn simple_length_px(token: &str) -> Option<f64> {
|
|
let m = SIMPLE_LENGTH.captures(js::trim(token))?;
|
|
let amount = string_to_number(&m[1]);
|
|
if !amount.is_finite() {
|
|
return None;
|
|
}
|
|
match m.get(2).map(|u| u.as_str()) {
|
|
Some("rem") | Some("em") => Some(amount * 16.0),
|
|
Some("%") => Some(amount * 0.16),
|
|
_ => Some(amount),
|
|
}
|
|
}
|
|
|
|
/// JS: checks.mjs#resolveHeroHeadingSizePx
|
|
pub fn resolve_hero_heading_size_px(value: Option<&str>) -> f64 {
|
|
let input = js::to_lower_case(js::trim(value.unwrap_or("")));
|
|
if input.is_empty() {
|
|
return 0.0;
|
|
}
|
|
if let Some(direct) = simple_length_px(&input) {
|
|
return direct;
|
|
}
|
|
if let Some(m) = CLAMP_RE.captures(&input) {
|
|
let parts: Vec<&str> = m[1].split(',').collect();
|
|
if parts.len() == 3 {
|
|
let bounds: Vec<f64> = [simple_length_px(parts[0]), simple_length_px(parts[2])]
|
|
.into_iter()
|
|
.flatten()
|
|
.collect();
|
|
if !bounds.is_empty() {
|
|
let mut mx = f64::NEG_INFINITY;
|
|
for b in bounds {
|
|
mx = math_max(mx, b);
|
|
}
|
|
return mx;
|
|
}
|
|
}
|
|
}
|
|
0.0
|
|
}
|
|
|
|
/// JS: checks.mjs#checkHeroEyebrow
|
|
pub fn check_hero_eyebrow(opts: &HeroEyebrowOpts) -> Vec<RuleHit> {
|
|
if opts.heading_tag != "h1" {
|
|
return Vec::new();
|
|
}
|
|
if opts.heading_in_application_context {
|
|
return Vec::new();
|
|
}
|
|
if !(opts.heading_font_size >= 48.0) {
|
|
return Vec::new();
|
|
}
|
|
let sibling_tag = match opts.sibling_tag.as_deref() {
|
|
None | Some("") => return Vec::new(),
|
|
Some(t) => t,
|
|
};
|
|
if is_heading_tag(sibling_tag) {
|
|
return Vec::new();
|
|
}
|
|
let text = js::trim(opts.sibling_text.as_deref().unwrap_or(""));
|
|
let text_len = utf16_length(text);
|
|
if text_len < 2 || text_len > 60 {
|
|
return Vec::new();
|
|
}
|
|
if !(opts.sibling_font_size > 0.0 && opts.sibling_font_size <= 14.0) {
|
|
return Vec::new();
|
|
}
|
|
let is_uppercased = opts.sibling_text_transform.as_deref() == Some("uppercase")
|
|
|| (text.bytes().any(|b| b.is_ascii_uppercase())
|
|
&& !text.bytes().any(|b| b.is_ascii_lowercase()));
|
|
let is_classic_tracked = is_uppercased && opts.sibling_letter_spacing >= 1.6;
|
|
|
|
let weight = {
|
|
let n = match opts.sibling_font_weight.as_deref() {
|
|
None => f64::NAN,
|
|
Some(s) => string_to_number(s),
|
|
};
|
|
if num_truthy(n) {
|
|
n
|
|
} else {
|
|
400.0
|
|
}
|
|
};
|
|
let is_accent_bold =
|
|
weight >= 700.0 && is_accent_color(opts.sibling_color.as_deref().unwrap_or(""));
|
|
let is_dash_prefixed = opts.sibling_has_accent_dash_pseudo;
|
|
if !is_classic_tracked && !is_accent_bold && !is_dash_prefixed {
|
|
return Vec::new();
|
|
}
|
|
let heading_text_snippet =
|
|
slice_utf16_start(js::trim(opts.heading_text.as_deref().unwrap_or("")), 60);
|
|
let eyebrow_snippet = slice_utf16_start(text, 40);
|
|
let style = if is_classic_tracked {
|
|
"tracked-caps"
|
|
} else if is_accent_bold {
|
|
"accent-bold"
|
|
} else {
|
|
"dash-prefix"
|
|
};
|
|
vec![RuleHit::new(
|
|
"hero-eyebrow-chip",
|
|
format!(
|
|
"eyebrow chip ({}) \"{}\" above {} \"{}\"",
|
|
style, eyebrow_snippet, opts.heading_tag, heading_text_snippet
|
|
),
|
|
)]
|
|
}
|
|
|
|
/// JS: checks.mjs#checkKickerAboveHeading
|
|
pub fn check_kicker_above_heading(candidates: &[KickerCandidate]) -> Vec<RuleHit> {
|
|
candidates
|
|
.iter()
|
|
.map(|c| {
|
|
RuleHit::new(
|
|
"kicker-above-heading",
|
|
format!(
|
|
"kicker \"{}\" above {} \"{}\"",
|
|
c.kicker_text, c.heading_tag, c.heading_text
|
|
),
|
|
)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
// ─── checkMotion ────────────────────────────────────────────────────────────
|
|
|
|
/// JS `LAYOUT_TRANSITION_PROPS`.
|
|
pub const LAYOUT_TRANSITION_PROPS: &[&str] = &[
|
|
"width",
|
|
"height",
|
|
"padding",
|
|
"margin",
|
|
"max-height",
|
|
"max-width",
|
|
"min-height",
|
|
"min-width",
|
|
"padding-top",
|
|
"padding-right",
|
|
"padding-bottom",
|
|
"padding-left",
|
|
"margin-top",
|
|
"margin-right",
|
|
"margin-bottom",
|
|
"margin-left",
|
|
];
|
|
|
|
re!(
|
|
BOUNCE_NAME,
|
|
format!(
|
|
"{}|{}|{}|{}|{}",
|
|
ci("bounce"),
|
|
ci("elastic"),
|
|
ci("wobble"),
|
|
ci("jiggle"),
|
|
ci("spring")
|
|
)
|
|
);
|
|
re!(TW_ANIMATE_BOUNCE, format!(r"{B}animate-bounce{B}"));
|
|
/// JS `/cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g`.
|
|
pub(crate) static BEZIER_RE: Lazy<Regex> = Lazy::new(|| {
|
|
Regex::new(&format!(
|
|
r"cubic-bezier\({WS}*([0-9.-]+){WS}*,{WS}*([0-9.-]+){WS}*,{WS}*([0-9.-]+){WS}*,{WS}*([0-9.-]+){WS}*\)"
|
|
))
|
|
.expect("BEZIER_RE")
|
|
});
|
|
|
|
/// JS: checks.mjs#checkMotion
|
|
pub fn check_motion(opts: &MotionOpts) -> Vec<RuleHit> {
|
|
if set_has(SAFE_TAGS, &opts.tag) {
|
|
return Vec::new();
|
|
}
|
|
let mut findings = Vec::new();
|
|
if let Some(name) = opts.animation_name.as_deref() {
|
|
if !name.is_empty() && name != "none" && BOUNCE_NAME.is_match(name) {
|
|
findings.push(RuleHit::new("bounce-easing", format!("animation: {name}")));
|
|
}
|
|
}
|
|
if let Some(cls) = opts.class_list.as_deref() {
|
|
if !cls.is_empty() && TW_ANIMATE_BOUNCE.is_match(cls) {
|
|
findings.push(RuleHit::new(
|
|
"bounce-easing",
|
|
"animate-bounce (Tailwind)".to_string(),
|
|
));
|
|
}
|
|
}
|
|
if let Some(tf) = opts.timing_functions.as_deref() {
|
|
if !tf.is_empty() {
|
|
for m in BEZIER_RE.captures_iter(tf) {
|
|
let y1 = parse_float(&m[2]);
|
|
let y2 = parse_float(&m[4]);
|
|
if y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1 {
|
|
findings.push(RuleHit::new(
|
|
"bounce-easing",
|
|
format!("cubic-bezier({}, {}, {}, {})", &m[1], &m[2], &m[3], &m[4]),
|
|
));
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if let Some(tp) = opts.transition_property.as_deref() {
|
|
if !tp.is_empty() && tp != "all" && tp != "none" {
|
|
let layout_found: Vec<String> = tp
|
|
.split(',')
|
|
.map(|p| js::to_lower_case(js::trim(p)))
|
|
.filter(|p| set_has(LAYOUT_TRANSITION_PROPS, p))
|
|
.collect();
|
|
if !layout_found.is_empty() {
|
|
findings.push(RuleHit::new(
|
|
"layout-transition",
|
|
format!("transition: {}", layout_found.join(", ")),
|
|
));
|
|
}
|
|
}
|
|
}
|
|
findings
|
|
}
|
|
|
|
fn glow_scan(value: Option<&str>, prop: &str, on_dark_bg: bool) -> Option<RuleHit> {
|
|
let value = match value {
|
|
None | Some("") | Some("none") => return None,
|
|
Some(v) => v,
|
|
};
|
|
for layer in split_commas_outside_parens(value) {
|
|
let info = match find_shadow_color(layer) {
|
|
Some(i) => i,
|
|
None => continue,
|
|
};
|
|
let color = match info.color {
|
|
Some(c) => c,
|
|
None => continue,
|
|
};
|
|
if !has_chroma(Some(&color), Some(30.0)) {
|
|
continue;
|
|
}
|
|
let vals = extract_shadow_lengths(layer, Some((info.start, info.end)));
|
|
if vals.len() < 3 || vals[2] <= 4.0 {
|
|
continue;
|
|
}
|
|
if vals[0] == 0.0 && vals[1] == 0.0 {
|
|
return Some(RuleHit::new(
|
|
"dark-glow",
|
|
format!("Zero-offset {} glow ({})", prop, color_to_hex(Some(&color))),
|
|
));
|
|
}
|
|
if on_dark_bg {
|
|
return Some(RuleHit::new(
|
|
"dark-glow",
|
|
format!(
|
|
"Colored {} glow ({}) on dark background",
|
|
prop,
|
|
color_to_hex(Some(&color))
|
|
),
|
|
));
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// JS: checks.mjs#checkGlow
|
|
pub fn check_glow(opts: &GlowOpts) -> Vec<RuleHit> {
|
|
let on_dark_bg = opts
|
|
.effective_bg
|
|
.map_or(false, |bg| relative_luminance(&bg) < 0.1);
|
|
let found = glow_scan(opts.box_shadow.as_deref(), "box-shadow", on_dark_bg)
|
|
.or_else(|| glow_scan(opts.text_shadow.as_deref(), "text-shadow", on_dark_bg));
|
|
match found {
|
|
Some(f) => vec![f],
|
|
None => Vec::new(),
|
|
}
|
|
}
|
|
|
|
// ─── Section 6 shared: flat type hierarchy ──────────────────────────────────
|
|
|
|
/// JS: checks.mjs#TYPE_HIERARCHY_SELECTOR
|
|
pub const TYPE_HIERARCHY_SELECTOR: &str = "h1,h2,h3,h4,h5,h6,p,li,td,th,dd,blockquote,figcaption";
|
|
/// JS: checks.mjs#TYPE_HIERARCHY_MIN_ROLES
|
|
pub const TYPE_HIERARCHY_MIN_ROLES: usize = 3;
|
|
/// JS: checks.mjs#TYPE_HIERARCHY_MIN_STEP_RATIO
|
|
pub const TYPE_HIERARCHY_MIN_STEP_RATIO: f64 = 1.25;
|
|
|
|
/// One `{ role, size }` entry the JS pushes into `samples`.
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct TypeSample {
|
|
pub role: String,
|
|
pub size: f64,
|
|
}
|
|
|
|
/// JS: checks.mjs#typeHierarchyRole
|
|
pub fn type_hierarchy_role(tag: &str) -> String {
|
|
let tag = js::to_lower_case(tag);
|
|
let b = tag.as_bytes();
|
|
if b.len() == 2 && b[0] == b'h' && (b'1'..=b'6').contains(&b[1]) {
|
|
tag
|
|
} else {
|
|
"body".to_string()
|
|
}
|
|
}
|
|
|
|
/// JS: checks.mjs#dominantTypeRoleSize
|
|
fn dominant_type_role_size(samples: &[f64]) -> Option<f64> {
|
|
// `new Map()` keeps insertion order; the JS sorts by count desc then size asc.
|
|
let mut counts: Vec<(f64, f64)> = Vec::new();
|
|
for size in samples {
|
|
match counts
|
|
.iter_mut()
|
|
.find(|(k, _)| crate::js_ext_b::same_value_zero(*k, *size))
|
|
{
|
|
Some(slot) => slot.1 += 1.0,
|
|
None => counts.push((*size, 1.0)),
|
|
}
|
|
}
|
|
let mut ranked = counts;
|
|
ranked.sort_by(|a, b| {
|
|
b.1.partial_cmp(&a.1)
|
|
.unwrap_or(std::cmp::Ordering::Equal)
|
|
.then(a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal))
|
|
});
|
|
if ranked.len() > 1 && ranked[0].1 == ranked[1].1 {
|
|
return None;
|
|
}
|
|
ranked.first().map(|(size, _)| *size)
|
|
}
|
|
|
|
/// JS: checks.mjs#checkFlatTypeHierarchySamples
|
|
pub fn check_flat_type_hierarchy_samples(samples: &[TypeSample]) -> Vec<RuleHit> {
|
|
// `new Map()` keyed by role, in first-seen order.
|
|
let mut by_role: Vec<(String, Vec<f64>)> = Vec::new();
|
|
for sample in samples {
|
|
let size = math_round(sample.size * 10.0) / 10.0;
|
|
if sample.role.is_empty() || !size.is_finite() || size < 8.0 || size >= 200.0 {
|
|
continue;
|
|
}
|
|
match by_role.iter_mut().find(|(r, _)| *r == sample.role) {
|
|
Some(slot) => slot.1.push(size),
|
|
None => by_role.push((sample.role.clone(), vec![size])),
|
|
}
|
|
}
|
|
|
|
let mut roles: Vec<(String, f64)> = by_role
|
|
.into_iter()
|
|
.filter_map(|(role, sizes)| dominant_type_role_size(&sizes).map(|size| (role, size)))
|
|
.collect();
|
|
|
|
if roles.len() < TYPE_HIERARCHY_MIN_ROLES {
|
|
return Vec::new();
|
|
}
|
|
|
|
roles.sort_by(|a, b| {
|
|
a.1.partial_cmp(&b.1)
|
|
.unwrap_or(std::cmp::Ordering::Equal)
|
|
// JS-PARITY: checks.mjs sorts ties with `a.role.localeCompare(b.role)`.
|
|
// Every role is `body` or `h1`..`h6`, lowercase ASCII, where the ICU
|
|
// root collation and byte order agree.
|
|
.then_with(|| a.0.cmp(&b.0))
|
|
});
|
|
let mut largest_step = 1.0f64;
|
|
for i in 1..roles.len() {
|
|
largest_step = math_max(largest_step, roles[i].1 / roles[i - 1].1);
|
|
}
|
|
if largest_step >= TYPE_HIERARCHY_MIN_STEP_RATIO {
|
|
return Vec::new();
|
|
}
|
|
|
|
let role_sizes: Vec<String> = roles
|
|
.iter()
|
|
.map(|(role, size)| format!("{} {}px", role, number_to_string(*size)))
|
|
.collect();
|
|
vec![RuleHit::new(
|
|
"flat-type-hierarchy",
|
|
format!(
|
|
"Role sizes: {} (largest adjacent step {}:1; target {}:1)",
|
|
role_sizes.join(", "),
|
|
to_fixed(largest_step, 2),
|
|
number_to_string(TYPE_HIERARCHY_MIN_STEP_RATIO)
|
|
),
|
|
)]
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
// Expected values below were produced by running the JS functions in
|
|
// Node against the same inputs.
|
|
|
|
#[test]
|
|
fn hover_contrast_matches_node() {
|
|
let hits = check_hover_contrast(&HoverContrastOpts {
|
|
tag: "div".into(),
|
|
text_color: Some(Rgba::new(120.0, 120.0, 120.0, 1.0)),
|
|
bg: Some(Rgba::new(255.0, 255.0, 255.0, 1.0)),
|
|
own_bg_alpha: Some(1.0),
|
|
font_size: 16.0,
|
|
font_weight: 400.0,
|
|
has_direct_text: true,
|
|
is_emoji_only: false,
|
|
});
|
|
assert_eq!(hits.len(), 1);
|
|
assert_eq!(
|
|
hits[0].snippet,
|
|
":hover state 4.4:1 (need 4.5:1) — text #787878 on #ffffff"
|
|
);
|
|
// SAFE_TAGS suppression without an own background.
|
|
let none = check_hover_contrast(&HoverContrastOpts {
|
|
tag: "a".into(),
|
|
text_color: Some(Rgba::new(120.0, 120.0, 120.0, 1.0)),
|
|
bg: Some(Rgba::new(255.0, 255.0, 255.0, 1.0)),
|
|
own_bg_alpha: Some(0.2),
|
|
font_size: 16.0,
|
|
font_weight: 400.0,
|
|
has_direct_text: true,
|
|
is_emoji_only: false,
|
|
});
|
|
assert!(none.is_empty());
|
|
// Large bold text uses the 3:1 threshold.
|
|
let large = check_hover_contrast(&HoverContrastOpts {
|
|
tag: "button".into(),
|
|
text_color: Some(Rgba::new(160.0, 160.0, 160.0, 1.0)),
|
|
bg: Some(Rgba::new(255.0, 255.0, 255.0, 1.0)),
|
|
own_bg_alpha: Some(1.0),
|
|
font_size: 19.0,
|
|
font_weight: 700.0,
|
|
has_direct_text: true,
|
|
is_emoji_only: false,
|
|
});
|
|
assert_eq!(
|
|
large[0].snippet,
|
|
":hover state 2.6:1 (need 3:1) — text #a0a0a0 on #ffffff"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn hero_heading_size_matches_node() {
|
|
assert_eq!(resolve_hero_heading_size_px(Some("3rem")), 48.0);
|
|
assert_eq!(resolve_hero_heading_size_px(Some(" 56PX ")), 56.0);
|
|
assert_eq!(resolve_hero_heading_size_px(Some("200%")), 32.0);
|
|
assert_eq!(
|
|
resolve_hero_heading_size_px(Some("clamp(2rem, 5vw, 4.5rem)")),
|
|
72.0
|
|
);
|
|
assert_eq!(
|
|
resolve_hero_heading_size_px(Some("clamp(40px, 6vw, 10vw)")),
|
|
40.0
|
|
);
|
|
assert_eq!(resolve_hero_heading_size_px(Some("clamp(5vw, 6vw)")), 0.0);
|
|
assert_eq!(resolve_hero_heading_size_px(Some("5vw")), 0.0);
|
|
assert_eq!(resolve_hero_heading_size_px(None), 0.0);
|
|
assert_eq!(resolve_hero_heading_size_px(Some("")), 0.0);
|
|
assert_eq!(resolve_hero_heading_size_px(Some(".5em")), 8.0);
|
|
}
|
|
|
|
#[test]
|
|
fn shadow_helpers_match_node() {
|
|
let c = find_shadow_color("0 0 20px rgba(59,130,246,0.4)").unwrap();
|
|
assert_eq!((c.start, c.end), (9, 29));
|
|
assert_eq!(c.color, Some(Rgba::new(59.0, 130.0, 246.0, 0.4)));
|
|
let hex = find_shadow_color("0 0 20px #3b82f6").unwrap();
|
|
assert_eq!((hex.start, hex.end), (9, 16));
|
|
let named = find_shadow_color("inset 0 0 4px Red").unwrap();
|
|
assert_eq!((named.start, named.end), (14, 17));
|
|
assert_eq!(named.color, Some(Rgba::new(255.0, 0.0, 0.0, 1.0)));
|
|
assert!(find_shadow_color("0 0 4px var(--x)").is_none());
|
|
let p3 = find_shadow_color("0 0 4px color(display-p3 1 0 0)").unwrap();
|
|
assert_eq!((p3.start, p3.end), (8, 31));
|
|
assert_eq!(p3.color, Some(Rgba::new(255.0, 0.0, 0.0, 1.0)));
|
|
assert_eq!(
|
|
extract_shadow_lengths("0 0 20px rgba(59,130,246,0.4)", Some((9, 29))),
|
|
vec![0.0, 0.0, 20.0]
|
|
);
|
|
assert_eq!(
|
|
extract_shadow_lengths("0 1rem .5em 2px", None),
|
|
vec![0.0, 16.0, 8.0, 2.0]
|
|
);
|
|
assert_eq!(
|
|
extract_shadow_lengths("rgb(1, 2, 3) 0px 0px 20px", None),
|
|
vec![1.0, 2.0, 3.0, 0.0, 0.0, 20.0]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn gray_on_color_opacity_tint() {
|
|
let hits = |class_list: &str| {
|
|
check_colors(&ColorOpts {
|
|
tag: "div".to_string(),
|
|
font_size: 14.0,
|
|
font_weight: 400.0,
|
|
has_direct_text: true,
|
|
class_list: Some(class_list.to_string()),
|
|
..Default::default()
|
|
})
|
|
.into_iter()
|
|
.filter(|h| h.id == "gray-on-color")
|
|
.map(|h| h.snippet)
|
|
.collect::<Vec<_>>()
|
|
};
|
|
// #707: a `/10` opacity tint is not a solid chromatic fill.
|
|
assert!(hits("text-slate-300 hover:bg-red-500/10").is_empty());
|
|
assert!(hits("text-slate-300 bg-red-500/10").is_empty());
|
|
assert_eq!(hits("text-slate-300 bg-red-500"), vec!["text-slate-300 on bg-red-500"]);
|
|
// A later solid class still pairs when an earlier one is a tint.
|
|
assert_eq!(
|
|
hits("text-slate-300 bg-red-500/10 bg-teal-600"),
|
|
vec!["text-slate-300 on bg-teal-600"]
|
|
);
|
|
}
|
|
|
|
/// REN-404. The bench's masthead: `#e8edf2` nav links on `#123a36`. The
|
|
/// ink is an off-white with a cool tint, not gray, and the pairing clears
|
|
/// contrast; the old test called everything under 0.85 relative luminance
|
|
/// gray and charged it three times over on a page with nothing wrong.
|
|
#[test]
|
|
fn off_white_on_a_colour_is_not_gray_ink() {
|
|
let ink = |hex_r: f64, hex_g: f64, hex_b: f64| {
|
|
check_colors(&ColorOpts {
|
|
tag: "p".to_string(),
|
|
font_size: 15.0,
|
|
font_weight: 400.0,
|
|
has_direct_text: true,
|
|
text_color: Some(Rgba::new(hex_r, hex_g, hex_b, 1.0)),
|
|
effective_bg: Some(Rgba::new(18.0, 58.0, 54.0, 1.0)),
|
|
..Default::default()
|
|
})
|
|
.into_iter()
|
|
.map(|h| h.id)
|
|
.collect::<Vec<_>>()
|
|
};
|
|
// #e8edf2 on #123a36.
|
|
assert_eq!(ink(232.0, 237.0, 242.0), Vec::<String>::new());
|
|
// White, the other neutral ink a coloured surface carries.
|
|
assert_eq!(ink(255.0, 255.0, 255.0), Vec::<String>::new());
|
|
// #8a8f8c: the muddy middle, still charged, and the contrast check
|
|
// beside it is untouched.
|
|
assert_eq!(
|
|
ink(138.0, 143.0, 140.0),
|
|
vec!["gray-on-color".to_string(), "low-contrast".to_string()]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn heading_tags_and_card_like() {
|
|
assert!(is_heading_tag("h4"));
|
|
assert!(!is_heading_tag("div"));
|
|
assert!(is_card_like_from_props(true, false, false, true));
|
|
assert!(!is_card_like_from_props(false, false, true, true));
|
|
}
|
|
}
|