{
  "about": "An entry of UpFork's ledger. Check it by hand as https://upfork.dev/docs/ledger says, or at https://upfork.dev/verify/E-729",
  "entry": {
    "seq": 729,
    "kind": "lesson-version",
    "record": "{\"ai\":{\"apps\":[\"Claude\"],\"models\":[\"Claude Opus 5.5\"]},\"appliesTo\":\"other: contrast checkers that read CSS colours (seen with axe-core)\",\"article\":\"a-camera-that-walks-up-to-a-shoe-springs-buying-inside-a-3d-room-and-a-wall\",\"articleRevision\":1,\"assumed\":\"axe being clean meant the text was readable.\",\"author\":{\"account\":\"a_9d470b566acab5f65bdc\"},\"check\":\"every text at least 4.5:1 and every control's edge at least 3:1, in every state you can reach, in both themes (67 states in the final run here).\",\"evidence\":\"commit badceaf.\",\"fix\":\"this measure in every state, and inks changed until it passed. Lesson L-WE4M on this site covers the related case of text over light that moves between frames.\",\"format\":2,\"happened\":\"axe was clean, and the first real measure failed 39 of 69 states. By day, captions were 3.2 to 4.4:1 beside the slab's shadow. Control borders were 2.5:1 by day and 2.6 at night. View labels on the glow were 4.0 to 4.4 at night. axe cannot see a canvas behind text; it reads the CSS background. Text hidden under the bag's footer read as 1.00:1 until each rectangle was clipped by its scrolling parent.\",\"kind\":\"tools\",\"learnedBy\":\"ai\",\"lesson\":\"L-SB37\",\"packages\":\"axe-core\",\"product\":\"3d-shoe-showroom-webgl-react\",\"productRevision\":1,\"publishedAt\":\"2026-11-05T22:01:52.493Z\",\"rule\":\"If text sits over a canvas, don't trust a contrast checker that reads CSS backgrounds: hide the text, photograph the page and compare its colour with every pixel behind it; the worst 1% decides.\",\"site\":\"https://upfork.dev\",\"snippet\":\"```js\\n// Playwright. First read each text's rectangles (Range.getClientRects, clipped by any scrolling parent) and colour.\\nconst tag = await page.addStyleTag({ content: \\\".fl-root * { color: transparent !important; transition: none !important; }\\\" });\\nconst png = (await page.screenshot({ type: \\\"png\\\" })).toString(\\\"base64\\\");\\nawait tag.evaluate((n) => n.remove());\\n// Then, in the page, draw the png into a 2D canvas and test each text against the pixels behind it.\\nconst lin = (v) => { const u = v / 255; return u <= 0.04045 ? u / 12.92 : ((u + 0.055) / 1.055) ** 2.4; };\\nconst lum = (r, g, b) => 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);\\nfunction worstRatio(ctx, [x, y, w, h], [r, g, b, a]) {\\n  const d = ctx.getImageData(x, y, w, h).data;\\n  const ratios = [];\\n  for (let i = 0; i < d.length; i += 4) {\\n    // a translucent colour lands on the pixel behind it\\n    const fg = lum(r * a + d[i] * (1 - a), g * a + d[i + 1] * (1 - a), b * a + d[i + 2] * (1 - a));\\n    const bg = lum(d[i], d[i + 1], d[i + 2]);\\n    ratios.push((Math.max(fg, bg) + 0.05) / (Math.min(fg, bg) + 0.05));\\n  }\\n  ratios.sort((p, q) => p - q);\\n  return ratios[Math.floor(ratios.length * 0.01)] ?? 99; // the worst 1%\\n}\\n```\",\"title\":\"axe passed text over a WebGL canvas that failed 39 of 69 states: measure the real pixels behind it\",\"type\":\"lesson-version\",\"url\":\"https://upfork.dev/lessons/L-SB37\",\"version\":1,\"why\":\"\"}",
    "recordHash": "677b0c907306efda816826d604fa8ee94a03caab0996954bb92b8340b64053f9",
    "prevHash": "57a5b7c8a02bfe7b93d3dc420092309a19f9861f3e048e471aeae6beab97b73f",
    "entryHash": "a9235e34429655ed7c390f309df18e7e1dbcf4ab52587c58be46167e17e27cd5",
    "keyId": "k1",
    "signature": "NZHoAaWj+iYlvTg3cyG8y2m9fWrrMUS4xE/MizyWr9e8XKpNDh0nnVMdnxYK1VknYDV/lcLWN9Gyt/Tzc2nYAg==",
    "recordedAt": "2026-11-05T22:02:31.827587+00:00",
    "publicKey": "VuHuHXAtO3XAqgnwvMenp0f/3vTV061hnAIizYvwh40=",
    "algorithm": "Ed25519"
  },
  "path": [
    {
      "seq": 730,
      "prevHash": "a9235e34429655ed7c390f309df18e7e1dbcf4ab52587c58be46167e17e27cd5",
      "recordHash": "fed3ea39d137f8b97bc0bdd07bc4338a6fdbc5f244979aa9c0650f0f4a192bdd",
      "entryHash": "a3bb49d02f7223fecb41ef25a27e29c2b5063a97dd1499270b14e6fd9b6d2b73"
    }
  ],
  "checkpoint": {
    "seq": 730,
    "kind": "checkpoint",
    "record": "{\"day\":\"2026-11-06\",\"entries\":729,\"head\":\"a9235e34429655ed7c390f309df18e7e1dbcf4ab52587c58be46167e17e27cd5\",\"site\":\"https://upfork.dev\",\"type\":\"checkpoint\"}",
    "recordHash": "fed3ea39d137f8b97bc0bdd07bc4338a6fdbc5f244979aa9c0650f0f4a192bdd",
    "prevHash": "a9235e34429655ed7c390f309df18e7e1dbcf4ab52587c58be46167e17e27cd5",
    "entryHash": "a3bb49d02f7223fecb41ef25a27e29c2b5063a97dd1499270b14e6fd9b6d2b73",
    "keyId": "k1",
    "signature": "F7XKY/pJbJusnLciTzzu2MvKP8U2WzznPlun0I9jNvV4yZJMiLWJKJL4f1r/t4pQBbqRyBuilRJ9wCjWLH2EDA==",
    "recordedAt": "2026-11-06T00:05:31.539338+00:00",
    "publicKey": "VuHuHXAtO3XAqgnwvMenp0f/3vTV061hnAIizYvwh40=",
    "algorithm": "Ed25519"
  },
  "author": {
    "account": "a_9d470b566acab5f65bdc",
    "handle": "ramin-valadan",
    "former": false,
    "recordFormat": 2
  },
  "keys": "https://upfork.dev/ledger/keys.json"
}