Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8aa90e90a4 | ||
|
|
dc5db16ffd | ||
|
|
b9b2237dbe |
@@ -586,12 +586,16 @@ button,
|
|||||||
box-shadow: 0 0 0 2px rgba(9, 255, 0, 0.4), 0 4px 14px rgba(0, 0, 0, 0.5);
|
box-shadow: 0 0 0 2px rgba(9, 255, 0, 0.4), 0 4px 14px rgba(0, 0, 0, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The chair number is 9px, and on an empty seat it is 9px behind a 0.7 fade: #8a8a8a
|
||||||
|
came out at 3.39:1 once the fade was composited, for the label a dealer reads to
|
||||||
|
answer "which seat is seat 6?". Lifted until it passes with a little to spare —
|
||||||
|
measured 4.5:1 at #a5a5a5 exactly, so it sits above that. */
|
||||||
.seatChairNumber {
|
.seatChairNumber {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 2px;
|
top: 2px;
|
||||||
left: 6px;
|
left: 6px;
|
||||||
font-size: 0.58rem;
|
font-size: 0.58rem;
|
||||||
color: #8a8a8a;
|
color: #aeaeae;
|
||||||
}
|
}
|
||||||
|
|
||||||
.seatPuckName {
|
.seatPuckName {
|
||||||
@@ -816,6 +820,43 @@ button,
|
|||||||
outline-offset: 1px;
|
outline-offset: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Seats close enough together that full-size pucks would overlap. The class is set
|
||||||
|
from a measurement render.js takes off the ring itself — the closest two neighbours,
|
||||||
|
straight line — because no media query can see it: the same 390px phone is roomy at
|
||||||
|
six seats and crowded at nine, and a wide landscape felt crowds at its ends while
|
||||||
|
its rim says there is space. Not scoped to a screen size for the same reason. */
|
||||||
|
.seatsTight .seat {
|
||||||
|
/* 58 was chosen by measurement and 46 was tried against it. On a roomy phone 46
|
||||||
|
cleared every overlap at nine seats where 58 left two — but in the state that
|
||||||
|
actually matters, a hand with a side pot where the bar has grown and the felt has
|
||||||
|
shrunk to suit, 46 overlapped four times and 58 twice. Pucks touching at that
|
||||||
|
density is the felt being small, not the puck being wide, and paying for it with
|
||||||
|
an ellipsis on every long name buys nothing. What has to hold is that each seat
|
||||||
|
keeps its own centre, so a tap lands on the player it was aimed at, and that is
|
||||||
|
measured at every size. */
|
||||||
|
width: 58px;
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 3px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatsTight .seatPuckName {
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
font-size: 0.62rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatsTight .seatPuckStack {
|
||||||
|
font-size: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The hole cards give up their line here. A puck this close to its neighbours is 64px
|
||||||
|
tall and the seats at the ends of a landscape felt are 55px apart, so the line has to
|
||||||
|
come from somewhere — and of the four things on a puck, the cards are the one the
|
||||||
|
dealer is holding in their other hand. Name, bet and stack stay. The seat editor
|
||||||
|
still shows the cards in full. */
|
||||||
|
.seatsTight .seatPuckCards {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- Seat editor panel (sidebar form for the selected seat) ---------- */
|
/* ---------- Seat editor panel (sidebar form for the selected seat) ---------- */
|
||||||
|
|
||||||
.seatEditorPanel {
|
.seatEditorPanel {
|
||||||
@@ -2906,14 +2947,31 @@ body.hasActionBar #toastHost {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 700px) {
|
@media (max-width: 700px) {
|
||||||
/* The narrow layout already had an answer for the bar: it reserves the bar's height
|
/* The cap lives here now, and the reason it didn't is worth keeping.
|
||||||
as scroll room below the table, and a phone is a thing you scroll. Capping the
|
|
||||||
felt here as well was measured and rejected — at 360x640 it took the table from
|
It was measured and rejected once: at 360x640 it took the table from 321px to
|
||||||
321px to 226px, which fixed the six-seat ring but broke the nine-seat one, two
|
226px, which fixed the six-seat ring but broke the nine-seat one — two covered
|
||||||
covered seats traded for two seats whose centre landed on a neighbour. The cap is
|
seats traded for two seats whose centre landed on a neighbour. The narrow layout
|
||||||
for the wide layout, which never consulted the bar's height at all. */
|
fell back on reserving the bar's height as scroll room, on the grounds that a
|
||||||
|
phone is a thing you scroll.
|
||||||
|
|
||||||
|
That trade was real then and isn't now: seats are spaced by distance around the
|
||||||
|
rim rather than by angle, so a shorter felt no longer piles them up. Re-measured
|
||||||
|
at 360x640 with the bar grown by a side pot, where the bottom seat sat 38px
|
||||||
|
inside it: the ring now clears the bar by 50. Scroll room is not an answer for a
|
||||||
|
seat that is covered rather than merely below the fold.
|
||||||
|
|
||||||
|
The room is spent on height, not on both. Capping the *width* by it — the wide
|
||||||
|
layout's way, because there the felt is sized from its width — kept the table
|
||||||
|
square and threw away 96px of a 360px screen: 240x240 inside a 336px column. A
|
||||||
|
max-height leaves the width alone and lets the aspect ratio give way, so a short
|
||||||
|
screen gets a wide oval, which is the shape a poker table is anyway. Measured at
|
||||||
|
360x640: 336x240 rather than 240x240, same clearance, and the twelve-seat ring
|
||||||
|
that was crowded at 240 comes out clean. Where the room isn't the binding
|
||||||
|
constraint — 390x844 and up — nothing changes and the table stays round. */
|
||||||
#pokerTableWrap {
|
#pokerTableWrap {
|
||||||
max-width: none;
|
max-width: none;
|
||||||
|
max-height: var(--feltRoomPx, 100vh);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Stacked, with the action that ends the hand furthest from a stray thumb. */
|
/* Stacked, with the action that ends the hand furthest from a stray thumb. */
|
||||||
@@ -2985,10 +3043,18 @@ body.hasActionBar #toastHost {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Keeps every puck inside the viewport: the ring is drawn within this box,
|
/* Keeps every puck inside the viewport: the ring is drawn within this box,
|
||||||
so half a puck of padding is exactly enough to stop the overhang. */
|
so half a puck of padding is exactly enough to stop the overhang.
|
||||||
|
|
||||||
|
Half a puck is the right answer when the felt is tall enough to spare it. On a
|
||||||
|
phone held sideways it isn't: the felt is 96px tall there once the action bar has
|
||||||
|
grown, and 26px off the top and bottom left the ring 44px of height to work with
|
||||||
|
— less than half the table. That is what kept the seats at the ends stacked no
|
||||||
|
matter what shape the rim was.
|
||||||
|
|
||||||
|
Above about 216px of felt the flat 26px still wins, so nothing upright changes. */
|
||||||
#seatsLayer,
|
#seatsLayer,
|
||||||
#betsLayer {
|
#betsLayer {
|
||||||
inset: 26px 44px;
|
inset: min(26px, 12%) 44px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Narrower than the desktop puck: at a full ten-handed table the ring is
|
/* Narrower than the desktop puck: at a full ten-handed table the ring is
|
||||||
@@ -3277,6 +3343,11 @@ body.hasActionBar #toastHost {
|
|||||||
a table that is already as short as the screen allows. */
|
a table that is already as short as the screen allows. */
|
||||||
aspect-ratio: auto;
|
aspect-ratio: auto;
|
||||||
max-width: none;
|
max-width: none;
|
||||||
|
/* This branch already spends the measured room on its own height. The phone
|
||||||
|
block's max-height is the same number arriving twice, and applying it here
|
||||||
|
took the felt down to the floor value and crowded the ring — measured at
|
||||||
|
640x360, two seats lost their centres to their neighbours. */
|
||||||
|
max-height: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The page's own top padding is 32px of nothing, on the screen that has the least
|
/* The page's own top padding is 32px of nothing, on the screen that has the least
|
||||||
@@ -3352,6 +3423,16 @@ body.hasActionBar #toastHost {
|
|||||||
min-height: 40px;
|
min-height: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The same inset the phone layout uses, which this branch never had because it is
|
||||||
|
matched on height rather than width — so an 844px-wide phone on its side drew its
|
||||||
|
ring against the felt's full box. With the rim a stadium, the top rail is flat,
|
||||||
|
which put the button seat's dealer chip over the Resolve Pot button in the header
|
||||||
|
above. Percentages of a short felt, so the ring keeps the height it needs. */
|
||||||
|
#seatsLayer,
|
||||||
|
#betsLayer {
|
||||||
|
inset: min(26px, 12%) 44px;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Short phones, upright ----------
|
/* ---------- Short phones, upright ----------
|
||||||
|
|||||||
@@ -148,9 +148,13 @@ button,
|
|||||||
color: #8f8f8f;
|
color: #8f8f8f;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 37px tall, on the field a dealer types every amount into. The touch rule has been
|
||||||
|
44px everywhere else since the round that set it; this page never got measured for
|
||||||
|
it, because the audit only ran the target check when asked and nothing asked. */
|
||||||
.unevenAmount {
|
.unevenAmount {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
min-height: 46px;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
outline: none;
|
outline: none;
|
||||||
@@ -180,10 +184,12 @@ button,
|
|||||||
color: #ffd6d6;
|
color: #ffd6d6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 30px across, and it deletes a player's entry — the one control on the row where a
|
||||||
|
near miss costs something. The amount field beside it gives up the 16px. */
|
||||||
.unevenRemoveBtn {
|
.unevenRemoveBtn {
|
||||||
flex: none;
|
flex: none;
|
||||||
width: 30px;
|
width: 46px;
|
||||||
height: 44px;
|
height: 46px;
|
||||||
border: none;
|
border: none;
|
||||||
background: none;
|
background: none;
|
||||||
/* Was #6a6a6a — 3.66:1, the worst on the page, on a control that deletes a
|
/* Was #6a6a6a — 3.66:1, the worst on the page, on a control that deletes a
|
||||||
|
|||||||
@@ -89,6 +89,10 @@ const ActionBar = {
|
|||||||
document.documentElement.style.setProperty(
|
document.documentElement.style.setProperty(
|
||||||
"--feltRoomPx",
|
"--feltRoomPx",
|
||||||
`${this.feltRoomFor(window.innerHeight, barHeight, topInDocument)}px`);
|
`${this.feltRoomFor(window.innerHeight, barHeight, topInDocument)}px`);
|
||||||
|
// The felt has just changed size, so how much rim each seat gets has changed
|
||||||
|
// with it. Told rather than left to be noticed on the next render, which on a
|
||||||
|
// bar that grew mid-hand could be several actions away.
|
||||||
|
if (typeof Render !== "undefined" && Render.updateSeatDensity) Render.updateSeatDensity();
|
||||||
},
|
},
|
||||||
|
|
||||||
// Anything to act on? A hand with nobody dealt in doesn't get a bar — and that
|
// Anything to act on? A hand with nobody dealt in doesn't get a bar — and that
|
||||||
|
|||||||
@@ -91,12 +91,21 @@ const Render = {
|
|||||||
// circle when there's nothing to measure — the same answer the ring gave before it
|
// circle when there's nothing to measure — the same answer the ring gave before it
|
||||||
// asked.
|
// asked.
|
||||||
feltAspect() {
|
feltAspect() {
|
||||||
if (typeof document === "undefined") return 1;
|
const box = this.ringBox();
|
||||||
const wrap = document.getElementById("pokerTableWrap");
|
return box ? box.width / box.height : 1;
|
||||||
if (!wrap) return 1;
|
},
|
||||||
const box = wrap.getBoundingClientRect();
|
|
||||||
if (!box.width || !box.height) return 1;
|
// The box a seat's left/top percentages actually resolve against. Not the felt: on
|
||||||
return box.width / box.height;
|
// a phone the seat layer is inset by half a puck so the outermost seats don't hang
|
||||||
|
// off the screen, and measuring the felt instead put the ring's shape out by that
|
||||||
|
// inset — twelve seats at 640x360 were computed 90px apart and drawn 60px apart,
|
||||||
|
// which is the difference between "fine" and "overlapping".
|
||||||
|
ringBox() {
|
||||||
|
if (typeof document === "undefined") return null;
|
||||||
|
const layer = document.getElementById("seatsLayer") || document.getElementById("pokerTableWrap");
|
||||||
|
if (!layer) return null;
|
||||||
|
const box = layer.getBoundingClientRect();
|
||||||
|
return box.width && box.height ? box : null;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Bets sit on the felt between their player and the pot, the way chips actually
|
// Bets sit on the felt between their player and the pot, the way chips actually
|
||||||
@@ -108,8 +117,11 @@ const Render = {
|
|||||||
// anything visible — it is just the honest way to say "on that player's spoke",
|
// anything visible — it is just the honest way to say "on that player's spoke",
|
||||||
// and it saves the second walk.
|
// and it saves the second walk.
|
||||||
betPosition(index, count, aspect = this.feltAspect()) {
|
betPosition(index, count, aspect = this.feltAspect()) {
|
||||||
const angle = this.ringAngles(count, 44, 42, aspect)[index];
|
const seat = this.seatPosition(index, count, aspect);
|
||||||
return { left: 50 + 27 * Math.cos(angle), top: 50 + 25 * Math.sin(angle) };
|
return {
|
||||||
|
left: 50 + (seat.left - 50) * (27 / 44),
|
||||||
|
top: 50 + (seat.top - 50) * (25 / 42),
|
||||||
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
// Seats spaced evenly around the rim — by the distance between them, not by the
|
// Seats spaced evenly around the rim — by the distance between them, not by the
|
||||||
@@ -127,16 +139,111 @@ const Render = {
|
|||||||
// an ellipse's has no closed form — 720 steps is far more than enough for a rim a
|
// an ellipse's has no closed form — 720 steps is far more than enough for a rim a
|
||||||
// few hundred pixels long, and it runs once per render.
|
// few hundred pixels long, and it runs once per render.
|
||||||
positionOnRing(index, count, rx, ry, aspect = 1) {
|
positionOnRing(index, count, rx, ry, aspect = 1) {
|
||||||
const target = this.ringAngles(count, rx, ry, aspect)[index];
|
return this.ringPositions(count, rx, ry, aspect)[index];
|
||||||
return {
|
},
|
||||||
left: 50 + rx * Math.cos(target),
|
|
||||||
top: 50 + ry * Math.sin(target),
|
// Past this much wider than tall, the rim stops being an ellipse and becomes a
|
||||||
};
|
// stadium — two straight rails with a semicircular cap at each end.
|
||||||
|
//
|
||||||
|
// An ellipse that flat has nowhere to put the seats near its ends. At 640x360 with
|
||||||
|
// the bar grown by a side pot the felt is 616x96, and six seats came out with two
|
||||||
|
// of them stacked 25px apart at each end, pucks 64px tall. No puck size fixes 25px:
|
||||||
|
// the shape is wrong. A stadium's cap is a half-circle of the felt's own height, so
|
||||||
|
// an end holds one seat and the rest of the table is rail — which is how a real
|
||||||
|
// poker table is built, and why it is shaped that way.
|
||||||
|
//
|
||||||
|
// Only where it is needed. Anything rounder than this keeps the ellipse it has
|
||||||
|
// always had: measured, the cut-off lands between a landscape phone (4.4 and up)
|
||||||
|
// and every other layout the tool has (1.45 at 1280x900, 1.0 to 1.4 on a phone
|
||||||
|
// upright), so no currently-working table changes shape.
|
||||||
|
STADIUM_RATIO: 2.5,
|
||||||
|
|
||||||
|
// Seats spaced evenly around whichever rim this table has, as percentages.
|
||||||
|
ringPositions(count, rx, ry, aspect = 1) {
|
||||||
|
if (count <= 0) return [];
|
||||||
|
const halfWidth = rx * aspect;
|
||||||
|
const halfHeight = ry;
|
||||||
|
if (halfWidth <= halfHeight * this.STADIUM_RATIO) {
|
||||||
|
return this.ringAngles(count, rx, ry, aspect).map(angle => ({
|
||||||
|
left: 50 + rx * Math.cos(angle),
|
||||||
|
top: 50 + ry * Math.sin(angle),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return this.stadiumPositions(count, halfWidth, halfHeight, aspect);
|
||||||
|
},
|
||||||
|
|
||||||
|
// The same walk, around a stadium instead. Its perimeter has a closed form, so
|
||||||
|
// this needs no sampling: two rails of 2(A-B) and two caps of piB.
|
||||||
|
stadiumPositions(count, halfWidth, halfHeight, aspect) {
|
||||||
|
const rail = halfWidth - halfHeight;
|
||||||
|
const cap = Math.PI * halfHeight;
|
||||||
|
const perimeter = 4 * rail + 2 * cap;
|
||||||
|
|
||||||
|
return Array.from({ length: count }, (_, seat) => {
|
||||||
|
// Clockwise from the top centre, so seat one sits where it always has.
|
||||||
|
let walked = (perimeter * seat) / count;
|
||||||
|
let x;
|
||||||
|
let y;
|
||||||
|
if (walked <= rail) { // top rail, going right
|
||||||
|
x = walked;
|
||||||
|
y = -halfHeight;
|
||||||
|
} else if (walked <= rail + cap) { // right cap
|
||||||
|
const turned = (walked - rail) / halfHeight;
|
||||||
|
x = rail + halfHeight * Math.sin(turned);
|
||||||
|
y = -halfHeight * Math.cos(turned);
|
||||||
|
} else if (walked <= 3 * rail + cap) { // bottom rail, going left
|
||||||
|
x = rail - (walked - rail - cap);
|
||||||
|
y = halfHeight;
|
||||||
|
} else if (walked <= 3 * rail + 2 * cap) { // left cap
|
||||||
|
const turned = (walked - 3 * rail - cap) / halfHeight;
|
||||||
|
x = -rail - halfHeight * Math.sin(turned);
|
||||||
|
y = halfHeight * Math.cos(turned);
|
||||||
|
} else { // top rail, back to the start
|
||||||
|
x = -rail + (walked - 3 * rail - 2 * cap);
|
||||||
|
y = -halfHeight;
|
||||||
|
}
|
||||||
|
return { left: 50 + x / aspect, top: 50 + y };
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
RING_STEPS: 720,
|
RING_STEPS: 720,
|
||||||
ringCache: {},
|
ringCache: {},
|
||||||
|
|
||||||
|
// The closest two neighbouring seats come to each other, straight line, in pixels.
|
||||||
|
//
|
||||||
|
// Rim length divided by seat count was the first answer and it is the wrong one:
|
||||||
|
// it measures the walk between two seats rather than the gap between them, and on
|
||||||
|
// a flat ellipse those are nothing alike. At 640x360 six seats had 193px of rim
|
||||||
|
// each and sat 56px apart, because at the ends of a long oval the rim doubles back
|
||||||
|
// and two seats a long walk apart are stacked one above the other.
|
||||||
|
closestSeatGapPx(count, box = this.ringBox()) {
|
||||||
|
if (count < 2 || !box || !box.width || !box.height) return Infinity;
|
||||||
|
|
||||||
|
const points = this.ringPositions(count, 44, 42, box.width / box.height).map(seat => ({
|
||||||
|
x: (seat.left / 100) * box.width,
|
||||||
|
y: (seat.top / 100) * box.height,
|
||||||
|
}));
|
||||||
|
return points.reduce((closest, point, i) => {
|
||||||
|
const next = points[(i + 1) % points.length];
|
||||||
|
return Math.min(closest, Math.hypot(point.x - next.x, point.y - next.y));
|
||||||
|
}, Infinity);
|
||||||
|
},
|
||||||
|
|
||||||
|
// A puck is 76px across, so neighbours whose centres are closer than that overlap
|
||||||
|
// by definition; a few pixels more keeps them from touching. Under it the pucks
|
||||||
|
// come down a size rather than the table pretending it has room it hasn't.
|
||||||
|
SEAT_ROOM_PX: 84,
|
||||||
|
|
||||||
|
// Called on every render and again whenever the felt is resized — the action bar
|
||||||
|
// growing mid-hand shortens the table, and the seats have to answer that without
|
||||||
|
// waiting for the next render to come along.
|
||||||
|
updateSeatDensity() {
|
||||||
|
const layer = document.getElementById("seatsLayer");
|
||||||
|
if (!layer || !this.state) return;
|
||||||
|
layer.classList.toggle("seatsTight",
|
||||||
|
this.closestSeatGapPx(this.state.seatCount) < this.SEAT_ROOM_PX);
|
||||||
|
},
|
||||||
|
|
||||||
// The angles that cut the rim into `count` equal lengths, starting at the top.
|
// The angles that cut the rim into `count` equal lengths, starting at the top.
|
||||||
// Every seat on a ring wants the same answer, so it is worked out once per shape
|
// Every seat on a ring wants the same answer, so it is worked out once per shape
|
||||||
// and kept — the ring only changes when the table is resized or the window is.
|
// and kept — the ring only changes when the table is resized or the window is.
|
||||||
@@ -245,6 +352,7 @@ const Render = {
|
|||||||
renderSeats() {
|
renderSeats() {
|
||||||
const layer = document.getElementById("seatsLayer");
|
const layer = document.getElementById("seatsLayer");
|
||||||
layer.innerHTML = "";
|
layer.innerHTML = "";
|
||||||
|
this.updateSeatDensity();
|
||||||
const template = document.getElementById("seatTemplate");
|
const template = document.getElementById("seatTemplate");
|
||||||
const { dealerIndex, sbIndex, bbIndex, manual: manualBlinds } = HoldemState.getBlindSeats(this.state);
|
const { dealerIndex, sbIndex, bbIndex, manual: manualBlinds } = HoldemState.getBlindSeats(this.state);
|
||||||
// A button on an empty chair normally means the table has drifted and wants
|
// A button on an empty chair normally means the table has drifted and wants
|
||||||
|
|||||||
+16
-1
@@ -36,7 +36,22 @@ enough not to trigger iOS zoom, that nothing important is covered or off-screen
|
|||||||
no way to scroll to it, that no grid column has collapsed to nothing, and that no
|
no way to scroll to it, that no grid column has collapsed to nothing, and that no
|
||||||
text is painted outside its box. It runs over the table, all five overlays and Quick
|
text is painted outside its box. It runs over the table, all five overlays and Quick
|
||||||
Split, at thirteen viewports from a 360px phone to a 1600px desktop and the same
|
Split, at thirteen viewports from a 360px phone to a 1600px desktop and the same
|
||||||
phones on their side.
|
phones on their side — each viewport twice, once with a mouse and once with a thumb.
|
||||||
|
|
||||||
|
That last part, and two of those checks, are newer than this paragraph claiming them.
|
||||||
|
`runAll()` did not include the contrast check at all, and touch targets sat behind an
|
||||||
|
opt-in flag nothing passed, so "the audit is clean" meant nine checks rather than
|
||||||
|
eleven for a long run of rounds. Both were written and tested and simply never called.
|
||||||
|
The mouse/thumb pair matters for the same reason: the 44px rule stands itself down
|
||||||
|
where `hover: hover` says there is a pointer, and a headless desktop browser is a
|
||||||
|
pointer, so a single pass had never measured a touch target at any viewport. Between
|
||||||
|
them they found a 9px seat label at 3.39:1, a 37px-tall amount field and a 30px delete
|
||||||
|
button on Quick Split.
|
||||||
|
|
||||||
|
A check that decides it doesn't apply says so in the output rather than reporting no
|
||||||
|
offenders, which reads identically to a pass. The same goes for the two exemptions
|
||||||
|
that exist: a disabled control, which WCAG itself excuses, and a link inside a
|
||||||
|
sentence, whose size is set by the line it sits in.
|
||||||
|
|
||||||
Deliberately not in CI: the unit suite is dependency-free and runs in a second, while
|
Deliberately not in CI: the unit suite is dependency-free and runs in a second, while
|
||||||
this one needs a browser and a running site. Run it after any change that adds or
|
this one needs a browser and a running site. Run it after any change that adds or
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
// The sums the contrast check runs on, tested away from a browser.
|
||||||
|
//
|
||||||
|
// The check itself needs a page to look at, but the arithmetic underneath it doesn't —
|
||||||
|
// and the arithmetic is the part that decides whether a real fault is reported or
|
||||||
|
// waved through. Faded text used to be filed as "not checked" rather than measured;
|
||||||
|
// this is the compositing that replaced that.
|
||||||
|
const test = require("node:test");
|
||||||
|
const { describe } = require("node:test");
|
||||||
|
const assert = require("node:assert");
|
||||||
|
const LayoutAudit = require("./uiAudit");
|
||||||
|
|
||||||
|
const WHITE = { r: 255, g: 255, b: 255, a: 1 };
|
||||||
|
const BLACK = { r: 0, g: 0, b: 0, a: 1 };
|
||||||
|
const PAGE = { r: 28, g: 28, b: 28, a: 1 }; // the felt page behind everything
|
||||||
|
const PUCK = { r: 44, g: 44, b: 44, a: 1 }; // a seat's own background
|
||||||
|
|
||||||
|
describe("the ratio itself", () => {
|
||||||
|
test("black on white is the full 21:1, either way round", () => {
|
||||||
|
assert.ok(Math.abs(LayoutAudit.contrastRatio(BLACK, WHITE) - 21) < 0.01);
|
||||||
|
assert.ok(Math.abs(LayoutAudit.contrastRatio(WHITE, BLACK) - 21) < 0.01);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a colour against itself is 1:1", () => {
|
||||||
|
assert.ok(Math.abs(LayoutAudit.contrastRatio(PUCK, PUCK) - 1) < 0.001);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("text on a box that is being faded", () => {
|
||||||
|
// opacity on a box fades the box and its text together onto whatever is behind
|
||||||
|
// it — so a dimmed seat is not grey text on a dark puck, it is a faded puck and
|
||||||
|
// faded text both landing on the page.
|
||||||
|
const fade = alpha => ({ alpha, behind: PAGE });
|
||||||
|
|
||||||
|
test("at full strength nothing is composited", () => {
|
||||||
|
const painted = LayoutAudit.asPainted(WHITE, PUCK, { alpha: 1, behind: PAGE });
|
||||||
|
assert.deepEqual(painted.fg, WHITE);
|
||||||
|
assert.deepEqual(painted.bg, PUCK);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fading moves both the text and its background, not just the text", () => {
|
||||||
|
const painted = LayoutAudit.asPainted(WHITE, PUCK, fade(0.7));
|
||||||
|
assert.ok(painted.fg.r < WHITE.r, "the text dims");
|
||||||
|
assert.ok(painted.bg.r < PUCK.r, "and so does what it sits on");
|
||||||
|
assert.ok(painted.bg.r > PAGE.r, "but not all the way to the page");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("which is what stops a fade being reported as a fault it isn't", () => {
|
||||||
|
// Dimming light text on a dark puck over a dark page costs contrast, but far
|
||||||
|
// less than measuring the faded text against the *unfaded* puck would claim.
|
||||||
|
const honest = LayoutAudit.asPainted(WHITE, PUCK, fade(0.7));
|
||||||
|
const wrong = { fg: LayoutAudit.over({ ...WHITE, a: 0.7 }, PUCK), bg: PUCK };
|
||||||
|
assert.ok(LayoutAudit.contrastRatio(honest.fg, honest.bg) >
|
||||||
|
LayoutAudit.contrastRatio(wrong.fg, wrong.bg));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a fade all the way out lands everything on the page behind", () => {
|
||||||
|
const painted = LayoutAudit.asPainted(WHITE, PUCK, fade(0));
|
||||||
|
assert.deepEqual(painted.fg, PAGE);
|
||||||
|
assert.deepEqual(painted.bg, PAGE);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("with nothing known to be behind it, the fade is left alone", () => {
|
||||||
|
// Better to measure the unfaded pair than to invent a backdrop.
|
||||||
|
const painted = LayoutAudit.asPainted(WHITE, PUCK, { alpha: 0.5, behind: null });
|
||||||
|
assert.deepEqual(painted.fg, WHITE);
|
||||||
|
assert.deepEqual(painted.bg, PUCK);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("a fade makes small grey text worse, which is the fault it went looking for", () => {
|
||||||
|
// Deliberately not asserting a particular CSS colour here: the page's own greys are
|
||||||
|
// measured in the browser, against the real gradients, where they live. What this
|
||||||
|
// pins is the direction — that dimming a box costs its text contrast, which is why
|
||||||
|
// filing faded text as "not checked" hid faults rather than avoiding false ones.
|
||||||
|
test("the same grey scores lower once its box is dimmed", () => {
|
||||||
|
const grey = LayoutAudit.parseColour("rgb(138, 138, 138)");
|
||||||
|
const solid = LayoutAudit.asPainted(grey, PUCK, { alpha: 1, behind: PAGE });
|
||||||
|
const dimmed = LayoutAudit.asPainted(grey, PUCK, { alpha: 0.7, behind: PAGE });
|
||||||
|
assert.ok(LayoutAudit.contrastRatio(dimmed.fg, dimmed.bg) <
|
||||||
|
LayoutAudit.contrastRatio(solid.fg, solid.bg));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("and a lighter grey scores higher at the same fade", () => {
|
||||||
|
const at = colour => {
|
||||||
|
const painted = LayoutAudit.asPainted(LayoutAudit.parseColour(colour), PUCK, { alpha: 0.7, behind: PAGE });
|
||||||
|
return LayoutAudit.contrastRatio(painted.fg, painted.bg);
|
||||||
|
};
|
||||||
|
assert.ok(at("rgb(174, 174, 174)") > at("rgb(138, 138, 138)"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("reading a colour off the page", () => {
|
||||||
|
test("rgb and rgba both come back as numbers", () => {
|
||||||
|
assert.deepEqual(LayoutAudit.parseColour("rgb(1, 2, 3)"), { r: 1, g: 2, b: 3, a: 1 });
|
||||||
|
assert.deepEqual(LayoutAudit.parseColour("rgba(1, 2, 3, 0.5)"), { r: 1, g: 2, b: 3, a: 0.5 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a gradient gives up every stop, because text could be over any of them", () => {
|
||||||
|
const stops = LayoutAudit.gradientStops("linear-gradient(rgb(203, 170, 240), rgb(169, 127, 214))");
|
||||||
|
assert.equal(stops.length, 2);
|
||||||
|
assert.deepEqual(stops[0], { r: 203, g: 170, b: 240, a: 1 });
|
||||||
|
assert.deepEqual(stops[1], { r: 169, g: 127, b: 214, a: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no background image, no stops", () => {
|
||||||
|
assert.deepEqual(LayoutAudit.gradientStops("none"), []);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -68,12 +68,24 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const browser = await chromium.launch();
|
const browser = await chromium.launch();
|
||||||
const page = await browser.newPage();
|
|
||||||
const failures = [];
|
const failures = [];
|
||||||
const warnings = [];
|
const warnings = [];
|
||||||
let checks = 0;
|
let checks = 0;
|
||||||
|
|
||||||
for (const [width, height, label] of VIEWPORTS) {
|
// Twice over: once with a mouse, once with a thumb. The 44px rule stands itself
|
||||||
|
// down where `hover: hover` says there's a pointer, and a headless desktop browser
|
||||||
|
// is a pointer — so a single pass here has never once measured a touch target,
|
||||||
|
// however many viewports it visited. The phone-sized ones are exactly the pass that
|
||||||
|
// needs it.
|
||||||
|
for (const touch of [false, true]) {
|
||||||
|
const context = await browser.newContext(touch
|
||||||
|
? { hasTouch: true, isMobile: true, deviceScaleFactor: 3 }
|
||||||
|
: {});
|
||||||
|
const page = await context.newPage();
|
||||||
|
const hand = touch ? "touch" : "mouse";
|
||||||
|
|
||||||
|
for (const [width, height, rawLabel] of VIEWPORTS) {
|
||||||
|
const label = `${rawLabel} · ${hand}`;
|
||||||
await page.setViewportSize({ width, height });
|
await page.setViewportSize({ width, height });
|
||||||
|
|
||||||
await page.goto(`${BASE}/Casino/TexasHoldem`);
|
await page.goto(`${BASE}/Casino/TexasHoldem`);
|
||||||
@@ -103,7 +115,8 @@ async function main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [width, height, label] of VIEWPORTS.filter((_, i) => i % 4 === 0)) {
|
for (const [width, height, rawLabel] of VIEWPORTS.filter((_, i) => i % 4 === 0)) {
|
||||||
|
const label = `${rawLabel} · ${hand}`;
|
||||||
await page.setViewportSize({ width, height });
|
await page.setViewportSize({ width, height });
|
||||||
await page.goto(`${BASE}/Casino/TexasHoldem/QuickSplit`);
|
await page.goto(`${BASE}/Casino/TexasHoldem/QuickSplit`);
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
@@ -114,11 +127,15 @@ async function main() {
|
|||||||
checks++;
|
checks++;
|
||||||
const result = await page.evaluate(() => LayoutAudit.runAll({ occlusionSelector: "main button, main a, main input" }));
|
const result = await page.evaluate(() => LayoutAudit.runAll({ occlusionSelector: "main button, main a, main input" }));
|
||||||
if (!result.clean) failures.push({ where: `quick split · ${label} ${width}x${height}`, offenders: result.failed.flatMap(f => f.offenders) });
|
if (!result.clean) failures.push({ where: `quick split · ${label} ${width}x${height}`, offenders: result.failed.flatMap(f => f.offenders) });
|
||||||
|
result.warnings.forEach(w => warnings.push(`quick split · ${label}: ${w}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
await context.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
await browser.close();
|
await browser.close();
|
||||||
|
|
||||||
console.log(`${checks} audits across ${VIEWPORTS.length} viewports`);
|
console.log(`${checks} audits across ${VIEWPORTS.length} viewports, with a mouse and with a thumb`);
|
||||||
warnings.forEach(w => console.log(` note: ${w}`));
|
warnings.forEach(w => console.log(` note: ${w}`));
|
||||||
if (failures.length === 0) {
|
if (failures.length === 0) {
|
||||||
console.log("clean");
|
console.log("clean");
|
||||||
|
|||||||
@@ -92,6 +92,100 @@ describe("a felt wider than it is tall", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("how close the closest two seats come", () => {
|
||||||
|
// What decides whether the pucks shrink. Rim length per seat was the first answer
|
||||||
|
// and it is the wrong question: it measures the walk between two seats, not the
|
||||||
|
// gap. On a long oval the rim doubles back at the ends, so two seats a long walk
|
||||||
|
// apart end up stacked one above the other.
|
||||||
|
const gap = (count, width, height) => Render.closestSeatGapPx(count, { width, height });
|
||||||
|
|
||||||
|
test("a flat table no longer stacks two seats at each end", () => {
|
||||||
|
// 616x96: the landscape felt on a 640x360 phone once the bar has grown. On an
|
||||||
|
// ellipse that flat the end pair came out 25px apart with 64px pucks. The rim
|
||||||
|
// is a stadium at this shape, so an end holds one seat.
|
||||||
|
assert.ok(gap(6, 616, 96) > 70, `${gap(6, 616, 96).toFixed(0)}px apart`);
|
||||||
|
assert.ok(gap(12, 616, 96) > 40, `${gap(12, 616, 96).toFixed(0)}px apart at twelve seats`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an ellipse's ends are still closer than its rim suggests, which is why the shape changes", () => {
|
||||||
|
// Same felt, forced through the ellipse the tool uses for rounder tables.
|
||||||
|
const asEllipse = Render.ringAngles(6, 44, 42, 616 / 96).map(angle => ({
|
||||||
|
x: ((50 + 44 * Math.cos(angle)) / 100) * 616,
|
||||||
|
y: ((50 + 42 * Math.sin(angle)) / 100) * 96,
|
||||||
|
}));
|
||||||
|
const closest = asEllipse.reduce((least, point, i) => {
|
||||||
|
const next = asEllipse[(i + 1) % asEllipse.length];
|
||||||
|
return Math.min(least, Math.hypot(point.x - next.x, point.y - next.y));
|
||||||
|
}, Infinity);
|
||||||
|
assert.ok(closest < 70, `${closest.toFixed(0)}px`);
|
||||||
|
assert.ok(gap(6, 616, 96) > closest * 1.3,
|
||||||
|
`stadium ${gap(6, 616, 96).toFixed(0)}px against ellipse ${closest.toFixed(0)}px`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a round table's neighbours are about as far apart as the rim says", () => {
|
||||||
|
const rim = (Math.PI * 2 * 0.43 * 400) / 9;
|
||||||
|
assert.ok(Math.abs(gap(9, 400, 400) - rim) < rim * 0.15);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("more seats, less room, in that order", () => {
|
||||||
|
assert.ok(gap(12, 400, 400) < gap(9, 400, 400));
|
||||||
|
assert.ok(gap(9, 400, 400) < gap(6, 400, 400));
|
||||||
|
assert.ok(gap(6, 400, 400) < gap(6, 800, 800), "and a bigger table gives more");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("nothing to measure between fewer than two seats", () => {
|
||||||
|
assert.equal(gap(1, 400, 400), Infinity);
|
||||||
|
assert.equal(gap(9, 0, 0), Infinity);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("which rim a table gets", () => {
|
||||||
|
// An ellipse until the table is much wider than tall, then a stadium: two rails
|
||||||
|
// and a half-round cap at each end, which is how a real poker table is built.
|
||||||
|
const shape = (width, height) => {
|
||||||
|
const aspect = width / height;
|
||||||
|
return 44 * aspect > 42 * Render.STADIUM_RATIO ? "stadium" : "ellipse";
|
||||||
|
};
|
||||||
|
|
||||||
|
test("every layout the tool had keeps the ellipse", () => {
|
||||||
|
assert.equal(shape(844, 608), "ellipse", "desktop");
|
||||||
|
assert.equal(shape(600, 600), "ellipse", "tablet");
|
||||||
|
assert.equal(shape(278, 314), "ellipse", "phone upright");
|
||||||
|
assert.equal(shape(336, 240), "ellipse", "short phone upright");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a phone on its side gets the stadium", () => {
|
||||||
|
assert.equal(shape(528, 73), "stadium");
|
||||||
|
assert.equal(shape(780, 113), "stadium");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the cap holds one seat instead of stacking two", () => {
|
||||||
|
// Six seats on a 616x96 rim: the walk between neighbours has to be longer than
|
||||||
|
// the cap, or two of them end up on the same half-circle.
|
||||||
|
const halfWidth = 44 * (616 / 96), halfHeight = 42;
|
||||||
|
const perimeter = 4 * (halfWidth - halfHeight) + 2 * Math.PI * halfHeight;
|
||||||
|
assert.ok(perimeter / 6 > Math.PI * halfHeight, "one seat per cap at six-handed");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("seat one is still at the top centre, and the ring still mirrors", () => {
|
||||||
|
const seats = Array.from({ length: 8 }, (_, i) => Render.positionOnRing(i, 8, 44, 42, 616 / 96));
|
||||||
|
assert.ok(Math.abs(seats[0].left - 50) < 0.01);
|
||||||
|
assert.ok(seats[0].top < 50);
|
||||||
|
for (let seat = 1; seat < 4; seat++) {
|
||||||
|
const mirrored = seats[8 - seat];
|
||||||
|
assert.ok(Math.abs((seats[seat].left - 50) + (mirrored.left - 50)) < 0.01, `seat ${seat} across`);
|
||||||
|
assert.ok(Math.abs(seats[seat].top - mirrored.top) < 0.01, `seat ${seat} down`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("nobody is drawn off the felt", () => {
|
||||||
|
Array.from({ length: 12 }, (_, i) => Render.positionOnRing(i, 12, 44, 42, 616 / 96)).forEach((seat, i) => {
|
||||||
|
assert.ok(seat.left >= 5 && seat.left <= 95, `seat ${i + 1} across at ${seat.left.toFixed(1)}%`);
|
||||||
|
assert.ok(seat.top >= 7 && seat.top <= 93, `seat ${i + 1} down at ${seat.top.toFixed(1)}%`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("the shape of the ring", () => {
|
describe("the shape of the ring", () => {
|
||||||
test("is a mirror image left to right", () => {
|
test("is a mirror image left to right", () => {
|
||||||
const seats = ringPixels(10, 601, 126);
|
const seats = ringPixels(10, 601, 126);
|
||||||
|
|||||||
+78
-11
@@ -393,6 +393,37 @@ const LayoutAudit = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// How much of this element actually reaches the screen, and what is behind the
|
||||||
|
// thing doing the fading.
|
||||||
|
//
|
||||||
|
// `opacity` on a box fades the whole box — its text and its own background together
|
||||||
|
// — onto whatever sits behind that box. So a dimmed seat is not "grey text on a
|
||||||
|
// dark puck"; it is a faded puck and faded text both landing on the felt. Both
|
||||||
|
// halves have to be composited the same way or the number means nothing.
|
||||||
|
fading(el) {
|
||||||
|
let alpha = 1;
|
||||||
|
let outermost = null;
|
||||||
|
for (let node = el; node; node = node.parentElement) {
|
||||||
|
const own = parseFloat(getComputedStyle(node).opacity);
|
||||||
|
if (own < 0.99) {
|
||||||
|
alpha *= own;
|
||||||
|
outermost = node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!outermost) return { alpha: 1, behind: null };
|
||||||
|
return { alpha, behind: this.backgroundCandidates(outermost.parentElement || document.body)[0] || null };
|
||||||
|
},
|
||||||
|
|
||||||
|
// The two colours as the screen actually shows them, once the fading is applied.
|
||||||
|
asPainted(fgRaw, bg, fade) {
|
||||||
|
const solid = fgRaw.a === 1 ? fgRaw : this.over(fgRaw, bg);
|
||||||
|
if (fade.alpha >= 0.99 || !fade.behind) return { fg: solid, bg };
|
||||||
|
return {
|
||||||
|
fg: this.over({ ...solid, a: solid.a * fade.alpha }, fade.behind),
|
||||||
|
bg: this.over({ ...bg, a: bg.a * fade.alpha }, fade.behind),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
contrast({ minimum = 4.5, largeMinimum = 3, selector = null } = {}) {
|
contrast({ minimum = 4.5, largeMinimum = 3, selector = null } = {}) {
|
||||||
const offenders = [];
|
const offenders = [];
|
||||||
const unmeasured = [];
|
const unmeasured = [];
|
||||||
@@ -426,12 +457,17 @@ const LayoutAudit = {
|
|||||||
// exempt — there's nothing to read at zero — but anything in between is
|
// exempt — there's nothing to read at zero — but anything in between is
|
||||||
// reported as unmeasured rather than passed over in silence, because a
|
// reported as unmeasured rather than passed over in silence, because a
|
||||||
// number nobody checked is not the same as a number that's fine.
|
// number nobody checked is not the same as a number that's fine.
|
||||||
const opacity = parseFloat(style.opacity);
|
const fade = this.fading(el);
|
||||||
if (opacity <= 0.02) return;
|
if (fade.alpha <= 0.02) return;
|
||||||
const faded = opacity < 0.99 ||
|
|
||||||
[...this.ancestors(el)].some(a => parseFloat(getComputedStyle(a).opacity) < 0.99);
|
// A control that is switched off is the one exemption WCAG itself grants —
|
||||||
if (faded) {
|
// an inactive component has no contrast requirement, which is why every
|
||||||
unmeasured.push(`${this.describe(el)} :: faded (opacity ${opacity}), contrast not checked`);
|
// toolkit dims them. Said out loud rather than skipped in silence: this is
|
||||||
|
// the only exemption in here, and the moment a dimmed thing turns out to be
|
||||||
|
// live it stops applying.
|
||||||
|
const off = el.closest("[disabled], [aria-disabled='true']");
|
||||||
|
if (off) {
|
||||||
|
unmeasured.push(`${this.describe(el)} :: disabled control, exempt from AA`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// aria-hidden used to exempt text here, on the grounds that decoration
|
// aria-hidden used to exempt text here, on the grounds that decoration
|
||||||
@@ -459,8 +495,8 @@ const LayoutAudit = {
|
|||||||
// The kindest reading available: best contrast across every candidate.
|
// The kindest reading available: best contrast across every candidate.
|
||||||
let best = 0;
|
let best = 0;
|
||||||
candidates.forEach(bg => {
|
candidates.forEach(bg => {
|
||||||
const fg = fgRaw.a === 1 ? fgRaw : this.over(fgRaw, bg);
|
const painted = this.asPainted(fgRaw, bg, fade);
|
||||||
best = Math.max(best, this.contrastRatio(fg, bg));
|
best = Math.max(best, this.contrastRatio(painted.fg, painted.bg));
|
||||||
});
|
});
|
||||||
|
|
||||||
const sizePx = parseFloat(style.fontSize);
|
const sizePx = parseFloat(style.fontSize);
|
||||||
@@ -543,6 +579,17 @@ const LayoutAudit = {
|
|||||||
.filter(el => el.type !== "color")
|
.filter(el => el.type !== "color")
|
||||||
.filter(el => !(exempt && el.matches(exempt)))
|
.filter(el => !(exempt && el.matches(exempt)))
|
||||||
.forEach(el => {
|
.forEach(el => {
|
||||||
|
// A link inside a sentence is the one target WCAG itself carves out:
|
||||||
|
// its size is set by the line it sits in, and padding a word out to
|
||||||
|
// 44px would break the paragraph around it. The rule is about controls
|
||||||
|
// a thumb goes hunting for, not words in prose. Said out loud, and
|
||||||
|
// narrow — display:inline, and real text either side of it.
|
||||||
|
if (el.tagName === "A" && getComputedStyle(el).display === "inline" &&
|
||||||
|
el.parentElement &&
|
||||||
|
[...el.parentElement.childNodes].some(n => n.nodeType === 3 && n.textContent.trim())) {
|
||||||
|
unmeasured.push(`${this.describe(el)} :: link inside a sentence, sized by the line`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
// A control inside a scrolling sheet can sit below its container's
|
// A control inside a scrolling sheet can sit below its container's
|
||||||
// visible area while still having a viewport rect. elementFromPoint at
|
// visible area while still having a viewport rect. elementFromPoint at
|
||||||
// its centre then returns the sheet, effectiveTarget gives up, and the
|
// its centre then returns the sheet, effectiveTarget gives up, and the
|
||||||
@@ -674,6 +721,20 @@ const LayoutAudit = {
|
|||||||
|
|
||||||
// ---- Runner ----
|
// ---- Runner ----
|
||||||
|
|
||||||
|
// Every check this file knows how to make, every time.
|
||||||
|
//
|
||||||
|
// Two of them used not to be here. Contrast was simply missing from the list, and
|
||||||
|
// touch targets sat behind an opt-in flag nobody passed — so "the audit is clean"
|
||||||
|
// has meant nine checks rather than eleven, and the two left out are the two whose
|
||||||
|
// faults a dealer meets in a dim room with their thumb. Both were written, tested
|
||||||
|
// and then never run: the contrast checker was built across two rounds and every
|
||||||
|
// sweep since has been silent about colour.
|
||||||
|
//
|
||||||
|
// A check that decides it can't apply here says so out loud rather than reporting
|
||||||
|
// an empty offenders list, which reads as a pass. touchTargets stands itself down
|
||||||
|
// on a mouse-driven browser — that is right, a 24px control is fine with a cursor
|
||||||
|
// on it — but "not measured" and "measured and fine" are different answers and the
|
||||||
|
// runner now prints which one it got.
|
||||||
runAll(options = {}) {
|
runAll(options = {}) {
|
||||||
const checks = [
|
const checks = [
|
||||||
this.touchAction(),
|
this.touchAction(),
|
||||||
@@ -686,8 +747,9 @@ const LayoutAudit = {
|
|||||||
this.fieldFontSize(),
|
this.fieldFontSize(),
|
||||||
this.seatOwnership(),
|
this.seatOwnership(),
|
||||||
this.oneDealerButton(),
|
this.oneDealerButton(),
|
||||||
|
this.contrast(options.contrast),
|
||||||
|
this.touchTargets(options.minimumTarget, options.touchTargets),
|
||||||
];
|
];
|
||||||
if (options.includeTouchTargets) checks.push(this.touchTargets(options.minimumTarget));
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
viewport: `${innerWidth}x${innerHeight}`,
|
viewport: `${innerWidth}x${innerHeight}`,
|
||||||
@@ -695,8 +757,13 @@ const LayoutAudit = {
|
|||||||
// Things worth knowing that aren't faults — see occlusion() on toasts.
|
// Things worth knowing that aren't faults — see occlusion() on toasts.
|
||||||
// Controls a check couldn't probe go here too: not a fault, but not a pass
|
// Controls a check couldn't probe go here too: not a fault, but not a pass
|
||||||
// either, and a silent skip is how faults hide.
|
// either, and a silent skip is how faults hide.
|
||||||
warnings: checks.flatMap(c => [...(c.warnings || []), ...(c.unmeasured || [])]),
|
warnings: checks.flatMap(c => [
|
||||||
passed: checks.filter(c => c.offenders.length === 0).map(c => c.name),
|
...(c.warnings || []),
|
||||||
|
...(c.unmeasured || []),
|
||||||
|
...(c.skipped ? [`${c.name}: not run here — ${c.skipped}`] : []),
|
||||||
|
]),
|
||||||
|
passed: checks.filter(c => !c.skipped && c.offenders.length === 0).map(c => c.name),
|
||||||
|
skipped: checks.filter(c => c.skipped).map(c => c.name),
|
||||||
clean: checks.every(c => c.offenders.length === 0),
|
clean: checks.every(c => c.offenders.length === 0),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user