import { Rule } from "@siteimprove/alfa-act" ; import { Array } from "@siteimprove/alfa-array" ; import { Cache } from "@siteimprove/alfa-cache" ; import { Color } from "@siteimprove/alfa-css" ; import { Device } from "@siteimprove/alfa-device" ; import { Element , Node , Text } from "@siteimprove/alfa-dom" ; import { Iterable } from "@siteimprove/alfa-iterable" ; import { Map } from "@siteimprove/alfa-map" ; import { Option , None } from "@siteimprove/alfa-option" ; import { Predicate } from "@siteimprove/alfa-predicate" ; import { Refinement } from "@siteimprove/alfa-refinement" ; import { Err , Ok , Result } from "@siteimprove/alfa-result" ; import { Context } from "@siteimprove/alfa-selector" ; import { Sequence } from "@siteimprove/alfa-sequence" ; import { Set } from "@siteimprove/alfa-set" ; import { Style } from "@siteimprove/alfa-style" ; import { Criterion } from "@siteimprove/alfa-wcag" ; import { Page } from "@siteimprove/alfa-web" ; import { Contrast } from "../../src/common/diagnostic/contrast" ; import { expectation } from "../common/expectation" ; import { contrast } from "../common/expectation/contrast" ; import { getForeground } from "../common/expectation/get-colors" ; import { hasBorder , hasComputedStyle , hasOutline , hasRole , hasTextDecoration , isVisible , isWhitespace , } from "../common/predicate" ; import { Scope , Stability } from "../tags" ; import { DistinguishingStyles , ElementDistinguishable } from "./diagnostics" ; const { isElement } = Element ; const { isText } = Text ; const { or , not , test } = Predicate ; const { and } = Refinement ; const pairwiseContrastCache = Cache . empty < Device , Cache < Context , Cache < Element , Cache < Element , ReadonlyArray < Contrast . Pairing > > > > > ( ) ; /**
 * This version of R62 accepts differences in `font-family`, differences
 * in `cursor` (in the `hover` state), and 3:1 or more contrast with surrounding
 * text.
 *
 * These cannot be easily displayed in the current Page report:
 * * `cursor` only shows on hovering, we could have a `a:link` and `a:hover`
 *   states that look similar, but the first one is bad and the second good,
 *   resulting in confusing info;
 * * contrast with surrounding text is not shown as only link text is shown in
 *   the decorator. Additionally, we could have similar looking states but with
 *   different colors, one being good and the other bad, with little way to
 *   understand the difference.
 * * `font-family` may depend on a font that for some reason can't be loaded,
 *   so the difference wouldn't be visible.
 */ export default Rule . Atomic . of < Page , Element > ( { uri : "https://alfa.siteimprove.com/rules/sia-r62#experimental" , requirements : [ Criterion . of ( "1.4.1" ) ] , tags : [ Scope . Component , Stability . Experimental ] , evaluate ( { device , document } ) { let containers = Map . empty ( ) ; return { applicability ( ) { return visit ( document , None ) ; function * visit ( node , container ) { if ( isElement ( node ) ) { if ( test ( hasRole ( device , ( role ) => role . is ( "link" ) ) , node ) ) { if ( container . isSome ( ) && node . descendants ( { flattened : true } ) . some ( and ( isText , isVisible ( device ) ) ) ) { containers = containers . set ( node , container . get ( ) ) ; return yield node ; } } else if ( test ( and ( hasRole ( device , "paragraph" ) , hasNonLinkText ( device ) ) , node ) ) { container = Option . of ( node ) ; } } const children = node . children ( { flattened : true , nested : true , } ) ; for ( const child of children ) { yield * visit ( child , container ) ; } } } , expectations ( target ) { const nonLinkElements = containers . get ( target ) . get ( ) . inclusiveDescendants ( { flattened : true , nested : true , } ) . filter ( and ( isElement , hasNonLinkText ( device ) ) ) ; const linkElements = target . inclusiveDescendants ( { flattened : true , nested : true , } ) . filter ( isElement ) . concat ( target . ancestors ( { flattened : true , nested : true , } ) . takeWhile ( and ( isElement , not ( hasNonLinkText ( device ) ) ) ) ) ; const hasDistinguishingStyle = ( context = Context . empty ( ) ) => Set . from ( linkElements . map ( ( link ) => { const hasDistinguishableStyle = nonLinkElements . some ( ( container ) => Distinguishable . isDistinguishable ( container , device , context ) ( link ) || ( context . isHovered ( target ) && Distinguishable . hasDistinguishableCursor ( container , device , context ) ( link ) ) ) ; const distinguishableContrast = Set . from ( nonLinkElements . flatMap ( ( container ) => Sequence . from ( pairwiseContrastCache . get ( device , Cache . empty ) . get ( context , Cache . empty ) . get ( container , Cache . empty ) . get ( link , ( ) => Distinguishable . getPairwiseContrast ( container , link , device , context ) ) ) ) ) ; return hasDistinguishableStyle ? Ok . of ( ElementDistinguishable . from ( link , device , target , context , distinguishableContrast ) ) : Err . of ( ElementDistinguishable . from ( link , device , target , context , distinguishableContrast ) ) ; } ) ) . toArray ( ) . sort ( ( a , b ) => ( b . isOk ( ) ? 1 : - 1 ) ) ; const isDefaultDistinguishable = hasDistinguishingStyle ( ) ; const isHoverDistinguishable = hasDistinguishingStyle ( Context . hover ( target ) ) ; const isFocusDistinguishable = hasDistinguishingStyle ( Context . focus ( target ) ) ; return { 1 : expectation ( isDefaultDistinguishable [ 0 ] . isOk ( ) && isHoverDistinguishable [ 0 ] . isOk ( ) && isFocusDistinguishable [ 0 ] . isOk ( ) , ( ) => Outcomes . IsDistinguishable ( isDefaultDistinguishable , isHoverDistinguishable , isFocusDistinguishable ) , ( ) => Outcomes . IsNotDistinguishable ( isDefaultDistinguishable , isHoverDistinguishable , isFocusDistinguishable ) ) , } ; } , } ; } , } ) ; export namespace Outcomes { export const IsDistinguishable = ( defaultStyles , hoverStyles , focusStyles ) => Ok . of ( DistinguishingStyles . of ( `The link is distinguishable from the surrounding text` , defaultStyles , hoverStyles , focusStyles ) ) ; export const IsNotDistinguishable = ( defaultStyles , hoverStyles , focusStyles ) => Err . of ( DistinguishingStyles . of ( `The link is not distinguishable from the surrounding text` , defaultStyles , hoverStyles , focusStyles ) ) ; } const hasNonLinkTextCache = Cache . empty < Element , boolean > ( ) ; function hasNonLinkText ( device ) { return function hasNonLinkText ( element ) { return hasNonLinkTextCache . get ( element , ( ) => { if ( element . inclusiveAncestors ( { flattened : true , } ) . some ( and ( isElement , hasRole ( device , ( role ) => role . is ( "link" ) ) ) ) ) { return false ; } const children = element . children ( { flattened : true , } ) ; if ( children . some ( and ( isText , and < Text > ( isVisible ( device ) , ( text ) => ! isWhitespace ( text . data ) ) ) ) ) { return true ; } return children . filter ( isElement ) . reject ( hasRole ( device , ( role ) => role . is ( "link" ) ) ) . some ( hasNonLinkText ) ; } ) ; } ; } namespace Distinguishable { export function isDistinguishable ( container , device , context = Context . empty ( ) ) { return or ( hasDistinguishableBackground ( container , device , context ) , hasDistinguishableContrast ( container , device , context ) , hasDistinguishableFont ( container , device , context ) , hasDistinguishableTextDecoration ( container , device , context ) , hasDistinguishableVerticalAlign ( container , device , context ) , hasBorder ( device , context ) , hasOutline ( device , context ) ) ; } function hasDistinguishableTextDecoration ( container , device , context ? ) { return ( element ) => test ( not ( hasTextDecoration ( device , context ) ) , container ) && test ( hasTextDecoration ( device , context ) , element ) ; } remarks function hasDistinguishableBackground ( container , device , context ? ) { const colorReference = Style . from ( container , device , context ) . computed ( "background-color" ) . value ; const imageReference = Style . from ( container , device , context ) . computed ( "background-image" ) . value ; return or ( hasComputedStyle ( "background-color" , not ( ( color ) => Color . isTransparent ( color ) || color . equals ( colorReference ) ) , device , context ) , hasComputedStyle ( "background-image" , not ( ( image ) => image . equals ( imageReference ) ) , device , context ) ) ; } export function getPairwiseContrast ( container , link , device , context = Context . empty ( ) ) { return getForeground ( container , device , context ) . map ( ( containerColors ) => [ ... Array . flatMap ( containerColors , ( containerColor ) => getForeground ( link , device , context ) . map ( ( linkColors ) => Array . map ( linkColors , ( linkColor ) => { return Contrast . Pairing . of ( containerColor , linkColor , contrast ( containerColor , linkColor ) ) ; } ) ) . getOr ( [ ] ) ) , ] ) . getOr ( [ ] ) ; } function hasDistinguishableContrast ( container , device , context = Context . empty ( ) ) { return ( link ) => { const contrastPairings = pairwiseContrastCache . get ( device , Cache . empty ) . get ( context , Cache . empty ) . get ( container , Cache . empty ) . get ( link , ( ) => getPairwiseContrast ( container , link , device , context ) ) ; for ( const contrastPairing of contrastPairings ) { if ( contrastPairing . contrast >= 3 ) { return true ; } } return false ; } ; } /**
     * Check if an element has a different font weight or family than its container.
     *
     * This is brittle and imperfect but removes a strong pain point until we find
     * a better solution.
     */ function hasDistinguishableFont ( container , device , context ? ) { const style = Style . from ( container , device , context ) ; const referenceWeight = style . computed ( "font-weight" ) . value ; const referenceFamily = Option . from ( style . computed ( "font-family" ) . value . values [ 0 ] ) ; return or ( hasComputedStyle ( "font-weight" , not ( ( weight ) => weight . equals ( referenceWeight ) ) , device , context ) , hasComputedStyle ( "font-family" , not ( ( family ) => Option . from ( family . values [ 0 ] ) . equals ( referenceFamily ) ) , device , context ) ) ; } function hasDistinguishableVerticalAlign ( container , device , context ? ) { const reference = Style . from ( container , device , context ) . computed ( "vertical-align" ) . value ; return hasComputedStyle ( "vertical-align" , not ( ( alignment ) => alignment . equals ( reference ) ) , device , context ) ; } export function hasDistinguishableCursor ( container , device , context ? ) { function getFirstCursor ( style ) { return style . values [ 0 ] . values . length !== 0 ? style . values [ 0 ] . values [ 0 ] : style . values [ 1 ] ; } const containerCursorStyle = Style . from ( container , device , context ) . computed ( "cursor" ) . value ; const reference = getFirstCursor ( containerCursorStyle ) ; return hasComputedStyle ( "cursor" , not ( ( cursor ) => getFirstCursor ( cursor ) . equals ( reference ) ) , device , context ) ; } }