[{"data":1,"prerenderedAt":798},["ShallowReactive",2],{"blog-post:\u002Fblog\u002Faccessibility-baseline-block-new-violations":3,"blog-navigation:\u002Fblog\u002Faccessibility-baseline-block-new-violations":727,"blog-audio:accessibility-baseline-block-new-violations":765,"i-lucide:share-2":770,"related-articles:\u002Fblog\u002Faccessibility-baseline-block-new-violations":774},{"path":4,"title":5,"description":6,"date":7,"tags":8,"body":11,"image":723,"imageLight":724,"imageAlt":725,"imagePosition":726},"\u002Fblog\u002Faccessibility-baseline-block-new-violations","Blocking new accessibility violations with an axe baseline","Turn on axe for every Storybook story without freezing releases: record known violations in a baseline and fail PRs only on new or worse ones.","2026-07-14",[9,10],"accessibility","testing",{"type":12,"value":13,"toc":714},"minimark",[14,18,21,26,29,32,35,39,42,148,151,562,565,569,576,579,582,586,589,633,636,646,650,653,656,659,663,666,692,695,699,710],[15,16,17],"p",{},"The first time we ran axe on every story in our Storybook, it reported more than 1,000 contrast issues across 323 stories.",[15,19,20],{},"That's what a component library looks like when it grows without a check. Nobody broke anything on purpose. There was just nothing that ever said no.",[22,23,25],"h2",{"id":24},"neither-of-the-obvious-fixes-works","Neither of the obvious fixes works",[15,27,28],{},"Fixing everything before turning the check on would have meant weeks with no releases. A cleanup that blocks the roadmap gets postponed, and then it gets forgotten.",[15,30,31],{},"Turning it on as a warning is worse, because it looks like progress. A non-blocking check with 1,000 findings is noise on day one, and by day ten a new violation is just one more line in a report nobody scrolls.",[15,33,34],{},"We needed a check that blocks today, on a codebase that can't pass it today.",[22,36,38],{"id":37},"record-what-exists-block-whats-new","Record what exists, block what's new",[15,40,41],{},"The baseline is a JSON file in the repo. For every story, it records each rule that fails and how many elements fail it:",[43,44,49],"pre",{"className":45,"code":46,"language":47,"meta":48,"style":48},"language-json shiki shiki-themes github-light-high-contrast-aaa github-light-high-contrast-aaa material-theme-palenight-aaa","{\n  \"badge--subtle\": { \"color-contrast\": 3 },\n  \"data-table--dense\": { \"color-contrast\": 12, \"aria-allowed-role\": 1 }\n}\n","json","",[50,51,52,61,99,142],"code",{"__ignoreMap":48},[53,54,57],"span",{"class":55,"line":56},"line",1,[53,58,60],{"class":59},"s8YBD","{\n",[53,62,64,68,72,75,78,81,84,88,90,92,96],{"class":55,"line":63},2,[53,65,67],{"class":66},"sbUHo","  \"",[53,69,71],{"class":70},"so3xt","badge--subtle",[53,73,74],{"class":66},"\"",[53,76,77],{"class":59},":",[53,79,80],{"class":59}," {",[53,82,83],{"class":66}," \"",[53,85,87],{"class":86},"sQLUd","color-contrast",[53,89,74],{"class":66},[53,91,77],{"class":59},[53,93,95],{"class":94},"s65Wh"," 3",[53,97,98],{"class":59}," },\n",[53,100,102,104,107,109,111,113,115,117,119,121,124,127,129,132,134,136,139],{"class":55,"line":101},3,[53,103,67],{"class":66},[53,105,106],{"class":70},"data-table--dense",[53,108,74],{"class":66},[53,110,77],{"class":59},[53,112,80],{"class":59},[53,114,83],{"class":66},[53,116,87],{"class":86},[53,118,74],{"class":66},[53,120,77],{"class":59},[53,122,123],{"class":94}," 12",[53,125,126],{"class":59},",",[53,128,83],{"class":66},[53,130,131],{"class":86},"aria-allowed-role",[53,133,74],{"class":66},[53,135,77],{"class":59},[53,137,138],{"class":94}," 1",[53,140,141],{"class":59}," }\n",[53,143,145],{"class":55,"line":144},4,[53,146,147],{"class":59},"}\n",[15,149,150],{},"Every pull request scans all 686 stories again and compares. axe runs in the same pass as the visual screenshots, so it adds about 40 seconds to the run instead of a separate job. A serious or critical violation blocks when the baseline doesn't know it, or when it now fails on more elements than recorded. Everything else passes, and stays visible in the report.",[43,152,156],{"className":153,"code":154,"language":155,"meta":48,"style":48},"language-typescript shiki shiki-themes github-light-high-contrast-aaa github-light-high-contrast-aaa material-theme-palenight-aaa","import type { Result } from \"axe-core\"\n\ntype Baseline = Record\u003Cstring, Record\u003Cstring, number>> \u002F\u002F story id -> rule id -> failing nodes\n\nfunction findBlocking(\n  results: Record\u003Cstring, Result[]>,\n  baseline: Baseline,\n  enforced: Set\u003Cstring>,\n) {\n  return Object.entries(results).flatMap(([story, violations]) =>\n    violations.filter((violation) => {\n      if (enforced.has(violation.id)) return true \u002F\u002F never baselined, see below\n      if (violation.impact !== \"serious\" && violation.impact !== \"critical\") return false\n      return violation.nodes.length > (baseline[story]?.[violation.id] ?? 0)\n    }),\n  )\n}\n","typescript",[50,157,158,189,195,239,243,256,281,294,311,320,368,393,432,483,541,551,557],{"__ignoreMap":48},[53,159,160,164,167,169,173,176,179,182,186],{"class":55,"line":56},[53,161,163],{"class":162},"slrJH","import",[53,165,166],{"class":162}," type",[53,168,80],{"class":59},[53,170,172],{"class":171},"sLvJc"," Result",[53,174,175],{"class":59}," }",[53,177,178],{"class":162}," from",[53,180,83],{"class":181},"sghLD",[53,183,185],{"class":184},"szLOH","axe-core",[53,187,188],{"class":181},"\"\n",[53,190,191],{"class":55,"line":63},[53,192,194],{"emptyLinePlaceholder":193},true,"\n",[53,196,197,201,205,209,212,215,219,221,223,225,227,229,232,235],{"class":55,"line":101},[53,198,200],{"class":199},"sk6OM","type",[53,202,204],{"class":203},"sa3fw"," Baseline",[53,206,208],{"class":207},"sBxmf"," =",[53,210,211],{"class":203}," Record",[53,213,214],{"class":59},"\u003C",[53,216,218],{"class":217},"sSU78","string",[53,220,126],{"class":59},[53,222,211],{"class":203},[53,224,214],{"class":59},[53,226,218],{"class":217},[53,228,126],{"class":59},[53,230,231],{"class":217}," number",[53,233,234],{"class":59},">>",[53,236,238],{"class":237},"skbv8"," \u002F\u002F story id -> rule id -> failing nodes\n",[53,240,241],{"class":55,"line":144},[53,242,194],{"emptyLinePlaceholder":193},[53,244,246,249,253],{"class":55,"line":245},5,[53,247,248],{"class":199},"function",[53,250,252],{"class":251},"sbs6Q"," findBlocking",[53,254,255],{"class":59},"(\n",[53,257,259,263,265,267,269,271,273,275,278],{"class":55,"line":258},6,[53,260,262],{"class":261},"sYw69","  results",[53,264,77],{"class":207},[53,266,211],{"class":203},[53,268,214],{"class":59},[53,270,218],{"class":217},[53,272,126],{"class":59},[53,274,172],{"class":203},[53,276,277],{"class":171},"[]",[53,279,280],{"class":59},">,\n",[53,282,284,287,289,291],{"class":55,"line":283},7,[53,285,286],{"class":261},"  baseline",[53,288,77],{"class":207},[53,290,204],{"class":203},[53,292,293],{"class":59},",\n",[53,295,297,300,302,305,307,309],{"class":55,"line":296},8,[53,298,299],{"class":261},"  enforced",[53,301,77],{"class":207},[53,303,304],{"class":203}," Set",[53,306,214],{"class":59},[53,308,218],{"class":217},[53,310,280],{"class":59},[53,312,314,317],{"class":55,"line":313},9,[53,315,316],{"class":59},")",[53,318,319],{"class":59}," {\n",[53,321,323,326,329,332,335,339,342,344,346,349,351,354,357,359,362,365],{"class":55,"line":322},10,[53,324,325],{"class":162},"  return",[53,327,328],{"class":171}," Object",[53,330,331],{"class":59},".",[53,333,334],{"class":251},"entries",[53,336,338],{"class":337},"sgwTZ","(",[53,340,341],{"class":171},"results",[53,343,316],{"class":337},[53,345,331],{"class":59},[53,347,348],{"class":251},"flatMap",[53,350,338],{"class":337},[53,352,353],{"class":59},"([",[53,355,356],{"class":261},"story",[53,358,126],{"class":59},[53,360,361],{"class":261}," violations",[53,363,364],{"class":59},"])",[53,366,367],{"class":199}," =>\n",[53,369,371,374,376,379,381,383,386,388,391],{"class":55,"line":370},11,[53,372,373],{"class":171},"    violations",[53,375,331],{"class":59},[53,377,378],{"class":251},"filter",[53,380,338],{"class":337},[53,382,338],{"class":59},[53,384,385],{"class":261},"violation",[53,387,316],{"class":59},[53,389,390],{"class":199}," =>",[53,392,319],{"class":59},[53,394,396,399,402,405,407,410,412,414,416,419,422,425,429],{"class":55,"line":395},12,[53,397,398],{"class":162},"      if",[53,400,401],{"class":337}," (",[53,403,404],{"class":171},"enforced",[53,406,331],{"class":59},[53,408,409],{"class":251},"has",[53,411,338],{"class":337},[53,413,385],{"class":171},[53,415,331],{"class":59},[53,417,418],{"class":171},"id",[53,420,421],{"class":337},")) ",[53,423,424],{"class":162},"return",[53,426,428],{"class":427},"smg_G"," true",[53,430,431],{"class":237}," \u002F\u002F never baselined, see below\n",[53,433,435,437,439,441,443,446,449,451,454,456,459,462,464,466,468,470,473,475,478,480],{"class":55,"line":434},13,[53,436,398],{"class":162},[53,438,401],{"class":337},[53,440,385],{"class":171},[53,442,331],{"class":59},[53,444,445],{"class":171},"impact",[53,447,448],{"class":207}," !==",[53,450,83],{"class":181},[53,452,453],{"class":184},"serious",[53,455,74],{"class":181},[53,457,458],{"class":207}," &&",[53,460,461],{"class":171}," violation",[53,463,331],{"class":59},[53,465,445],{"class":171},[53,467,448],{"class":207},[53,469,83],{"class":181},[53,471,472],{"class":184},"critical",[53,474,74],{"class":181},[53,476,477],{"class":337},") ",[53,479,424],{"class":162},[53,481,482],{"class":427}," false\n",[53,484,486,489,491,493,496,498,502,505,507,510,513,515,518,521,523,525,527,529,532,535,538],{"class":55,"line":485},14,[53,487,488],{"class":162},"      return",[53,490,461],{"class":171},[53,492,331],{"class":59},[53,494,495],{"class":171},"nodes",[53,497,331],{"class":59},[53,499,501],{"class":500},"sqHe6","length",[53,503,504],{"class":207}," >",[53,506,401],{"class":337},[53,508,509],{"class":171},"baseline",[53,511,512],{"class":337},"[",[53,514,356],{"class":171},[53,516,517],{"class":337},"]",[53,519,520],{"class":59},"?.",[53,522,512],{"class":337},[53,524,385],{"class":171},[53,526,331],{"class":59},[53,528,418],{"class":171},[53,530,531],{"class":337},"] ",[53,533,534],{"class":207},"??",[53,536,537],{"class":94}," 0",[53,539,540],{"class":337},")\n",[53,542,544,547,549],{"class":55,"line":543},15,[53,545,546],{"class":59},"    }",[53,548,316],{"class":337},[53,550,293],{"class":59},[53,552,554],{"class":55,"line":553},16,[53,555,556],{"class":337},"  )\n",[53,558,560],{"class":55,"line":559},17,[53,561,147],{"class":59},[15,563,564],{},"A rule the baseline has never seen has a known count of zero, so \"new\" and \"worse\" are the same comparison.",[22,566,568],{"id":567},"why-it-counts-elements-instead-of-matching-them","Why it counts elements instead of matching them",[15,570,571,572,575],{},"The obvious alternative is to record which elements fail, by selector, and block any element that isn't on the list. It's more precise, but on a real component library it's also noisy. axe's selectors can include generated class names and ",[50,573,574],{},"nth-child"," positions, so adding a row to a table \"changes\" every failing cell below it. A check that cries wolf on refactors gets turned off.",[15,577,578],{},"Counting is stable, but it has a hole I want to be upfront about: if a PR fixes one low-contrast element and breaks another in the same story, the count doesn't change and the PR passes. That's the trade. It's the same rule on the same story, the report still shows it, and a check people keep turned on beats a precise one they disable.",[15,580,581],{},"What counts can't miss is the common case: a story with three known low-contrast elements gets a fourth. Matching on the rule alone would let that through.",[22,583,585],{"id":584},"eight-rules-with-no-baseline","Eight rules with no baseline",[15,587,588],{},"Some rules can't be baselined. Any violation fails, whatever the file says:",[590,591,592,609,615,621,627],"ul",{},[593,594,595,598,599,598,602,598,605,608],"li",{},[50,596,597],{},"button-name",", ",[50,600,601],{},"label",[50,603,604],{},"select-name",[50,606,607],{},"image-alt",": controls and images need names",[593,610,611,614],{},[50,612,613],{},"aria-valid-attr-value",": ARIA attributes need valid values",[593,616,617,620],{},[50,618,619],{},"nested-interactive",": no button inside a button",[593,622,623,626],{},[50,624,625],{},"scrollable-region-focusable",": scroll areas must be reachable by keyboard",[593,628,629,632],{},[50,630,631],{},"heading-order",": headings don't skip levels",[15,634,635],{},"Seven of these are already rated critical or serious by axe, so the impact filter would catch new ones anyway. Putting them on this list means existing violations aren't grandfathered in either. They're cheap to fix: before turning the check on, we cleared the existing ones, 41 violations across 17 stories, in about two days. And a screen reader user who lands on an unnamed icon button doesn't care that it's \"known debt\".",[15,637,638,640,641,645],{},[50,639,631],{}," is the odd one. axe rates it ",[642,643,644],"strong",{},"moderate",", so a check that only blocks serious and critical would never fail on it, baseline or not. Enforcing it by name is the only way to make it count. If you copy this setup, check the impact of every rule you care about. The impact filter quietly decides more than it looks like.",[22,647,649],{"id":648},"the-baseline-is-code","The baseline is code",[15,651,652],{},"The weak spot of any baseline is the command that regenerates it. If a PR can run it and go green, the check means nothing.",[15,654,655],{},"So the file lives in the repo and is reviewed like code. Raising a count shows up in the diff, and \"I added three contrast failures to the baseline\" is a hard sentence to defend in review. That visibility does more work than the CI job.",[15,657,658],{},"I treat it like updated snapshots in a test suite. A baseline change without an explanation is one of the first things I look for in a review, especially in a PR an AI agent wrote. Agents are very good at making checks pass. I want to know whether they fixed the code or the baseline.",[22,660,662],{"id":661},"where-its-still-blind","Where it's still blind",[15,664,665],{},"A baseline stops regressions. It doesn't make a library accessible, and this one has gaps I know about:",[590,667,668,674,680,686],{},[593,669,670,673],{},[642,671,672],{},"axe's \"incomplete\" results never block."," When axe can't compute contrast, for example text over a gradient, a background image or another element, it doesn't report a violation. It reports \"needs review\". Only violations are counted, so that whole category passes silently.",[593,675,676,679],{},[642,677,678],{},"Renaming a story resets its debt."," The baseline is keyed by story id. Rename a story and every known violation under the old id shows up as new. That's annoying, but it's also correct: the fix is to move the entry in the same PR, where a reviewer sees it.",[593,681,682,685],{},[642,683,684],{},"The baseline doesn't tighten itself."," If a story goes from 3 contrast failures to 1, the file still says 3 until someone lowers it, and that slack lets two regressions back in for free. Failing when a count drops below the baseline would close the gap.",[593,687,688,691],{},[642,689,690],{},"axe can't judge the experience."," It can't tell whether the focus order makes sense or whether a screen reader announcement is useful. That still takes a keyboard, a screen reader and time.",[15,693,694],{},"What it does do is stop the regressions a machine can catch, on every PR, without anyone having to remember. Releases kept shipping while the known issues got fixed component by component, in the PRs that were already touching them. Six weeks in, the 1,000+ contrast issues were down to about 640, without a single cleanup sprint.",[22,696,698],{"id":697},"update-october-2026","Update, October 2026",[15,700,701,702,709],{},"I later built the same check into ",[703,704,708],"a",{"href":705,"rel":706},"https:\u002F\u002Fgithub.com\u002FArchetipo95\u002Fstorybook-github-swiss-knife",[707],"nofollow","Storybook Swiss Knife",", my open-source tool for visual and accessibility checks on pull requests. Two things changed on the way. The trusted part of the workflow recomputes what's blocking from the raw node counts instead of trusting the PR's own run. And any baseline entry a PR adds or raises is listed in the check, so it can't slip through as one line in a long diff.",[711,712,713],"style",{},"html pre.shiki code .s8YBD, html code.shiki .s8YBD{--shiki-light:#0E1116;--shiki-default:#0E1116;--shiki-dark:#89DDFF}html pre.shiki code .sbUHo, html code.shiki .sbUHo{--shiki-light:#024C1A;--shiki-default:#024C1A;--shiki-dark:#89DDFF}html pre.shiki code .so3xt, html code.shiki .so3xt{--shiki-light:#024C1A;--shiki-default:#024C1A;--shiki-dark:#C792EA}html pre.shiki code .sQLUd, html code.shiki .sQLUd{--shiki-light:#024C1A;--shiki-default:#024C1A;--shiki-dark:#FFCB6B}html pre.shiki code .s65Wh, html code.shiki .s65Wh{--shiki-light:#023B95;--shiki-default:#023B95;--shiki-dark:#F78C6C}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .slrJH, html code.shiki .slrJH{--shiki-light:#A0111F;--shiki-light-font-style:inherit;--shiki-default:#A0111F;--shiki-default-font-style:inherit;--shiki-dark:#89DDFF;--shiki-dark-font-style:italic}html pre.shiki code .sLvJc, html code.shiki .sLvJc{--shiki-light:#0E1116;--shiki-default:#0E1116;--shiki-dark:#BABED8}html pre.shiki code .sghLD, html code.shiki .sghLD{--shiki-light:#032563;--shiki-default:#032563;--shiki-dark:#89DDFF}html pre.shiki code .szLOH, html code.shiki .szLOH{--shiki-light:#032563;--shiki-default:#032563;--shiki-dark:#C3E88D}html pre.shiki code .sk6OM, html code.shiki .sk6OM{--shiki-light:#A0111F;--shiki-default:#A0111F;--shiki-dark:#C792EA}html pre.shiki code .sa3fw, html code.shiki .sa3fw{--shiki-light:#702C00;--shiki-default:#702C00;--shiki-dark:#FFCB6B}html pre.shiki code .sBxmf, html code.shiki .sBxmf{--shiki-light:#A0111F;--shiki-default:#A0111F;--shiki-dark:#89DDFF}html pre.shiki code .sSU78, html code.shiki .sSU78{--shiki-light:#023B95;--shiki-default:#023B95;--shiki-dark:#FFCB6B}html pre.shiki code .skbv8, html code.shiki .skbv8{--shiki-light:#495159;--shiki-light-font-style:inherit;--shiki-default:#495159;--shiki-default-font-style:inherit;--shiki-dark:#969EC4;--shiki-dark-font-style:italic}html pre.shiki code .sbs6Q, html code.shiki .sbs6Q{--shiki-light:#622CBC;--shiki-default:#622CBC;--shiki-dark:#82AAFF}html pre.shiki code .sYw69, html code.shiki .sYw69{--shiki-light:#702C00;--shiki-light-font-style:inherit;--shiki-default:#702C00;--shiki-default-font-style:inherit;--shiki-dark:#BABED8;--shiki-dark-font-style:italic}html pre.shiki code .sgwTZ, html code.shiki .sgwTZ{--shiki-light:#0E1116;--shiki-default:#0E1116;--shiki-dark:#F1777D}html pre.shiki code .smg_G, html code.shiki .smg_G{--shiki-light:#023B95;--shiki-default:#023B95;--shiki-dark:#FF9CAC}html pre.shiki code .sqHe6, html code.shiki .sqHe6{--shiki-light:#023B95;--shiki-default:#023B95;--shiki-dark:#BABED8}",{"title":48,"searchDepth":63,"depth":63,"links":715},[716,717,718,719,720,721,722],{"id":24,"depth":63,"text":25},{"id":37,"depth":63,"text":38},{"id":567,"depth":63,"text":568},{"id":584,"depth":63,"text":585},{"id":648,"depth":63,"text":649},{"id":661,"depth":63,"text":662},{"id":697,"depth":63,"text":698},"\u002Fimages\u002Fblog\u002Faccessibility-baseline-block-new-violations.png","\u002Fimages\u002Fblog\u002Faccessibility-baseline-block-new-violations-light.jpg","An editorial cartoon of Martin as a Renaissance sculptor in a neon-lit Florence workshop, carving a marble UI button while a baseline codex lists known axe violations and a wax seal stamps a new one as blocked.","51% 11%",[728,732,736,740,744,748,752,753,757,761],{"path":729,"title":730,"date":731},"\u002Fblog\u002Fdebugging-home-wifi-claude-code-dfs-radar","Debugging Home Wi-Fi with Claude Code and the 5 GHz Radar Rules","2026-11-06",{"path":733,"title":734,"date":735},"\u002Fblog\u002Fbuilding-martinmasevski-dev-nuxt-vue","How I built this site with Nuxt and Vue and kept Lighthouse at 100","2026-11-04",{"path":737,"title":738,"date":739},"\u002Fblog\u002Fdesigning-an-accessible-light-theme","Turning a neon dark site into an accessible light theme","2026-11-01",{"path":741,"title":742,"date":743},"\u002Fblog\u002Fstorybook-swiss-knife-visual-accessibility-checks","Visual and accessibility checks for every Storybook PR","2026-10-14",{"path":745,"title":746,"date":747},"\u002Fblog\u002Fstorybook-pr-previews-github-pages-untrusted-code","Storybook PR previews on GitHub Pages without trusting PR code","2026-09-24",{"path":749,"title":750,"date":751},"\u002Fblog\u002Fvisual-regression-baseline-cache-key","A GitHub Actions cache key for visual regression baselines","2026-08-18",{"path":4,"title":5,"date":7},{"path":754,"title":755,"date":756},"\u002Fblog\u002Freviving-a-raspberry-pi-with-ai","Reviving a Raspberry Pi with AI","2026-05-26",{"path":758,"title":759,"date":760},"\u002Fblog\u002Fbuilding-local-first-browser-tts-studio-kokoro","Building a local-first browser TTS studio with Kokoro","2026-03-28",{"path":762,"title":763,"date":764},"\u002Fblog\u002Fdebugging-timezone-chrome-devtools-mcp","How to change the timezone in Chrome DevTools and debug it with MCP","2026-03-14",{"hasAudio":766,"sources":767},false,{"opus":768,"mp3":769},"https:\u002F\u002Fbucket.martinmasevski.dev\u002Fblog\u002Faccessibility-baseline-block-new-violations\u002Faccessibility-baseline-block-new-violations.opus","https:\u002F\u002Fbucket.martinmasevski.dev\u002Fblog\u002Faccessibility-baseline-block-new-violations\u002Faccessibility-baseline-block-new-violations.mp3",{"left":771,"top":771,"width":772,"height":772,"rotate":771,"vFlip":766,"hFlip":766,"body":773},0,24,"\u003Cg fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\">\u003Ccircle cx=\"18\" cy=\"5\" r=\"3\"\u002F>\u003Ccircle cx=\"6\" cy=\"12\" r=\"3\"\u002F>\u003Ccircle cx=\"18\" cy=\"19\" r=\"3\"\u002F>\u003Cpath d=\"m8.59 13.51l6.83 3.98m-.01-10.98l-6.82 3.98\"\u002F>\u003C\u002Fg>",[775,782,789],{"path":737,"title":738,"description":776,"date":739,"tags":777,"image":779,"imageLight":780,"imageAlt":781},"My site was dark only, all neon pink and gold. Adding a light theme meant new colors that pass AAA, a switch that remembers you, a reveal animation and daytime covers.",[9,10,778],"side-projects","\u002Fimages\u002Fblog\u002Fdesigning-an-accessible-light-theme.jpg","\u002Fimages\u002Fblog\u002Fdesigning-an-accessible-light-theme-light.jpg","An AI-generated cartoon of Martin as a lamplighter on a wooden ladder in Milan's Galleria Vittorio Emanuele II, lighting a lamp under the glass dome, with the Duomo through the far archway, pink and gold shop windows, an easel of pink and gold paint swatches and a sleeping black cat.",{"path":741,"title":742,"description":783,"date":743,"tags":784,"image":786,"imageLight":787,"imageAlt":788},"Storybook Swiss Knife screenshots every story, compares it with the base branch and runs axe on every PR. Here's what made those checks trustworthy.",[10,9,778,785],"devtools","\u002Fimages\u002Fblog\u002Fstorybook-swiss-knife-visual-accessibility-checks.png","\u002Fimages\u002Fblog\u002Fstorybook-swiss-knife-visual-accessibility-checks-light.jpg","An editorial cartoon of Martin as a Renaissance surveyor in a neon-lit Piazza dei Miracoli, comparing an old and a new photo of the Leaning Tower through a magnifying glass, with the part that moved outlined in pink and a multi-tool of camera lens, magnifier and accessibility icon in his hand.",{"path":758,"title":759,"description":790,"date":760,"tags":791,"image":795,"imageLight":796,"imageAlt":797},"How I built LocalVoice Studio to run Kokoro TTS locally in the browser with kokoro-js, and what AI-assisted development still needed to make it shippable.",[792,793,794,9,10],"ai","tts","local-first","\u002Fimages\u002Fblog\u002Fbuilding-local-first-browser-tts-studio-kokoro.png",null,"A cyberpunk illustration of Martin in a neon-lit recording studio operating a futuristic audio console. Glowing soundwaves flow from a 'Kokoro ONNX' hopper, surrounded by Vue.js holograms and an 'AI Speed' rocket anchored by chains labeled 'Linting' and 'Accessibility'",1794215543947]