[{"data":1,"prerenderedAt":1777},["ShallowReactive",2],{"i-lucide:chevron-down":3,"i-lucide:search":8,"i-lucide:sun":10,"i-lucide:moon":12,"i-lucide:arrow-right":14,"i-lucide:menu":16,"i-lucide:mail":18,"i-simple-icons:x":20,"i-simple-icons:linkedin":22,"i-simple-icons:dribbble":25,"i-simple-icons:github":27,"blog-\u002Fblog\u002Fen\u002Fperformance-budgets-in-ci":29,"blog-related-\u002Fblog\u002Fen\u002Fperformance-budgets-in-ci":434,"i-lucide:chevron-right":1763,"i-lucide:calendar":1765,"i-lucide:clock":1767,"i-lucide:link":1769,"i-lucide:blocks":1771,"i-lucide:component":1773,"i-lucide:sparkles":1775},{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":7},0,24,false,"\u003Cpath fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"m6 9l6 6l6-6\"\u002F>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":9},"\u003Cg fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\">\u003Cpath d=\"m21 21l-4.34-4.34\"\u002F>\u003Ccircle cx=\"11\" cy=\"11\" r=\"8\"\u002F>\u003C\u002Fg>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":11},"\u003Cg fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\">\u003Ccircle cx=\"12\" cy=\"12\" r=\"4\"\u002F>\u003Cpath d=\"M12 2v2m0 16v2M4.93 4.93l1.41 1.41m11.32 11.32l1.41 1.41M2 12h2m16 0h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41\"\u002F>\u003C\u002Fg>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":13},"\u003Cpath fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401\"\u002F>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":15},"\u003Cpath fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M5 12h14m-7-7l7 7l-7 7\"\u002F>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":17},"\u003Cpath fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M4 5h16M4 12h16M4 19h16\"\u002F>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":19},"\u003Cg fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\">\u003Cpath d=\"m22 7l-8.991 5.727a2 2 0 0 1-2.009 0L2 7\"\u002F>\u003Crect width=\"20\" height=\"16\" x=\"2\" y=\"4\" rx=\"2\"\u002F>\u003C\u002Fg>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":21},"\u003Cpath fill=\"currentColor\" d=\"M14.234 10.162L22.977 0h-2.072l-7.591 8.824L7.251 0H.258l9.168 13.343L.258 24H2.33l8.016-9.318L16.749 24h6.993zm-2.837 3.299l-.929-1.329L3.076 1.56h3.182l5.965 8.532l.929 1.329l7.754 11.09h-3.182z\"\u002F>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":23,"hidden":24},"\u003Cpath fill=\"currentColor\" d=\"M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037c-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85c3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.06 2.06 0 0 1-2.063-2.065a2.064 2.064 0 1 1 2.063 2.065m1.782 13.019H3.555V9h3.564zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0z\"\u002F>",true,{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":26},"\u003Cpath fill=\"currentColor\" d=\"M12 24C5.385 24 0 18.615 0 12S5.385 0 12 0s12 5.385 12 12s-5.385 12-12 12m10.12-10.358c-.35-.11-3.17-.953-6.384-.438c1.34 3.684 1.887 6.684 1.992 7.308a10.28 10.28 0 0 0 4.395-6.87zm-6.115 7.808c-.153-.9-.75-4.032-2.19-7.77l-.066.02c-5.79 2.015-7.86 6.025-8.04 6.4a10.16 10.16 0 0 0 6.29 2.166c1.42 0 2.77-.29 4-.814zm-11.62-2.58c.232-.4 3.045-5.055 8.332-6.765q.202-.067.405-.12q-.392-.879-.832-1.74C7.17 11.775 2.206 11.71 1.756 11.7l-.004.312c0 2.633.998 5.037 2.634 6.855zm-2.42-8.955c.46.008 4.683.026 9.477-1.248a66 66 0 0 0-3.8-5.928a10.28 10.28 0 0 0-5.676 7.17zM9.6 2.052c.282.38 2.145 2.914 3.822 6c3.645-1.365 5.19-3.44 5.373-3.702A10.2 10.2 0 0 0 12 1.764c-.825 0-1.63.1-2.4.285zm10.335 3.483c-.218.29-1.935 2.493-5.724 4.04c.24.49.47.985.68 1.486c.08.18.15.36.22.53c3.41-.43 6.8.26 7.14.33c-.02-2.42-.88-4.64-2.31-6.38z\"\u002F>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":28},"\u003Cpath fill=\"currentColor\" d=\"M12 .297c-6.63 0-12 5.373-12 12c0 5.303 3.438 9.8 8.205 11.385c.6.113.82-.258.82-.577c0-.285-.01-1.04-.015-2.04c-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729c1.205.084 1.838 1.236 1.838 1.236c1.07 1.835 2.809 1.305 3.495.998c.108-.776.417-1.305.76-1.605c-2.665-.3-5.466-1.332-5.466-5.93c0-1.31.465-2.38 1.235-3.22c-.135-.303-.54-1.523.105-3.176c0 0 1.005-.322 3.3 1.23c.96-.267 1.98-.399 3-.405c1.02.006 2.04.138 3 .405c2.28-1.552 3.285-1.23 3.285-1.23c.645 1.653.24 2.873.12 3.176c.765.84 1.23 1.91 1.23 3.22c0 4.61-2.805 5.625-5.475 5.92c.42.36.81 1.096.81 2.22c0 1.606-.015 2.896-.015 3.286c0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12\"\u002F>",{"id":30,"title":31,"author":32,"body":35,"category":419,"cover":420,"coverAlt":31,"date":421,"description":422,"draft":6,"extension":423,"featured":6,"lang":424,"meta":425,"navigation":24,"path":426,"readingTime":109,"seo":427,"stem":428,"tags":429,"__hash__":433},"blog\u002Fblog\u002Fen\u002Fperformance-budgets-in-ci.md","Performance budgets belong in CI, not in a retrospective",{"name":33,"role":34},"Sofia Lindqvist","Staff Frontend Engineer",{"type":36,"value":37,"toc":411},"minimark",[38,42,45,50,53,56,238,241,244,248,259,262,266,269,287,301,307,313,317,320,327,331,334,394,397,401,404,407],[39,40,41],"p",{},"Every team has run a Lighthouse audit, celebrated a 96, and watched it drift to 71 over the following quarter. The audit was not wrong. It was just a photograph of a moving thing.",[39,43,44],{},"Performance is not a score you achieve. It is a constraint you enforce, and the only place a constraint holds is in the pipeline.",[46,47,49],"h2",{"id":48},"budget-the-bytes-not-the-score","Budget the bytes, not the score",[39,51,52],{},"Lighthouse scores are noisy — they move with machine load, network conditions and the phase of the moon. Failing a build on a composite score produces flaky CI and, within two weeks, a team that skips the check.",[39,54,55],{},"Budget the things that are deterministic:",[57,58,63],"pre",{"className":59,"code":60,"language":61,"meta":62,"style":62},"language-json shiki shiki-themes github-dark-dimmed github-dark-dimmed","{\n  \"budgets\": [\n    {\n      \"path\": \"\u002F*\",\n      \"resourceSizes\": [\n        { \"resourceType\": \"script\", \"budget\": 180 },\n        { \"resourceType\": \"stylesheet\", \"budget\": 60 },\n        { \"resourceType\": \"font\", \"budget\": 120 },\n        { \"resourceType\": \"total\", \"budget\": 500 }\n      ]\n    }\n  ]\n}\n","json","",[64,65,66,75,85,91,107,115,144,167,190,214,220,226,232],"code",{"__ignoreMap":62},[67,68,71],"span",{"class":69,"line":70},"line",1,[67,72,74],{"class":73},"sM9_K","{\n",[67,76,78,82],{"class":69,"line":77},2,[67,79,81],{"class":80},"sza-u","  \"budgets\"",[67,83,84],{"class":73},": [\n",[67,86,88],{"class":69,"line":87},3,[67,89,90],{"class":73},"    {\n",[67,92,94,97,100,104],{"class":69,"line":93},4,[67,95,96],{"class":80},"      \"path\"",[67,98,99],{"class":73},": ",[67,101,103],{"class":102},"szYpP","\"\u002F*\"",[67,105,106],{"class":73},",\n",[67,108,110,113],{"class":69,"line":109},5,[67,111,112],{"class":80},"      \"resourceSizes\"",[67,114,84],{"class":73},[67,116,118,121,124,126,129,132,135,137,141],{"class":69,"line":117},6,[67,119,120],{"class":73},"        { ",[67,122,123],{"class":80},"\"resourceType\"",[67,125,99],{"class":73},[67,127,128],{"class":102},"\"script\"",[67,130,131],{"class":73},", ",[67,133,134],{"class":80},"\"budget\"",[67,136,99],{"class":73},[67,138,140],{"class":139},"sQdni","180",[67,142,143],{"class":73}," },\n",[67,145,147,149,151,153,156,158,160,162,165],{"class":69,"line":146},7,[67,148,120],{"class":73},[67,150,123],{"class":80},[67,152,99],{"class":73},[67,154,155],{"class":102},"\"stylesheet\"",[67,157,131],{"class":73},[67,159,134],{"class":80},[67,161,99],{"class":73},[67,163,164],{"class":139},"60",[67,166,143],{"class":73},[67,168,170,172,174,176,179,181,183,185,188],{"class":69,"line":169},8,[67,171,120],{"class":73},[67,173,123],{"class":80},[67,175,99],{"class":73},[67,177,178],{"class":102},"\"font\"",[67,180,131],{"class":73},[67,182,134],{"class":80},[67,184,99],{"class":73},[67,186,187],{"class":139},"120",[67,189,143],{"class":73},[67,191,193,195,197,199,202,204,206,208,211],{"class":69,"line":192},9,[67,194,120],{"class":73},[67,196,123],{"class":80},[67,198,99],{"class":73},[67,200,201],{"class":102},"\"total\"",[67,203,131],{"class":73},[67,205,134],{"class":80},[67,207,99],{"class":73},[67,209,210],{"class":139},"500",[67,212,213],{"class":73}," }\n",[67,215,217],{"class":69,"line":216},10,[67,218,219],{"class":73},"      ]\n",[67,221,223],{"class":69,"line":222},11,[67,224,225],{"class":73},"    }\n",[67,227,229],{"class":69,"line":228},12,[67,230,231],{"class":73},"  ]\n",[67,233,235],{"class":69,"line":234},13,[67,236,237],{"class":73},"}\n",[39,239,240],{},"Bundle size is stable across runs. If the JavaScript budget goes from 178KB to 214KB, something specific happened in this pull request, and the diff is right there.",[39,242,243],{},"Keep the Lighthouse run, but report it rather than gate on it. Gate on bytes.",[46,245,247],{"id":246},"attribute-the-regression-in-the-pr","Attribute the regression in the PR",[39,249,250,251,254,255,258],{},"A build that fails with \"bundle too large\" sends someone on a twenty-minute hunt. A build that fails with \"",[64,252,253],{},"+34KB"," — ",[64,256,257],{},"date-fns"," added to the client bundle\" is fixed in two minutes.",[39,260,261],{},"Emit a comparison against the base branch as a PR comment: per-chunk deltas, and the new dependencies responsible. The point is to make the cheapest moment to fix a regression also the most obvious one.",[46,263,265],{"id":264},"the-four-things-that-actually-regress","The four things that actually regress",[39,267,268],{},"Across the front-ends we have built, budget failures cluster:",[39,270,271,275,276,279,280,283,284,286],{},[272,273,274],"strong",{},"A date or utility library reaching the client."," Almost always importable server-side only, or replaceable with ",[64,277,278],{},"Intl",". ",[64,281,282],{},"Intl.DateTimeFormat"," covers the majority of what teams reach for ",[64,285,257],{}," to do.",[39,288,289,292,293,296,297,300],{},[272,290,291],{},"An icon set imported wholesale."," ",[64,294,295],{},"import * as Icons"," pulls in thousands. Bundle the icons you use explicitly — and remember that dynamically named icons (",[64,298,299],{},":name=\"item.icon\"",") are invisible to static scanners, so they need listing by hand.",[39,302,303,306],{},[272,304,305],{},"Fonts."," Self-host, subset to the ranges you serve, preload only the weights used above the fold. Four weights of two families at full Latin coverage is 300KB nobody asked for.",[39,308,309,312],{},[272,310,311],{},"Images without dimensions."," Not a byte problem, a layout-shift problem, and the one most likely to make a fast site feel slow. Width and height on every image, always.",[46,314,316],{"id":315},"measure-the-field-not-the-lab","Measure the field, not the lab",[39,318,319],{},"Lab metrics tell you whether a change helped. Field metrics tell you whether your users are having a good time. They disagree more often than people expect, because your laptop is not a mid-range Android on a train.",[39,321,322,323,326],{},"Ship ",[64,324,325],{},"web-vitals",", send p75 to your analytics, and put LCP, INP and CLS on a dashboard someone looks at weekly. When lab and field diverge, the field is right.",[46,328,330],{"id":329},"reasonable-starting-numbers","Reasonable starting numbers",[39,332,333],{},"For a content-led marketing site, these are achievable without heroics:",[335,336,337,350],"table",{},[338,339,340],"thead",{},[341,342,343,347],"tr",{},[344,345,346],"th",{},"Metric",[344,348,349],{},"Target (p75, field)",[351,352,353,362,370,378,386],"tbody",{},[341,354,355,359],{},[356,357,358],"td",{},"LCP",[356,360,361],{},"\u003C 1.5s",[341,363,364,367],{},[356,365,366],{},"INP",[356,368,369],{},"\u003C 150ms",[341,371,372,375],{},[356,373,374],{},"CLS",[356,376,377],{},"\u003C 0.05",[341,379,380,383],{},[356,381,382],{},"JS transferred",[356,384,385],{},"\u003C 180KB",[341,387,388,391],{},[356,389,390],{},"Total transferred",[356,392,393],{},"\u003C 500KB",[39,395,396],{},"An application will need looser numbers. Set them deliberately rather than inheriting them, and write down why.",[46,398,400],{"id":399},"the-part-that-is-cultural","The part that is cultural",[39,402,403],{},"The technical setup takes an afternoon. Keeping it takes agreement that a budget failure blocks a merge like a failing test does.",[39,405,406],{},"Without that, the check becomes advisory, advisory becomes ignored, and you are back to running an audit every quarter and wondering where the score went.",[408,409,410],"style",{},"html pre.shiki code .sM9_K, html code.shiki .sM9_K{--shiki-default:#ADBAC7;--shiki-dark:#ADBAC7}html pre.shiki code .sza-u, html code.shiki .sza-u{--shiki-default:#8DDB8C;--shiki-dark:#8DDB8C}html pre.shiki code .szYpP, html code.shiki .szYpP{--shiki-default:#96D0FF;--shiki-dark:#96D0FF}html pre.shiki code .sQdni, html code.shiki .sQdni{--shiki-default:#6CB6FF;--shiki-dark:#6CB6FF}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);}",{"title":62,"searchDepth":87,"depth":87,"links":412},[413,414,415,416,417,418],{"id":48,"depth":77,"text":49},{"id":246,"depth":77,"text":247},{"id":264,"depth":77,"text":265},{"id":315,"depth":77,"text":316},{"id":329,"depth":77,"text":330},{"id":399,"depth":77,"text":400},"engineering","\u002Fcovers\u002Fblog\u002Fen\u002Fperformance-budgets-in-ci.webp","2026-01-22","A Lighthouse score is a snapshot. A budget that fails the build is a constraint. Here is the setup we ship with every front-end.","md","en",{},"\u002Fblog\u002Fen\u002Fperformance-budgets-in-ci",{"title":31,"description":422},"blog\u002Fen\u002Fperformance-budgets-in-ci",[430,431,432],"Performance","CI","Nuxt","BFNUPuWxsvteSofWkZcCgqIh7D3iOEk1VX-50PcfS5s",[435,972,1599],{"id":436,"title":437,"author":438,"body":439,"category":419,"cover":960,"coverAlt":437,"date":961,"description":962,"draft":6,"extension":423,"featured":6,"lang":424,"meta":963,"navigation":24,"path":964,"readingTime":117,"seo":965,"stem":966,"tags":967,"__hash__":971},"blog\u002Fblog\u002Fen\u002Fcomponent-api-design.md","Your component API is a contract, so write it down",{"name":33,"role":34},{"type":36,"value":440,"toc":951},[441,444,455,458,462,465,614,621,624,628,640,643,711,724,728,731,744,756,760,763,827,838,841,876,880,886,899,917,921,924,935,939,942,948],[39,442,443],{},"A component library fails in a predictable way. Not all at once — one prop at a time.",[39,445,446,447,450,451,454],{},"Someone needs a slightly different button. They add ",[64,448,449],{},"isCompact",". Someone else needs a different one and adds ",[64,452,453],{},"variant=\"small\"",". Six months later the component takes fourteen props, four of which interact in ways nobody has documented, and everyone has quietly gone back to writing their own.",[39,456,457],{},"The fix is to treat the component's visual API as a contract that is written down in one place and enforced.",[46,459,461],{"id":460},"the-variant-table","The variant table",[39,463,464],{},"Every component should declare its full surface as data, not as branching logic scattered through the template.",[57,466,470],{"className":467,"code":468,"language":469,"meta":62,"style":62},"language-ts shiki shiki-themes github-dark-dimmed github-dark-dimmed","const button = tv({\n  base: 'inline-flex items-center justify-center font-medium',\n  variants: {\n    variant: {\n      primary: 'bg-primary text-primary-foreground',\n      outline: 'border border-border-strong bg-transparent',\n      ghost: 'text-foreground-secondary hover:bg-surface-hover',\n    },\n    size: {\n      sm: 'h-9 px-3.5 text-sm',\n      md: 'h-11 px-5 text-sm',\n      lg: 'h-12 px-6 text-md',\n    },\n  },\n  defaultVariants: { variant: 'primary', size: 'md' },\n})\n","ts",[64,471,472,491,501,506,511,521,531,541,546,551,561,571,581,585,591,608],{"__ignoreMap":62},[67,473,474,478,481,484,488],{"class":69,"line":70},[67,475,477],{"class":476},"sEYkB","const",[67,479,480],{"class":139}," button",[67,482,483],{"class":476}," =",[67,485,487],{"class":486},"s-wk_"," tv",[67,489,490],{"class":73},"({\n",[67,492,493,496,499],{"class":69,"line":77},[67,494,495],{"class":73},"  base: ",[67,497,498],{"class":102},"'inline-flex items-center justify-center font-medium'",[67,500,106],{"class":73},[67,502,503],{"class":69,"line":87},[67,504,505],{"class":73},"  variants: {\n",[67,507,508],{"class":69,"line":93},[67,509,510],{"class":73},"    variant: {\n",[67,512,513,516,519],{"class":69,"line":109},[67,514,515],{"class":73},"      primary: ",[67,517,518],{"class":102},"'bg-primary text-primary-foreground'",[67,520,106],{"class":73},[67,522,523,526,529],{"class":69,"line":117},[67,524,525],{"class":73},"      outline: ",[67,527,528],{"class":102},"'border border-border-strong bg-transparent'",[67,530,106],{"class":73},[67,532,533,536,539],{"class":69,"line":146},[67,534,535],{"class":73},"      ghost: ",[67,537,538],{"class":102},"'text-foreground-secondary hover:bg-surface-hover'",[67,540,106],{"class":73},[67,542,543],{"class":69,"line":169},[67,544,545],{"class":73},"    },\n",[67,547,548],{"class":69,"line":192},[67,549,550],{"class":73},"    size: {\n",[67,552,553,556,559],{"class":69,"line":216},[67,554,555],{"class":73},"      sm: ",[67,557,558],{"class":102},"'h-9 px-3.5 text-sm'",[67,560,106],{"class":73},[67,562,563,566,569],{"class":69,"line":222},[67,564,565],{"class":73},"      md: ",[67,567,568],{"class":102},"'h-11 px-5 text-sm'",[67,570,106],{"class":73},[67,572,573,576,579],{"class":69,"line":228},[67,574,575],{"class":73},"      lg: ",[67,577,578],{"class":102},"'h-12 px-6 text-md'",[67,580,106],{"class":73},[67,582,583],{"class":69,"line":234},[67,584,545],{"class":73},[67,586,588],{"class":69,"line":587},14,[67,589,590],{"class":73},"  },\n",[67,592,594,597,600,603,606],{"class":69,"line":593},15,[67,595,596],{"class":73},"  defaultVariants: { variant: ",[67,598,599],{"class":102},"'primary'",[67,601,602],{"class":73},", size: ",[67,604,605],{"class":102},"'md'",[67,607,143],{"class":73},[67,609,611],{"class":69,"line":610},16,[67,612,613],{"class":73},"})\n",[39,615,616,617,620],{},"The value here is not the terseness. It is that the table is exhaustive. If a variant is not in it, it does not exist, and a request for a new one is a visible decision rather than an inline ",[64,618,619],{},"class"," override that nobody reviews.",[39,622,623],{},"This is the single highest-leverage thing you can do to stop a library from decaying.",[46,625,627],{"id":626},"booleans-are-enums-that-have-not-grown-up-yet","Booleans are enums that have not grown up yet",[39,629,630,632,633,635,636,639],{},[64,631,449],{}," seems fine until you need a third density. Then you get ",[64,634,449],{}," and ",[64,637,638],{},"isSpacious"," and the undefined behaviour when both are true.",[39,641,642],{},"If a property has any chance of gaining a third value, make it an enum on day one:",[57,644,646],{"className":467,"code":645,"language":469,"meta":62,"style":62},"\u002F\u002F Will break\ndefineProps\u003C{ isCompact?: boolean }>()\n\n\u002F\u002F Will not\ndefineProps\u003C{ density?: 'compact' | 'default' | 'comfortable' }>()\n",[64,647,648,654,674,679,684],{"__ignoreMap":62},[67,649,650],{"class":69,"line":70},[67,651,653],{"class":652},"s0RzX","\u002F\u002F Will break\n",[67,655,656,659,662,665,668,671],{"class":69,"line":77},[67,657,658],{"class":486},"defineProps",[67,660,661],{"class":73},"\u003C{ ",[67,663,449],{"class":664},"siJwO",[67,666,667],{"class":476},"?:",[67,669,670],{"class":139}," boolean",[67,672,673],{"class":73}," }>()\n",[67,675,676],{"class":69,"line":87},[67,677,678],{"emptyLinePlaceholder":24},"\n",[67,680,681],{"class":69,"line":93},[67,682,683],{"class":652},"\u002F\u002F Will not\n",[67,685,686,688,690,693,695,698,701,704,706,709],{"class":69,"line":109},[67,687,658],{"class":486},[67,689,661],{"class":73},[67,691,692],{"class":664},"density",[67,694,667],{"class":476},[67,696,697],{"class":102}," 'compact'",[67,699,700],{"class":476}," |",[67,702,703],{"class":102}," 'default'",[67,705,700],{"class":476},[67,707,708],{"class":102}," 'comfortable'",[67,710,673],{"class":73},[39,712,713,714,131,717,131,720,723],{},"Booleans are correct for genuinely binary states — ",[64,715,716],{},"disabled",[64,718,719],{},"loading",[64,721,722],{},"required",". Everything describing appearance is a candidate for growth.",[46,725,727],{"id":726},"name-props-after-what-they-do","Name props after what they do",[39,729,730],{},"Two habits that pay for themselves:",[39,732,733,292,736,739,740,743],{},[272,734,735],{},"Name the role, not the implementation.",[64,737,738],{},"trailingIcon"," survives a switch from an icon font to inline SVG. ",[64,741,742],{},"iconRightSvg"," does not.",[39,745,746,749,750,752,753,755],{},[272,747,748],{},"Match the platform where one exists."," If the DOM calls it ",[64,751,716],{},", call it ",[64,754,716],{},". Every renamed standard property is a thing every consumer has to learn.",[46,757,759],{"id":758},"slots-for-content-props-for-configuration","Slots for content, props for configuration",[39,761,762],{},"The rule that keeps components from growing forever: if it is content, it belongs in a slot; if it changes behaviour or appearance, it is a prop.",[57,764,768],{"className":765,"code":766,"language":767,"meta":62,"style":62},"language-vue shiki shiki-themes github-dark-dimmed github-dark-dimmed","\u003CUiButton variant=\"primary\" size=\"lg\">\n  \u003Ctemplate #leading>\n    \u003CAvatar :src=\"user.avatar\" size=\"xs\" \u002F>\n  \u003C\u002Ftemplate>\n  Continue as {{ user.name }}\n\u003C\u002FUiButton>\n","vue",[64,769,770,798,803,808,813,818],{"__ignoreMap":62},[67,771,772,775,778,781,784,787,790,792,795],{"class":69,"line":70},[67,773,774],{"class":73},"\u003C",[67,776,777],{"class":80},"UiButton",[67,779,780],{"class":139}," variant",[67,782,783],{"class":73},"=",[67,785,786],{"class":102},"\"primary\"",[67,788,789],{"class":139}," size",[67,791,783],{"class":73},[67,793,794],{"class":102},"\"lg\"",[67,796,797],{"class":73},">\n",[67,799,800],{"class":69,"line":77},[67,801,802],{"class":73},"  \u003Ctemplate #leading>\n",[67,804,805],{"class":69,"line":87},[67,806,807],{"class":73},"    \u003CAvatar :src=\"user.avatar\" size=\"xs\" \u002F>\n",[67,809,810],{"class":69,"line":93},[67,811,812],{"class":73},"  \u003C\u002Ftemplate>\n",[67,814,815],{"class":69,"line":109},[67,816,817],{"class":73},"  Continue as {{ user.name }}\n",[67,819,820,823,825],{"class":69,"line":117},[67,821,822],{"class":73},"\u003C\u002F",[67,824,777],{"class":80},[67,826,797],{"class":73},[39,828,829,830,833,834,837],{},"Without the slot, someone eventually asks for an ",[64,831,832],{},"avatarSrc"," prop, and then an ",[64,835,836],{},"avatarSize"," prop, and the button is now responsible for rendering avatars.",[39,839,840],{},"Provide a default inside the slot so the common case stays one line:",[57,842,844],{"className":765,"code":843,"language":767,"meta":62,"style":62},"\u003Cslot name=\"leading\">\n  \u003CIcon v-if=\"icon\" :name=\"icon\" \u002F>\n\u003C\u002Fslot>\n",[64,845,846,863,868],{"__ignoreMap":62},[67,847,848,850,853,856,858,861],{"class":69,"line":70},[67,849,774],{"class":73},[67,851,852],{"class":80},"slot",[67,854,855],{"class":139}," name",[67,857,783],{"class":73},[67,859,860],{"class":102},"\"leading\"",[67,862,797],{"class":73},[67,864,865],{"class":69,"line":77},[67,866,867],{"class":73},"  \u003CIcon v-if=\"icon\" :name=\"icon\" \u002F>\n",[67,869,870,872,874],{"class":69,"line":87},[67,871,822],{"class":73},[67,873,852],{"class":80},[67,875,797],{"class":73},[46,877,879],{"id":878},"let-consumers-override-but-make-it-lose-predictably","Let consumers override, but make it lose predictably",[39,881,882,883,885],{},"A component that cannot be adjusted gets forked. A component that can be adjusted arbitrarily stops being a system. The middle path is to accept a ",[64,884,619],{}," prop, apply it last, and resolve conflicts deterministically:",[57,887,889],{"className":467,"code":888,"language":469,"meta":62,"style":62},"cx(base, variantClasses, props.class)\n",[64,890,891],{"__ignoreMap":62},[67,892,893,896],{"class":69,"line":70},[67,894,895],{"class":486},"cx",[67,897,898],{"class":73},"(base, variantClasses, props.class)\n",[39,900,901,902,905,906,908,909,912,913,916],{},"With ",[64,903,904],{},"tailwind-merge"," in ",[64,907,895],{},", a consumer passing ",[64,910,911],{},"h-14"," beats the variant's ",[64,914,915],{},"h-11"," instead of both landing in the class list and the winner depending on Tailwind's internal ordering. Overrides become predictable, which means people use them for genuine one-offs rather than forking.",[46,918,920],{"id":919},"accessibility-belongs-in-the-primitive","Accessibility belongs in the primitive",[39,922,923],{},"If focus rings, ARIA wiring and reduced-motion handling live in the consumer, they will be forgotten. Not sometimes — reliably, under deadline.",[39,925,926,927,930,931,934],{},"Put them in the primitive. A ",[64,928,929],{},"UiField"," that owns its own id, ",[64,932,933],{},"aria-describedby"," and error announcement means no form in the codebase can ship an undescribed input, because the wiring is not the consumer's job.",[46,936,938],{"id":937},"the-test","The test",[39,940,941],{},"Ask someone who did not build the library to add a new page with it, and watch without helping.",[39,943,944,945,947],{},"Every time they open the component's source to work out what a prop does, that is a documentation failure. Every time they write a custom ",[64,946,619],{}," to get something the variant table should have offered, that is an API failure. Both are cheap to fix in week one and expensive in month nine.",[408,949,950],{},"html pre.shiki code .sEYkB, html code.shiki .sEYkB{--shiki-default:#F47067;--shiki-dark:#F47067}html pre.shiki code .sQdni, html code.shiki .sQdni{--shiki-default:#6CB6FF;--shiki-dark:#6CB6FF}html pre.shiki code .s-wk_, html code.shiki .s-wk_{--shiki-default:#DCBDFB;--shiki-dark:#DCBDFB}html pre.shiki code .sM9_K, html code.shiki .sM9_K{--shiki-default:#ADBAC7;--shiki-dark:#ADBAC7}html pre.shiki code .szYpP, html code.shiki .szYpP{--shiki-default:#96D0FF;--shiki-dark:#96D0FF}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 .s0RzX, html code.shiki .s0RzX{--shiki-default:#768390;--shiki-dark:#768390}html pre.shiki code .siJwO, html code.shiki .siJwO{--shiki-default:#F69D50;--shiki-dark:#F69D50}html pre.shiki code .sza-u, html code.shiki .sza-u{--shiki-default:#8DDB8C;--shiki-dark:#8DDB8C}",{"title":62,"searchDepth":87,"depth":87,"links":952},[953,954,955,956,957,958,959],{"id":460,"depth":77,"text":461},{"id":626,"depth":77,"text":627},{"id":726,"depth":77,"text":727},{"id":758,"depth":77,"text":759},{"id":878,"depth":77,"text":879},{"id":919,"depth":77,"text":920},{"id":937,"depth":77,"text":938},"\u002Fcovers\u002Fblog\u002Fen\u002Fcomponent-api-design.webp","2026-04-09","Variant tables, prop naming and the boolean that should have been an enum. Notes from maintaining component libraries other people have to use.",{},"\u002Fblog\u002Fen\u002Fcomponent-api-design",{"title":437,"description":962},"blog\u002Fen\u002Fcomponent-api-design",[968,969,970],"Vue","Components","API Design","UGHo0vBRPnye5ZBaateSnSRWcf_erhdcP4Ws9hcrWWM",{"id":973,"title":974,"author":975,"body":978,"category":1586,"cover":1587,"coverAlt":974,"date":1588,"description":1589,"draft":6,"extension":423,"featured":24,"lang":424,"meta":1590,"navigation":24,"path":1591,"readingTime":146,"seo":1592,"stem":1593,"tags":1594,"__hash__":1598},"blog\u002Fblog\u002Fen\u002Fdesign-tokens-that-survive-a-rebrand.md","Design tokens that survive a rebrand",{"name":976,"role":977},"Elena Marsh","Design Systems Lead",{"type":36,"value":979,"toc":1578},[980,983,987,990,1084,1087,1101,1107,1111,1123,1403,1406,1417,1421,1424,1479,1483,1494,1525,1546,1554,1556,1559,1565,1568,1572,1575],[39,981,982],{},"A design system survives its first rebrand or it does not. Most do not, and the reason is almost always the same: the system has two layers where it needs three.",[46,984,986],{"id":985},"the-two-layer-trap","The two-layer trap",[39,988,989],{},"Here is the shape nearly every token system starts with. A palette, and components that consume it.",[57,991,995],{"className":992,"code":993,"language":994,"meta":62,"style":62},"language-css shiki shiki-themes github-dark-dimmed github-dark-dimmed",":root {\n  --blue-500: #00d4ff;\n  --gray-900: #18181b;\n}\n\n.card {\n  background: var(--gray-900);\n  border-color: var(--blue-500);\n}\n","css",[64,996,997,1005,1018,1030,1034,1038,1045,1064,1080],{"__ignoreMap":62},[67,998,999,1002],{"class":69,"line":70},[67,1000,1001],{"class":139},":root",[67,1003,1004],{"class":73}," {\n",[67,1006,1007,1010,1012,1015],{"class":69,"line":77},[67,1008,1009],{"class":664},"  --blue-500",[67,1011,99],{"class":73},[67,1013,1014],{"class":139},"#00d4ff",[67,1016,1017],{"class":73},";\n",[67,1019,1020,1023,1025,1028],{"class":69,"line":87},[67,1021,1022],{"class":664},"  --gray-900",[67,1024,99],{"class":73},[67,1026,1027],{"class":139},"#18181b",[67,1029,1017],{"class":73},[67,1031,1032],{"class":69,"line":93},[67,1033,237],{"class":73},[67,1035,1036],{"class":69,"line":109},[67,1037,678],{"emptyLinePlaceholder":24},[67,1039,1040,1043],{"class":69,"line":117},[67,1041,1042],{"class":139},".card",[67,1044,1004],{"class":73},[67,1046,1047,1050,1052,1055,1058,1061],{"class":69,"line":146},[67,1048,1049],{"class":139},"  background",[67,1051,99],{"class":73},[67,1053,1054],{"class":139},"var",[67,1056,1057],{"class":73},"(",[67,1059,1060],{"class":664},"--gray-900",[67,1062,1063],{"class":73},");\n",[67,1065,1066,1069,1071,1073,1075,1078],{"class":69,"line":169},[67,1067,1068],{"class":139},"  border-color",[67,1070,99],{"class":73},[67,1072,1054],{"class":139},[67,1074,1057],{"class":73},[67,1076,1077],{"class":664},"--blue-500",[67,1079,1063],{"class":73},[67,1081,1082],{"class":69,"line":192},[67,1083,237],{"class":73},[39,1085,1086],{},"This is already better than hardcoded hexes, and it feels like the job is done. It is not, and the failure mode is specific.",[39,1088,1089,1090,1092,1093,1096,1097,1100],{},"Six months in, someone asks for a light theme. ",[64,1091,1060],{}," is the card background in dark mode and needs to be near-white in light mode. So you either rename the variable to something that is now a lie, or you write ",[64,1094,1095],{},"--gray-900: #ffffff"," under ",[64,1098,1099],{},"[data-theme=\"light\"]",", which is worse.",[39,1102,1103,1104,1106],{},"Then the rebrand lands, blue becomes violet, and you discover ",[64,1105,1077],{}," is referenced in 340 places — some of which meant \"our brand colour\" and some of which meant \"the colour of a link\" and some of which meant \"this particular chart series.\" Those are three different concepts that happened to share a hex.",[46,1108,1110],{"id":1109},"the-layer-you-are-missing","The layer you are missing",[39,1112,1113,1114,1118,1119,1122],{},"Primitives describe what a value ",[1115,1116,1117],"em",{},"is",". Semantics describe what it is ",[1115,1120,1121],{},"for",". You need both, and components may only touch the second.",[57,1124,1126],{"className":992,"code":1125,"language":994,"meta":62,"style":62},"\u002F* 1. Primitives — raw values, no product meaning *\u002F\n:root {\n  --cyan-500: #00d4ff;\n  --neutral-900: #18181b;\n  --neutral-0: #ffffff;\n}\n\n\u002F* 2. Semantics — roles, which flip per theme *\u002F\n:root,\n[data-theme='dark'] {\n  --surface: var(--neutral-900);\n  --primary: var(--cyan-500);\n  --border: rgb(255 255 255 \u002F 0.08);\n}\n\n[data-theme='light'] {\n  --surface: var(--neutral-0);\n  --primary: #0093b8;\n  --border: rgb(9 9 11 \u002F 0.09);\n}\n\n\u002F* 3. Components — roles only *\u002F\n.card {\n  background: var(--surface);\n  border-color: var(--border);\n}\n",[64,1127,1128,1133,1139,1150,1161,1173,1177,1181,1186,1192,1208,1224,1240,1268,1272,1276,1289,1305,1317,1344,1349,1354,1360,1367,1382,1398],{"__ignoreMap":62},[67,1129,1130],{"class":69,"line":70},[67,1131,1132],{"class":652},"\u002F* 1. Primitives — raw values, no product meaning *\u002F\n",[67,1134,1135,1137],{"class":69,"line":77},[67,1136,1001],{"class":139},[67,1138,1004],{"class":73},[67,1140,1141,1144,1146,1148],{"class":69,"line":87},[67,1142,1143],{"class":664},"  --cyan-500",[67,1145,99],{"class":73},[67,1147,1014],{"class":139},[67,1149,1017],{"class":73},[67,1151,1152,1155,1157,1159],{"class":69,"line":93},[67,1153,1154],{"class":664},"  --neutral-900",[67,1156,99],{"class":73},[67,1158,1027],{"class":139},[67,1160,1017],{"class":73},[67,1162,1163,1166,1168,1171],{"class":69,"line":109},[67,1164,1165],{"class":664},"  --neutral-0",[67,1167,99],{"class":73},[67,1169,1170],{"class":139},"#ffffff",[67,1172,1017],{"class":73},[67,1174,1175],{"class":69,"line":117},[67,1176,237],{"class":73},[67,1178,1179],{"class":69,"line":146},[67,1180,678],{"emptyLinePlaceholder":24},[67,1182,1183],{"class":69,"line":169},[67,1184,1185],{"class":652},"\u002F* 2. Semantics — roles, which flip per theme *\u002F\n",[67,1187,1188,1190],{"class":69,"line":192},[67,1189,1001],{"class":139},[67,1191,106],{"class":73},[67,1193,1194,1197,1200,1202,1205],{"class":69,"line":216},[67,1195,1196],{"class":73},"[",[67,1198,1199],{"class":139},"data-theme",[67,1201,783],{"class":476},[67,1203,1204],{"class":102},"'dark'",[67,1206,1207],{"class":73},"] {\n",[67,1209,1210,1213,1215,1217,1219,1222],{"class":69,"line":222},[67,1211,1212],{"class":664},"  --surface",[67,1214,99],{"class":73},[67,1216,1054],{"class":139},[67,1218,1057],{"class":73},[67,1220,1221],{"class":664},"--neutral-900",[67,1223,1063],{"class":73},[67,1225,1226,1229,1231,1233,1235,1238],{"class":69,"line":228},[67,1227,1228],{"class":664},"  --primary",[67,1230,99],{"class":73},[67,1232,1054],{"class":139},[67,1234,1057],{"class":73},[67,1236,1237],{"class":664},"--cyan-500",[67,1239,1063],{"class":73},[67,1241,1242,1245,1247,1250,1252,1255,1258,1260,1263,1266],{"class":69,"line":234},[67,1243,1244],{"class":664},"  --border",[67,1246,99],{"class":73},[67,1248,1249],{"class":139},"rgb",[67,1251,1057],{"class":73},[67,1253,1254],{"class":139},"255",[67,1256,1257],{"class":139}," 255",[67,1259,1257],{"class":139},[67,1261,1262],{"class":73}," \u002F ",[67,1264,1265],{"class":139},"0.08",[67,1267,1063],{"class":73},[67,1269,1270],{"class":69,"line":587},[67,1271,237],{"class":73},[67,1273,1274],{"class":69,"line":593},[67,1275,678],{"emptyLinePlaceholder":24},[67,1277,1278,1280,1282,1284,1287],{"class":69,"line":610},[67,1279,1196],{"class":73},[67,1281,1199],{"class":139},[67,1283,783],{"class":476},[67,1285,1286],{"class":102},"'light'",[67,1288,1207],{"class":73},[67,1290,1292,1294,1296,1298,1300,1303],{"class":69,"line":1291},17,[67,1293,1212],{"class":664},[67,1295,99],{"class":73},[67,1297,1054],{"class":139},[67,1299,1057],{"class":73},[67,1301,1302],{"class":664},"--neutral-0",[67,1304,1063],{"class":73},[67,1306,1308,1310,1312,1315],{"class":69,"line":1307},18,[67,1309,1228],{"class":664},[67,1311,99],{"class":73},[67,1313,1314],{"class":139},"#0093b8",[67,1316,1017],{"class":73},[67,1318,1320,1322,1324,1326,1328,1331,1334,1337,1339,1342],{"class":69,"line":1319},19,[67,1321,1244],{"class":664},[67,1323,99],{"class":73},[67,1325,1249],{"class":139},[67,1327,1057],{"class":73},[67,1329,1330],{"class":139},"9",[67,1332,1333],{"class":139}," 9",[67,1335,1336],{"class":139}," 11",[67,1338,1262],{"class":73},[67,1340,1341],{"class":139},"0.09",[67,1343,1063],{"class":73},[67,1345,1347],{"class":69,"line":1346},20,[67,1348,237],{"class":73},[67,1350,1352],{"class":69,"line":1351},21,[67,1353,678],{"emptyLinePlaceholder":24},[67,1355,1357],{"class":69,"line":1356},22,[67,1358,1359],{"class":652},"\u002F* 3. Components — roles only *\u002F\n",[67,1361,1363,1365],{"class":69,"line":1362},23,[67,1364,1042],{"class":139},[67,1366,1004],{"class":73},[67,1368,1369,1371,1373,1375,1377,1380],{"class":69,"line":5},[67,1370,1049],{"class":139},[67,1372,99],{"class":73},[67,1374,1054],{"class":139},[67,1376,1057],{"class":73},[67,1378,1379],{"class":664},"--surface",[67,1381,1063],{"class":73},[67,1383,1385,1387,1389,1391,1393,1396],{"class":69,"line":1384},25,[67,1386,1068],{"class":139},[67,1388,99],{"class":73},[67,1390,1054],{"class":139},[67,1392,1057],{"class":73},[67,1394,1395],{"class":664},"--border",[67,1397,1063],{"class":73},[67,1399,1401],{"class":69,"line":1400},26,[67,1402,237],{"class":73},[39,1404,1405],{},"Now the light theme is a block of overrides in one place, and the rebrand is an edit to the semantic layer. The 340 call sites do not move, because they were never asking for blue. They were asking for \"primary,\" and primary is now violet.",[39,1407,1408,1409,1412,1413,1416],{},"Note also that ",[64,1410,1411],{},"--primary"," is not the same hex in both themes. ",[64,1414,1415],{},"#00D4FF"," on white fails contrast badly; the light theme needs a darker cyan to clear WCAG AA. A two-layer system cannot express that without a conditional at every call site. A three-layer system expresses it once.",[46,1418,1420],{"id":1419},"naming-the-semantic-layer","Naming the semantic layer",[39,1422,1423],{},"The semantic layer is only useful if the names describe roles rather than appearance. Some rules that hold up:",[1425,1426,1427,1447,1453,1469],"ul",{},[1428,1429,1430,292,1433,1436,1437,279,1440,1436,1443,1446],"li",{},[272,1431,1432],{},"Name the job, not the look.",[64,1434,1435],{},"--surface-raised",", not ",[64,1438,1439],{},"--gray-800",[64,1441,1442],{},"--danger",[64,1444,1445],{},"--red",".",[1428,1448,1449,1452],{},[272,1450,1451],{},"Pair foreground with background."," Every surface token gets a matching foreground token, checked for contrast once, so no component has to think about it.",[1428,1454,1455,292,1458,131,1461,1464,1465,1468],{},[272,1456,1457],{},"Encode state in the name.",[64,1459,1460],{},"--surface-hover",[64,1462,1463],{},"--surface-active",". If hover is derived at the call site with ",[64,1466,1467],{},"color-mix"," or an opacity modifier, you have leaked appearance logic back into components.",[1428,1470,1471,1474,1475,1478],{},[272,1472,1473],{},"Resist the urge to add a third brand colour."," Most systems that end up with ",[64,1476,1477],{},"--tertiary"," did so because someone needed a specific shade once. That is what a one-off class is for.",[46,1480,1482],{"id":1481},"wiring-it-to-tailwind-v4","Wiring it to Tailwind v4",[39,1484,1485,1486,1489,1490,1493],{},"Tailwind v4's ",[64,1487,1488],{},"@theme"," generates utilities from CSS variables, and it has an ",[64,1491,1492],{},"inline"," variant that matters here.",[57,1495,1497],{"className":992,"code":1496,"language":994,"meta":62,"style":62},"@theme inline {\n  --color-surface: var(--surface);\n  --color-primary: var(--primary);\n  --color-border: var(--border);\n}\n",[64,1498,1499,1506,1511,1516,1521],{"__ignoreMap":62},[67,1500,1501,1503],{"class":69,"line":70},[67,1502,1488],{"class":476},[67,1504,1505],{"class":73}," inline {\n",[67,1507,1508],{"class":69,"line":77},[67,1509,1510],{"class":73},"  --color-surface: var(--surface);\n",[67,1512,1513],{"class":69,"line":87},[67,1514,1515],{"class":73},"  --color-primary: var(--primary);\n",[67,1517,1518],{"class":69,"line":93},[67,1519,1520],{"class":73},"  --color-border: var(--border);\n",[67,1522,1523],{"class":69,"line":109},[67,1524,237],{"class":73},[39,1526,1527,1530,1531,1534,1535,1538,1539,1542,1543,1545],{},[64,1528,1529],{},"@theme inline"," keeps the ",[64,1532,1533],{},"var()"," reference live in the generated utility, so ",[64,1536,1537],{},"bg-surface"," emits ",[64,1540,1541],{},"background-color: var(--surface)"," and re-resolves when the theme attribute changes. Without ",[64,1544,1492],{},", Tailwind resolves the value at build time and your theme switch silently does nothing — a bug that is genuinely unpleasant to track down because the CSS looks correct.",[39,1547,1548,1549,1551,1552,1446],{},"Static scales that never flip — spacing, radius, duration, easing — go in a plain ",[64,1550,1488],{}," block. Only the colour roles need ",[64,1553,1492],{},[46,1555,938],{"id":937},[39,1557,1558],{},"There is one question that tells you whether your token layer is real:",[1560,1561,1562],"blockquote",{},[39,1563,1564],{},"If the brand colour changed tomorrow, how many files would you edit?",[39,1566,1567],{},"If the answer is more than one, you have a naming convention, not an architecture. The gap between those two is roughly a day of work, and it is the difference between a rebrand being a variable change and a rebrand being a quarter.",[46,1569,1571],{"id":1570},"where-this-stops-being-worth-it","Where this stops being worth it",[39,1573,1574],{},"For a marketing site with six components, three layers is overhead. The break-even is somewhere around the point where you have a second theme, a second product, or a second team — whichever arrives first. Below that, a flat palette is fine and you should not let anyone tell you otherwise.",[408,1576,1577],{},"html pre.shiki code .sQdni, html code.shiki .sQdni{--shiki-default:#6CB6FF;--shiki-dark:#6CB6FF}html pre.shiki code .sM9_K, html code.shiki .sM9_K{--shiki-default:#ADBAC7;--shiki-dark:#ADBAC7}html pre.shiki code .siJwO, html code.shiki .siJwO{--shiki-default:#F69D50;--shiki-dark:#F69D50}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 .s0RzX, html code.shiki .s0RzX{--shiki-default:#768390;--shiki-dark:#768390}html pre.shiki code .sEYkB, html code.shiki .sEYkB{--shiki-default:#F47067;--shiki-dark:#F47067}html pre.shiki code .szYpP, html code.shiki .szYpP{--shiki-default:#96D0FF;--shiki-dark:#96D0FF}",{"title":62,"searchDepth":87,"depth":87,"links":1579},[1580,1581,1582,1583,1584,1585],{"id":985,"depth":77,"text":986},{"id":1109,"depth":77,"text":1110},{"id":1419,"depth":77,"text":1420},{"id":1481,"depth":77,"text":1482},{"id":937,"depth":77,"text":938},{"id":1570,"depth":77,"text":1571},"design-systems","\u002Fcovers\u002Fblog\u002Fen\u002Fdesign-tokens-that-survive-a-rebrand.webp","2026-06-18","Most token systems break the first time the brand changes. The fix is a layer most teams skip — and it costs about a day to add.",{},"\u002Fblog\u002Fen\u002Fdesign-tokens-that-survive-a-rebrand",{"title":974,"description":1589},"blog\u002Fen\u002Fdesign-tokens-that-survive-a-rebrand",[1595,1596,1597],"Design Tokens","Architecture","Tailwind","7dPa213uoSmziFZ1uEs730NPYoCBRpGHOQQ_ovl_Bs0",{"id":1600,"title":1601,"author":1602,"body":1605,"category":1750,"cover":1751,"coverAlt":1601,"date":1752,"description":1753,"draft":6,"extension":423,"featured":24,"lang":424,"meta":1754,"navigation":24,"path":1755,"readingTime":169,"seo":1756,"stem":1757,"tags":1758,"__hash__":1762},"blog\u002Fblog\u002Fen\u002Fdesigning-for-models-that-are-wrong.md","Designing for models that are wrong",{"name":1603,"role":1604},"Kwame Boateng","Principal Product Designer",{"type":36,"value":1606,"toc":1740},[1607,1610,1613,1617,1620,1623,1628,1634,1637,1641,1644,1647,1667,1670,1674,1677,1684,1688,1691,1694,1714,1718,1724,1727,1730,1734,1737],[39,1608,1609],{},"A model with 94% accuracy is wrong roughly once every sixteen times. If your interface presents every output with identical confidence, users will meet that sixteenth case, and from then on they will discount all sixteen.",[39,1611,1612],{},"This is the central design problem in AI products, and it is not solved by making the model better. A 99% model is wrong once every hundred times, and a user who cannot tell which time it is still has to check all hundred.",[46,1614,1616],{"id":1615},"uncertainty-is-a-design-surface","Uncertainty is a design surface",[39,1618,1619],{},"The useful question is not \"how do we hide that the model is uncertain\" but \"how do we put uncertainty where the user can act on it.\"",[39,1621,1622],{},"A few patterns that hold up in practice.",[1624,1625,1627],"h3",{"id":1626},"show-the-evidence-next-to-the-conclusion","Show the evidence next to the conclusion",[39,1629,1630,1631,1446],{},"An answer alone is unverifiable. An answer with its sources is checkable in seconds. This is why citation-first interfaces outperform confident-summary interfaces even when the summaries are equally accurate — the user is not reading the citation, they are reading the ",[1115,1632,1633],{},"fact that there is one",[39,1635,1636],{},"The cheap version of this is a link. The good version highlights the specific span of the source that produced the claim, so verification costs a glance rather than a page of reading.",[1624,1638,1640],{"id":1639},"bands-beat-percentages","Bands beat percentages",[39,1642,1643],{},"\"87% confident\" reads as false precision to most people, and as noise to the rest. In user testing we consistently see people round anything above 80% to \"yes\" and anything below to \"no,\" which discards the entire signal.",[39,1645,1646],{},"Calibrated bands work better because they carry a meaning rather than a number:",[1425,1648,1649,1655,1661],{},[1428,1650,1651,1654],{},[272,1652,1653],{},"Strong"," — many similar cases in training, historically reliable here",[1428,1656,1657,1660],{},[272,1658,1659],{},"Mixed"," — features disagree, worth a closer look",[1428,1662,1663,1666],{},[272,1664,1665],{},"Sparse"," — little comparable data, treat as a prompt rather than an answer",[39,1668,1669],{},"The third band is the one teams resist shipping and the one users value most. Admitting the model is out of its depth is what makes the other two bands credible.",[1624,1671,1673],{"id":1672},"make-disagreement-cheap","Make disagreement cheap",[39,1675,1676],{},"If accepting a suggestion is one click and rejecting it is a modal with a required reason code, you have not built a feedback loop. You have built a compliance funnel, and the data coming out of it is worthless.",[39,1678,1679,1680,1683],{},"Symmetrical cost is the rule: rejecting should take exactly as long as accepting. Ask for a reason, make it optional, and accept free text. You will get ",[1115,1681,1682],{},"more"," structured feedback this way, not less, because people who were previously avoiding the interaction entirely will now engage with it.",[46,1685,1687],{"id":1686},"latency-is-a-trust-signal","Latency is a trust signal",[39,1689,1690],{},"Streaming changed what users expect, but it also created a new failure mode: an interface that streams confidently and then contradicts itself three sentences in.",[39,1692,1693],{},"Some rules we apply:",[1425,1695,1696,1702,1708],{},[1428,1697,1698,1701],{},[272,1699,1700],{},"Never stream a verdict you might retract."," Stream the reasoning, then commit to the conclusion. Reversing a rendered answer is worse than a slower answer.",[1428,1703,1704,1707],{},[272,1705,1706],{},"Show the shape of the wait."," \"Searching 1,240 documents\" beats a spinner, not because it is faster but because it is falsifiable — the user can tell whether the system is doing something plausible.",[1428,1709,1710,1713],{},[272,1711,1712],{},"Budget for the p95, not the median."," AI features have long tails. An interface that feels good at 800ms and broken at 6s will be remembered as broken.",[46,1715,1717],{"id":1716},"the-interaction-nobody-designs","The interaction nobody designs",[39,1719,1720,1721],{},"Almost every AI feature ships without a designed answer to: ",[1115,1722,1723],{},"what happens after the user notices it was wrong?",[39,1725,1726],{},"Not the error state — the model returning something plausible and incorrect is not an error, it is normal operation. The question is whether the user has a path from \"this is wrong\" to \"the system knows it was wrong and I can move on.\"",[39,1728,1729],{},"Without that path, the user's only option is to stop using the feature. Which is what most of them do, quietly, and it does not show up as an error in any dashboard.",[46,1731,1733],{"id":1732},"what-this-costs","What this costs",[39,1735,1736],{},"Designing for uncertainty makes the happy path slightly worse. Citations take space. Bands are less satisfying than a big number. An easy reject button will be used.",[39,1738,1739],{},"That trade is worth making, because the alternative is not a cleaner interface — it is a feature users abandon after the first time it burns them. A product that is honest about being wrong 6% of the time gets used for the other 94%. A product that pretends to be certain gets used until the sixteenth case, and then not at all.",{"title":62,"searchDepth":87,"depth":87,"links":1741},[1742,1747,1748,1749],{"id":1615,"depth":77,"text":1616,"children":1743},[1744,1745,1746],{"id":1626,"depth":87,"text":1627},{"id":1639,"depth":87,"text":1640},{"id":1672,"depth":87,"text":1673},{"id":1686,"depth":77,"text":1687},{"id":1716,"depth":77,"text":1717},{"id":1732,"depth":77,"text":1733},"ai","\u002Fcovers\u002Fblog\u002Fen\u002Fdesigning-for-models-that-are-wrong.webp","2026-05-02","Every AI feature has a failure rate. Most interfaces are designed as though it were zero, and users learn to distrust the whole product.",{},"\u002Fblog\u002Fen\u002Fdesigning-for-models-that-are-wrong",{"title":1601,"description":1753},"blog\u002Fen\u002Fdesigning-for-models-that-are-wrong",[1759,1760,1761],"AI","Product Design","Trust","MBTX59kM1xlP_eNQBxC5t-JS5qQhe3ZkMfkjV-vlp0U",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":1764},"\u003Cpath fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"m9 18l6-6l-6-6\"\u002F>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":1766},"\u003Cg fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\">\u003Cpath d=\"M8 2v3m8-3v3\"\u002F>\u003Crect width=\"18\" height=\"18\" x=\"3\" y=\"3\" rx=\"2\"\u002F>\u003Cpath d=\"M3 9h18\"\u002F>\u003C\u002Fg>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":1768},"\u003Cg fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\">\u003Ccircle cx=\"12\" cy=\"12\" r=\"10\"\u002F>\u003Cpath d=\"M12 6v6l4 2\"\u002F>\u003C\u002Fg>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":1770},"\u003Cg fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\">\u003Cpath d=\"M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71\"\u002F>\u003Cpath d=\"M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71\"\u002F>\u003C\u002Fg>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":1772},"\u003Cg fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\">\u003Cpath d=\"M10 22V7a1 1 0 0 0-1-1H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-5a1 1 0 0 0-1-1H2\"\u002F>\u003Crect width=\"8\" height=\"8\" x=\"14\" y=\"2\" rx=\"1\"\u002F>\u003C\u002Fg>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":1774},"\u003Cpath fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M15.536 11.293a1 1 0 0 0 0 1.414l2.376 2.377a1 1 0 0 0 1.414 0l2.377-2.377a1 1 0 0 0 0-1.414l-2.377-2.377a1 1 0 0 0-1.414 0zm-13.239 0a1 1 0 0 0 0 1.414l2.377 2.377a1 1 0 0 0 1.414 0l2.377-2.377a1 1 0 0 0 0-1.414L6.088 8.916a1 1 0 0 0-1.414 0zm6.619 6.619a1 1 0 0 0 0 1.415l2.377 2.376a1 1 0 0 0 1.414 0l2.377-2.376a1 1 0 0 0 0-1.415l-2.377-2.376a1 1 0 0 0-1.414 0zm0-13.238a1 1 0 0 0 0 1.414l2.377 2.376a1 1 0 0 0 1.414 0l2.377-2.376a1 1 0 0 0 0-1.414l-2.377-2.377a1 1 0 0 0-1.414 0z\"\u002F>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":1776},"\u003Cg fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\">\u003Cpath d=\"M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594zM20 2v4m2-2h-4\"\u002F>\u003Ccircle cx=\"4\" cy=\"20\" r=\"2\"\u002F>\u003C\u002Fg>",1787597895226]