The rule is on Cards and surfaces: check what sits on a surface, not just the text in it. This page is how to actually check it, because the cases that break are the ones you cannot see.
A worked example. The dashboard shortcut rows fill with brand purple on hover
and their label inherits white from that fill. A muted-ink rule scoped
body.ts-glass-page .card p matched them, because those rows are cards
whose label is a <p>. Setting a colour on the <p> directly beats the
inherited white, since inheritance loses to any rule that names the
element. The label stayed #5b5872 on #712F79: 1.29:1, against a 4.5
threshold.
Nothing about that is visible in a screenshot, a design review, or the page at rest. The surface only exists while the pointer is on it.
A state indicator can pass its text check and still fail as an indicator. Both numbers matter and they are not the same number.
Paste into the console on any page. It reports every interactive element whose text would fall below 4.5:1 in a hover, focus or active state.
For each link, button and list item it finds any rule giving it or an ancestor a background in an interaction state, resolves which colour rule wins on the text in that state by specificity and source order, and computes the contrast.
(() => {
const lum=c=>{const f=v=>{v/=255;return v<=0.03928?v/12.92:Math.pow((v+0.055)/1.055,2.4);};
return 0.2126*f(c[0])+0.7152*f(c[1])+0.0722*f(c[2]);};
const pr=s=>(s.match(/\d+(\.\d+)?/g)||[]).map(Number);
const resolve=v=>{v=(v||'').trim();
const m=v.match(/var\(\s*(--[\w-]+)\s*(?:,\s*([^)]+))?\)/);
if(m){const r=getComputedStyle(document.documentElement).getPropertyValue(m[1]).trim();v=r||m[2]||'';}
v=v.trim();
if(v.startsWith('#')){const h=v.replace('#','');
const e=h.length===3?h.split('').map(x=>x+x).join(''):h;
return [0,2,4].map(i=>parseInt(e.slice(i,i+2),16));}
const n=pr(v); return n.length>=3?n.slice(0,3):null;};
const ratio=(a,b)=>{if(!a||!b)return null;const L1=lum(a),L2=lum(b);
return Math.round(((Math.max(L1,L2)+0.05)/(Math.min(L1,L2)+0.05))*100)/100;};
const spec=s=>{const i=(s.match(/#[\w-]+/g)||[]).length,
c=(s.match(/\.[\w-]+|\[[^\]]+\]|:(?!:)[\w-]+(\([^)]*\))?/g)||[]).length,
e=(s.match(/(^|[\s>+~])[a-zA-Z][\w-]*/g)||[]).length; return i*100+c*10+e;};
const all=[]; let o=0;
for(const s of document.styleSheets){let R;try{R=s.cssRules;}catch(e){continue;}
(function w(l){for(const r of l){
if(r.cssRules&&!r.selectorText){w(r.cssRules);continue;}
if(!r.selectorText||!r.style)continue; o++;
const bg=r.style.getPropertyValue('background-color')||r.style.getPropertyValue('background');
const col=r.style.getPropertyValue('color');
for(const p of r.selectorText.split(',').map(x=>x.trim())){
const st=/:hover|:focus|\.active|\.is-active/.test(p);
if(bg&&st)all.push({p,b:p.replace(/:hover|:focus/g,''),bg,s:spec(p),o,k:'bg'});
if(col)all.push({p,b:p.replace(/:hover|:focus/g,''),col,s:spec(p),o,k:'col'});
}}})(R);}
const out=[];
document.querySelectorAll('a,button,li,[role=button]').forEach(el=>{
if(!el.getBoundingClientRect().width)return;
let fill=null,n=el;
for(let d=0;d<3&&n;d++,n=n.parentElement){
for(const r of all){if(r.k!=='bg')continue;let m=false;try{m=n.matches(r.b);}catch(e){}
if(m){const c=resolve(r.bg);if(c&&(!fill||r.s>=fill.s))fill={c,s:r.s,sel:r.p};}}
if(fill)break;}
if(!fill)return;
[el,...el.querySelectorAll('p,span')]
.filter(t=>!t.children.length&&(t.textContent||'').trim()).slice(0,2)
.forEach(t=>{
let win=null;
for(const r of all){if(r.k!=='col')continue;let m=false;try{m=t.matches(r.b);}catch(e){}
if(m&&(!win||r.s>win.s||(r.s===win.s&&r.o>win.o)))win=r;}
const rr=ratio(win?resolve(win.col):null, fill.c);
if(rr!==null&&rr<4.5)out.push({text:(t.textContent||'').trim().slice(0,22),
fill:fill.sel.slice(0,40), ink:(win&&win.p||'').slice(0,40), contrast:rr});});
});
const seen=new Set();
return out.filter(f=>{const k=f.text+f.fill;if(seen.has(k))return false;seen.add(k);return true;});
})()An empty array is a pass. Anything returned gives you the text, the rule supplying the fill, the rule supplying the ink, and the ratio.
It rules out one specific thing well: a broad colour rule pinning text over an interaction fill. That is the bug it was written for.
And it cannot see a control that is wrong in what it promises rather than in what it measures. A dropdown trigger with no caret passes every check on this page and is still wrong. See Controls and affordance.
A rule that names an element beats one that only reaches it by inheritance. Most of these failures are that sentence, arriving somewhere nobody looked.