Every filled control on a page, against both thresholds from the button palette:
Checking only the first is how a palette passes review and fails in practice.
It also flags any control still wearing a Bootstrap default, which means a role class the theme never defined.
edge, use var(--ts-border-control)That is the house border for a quiet control: a pale button, a filter chip, a
form field. It resolves to #6f6786, 3.04:1 on white, which clears the
3:1 non-text threshold with almost nothing to spare, and that is deliberate.
It is the darkest a border can be while still reading as quiet rather than as
an outlined button.
A divider and a control boundary are not the same value. They sit next to
each other in tokens.css precisely because conflating them is the mistake:
| Token | Measures | For |
|---|---|---|
--ts-border-quiet |
1.21:1 | a divider, which separates by position |
--ts-border-control |
3.04:1 | the edge of a control, which owes 3:1 |
A divider is allowed to be faint. A control is not. Reaching for the divider value on a control is the single most repeated defect in this codebase.
This page previously said to use the literal and not add a token, on the
grounds that a token invites reuse where the value is wrong. That was the
wrong call and it is worth saying why. The value ended up hardcoded in 17
places across five stylesheets, one of them a module shipping its own copy,
which is worse than the reuse risk it was avoiding. The reuse risk is real,
so it is handled by naming: control, not border or line. On glass or on
dark, measure again regardless.
The same defect has now turned up six times through three different mechanisms, so it is worth knowing all three:
btn-secondary, btn-light and
btn-outline-primary all fell through to a Bootstrap default nobody chose.Advanced toggle carried
border: "1px solid #e9ecef" in the component. The CSS sweep cannot see it.var(--primary-light) for both, so the border existed, had a
width, and was invisible. Edge 1.14.The last one is the one to search for by pattern rather than by symptom:
look for a border and a background resolving to the same value. Doing that
found five candidates, of which two were rows and a pseudo-element that are
correctly borderless, one was in a stylesheet libraries.yml never attaches,
and one was a real defect nobody had reported.
This is the single most important thing on this page, because it decides whether a sweep is measuring the page or measuring nothing.
| Vocabulary | Classes | Where |
|---|---|---|
| Bootstrap | .btn, .btn-primary, .card |
most of the estate |
| Drupal core | .button, .button--primary, .button--danger |
every Form API action, core-buttons.css |
ts-* |
.ts-card, .ts-btn--primary, .ts-btn--ghost, .ts-ibtn--danger |
connected accounts, social composer |
A sweep written against .btn reported the connected-accounts page as having
one control when it has five, and reported it clean. The same blind
spot hid the page from the glass conversion: the rules name .card, and that
page has no .card on it, so it kept solid white panels on a glass field and
looked like a page somebody had forgotten.
A clean result from a checker that cannot see the controls is not a pass. Before trusting a sweep on an unfamiliar page, count the controls it says it checked and compare that with what you can see.
ts-* controls draw their boundary with an inset box-shadow, not a
border, so borderTopWidth is 0 and a border-only check finds nothing.
Worse, getComputedStyle returns the shadow with the colour first:
rgb(167, 139, 174) 0px 0px 0px 1px insetA naive parse takes the leading numbers as the colour, reads the offsets as
rgb(0,0,0) or falls through entirely. That produced a confident 1.14 on
buttons with plainly visible outlines. Take the first rgba?(...) in the
string, never the first numbers.
It found one real defect once fixed: --ca-danger-ring at 2.70:1, on a
destructive control whose #FDECEA fill sits at about 1.1 against the card,
so the ring was the only boundary it had.
(() => {
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 ratio=(a,b)=>{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 hex=c=>'#'+c.slice(0,3).map(x=>Math.round(x).toString(16).padStart(2,'0')).join('');
// The surface BEHIND an element. Never use this as the thing text sits on:
// if the element has its own fill, the text sits on that. Getting this wrong
// reports white-on-purple as white-on-white.
const behind=el=>{let n=el.parentElement;
while(n){const c=pr(getComputedStyle(n).backgroundColor);
if(c.length>=3&&(c[3]===undefined||c[3]>0.5))return c.slice(0,3);
n=n.parentElement;}
return [255,255,255];};
// The surface the element's OWN text sits on.
const surfaceOf=el=>{const c=pr(getComputedStyle(el).backgroundColor);
return (c.length>=3&&(c[3]===undefined||c[3]>0.5)) ? c.slice(0,3) : behind(el);};
// An edge can be an inset box-shadow rather than a border. The computed
// string puts the COLOUR FIRST, so take the first rgb(), not the first
// numbers, or you read the offsets as the colour.
const ringColour=s=>{const m=(s||'').match(/rgba?\(([^)]+)\)/);
return m?m[1].split(',').slice(0,3).map(Number):null;};
const BOOTSTRAP={'#6c757d':'bootstrap grey','#0d6efd':'bootstrap blue',
'#198754':'bootstrap green','#dc3545':'bootstrap red'};
const seen=new Set(), out=[];
// All three vocabularies, plus fields. Omitting any of these reports a page
// as clean because nothing was measured on it.
document.querySelectorAll('.btn, button, [role=button], [role=tab], .nav-link,'
+' input, select, textarea, .ts-btn, .ts-ibtn, .button, .button--primary,'
+' .button--danger')
.forEach(el=>{
const r=el.getBoundingClientRect(); if(r.width<28||r.height<16) return;
const cs=getComputedStyle(el);
const surface=surfaceOf(el);
const ink=pr(cs.color).slice(0,3);
const label=(el.textContent||'').trim().replace(/\s+/g,' ').slice(0,24)||'(icon)';
const k=label+hex(surface); if(seen.has(k)) return; seen.add(k);
// A tab or link has no button boundary, so only its label is judged.
// Only something that PRESENTS as a button owes you an edge. A control
// with no border whose fill matches what is behind it is a row or a
// link: it separates by divider or position, and demanding 3:1 of it
// reports every list item on the page.
const bw=parseFloat(cs.borderTopWidth)||0;
const ring=ringColour(cs.boxShadow);
const bg=behind(el);
const hasOwnFill = hex(surface)!==hex(bg);
const isTab = el.matches('[role=tab], .nav-link');
const isField = el.matches('input,select,textarea');
const presentsAsButton = isField || (!isTab && (bw>0 || ring || hasOwnFill));
const edge = !presentsAsButton ? null
: (bw>0 ? ratio(pr(cs.borderTopColor).slice(0,3), bg)
: ring ? ratio(ring, bg)
: ratio(surface, bg));
const hasText = (el.textContent||'').trim().length>0;
const labelRatio = hasText ? ratio(ink,surface) : null;
const problems=[];
if(labelRatio!==null && labelRatio<4.5) problems.push('label '+labelRatio);
if(edge!==null && edge<3) problems.push('edge '+edge);
if(BOOTSTRAP[hex(surface)]) problems.push(BOOTSTRAP[hex(surface)]);
if(problems.length) out.push({label, fill:hex(surface), problems:problems.join(', ')});
});
return out;
})()An empty array is a pass.
Comparing text to the wrong surface. The first version used the
behind() helper for label contrast. For an element with its own fill that is
the wrong surface: it reported the YouTube Search tab, white on #712f79 at
8.81:1, as white-on-white at 1:1. Three of four reported failures on
that page were this bug. surfaceOf() above is the fix.
Judging a tab as if it were a button. A tab has no boundary to measure, so an “edge” figure for one is meaningless. The version above skips the edge test for tabs and links entirely.
Demanding an edge from things that are not buttons. The dashboard’s
next-steps rows are <button> elements, but they are transparent full-width
rows separated by dividers. The checker reported all five as edge 1, plus an
icon-only button as label 1 for having no text to measure. A control only
owes you an edge if it presents as a button: a border, or a fill that
differs from what is behind it. Otherwise it is a row or a link.
Reading an inset box-shadow as if it were a border. Covered above: the
ts-* controls draw their edge with a shadow whose computed string leads with
the colour. Parsing the leading numbers reported 1.14 on buttons that
visibly had outlines.
That is four corrections to this checker, three of them in one afternoon, which is the point of the last section below.
Both produced tidy-looking lists of failures that were not failures. A clean result from a checker is only as trustworthy as the checker, and the way to find out is to take one reported failure and confirm it by eye before acting on the rest.
This one produced a wrong diagnosis that reached a merged commit, so it is worth more than a footnote.
Driving a page through browser automation, CSS transitions do not run. A plain
opacity 1 to 0 over 200ms still reads 1.0 after 900ms. Every transitioned
property therefore reports its previous value indefinitely.
// paste this before trusting any measurement of a transitioned property
(() => { const e=document.createElement('div');
e.style.cssText='position:fixed;width:9px;height:9px;opacity:1;transition:opacity 200ms linear;';
document.body.appendChild(e); void e.offsetHeight; e.style.opacity='0';
return new Promise(r=>setTimeout(()=>{const v=getComputedStyle(e).opacity;
e.remove(); r(v==='0' ? 'transitions run' : 'transitions DO NOT run: see below');},800));})()What this looks like when it fools you: a state indicator that is correct on page load and appears frozen on the previous selection after every click. That is indistinguishable from a genuinely broken indicator, and it is what led to a confident and incorrect explanation involving font weights and font files.
Three different properties appeared to stick before the instrument was suspected. Repetition across unrelated properties is the tell.
Measure a transitioned property one of two ways:
transition: none injected first, which makes the value snap to
what the cascade actually says.Both are cheap. Use one of them before reporting that a state is stuck.