[{"data":1,"prerenderedAt":3254},["ShallowReactive",2],{"writing-posts":3},[4,246,772,1651],{"id":5,"title":6,"body":7,"category":234,"date":235,"description":236,"extension":237,"i18nKey":238,"meta":239,"navigation":240,"path":241,"readingTime":242,"seo":243,"stem":244,"__hash__":245},"writing\u002Fwriting\u002Fdefi-swap-ui.md","Building a DeFi swap UI users don't fear",{"type":8,"value":9,"toc":225},"minimark",[10,14,21,26,29,36,58,62,65,71,82,88,99,103,106,160,164,171,202,205,209,216],[11,12,13],"p",{},"Swapping tokens is, functionally, a two-input form. Yet most swap UIs manage to look like a Bloomberg terminal having a bad day: exchange rates, price impact, slippage settings, route diagrams, gas estimates, USD values, pool fees — all rendered at once, all at the same visual volume.",[11,15,16,17],{},"The audience problem makes it worse. A DeFi interface serves two users at the same URL: someone making their third swap ever, and someone who checks price impact to two decimals. Working on CaveSwap, the design question was never \"how do we show all this data\" — it was ",[18,19,20],"strong",{},"\"who needs which number, and when?\"",[22,23,25],"h2",{"id":24},"fear-comes-from-irreversibility-not-complexity","Fear comes from irreversibility, not complexity",[11,27,28],{},"Web apps train people that mistakes are undoable. Crypto breaks that contract: a confirmed transaction is final, and users know it. That background anxiety changes how they read your UI — every ambiguous label becomes a threat, every spinning loader a small crisis.",[11,30,31,32,35],{},"So the design goal isn't \"simple\". It's ",[18,33,34],{},"legible under stress",". A user about to move real money re-reads everything, and the interface has to survive that suspicious second reading. Concretely:",[37,38,39,46,52],"ul",{},[40,41,42,45],"li",{},[18,43,44],{},"The primary action names what it does."," \"Swap ETH for USDC\" beats \"Confirm\". If the button can say the consequence, it should.",[40,47,48,51],{},[18,49,50],{},"The confirm step is a sentence, not a table."," \"You pay 0.5 ETH. You receive at least 1,643.20 USDC. Rate expires in 24s.\" A human sentence with a worst-case number beats twelve rows of key–value pairs.",[40,53,54,57],{},[18,55,56],{},"The worst case is explicit."," \"At least\" is doing real work up there — it converts slippage from a setting into a promise.",[22,59,61],{"id":60},"progressive-disclosure-the-volume-knob","Progressive disclosure: the volume knob",[11,63,64],{},"The CaveSwap redesign boiled down to sorting every data point into three tiers:",[11,66,67,70],{},[18,68,69],{},"Tier 1 — always visible:"," the two amounts, the tokens, your balance of each, the primary button. That's it. This is the whole interface for the 90% case.",[11,72,73,76,77,81],{},[18,74,75],{},"Tier 2 — one tap away:"," rate, price impact, minimum received, network fee, route. Collapsed into a single summary row (\"1 ETH ≈ 3,291 USDC · details\") that expands. Power users open it every time and that's fine — ",[78,79,80],"em",{},"opening it is a choice",", and the beginner never pays the cognitive cost.",[11,83,84,87],{},[18,85,86],{},"Tier 3 — settings:"," slippage tolerance, deadline, expert toggles. Behind a gear icon, with defaults good enough that most users never look.",[11,89,90,91,94,95,98],{},"The exception that proves the rule: ",[18,92,93],{},"warnings jump tiers."," Price impact lives in tier 2 — until it exceeds a threshold, at which point it moves into tier 1, colored, next to the button it's warning you about (\"Price impact 4.8% — you're trading into thin liquidity\"). Information earns screen space by being ",[78,96,97],{},"currently important",", not by existing.",[22,100,102],{"id":101},"numbers-are-a-ui-component","Numbers are a UI component",[11,104,105],{},"DeFi numbers are user-hostile by default: 18 decimals, scientific notation, dust balances. Formatting them is real design work:",[37,107,108,123,137,154],{},[40,109,110,113,114,118,119,122],{},[18,111,112],{},"Balances truncate, inputs don't."," Display ",[115,116,117],"code",{},"1.2847 ETH",", but tapping ",[18,120,121],{},"Max"," fills the input with the exact value. Truncate an input and you've either blocked a full swap or invented dust.",[40,124,125,128,129,132,133,136],{},[18,126,127],{},"Show fiat next to crypto."," ",[115,130,131],{},"0.5 ETH ($1,645)"," — the dollar value is what catches the extra zero ",[78,134,135],{},"before"," the confirm screen does.",[40,138,139,128,142,145,146,149,150,153],{},[18,140,141],{},"Significant digits scale with magnitude.",[115,143,144],{},"1,643.20"," and ",[115,147,148],{},"0.000032"," are both four significant digits doing their job; ",[115,151,152],{},"1,643.204818"," is noise wearing a suit.",[40,155,156,159],{},[18,157,158],{},"Never let a number silently be stale."," Quotes age. Show it: \"rate updates in 12s\", and re-quote before confirm if it expired. A user who swaps on a dead quote and gets a worse fill will — correctly — blame you.",[22,161,163],{"id":162},"transaction-states-that-read-like-a-story","Transaction states that read like a story",[11,165,166,167,170],{},"Between \"I clicked\" and \"it's done\" lives the most anxious UX in software. The states need to be ",[78,168,169],{},"narrated",":",[172,173,174,180,190,196],"ol",{},[40,175,176,179],{},[18,177,178],{},"Waiting for wallet"," — \"Confirm in your wallet\" with the wallet's icon. The #1 confusion moment: the action moved to another window and the UI should say so.",[40,181,182,185,186,189],{},[18,183,184],{},"Pending"," — hash truncated, explorer link, elapsed time. The explorer link matters even for users who never click it: ",[78,187,188],{},"being checkable"," is what makes the state trustworthy.",[40,191,192,195],{},[18,193,194],{},"Confirmed"," — what actually happened, with final amounts: \"Swapped 0.5 ETH → 1,650.80 USDC\". Received amount can differ from the quote; show the real number.",[40,197,198,201],{},[18,199,200],{},"Failed"," — translated. \"Transaction reverted: slippage exceeded — the price moved more than 0.5% while your transaction was pending. Try again or raise your tolerance.\" Raw RPC errors are not messages, they're stack traces with gas fees.",[11,203,204],{},"And when the user comes back later: pending transactions survive a page refresh. Losing track of an in-flight transaction is the single fastest way to teach someone your app can't be trusted with money.",[22,206,208],{"id":207},"restraint-is-the-feature","Restraint is the feature",[11,210,211,212,215],{},"The uncomfortable part of this work is that it mostly consists of ",[78,213,214],{},"removing"," things — data, borders, colors, urgency. Every element you delete makes the remaining ones more legible, and in an interface where users are already scared, legibility is the entire game. The power user loses nothing (tier 2 is one tap away); the new user gains the ability to complete a swap without feeling like they're defusing something.",[11,217,218,219,224],{},"I design and build these interfaces end to end — Vue\u002FNuxt, Wagmi\u002FViem, from the Figma exploration to the deployed app. If your protocol's UI is scaring users away, ",[220,221,223],"a",{"href":222},"\u002F#contact","I can help",".",{"title":226,"searchDepth":227,"depth":227,"links":228},"",2,[229,230,231,232,233],{"id":24,"depth":227,"text":25},{"id":60,"depth":227,"text":61},{"id":101,"depth":227,"text":102},{"id":162,"depth":227,"text":163},{"id":207,"depth":227,"text":208},"Web3","2026-08-03","What I learned reducing visual noise in CaveSwap — progressive disclosure, honest numbers, and transaction states that read like sentences instead of hashes.","md","defiUi",{},true,"\u002Fwriting\u002Fdefi-swap-ui","7 min read",{"title":6,"description":236},"writing\u002Fdefi-swap-ui","39SPouVFVqlRPhDiwakJgllBJGGGGT2iRFMOwowj1yU",{"id":247,"title":248,"body":249,"category":764,"date":235,"description":765,"extension":237,"i18nKey":766,"meta":767,"navigation":240,"path":768,"readingTime":242,"seo":769,"stem":770,"__hash__":771},"writing\u002Fwriting\u002Ffigma-vue-handoff.md","The Figma → Vue handoff I actually use",{"type":8,"value":250,"toc":757},[251,266,269,273,284,410,424,431,435,454,457,471,585,588,592,598,683,700,704,707,733,736,740,747,753],[11,252,253,254,257,258,261,262,265],{},"I sit on both sides of the handoff: I design in Figma and I build in Vue. That vantage point taught me something uncomfortable — most handoff pain isn't a tooling problem, it's a ",[78,255,256],{},"naming"," problem. The design says ",[115,259,260],{},"Primary\u002FHover",", the code says ",[115,263,264],{},"btn-main-alt",", and every conversation about the UI needs a translator.",[11,267,268],{},"Nothing below requires a plugin, a design-ops team, or a \"design system project\". It's a handful of conventions I apply on every project, including solo ones — because the person translating my own Figma file into code three weeks later is me, with no memory of what I meant.",[22,270,272],{"id":271},"tokens-live-in-one-place-and-that-place-is-css","Tokens live in one place, and that place is CSS",[11,274,275,276,279,280,283],{},"Every value that Figma calls a ",[78,277,278],{},"variable"," — colors, type scale, radii, spacing — becomes a CSS variable in code. With Tailwind v4 this is delightfully direct, because the theme ",[78,281,282],{},"is"," CSS:",[285,286,290],"pre",{"className":287,"code":288,"language":289,"meta":226,"style":226},"language-css shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","\u002F* main.css *\u002F\n@theme static {\n  --font-sans: 'Inter', sans-serif;\n  --font-serif: 'Instrument Serif', serif;\n\n  --color-primary: #a11ee2;\n  --color-secondary: #fac789;\n  --color-heading: #0f172b;\n  --color-body: #45556c;\n\n  --radius-card: 1rem;\n}\n","css",[115,291,292,301,315,331,342,348,360,371,382,393,398,404],{"__ignoreMap":226},[293,294,297],"span",{"class":295,"line":296},"line",1,[293,298,300],{"class":299},"sHwdD","\u002F* main.css *\u002F\n",[293,302,303,307,311],{"class":295,"line":227},[293,304,306],{"class":305},"s7zQu","@theme",[293,308,310],{"class":309},"sTEyZ"," static ",[293,312,314],{"class":313},"sMK4o","{\n",[293,316,318,321,324,328],{"class":295,"line":317},3,[293,319,320],{"class":309},"  --font-sans: 'Inter'",[293,322,323],{"class":313},",",[293,325,327],{"class":326},"sBMFI"," sans-serif",[293,329,330],{"class":309},";\n",[293,332,334,337,339],{"class":295,"line":333},4,[293,335,336],{"class":309},"  --font-serif: 'Instrument Serif'",[293,338,323],{"class":313},[293,340,341],{"class":309}," serif;\n",[293,343,345],{"class":295,"line":344},5,[293,346,347],{"emptyLinePlaceholder":240},"\n",[293,349,351,354,357],{"class":295,"line":350},6,[293,352,353],{"class":309},"  --color-primary: ",[293,355,356],{"class":313},"#",[293,358,359],{"class":309},"a11ee2;\n",[293,361,363,366,368],{"class":295,"line":362},7,[293,364,365],{"class":309},"  --color-secondary: ",[293,367,356],{"class":313},[293,369,370],{"class":309},"fac789;\n",[293,372,374,377,379],{"class":295,"line":373},8,[293,375,376],{"class":309},"  --color-heading: ",[293,378,356],{"class":313},[293,380,381],{"class":309},"0f172b;\n",[293,383,385,388,390],{"class":295,"line":384},9,[293,386,387],{"class":309},"  --color-body: ",[293,389,356],{"class":313},[293,391,392],{"class":309},"45556c;\n",[293,394,396],{"class":295,"line":395},10,[293,397,347],{"emptyLinePlaceholder":240},[293,399,401],{"class":295,"line":400},11,[293,402,403],{"class":309},"  --radius-card: 1rem;\n",[293,405,407],{"class":295,"line":406},12,[293,408,409],{"class":309},"}\n",[11,411,412,413,128,416,419,420,423],{},"The rule that makes this work: ",[18,414,415],{},"the Figma variable name and the CSS variable name are the same name.",[115,417,418],{},"color\u002Fprimary"," ↔ ",[115,421,422],{},"--color-primary",". When the designer (me, or a client's designer) renames or retunes a token, the diff in code is one line, and nobody has to reverse-engineer which of five near-identical purples this button was supposed to use.",[11,425,426,427,430],{},"The anti-pattern is hex values scattered through templates. The moment a ",[115,428,429],{},"#a11ee2"," appears inline, that element has silently left the design system, and no Figma update will ever reach it again.",[22,432,434],{"id":433},"components-map-11-same-name-same-boundaries","Components map 1:1 — same name, same boundaries",[11,436,437,438,441,442,445,446,449,450,453],{},"If the Figma file has a component called ",[115,439,440],{},"ProjectCard",", the repo has ",[115,443,444],{},"ProjectCard.vue",". Not ",[115,447,448],{},"WorkItem.vue",", not a ",[115,451,452],{},"Card.vue"," with fifteen conditional slots. Same name, same boundary.",[11,455,456],{},"This sounds cosmetic. It isn't. The component boundary is where every conversation about a UI happens — \"the testimonial card looks off on mobile\" — and when boundaries match, that sentence points at exactly one file and one Figma frame. When they don't, you get archaeology.",[11,458,459,460,463,464,145,467,470],{},"The corollary: ",[18,461,462],{},"variants become props."," A Figma component with variants ",[115,465,466],{},"size: sm | md",[115,468,469],{},"tone: default | featured"," becomes:",[285,472,476],{"className":473,"code":474,"language":475,"meta":226,"style":226},"language-vue shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","\u003Cscript setup lang=\"ts\">\ndefineProps\u003C{\n  size?: 'sm' | 'md'\n  tone?: 'default' | 'featured'\n}>()\n\u003C\u002Fscript>\n","vue",[115,477,478,509,518,545,568,576],{"__ignoreMap":226},[293,479,480,483,487,491,494,497,500,504,506],{"class":295,"line":296},[293,481,482],{"class":313},"\u003C",[293,484,486],{"class":485},"swJcz","script",[293,488,490],{"class":489},"spNyl"," setup",[293,492,493],{"class":489}," lang",[293,495,496],{"class":313},"=",[293,498,499],{"class":313},"\"",[293,501,503],{"class":502},"sfazB","ts",[293,505,499],{"class":313},[293,507,508],{"class":313},">\n",[293,510,511,515],{"class":295,"line":227},[293,512,514],{"class":513},"s2Zo4","defineProps",[293,516,517],{"class":313},"\u003C{\n",[293,519,520,523,526,529,532,535,538,540,542],{"class":295,"line":317},[293,521,522],{"class":485},"  size",[293,524,525],{"class":313},"?:",[293,527,528],{"class":313}," '",[293,530,531],{"class":502},"sm",[293,533,534],{"class":313},"'",[293,536,537],{"class":313}," |",[293,539,528],{"class":313},[293,541,237],{"class":502},[293,543,544],{"class":313},"'\n",[293,546,547,550,552,554,557,559,561,563,566],{"class":295,"line":333},[293,548,549],{"class":485},"  tone",[293,551,525],{"class":313},[293,553,528],{"class":313},[293,555,556],{"class":502},"default",[293,558,534],{"class":313},[293,560,537],{"class":313},[293,562,528],{"class":313},[293,564,565],{"class":502},"featured",[293,567,544],{"class":313},[293,569,570,573],{"class":295,"line":344},[293,571,572],{"class":313},"}>",[293,574,575],{"class":309},"()\n",[293,577,578,581,583],{"class":295,"line":350},[293,579,580],{"class":313},"\u003C\u002F",[293,582,486],{"class":485},[293,584,508],{"class":313},[11,586,587],{},"Same axis names, same option names. If a variant combination doesn't exist in Figma, it shouldn't be expressible in code — that's what the union types are for. I've come to treat a prop that has no corresponding variant as a smell: either the design is missing a state, or the code grew an option nobody designed.",[22,589,591],{"id":590},"auto-layout-is-a-flexbox-spec-read-it-as-one","Auto layout is a flexbox spec — read it as one",[11,593,594,595,597],{},"Figma's auto layout panel ",[78,596,282],{}," the flex spec, written in different words. The mapping is mechanical:",[599,600,601,614],"table",{},[602,603,604],"thead",{},[605,606,607,611],"tr",{},[608,609,610],"th",{},"Figma",[608,612,613],{},"CSS",[615,616,617,629,639,649,657,671],"tbody",{},[605,618,619,623],{},[620,621,622],"td",{},"Direction: horizontal",[620,624,625,628],{},[115,626,627],{},"flex"," (row)",[605,630,631,634],{},[620,632,633],{},"Gap: 24",[620,635,636],{},[115,637,638],{},"gap-6",[605,640,641,644],{},[620,642,643],{},"Padding: 16 \u002F 24",[620,645,646],{},[115,647,648],{},"px-6 py-4",[605,650,651,654],{},[620,652,653],{},"Hug contents",[620,655,656],{},"(natural size — no class)",[605,658,659,662],{},[620,660,661],{},"Fill container",[620,663,664,667,668],{},[115,665,666],{},"flex-1"," \u002F ",[115,669,670],{},"w-full",[605,672,673,676],{},[620,674,675],{},"Fixed width",[620,677,678,679,682],{},"a real ",[115,680,681],{},"w-*"," — and a question",[11,684,685,686,689,690,692,693,696,697,699],{},"Two habits on top of the table. First, ",[18,687,688],{},"snap Figma values to the spacing scale"," — a gap of 24 is ",[115,691,638],{},", a gap of 23 is a mistake in the file, not a ",[115,694,695],{},"gap-[23px]"," in the code. Arbitrary values are how a design system dies one pixel at a time. Second, treat ",[78,698,675],{}," as a prompt to ask \"what happens when the content grows?\" — fixed sizes in Figma are usually placeholders for a responsive rule the design hasn't stated yet. Better to resolve that at handoff than in a bug report.",[22,701,703],{"id":702},"spec-states-not-screenshots","Spec states, not screenshots",[11,705,706],{},"The static frame is the least interesting part of a handoff. What the build actually needs, and what I write as a short block of notes next to each component in the Figma file:",[37,708,709,715,721,727],{},[40,710,711,714],{},[18,712,713],{},"Hover \u002F focus \u002F active"," — even just \"darken 5%, ring on focus\"",[40,716,717,720],{},[18,718,719],{},"Empty, loading, error"," — the states everyone designs last and users see first",[40,722,723,726],{},[18,724,725],{},"Text overflow"," — does the title truncate, wrap, or push the layout?",[40,728,729,732],{},[18,730,731],{},"Motion intent"," — one line, like \"card lifts on hover, fades in on scroll, 300ms\"",[11,734,735],{},"Five minutes of writing per component. It replaces the entire class of \"the design didn't say\" bugs, which in my experience outnumber the \"the code got it wrong\" bugs comfortably.",[22,737,739],{"id":738},"the-point-is-fewer-translations","The point is fewer translations",[11,741,742,743,746],{},"None of this is a process. There's no sync meeting, no token pipeline, no plugin subscription. It's one principle applied everywhere: ",[18,744,745],{},"design and code should use the same words for the same things"," — token names, component names, variant names, spacing steps. Every place the vocabularies match is a translation that no longer needs to happen, and every translation you delete is a bug that can't be introduced.",[11,748,749,750,224],{},"I offer this end to end — same person designing the file and shipping the Nuxt app, so the handoff cost rounds to zero. If that's the kind of help your project needs, ",[220,751,752],{"href":222},"let's talk",[754,755,756],"style",{},"html pre.shiki code .sHwdD, html code.shiki .sHwdD{--shiki-light:#90A4AE;--shiki-light-font-style:italic;--shiki-default:#546E7A;--shiki-default-font-style:italic;--shiki-dark:#676E95;--shiki-dark-font-style:italic}html pre.shiki code .s7zQu, html code.shiki .s7zQu{--shiki-light:#39ADB5;--shiki-light-font-style:italic;--shiki-default:#89DDFF;--shiki-default-font-style:italic;--shiki-dark:#89DDFF;--shiki-dark-font-style:italic}html pre.shiki code .sTEyZ, html code.shiki .sTEyZ{--shiki-light:#90A4AE;--shiki-default:#EEFFFF;--shiki-dark:#BABED8}html pre.shiki code .sMK4o, html code.shiki .sMK4o{--shiki-light:#39ADB5;--shiki-default:#89DDFF;--shiki-dark:#89DDFF}html pre.shiki code .sBMFI, html code.shiki .sBMFI{--shiki-light:#E2931D;--shiki-default:#FFCB6B;--shiki-dark:#FFCB6B}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 .swJcz, html code.shiki .swJcz{--shiki-light:#E53935;--shiki-default:#F07178;--shiki-dark:#F07178}html pre.shiki code .spNyl, html code.shiki .spNyl{--shiki-light:#9C3EDA;--shiki-default:#C792EA;--shiki-dark:#C792EA}html pre.shiki code .sfazB, html code.shiki .sfazB{--shiki-light:#91B859;--shiki-default:#C3E88D;--shiki-dark:#C3E88D}html pre.shiki code .s2Zo4, html code.shiki .s2Zo4{--shiki-light:#6182B8;--shiki-default:#82AAFF;--shiki-dark:#82AAFF}",{"title":226,"searchDepth":227,"depth":227,"links":758},[759,760,761,762,763],{"id":271,"depth":227,"text":272},{"id":433,"depth":227,"text":434},{"id":590,"depth":227,"text":591},{"id":702,"depth":227,"text":703},{"id":738,"depth":227,"text":739},"Frontend","Tokens as CSS variables, components named 1:1, variants that map to props — the small conventions that keep design and code in sync without a process tax.","figmaVue",{},"\u002Fwriting\u002Ffigma-vue-handoff",{"title":248,"description":765},"writing\u002Ffigma-vue-handoff","dMPgLfGRvvj_dO8MnGCCzWQCL5-N55LaiO69Hog7c6Y",{"id":773,"title":774,"body":775,"category":1642,"date":235,"description":1643,"extension":237,"i18nKey":1644,"meta":1645,"navigation":240,"path":1646,"readingTime":1647,"seo":1648,"stem":1649,"__hash__":1650},"writing\u002Fwriting\u002Frag-retrieval-evals.md","Evaluating RAG retrieval quality",{"type":8,"value":776,"toc":1633},[777,780,787,791,794,808,815,819,826,846,849,977,984,988,991,1001,1007,1436,1451,1455,1458,1472,1475,1479,1482,1567,1574,1578,1581,1607,1614,1618,1624,1630],[11,778,779],{},"Every RAG pipeline demos well. You ask it three questions you already know it can answer, the answers look great, everyone ships. Then a user asks something phrased slightly differently, retrieval returns the wrong chunks, and the model — confident as ever — writes a beautiful answer grounded in the wrong context.",[11,781,782,783,786],{},"The failure wasn't generation. In my experience, ",[18,784,785],{},"when a RAG system is wrong, retrieval is the culprit far more often than the model"," — and retrieval is also the part almost nobody measures. This is the lightweight eval setup I use on client projects: small enough to build in a day, honest enough to catch regressions.",[22,788,790],{"id":789},"separate-the-two-questions","Separate the two questions",[11,792,793],{},"A RAG answer can fail in two independent places, and mixing them up makes debugging miserable:",[172,795,796,802],{},[40,797,798,801],{},[18,799,800],{},"Did we retrieve the right context?"," (retrieval quality)",[40,803,804,807],{},[18,805,806],{},"Did the model answer faithfully from that context?"," (generation quality)",[11,809,810,811,814],{},"Evaluate them separately. If retrieval recall is bad, no prompt engineering will save you. If retrieval is fine and answers are still wrong, ",[78,812,813],{},"then"," it's a prompting or model problem. Most teams jump straight to end-to-end \"does the answer look good\" checks and can never tell which knob to turn.",[22,816,818],{"id":817},"build-a-gold-set-small-is-fine","Build a gold set — small is fine",[11,820,821,822,825],{},"The eval set that actually gets maintained is a small one. Mine are usually ",[18,823,824],{},"30–60 questions",", built from three sources:",[37,827,828,834,840],{},[40,829,830,833],{},[18,831,832],{},"Real user questions"," from logs, the moment you have any. These are gold — users phrase things in ways you never will.",[40,835,836,839],{},[18,837,838],{},"Questions written from the docs"," — for each important document, write 2–3 questions it should answer.",[40,841,842,845],{},[18,843,844],{},"Adversarial phrasings"," — the same question asked colloquially, with typos, or with vocabulary that doesn't appear in the source (\"¿cuánto sale?\" vs \"pricing tiers\").",[11,847,848],{},"For each question, record which chunks (or at least which documents) contain the answer:",[285,850,853],{"className":851,"code":852,"language":503,"meta":226,"style":226},"language-ts shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","\u002F\u002F evals\u002Fgold.ts\nexport const goldSet: GoldItem[] = [\n  {\n    id: 'refund-window',\n    question: 'How long do I have to request a refund?',\n    relevantDocs: ['policies\u002Frefunds.md'],\n    answerContains: ['30 days']\n  }\n  \u002F\u002F ...40 more\n]\n",[115,854,855,860,884,889,906,922,944,963,968,973],{"__ignoreMap":226},[293,856,857],{"class":295,"line":296},[293,858,859],{"class":299},"\u002F\u002F evals\u002Fgold.ts\n",[293,861,862,865,868,871,873,876,879,881],{"class":295,"line":227},[293,863,864],{"class":305},"export",[293,866,867],{"class":489}," const",[293,869,870],{"class":309}," goldSet",[293,872,170],{"class":313},[293,874,875],{"class":326}," GoldItem",[293,877,878],{"class":309},"[] ",[293,880,496],{"class":313},[293,882,883],{"class":309}," [\n",[293,885,886],{"class":295,"line":317},[293,887,888],{"class":313},"  {\n",[293,890,891,894,896,898,901,903],{"class":295,"line":333},[293,892,893],{"class":485},"    id",[293,895,170],{"class":313},[293,897,528],{"class":313},[293,899,900],{"class":502},"refund-window",[293,902,534],{"class":313},[293,904,905],{"class":313},",\n",[293,907,908,911,913,915,918,920],{"class":295,"line":344},[293,909,910],{"class":485},"    question",[293,912,170],{"class":313},[293,914,528],{"class":313},[293,916,917],{"class":502},"How long do I have to request a refund?",[293,919,534],{"class":313},[293,921,905],{"class":313},[293,923,924,927,929,932,934,937,939,942],{"class":295,"line":350},[293,925,926],{"class":485},"    relevantDocs",[293,928,170],{"class":313},[293,930,931],{"class":309}," [",[293,933,534],{"class":313},[293,935,936],{"class":502},"policies\u002Frefunds.md",[293,938,534],{"class":313},[293,940,941],{"class":309},"]",[293,943,905],{"class":313},[293,945,946,949,951,953,955,958,960],{"class":295,"line":362},[293,947,948],{"class":485},"    answerContains",[293,950,170],{"class":313},[293,952,931],{"class":309},[293,954,534],{"class":313},[293,956,957],{"class":502},"30 days",[293,959,534],{"class":313},[293,961,962],{"class":309},"]\n",[293,964,965],{"class":295,"line":373},[293,966,967],{"class":313},"  }\n",[293,969,970],{"class":295,"line":384},[293,971,972],{"class":299},"  \u002F\u002F ...40 more\n",[293,974,975],{"class":295,"line":395},[293,976,962],{"class":309},[11,978,979,980,983],{},"That ",[115,981,982],{},"answerContains"," field is deliberately dumb — a substring the correct answer must include. Dumb checks are underrated: they're free, deterministic, and catch a surprising share of regressions.",[22,985,987],{"id":986},"the-two-retrieval-metrics-worth-computing","The two retrieval metrics worth computing",[11,989,990],{},"You don't need an IR degree. Two numbers tell you nearly everything:",[11,992,993,996,997,1000],{},[18,994,995],{},"Recall@k"," — for what fraction of questions does the top-k contain at least one relevant chunk? This is your ceiling: if recall@5 is 70%, then 30% of questions ",[78,998,999],{},"cannot"," be answered correctly no matter how good the model is.",[11,1002,1003,1006],{},[18,1004,1005],{},"MRR (mean reciprocal rank)"," — how high does the first relevant chunk rank? Two systems with identical recall@5 feel very different when one puts the answer at rank 1 and the other at rank 5, because rank affects what the model attends to — and what you can afford to include in a tight context.",[285,1008,1010],{"className":851,"code":1009,"language":503,"meta":226,"style":226},"const evalRetrieval = async (gold: GoldItem[], k = 5) => {\n  const rows = []\n  for (const item of gold) {\n    const hits = await retrieve(item.question, k)\n    const rank = hits.findIndex(h => item.relevantDocs.includes(h.doc)) + 1\n    rows.push({ id: item.id, hit: rank > 0, rr: rank > 0 ? 1 \u002F rank : 0 })\n  }\n  return {\n    recallAtK: rows.filter(r => r.hit).length \u002F rows.length,\n    mrr: rows.reduce((s, r) => s + r.rr, 0) \u002F rows.length,\n    misses: rows.filter(r => !r.hit).map(r => r.id) \u002F\u002F ← the actionable part\n  }\n}\n",[115,1011,1012,1060,1073,1096,1130,1183,1259,1263,1270,1316,1375,1427,1431],{"__ignoreMap":226},[293,1013,1014,1017,1020,1022,1025,1028,1032,1034,1036,1039,1041,1044,1047,1051,1054,1057],{"class":295,"line":296},[293,1015,1016],{"class":489},"const",[293,1018,1019],{"class":309}," evalRetrieval ",[293,1021,496],{"class":313},[293,1023,1024],{"class":489}," async",[293,1026,1027],{"class":313}," (",[293,1029,1031],{"class":1030},"sHdIc","gold",[293,1033,170],{"class":313},[293,1035,875],{"class":326},[293,1037,1038],{"class":309},"[]",[293,1040,323],{"class":313},[293,1042,1043],{"class":1030}," k",[293,1045,1046],{"class":313}," =",[293,1048,1050],{"class":1049},"sbssI"," 5",[293,1052,1053],{"class":313},")",[293,1055,1056],{"class":489}," =>",[293,1058,1059],{"class":313}," {\n",[293,1061,1062,1065,1068,1070],{"class":295,"line":227},[293,1063,1064],{"class":489},"  const",[293,1066,1067],{"class":309}," rows",[293,1069,1046],{"class":313},[293,1071,1072],{"class":485}," []\n",[293,1074,1075,1078,1080,1082,1085,1088,1091,1094],{"class":295,"line":317},[293,1076,1077],{"class":305},"  for",[293,1079,1027],{"class":485},[293,1081,1016],{"class":489},[293,1083,1084],{"class":309}," item",[293,1086,1087],{"class":313}," of",[293,1089,1090],{"class":309}," gold",[293,1092,1093],{"class":485},") ",[293,1095,314],{"class":313},[293,1097,1098,1101,1104,1106,1109,1112,1115,1118,1120,1123,1125,1127],{"class":295,"line":333},[293,1099,1100],{"class":489},"    const",[293,1102,1103],{"class":309}," hits",[293,1105,1046],{"class":313},[293,1107,1108],{"class":305}," await",[293,1110,1111],{"class":513}," retrieve",[293,1113,1114],{"class":485},"(",[293,1116,1117],{"class":309},"item",[293,1119,224],{"class":313},[293,1121,1122],{"class":309},"question",[293,1124,323],{"class":313},[293,1126,1043],{"class":309},[293,1128,1129],{"class":485},")\n",[293,1131,1132,1134,1137,1139,1141,1143,1146,1148,1151,1153,1155,1157,1160,1162,1165,1167,1169,1171,1174,1177,1180],{"class":295,"line":344},[293,1133,1100],{"class":489},[293,1135,1136],{"class":309}," rank",[293,1138,1046],{"class":313},[293,1140,1103],{"class":309},[293,1142,224],{"class":313},[293,1144,1145],{"class":513},"findIndex",[293,1147,1114],{"class":485},[293,1149,1150],{"class":1030},"h",[293,1152,1056],{"class":489},[293,1154,1084],{"class":309},[293,1156,224],{"class":313},[293,1158,1159],{"class":309},"relevantDocs",[293,1161,224],{"class":313},[293,1163,1164],{"class":513},"includes",[293,1166,1114],{"class":485},[293,1168,1150],{"class":309},[293,1170,224],{"class":313},[293,1172,1173],{"class":309},"doc",[293,1175,1176],{"class":485},")) ",[293,1178,1179],{"class":313},"+",[293,1181,1182],{"class":1049}," 1\n",[293,1184,1185,1188,1190,1193,1195,1198,1201,1203,1205,1207,1210,1212,1215,1217,1219,1222,1225,1227,1230,1232,1234,1236,1238,1241,1244,1247,1249,1252,1254,1257],{"class":295,"line":350},[293,1186,1187],{"class":309},"    rows",[293,1189,224],{"class":313},[293,1191,1192],{"class":513},"push",[293,1194,1114],{"class":485},[293,1196,1197],{"class":313},"{",[293,1199,1200],{"class":485}," id",[293,1202,170],{"class":313},[293,1204,1084],{"class":309},[293,1206,224],{"class":313},[293,1208,1209],{"class":309},"id",[293,1211,323],{"class":313},[293,1213,1214],{"class":485}," hit",[293,1216,170],{"class":313},[293,1218,1136],{"class":309},[293,1220,1221],{"class":313}," >",[293,1223,1224],{"class":1049}," 0",[293,1226,323],{"class":313},[293,1228,1229],{"class":485}," rr",[293,1231,170],{"class":313},[293,1233,1136],{"class":309},[293,1235,1221],{"class":313},[293,1237,1224],{"class":1049},[293,1239,1240],{"class":313}," ?",[293,1242,1243],{"class":1049}," 1",[293,1245,1246],{"class":313}," \u002F",[293,1248,1136],{"class":309},[293,1250,1251],{"class":313}," :",[293,1253,1224],{"class":1049},[293,1255,1256],{"class":313}," }",[293,1258,1129],{"class":485},[293,1260,1261],{"class":295,"line":362},[293,1262,967],{"class":313},[293,1264,1265,1268],{"class":295,"line":373},[293,1266,1267],{"class":305},"  return",[293,1269,1059],{"class":313},[293,1271,1272,1275,1277,1279,1281,1284,1286,1289,1291,1294,1296,1299,1301,1303,1306,1308,1310,1312,1314],{"class":295,"line":384},[293,1273,1274],{"class":485},"    recallAtK",[293,1276,170],{"class":313},[293,1278,1067],{"class":309},[293,1280,224],{"class":313},[293,1282,1283],{"class":513},"filter",[293,1285,1114],{"class":485},[293,1287,1288],{"class":1030},"r",[293,1290,1056],{"class":489},[293,1292,1293],{"class":309}," r",[293,1295,224],{"class":313},[293,1297,1298],{"class":309},"hit",[293,1300,1053],{"class":485},[293,1302,224],{"class":313},[293,1304,1305],{"class":309},"length",[293,1307,1246],{"class":313},[293,1309,1067],{"class":309},[293,1311,224],{"class":313},[293,1313,1305],{"class":309},[293,1315,905],{"class":313},[293,1317,1318,1321,1323,1325,1327,1330,1332,1334,1337,1339,1341,1343,1345,1348,1351,1353,1355,1358,1360,1362,1364,1367,1369,1371,1373],{"class":295,"line":395},[293,1319,1320],{"class":485},"    mrr",[293,1322,170],{"class":313},[293,1324,1067],{"class":309},[293,1326,224],{"class":313},[293,1328,1329],{"class":513},"reduce",[293,1331,1114],{"class":485},[293,1333,1114],{"class":313},[293,1335,1336],{"class":1030},"s",[293,1338,323],{"class":313},[293,1340,1293],{"class":1030},[293,1342,1053],{"class":313},[293,1344,1056],{"class":489},[293,1346,1347],{"class":309}," s",[293,1349,1350],{"class":313}," +",[293,1352,1293],{"class":309},[293,1354,224],{"class":313},[293,1356,1357],{"class":309},"rr",[293,1359,323],{"class":313},[293,1361,1224],{"class":1049},[293,1363,1093],{"class":485},[293,1365,1366],{"class":313},"\u002F",[293,1368,1067],{"class":309},[293,1370,224],{"class":313},[293,1372,1305],{"class":309},[293,1374,905],{"class":313},[293,1376,1377,1380,1382,1384,1386,1388,1390,1392,1394,1397,1399,1401,1403,1405,1407,1410,1412,1414,1416,1418,1420,1422,1424],{"class":295,"line":400},[293,1378,1379],{"class":485},"    misses",[293,1381,170],{"class":313},[293,1383,1067],{"class":309},[293,1385,224],{"class":313},[293,1387,1283],{"class":513},[293,1389,1114],{"class":485},[293,1391,1288],{"class":1030},[293,1393,1056],{"class":489},[293,1395,1396],{"class":313}," !",[293,1398,1288],{"class":309},[293,1400,224],{"class":313},[293,1402,1298],{"class":309},[293,1404,1053],{"class":485},[293,1406,224],{"class":313},[293,1408,1409],{"class":513},"map",[293,1411,1114],{"class":485},[293,1413,1288],{"class":1030},[293,1415,1056],{"class":489},[293,1417,1293],{"class":309},[293,1419,224],{"class":313},[293,1421,1209],{"class":309},[293,1423,1093],{"class":485},[293,1425,1426],{"class":299},"\u002F\u002F ← the actionable part\n",[293,1428,1429],{"class":295,"line":406},[293,1430,967],{"class":313},[293,1432,1434],{"class":295,"line":1433},13,[293,1435,409],{"class":313},[11,1437,1438,1439,1442,1443,1446,1447,1450],{},"The ",[115,1440,1441],{},"misses"," list is the real product of the eval. A recall number tells you ",[78,1444,1445],{},"that"," something is wrong; the list of missed questions tells you ",[78,1448,1449],{},"what"," — and the misses almost always cluster (all the colloquial phrasings, all the questions about one badly-chunked doc).",[22,1452,1454],{"id":1453},"judging-generation-without-fooling-yourself","Judging generation without fooling yourself",[11,1456,1457],{},"For the generation side I use an LLM judge, with two rules that keep it honest:",[37,1459,1460,1466],{},[40,1461,1462,1465],{},[18,1463,1464],{},"Judge faithfulness, not quality."," The question for the judge is narrow: \"Is every claim in this answer supported by the provided context — yes or no, and which claim isn't?\" Narrow questions get reliable judgments; \"rate this answer 1–10\" gets vibes.",[40,1467,1468,1471],{},[18,1469,1470],{},"Calibrate it once."," Hand-label 20 answers yourself, run the judge on the same 20, and check agreement. If the judge disagrees with you more than ~15% of the time, tighten its prompt before trusting it at scale.",[11,1473,1474],{},"The judge runs with a stronger model than the pipeline uses, and its verdict lands next to the retrieval metrics — one row per gold question.",[22,1476,1478],{"id":1477},"the-dashboard-is-a-table","The dashboard is a table",[11,1480,1481],{},"My \"eval dashboard\" is deliberately unglamorous: a run per row, a metric per column, stored as JSON and rendered in a simple Nuxt page.",[599,1483,1484,1506],{},[602,1485,1486],{},[605,1487,1488,1491,1494,1497,1500,1503],{},[608,1489,1490],{},"run",[608,1492,1493],{},"change",[608,1495,1496],{},"recall@5",[608,1498,1499],{},"MRR",[608,1501,1502],{},"faithful",[608,1504,1505],{},"dumb-checks",[615,1507,1508,1528,1548],{},[605,1509,1510,1513,1516,1519,1522,1525],{},[620,1511,1512],{},"#14",[620,1514,1515],{},"baseline",[620,1517,1518],{},"0.72",[620,1520,1521],{},"0.58",[620,1523,1524],{},"0.88",[620,1526,1527],{},"34\u002F41",[605,1529,1530,1533,1536,1539,1542,1545],{},[620,1531,1532],{},"#15",[620,1534,1535],{},"smaller chunks (400 tok)",[620,1537,1538],{},"0.83",[620,1540,1541],{},"0.66",[620,1543,1544],{},"0.90",[620,1546,1547],{},"37\u002F41",[605,1549,1550,1553,1556,1558,1561,1564],{},[620,1551,1552],{},"#16",[620,1554,1555],{},"+ hybrid search",[620,1557,1544],{},[620,1559,1560],{},"0.74",[620,1562,1563],{},"0.91",[620,1565,1566],{},"39\u002F41",[11,1568,1569,1570,1573],{},"What matters is that ",[18,1571,1572],{},"every pipeline change gets a row before it merges",". Chunking tweak? Row. New embedding model? Row. Re-ranker? Row. The table turns \"I think this made it better\" into a diff you can read — and it catches the sneaky cases where a change helps one cluster of questions while quietly breaking another.",[22,1575,1577],{"id":1576},"where-the-wins-actually-come-from","Where the wins actually come from",[11,1579,1580],{},"Across projects, the improvements that move these numbers, in rough order of return on effort:",[172,1582,1583,1589,1595,1601],{},[40,1584,1585,1588],{},[18,1586,1587],{},"Chunking."," Oversized chunks are the most common silent killer. Respect document structure (headings, sections) instead of fixed windows, and keep a small overlap.",[40,1590,1591,1594],{},[18,1592,1593],{},"Hybrid search."," Vector search misses exact terms — SKUs, error codes, product names. Adding BM25 alongside embeddings and merging results is a reliable recall boost.",[40,1596,1597,1600],{},[18,1598,1599],{},"Re-ranking."," A cross-encoder re-ranker over the top-20 fixes ordering, which shows up directly in MRR.",[40,1602,1603,1606],{},[18,1604,1605],{},"Query rewriting."," Expanding the user's question with the model before retrieval helps colloquial phrasings find formal docs.",[11,1608,1609,1610,1613],{},"None of these are exotic. The eval is what tells you which one ",[78,1611,1612],{},"your"," system needs — instead of implementing all four and hoping.",[22,1615,1617],{"id":1616},"takeaways","Takeaways",[11,1619,1620,1621,1623],{},"Split retrieval from generation and measure them separately. Keep the gold set small enough to maintain and seeded with real user phrasings. Track recall@k and MRR per change, read the misses instead of the averages, and keep the LLM judge on a narrow, calibrated question. A day of setup buys you the thing most RAG systems never have: the ability to know you broke something ",[78,1622,135],{}," your users find out.",[11,1625,1626,1627,224],{},"If your team is shipping a RAG feature and flying blind on retrieval, ",[220,1628,1629],{"href":222},"this is exactly the kind of setup I build",[754,1631,1632],{},"html pre.shiki code .sHwdD, html code.shiki .sHwdD{--shiki-light:#90A4AE;--shiki-light-font-style:italic;--shiki-default:#546E7A;--shiki-default-font-style:italic;--shiki-dark:#676E95;--shiki-dark-font-style:italic}html pre.shiki code .s7zQu, html code.shiki .s7zQu{--shiki-light:#39ADB5;--shiki-light-font-style:italic;--shiki-default:#89DDFF;--shiki-default-font-style:italic;--shiki-dark:#89DDFF;--shiki-dark-font-style:italic}html pre.shiki code .spNyl, html code.shiki .spNyl{--shiki-light:#9C3EDA;--shiki-default:#C792EA;--shiki-dark:#C792EA}html pre.shiki code .sTEyZ, html code.shiki .sTEyZ{--shiki-light:#90A4AE;--shiki-default:#EEFFFF;--shiki-dark:#BABED8}html pre.shiki code .sMK4o, html code.shiki .sMK4o{--shiki-light:#39ADB5;--shiki-default:#89DDFF;--shiki-dark:#89DDFF}html pre.shiki code .sBMFI, html code.shiki .sBMFI{--shiki-light:#E2931D;--shiki-default:#FFCB6B;--shiki-dark:#FFCB6B}html pre.shiki code .swJcz, html code.shiki .swJcz{--shiki-light:#E53935;--shiki-default:#F07178;--shiki-dark:#F07178}html pre.shiki code .sfazB, html code.shiki .sfazB{--shiki-light:#91B859;--shiki-default:#C3E88D;--shiki-dark:#C3E88D}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 .sHdIc, html code.shiki .sHdIc{--shiki-light:#90A4AE;--shiki-light-font-style:italic;--shiki-default:#EEFFFF;--shiki-default-font-style:italic;--shiki-dark:#BABED8;--shiki-dark-font-style:italic}html pre.shiki code .sbssI, html code.shiki .sbssI{--shiki-light:#F76D47;--shiki-default:#F78C6C;--shiki-dark:#F78C6C}html pre.shiki code .s2Zo4, html code.shiki .s2Zo4{--shiki-light:#6182B8;--shiki-default:#82AAFF;--shiki-dark:#82AAFF}",{"title":226,"searchDepth":227,"depth":227,"links":1634},[1635,1636,1637,1638,1639,1640,1641],{"id":789,"depth":227,"text":790},{"id":817,"depth":227,"text":818},{"id":986,"depth":227,"text":987},{"id":1453,"depth":227,"text":1454},{"id":1477,"depth":227,"text":1478},{"id":1576,"depth":227,"text":1577},{"id":1616,"depth":227,"text":1617},"LLM Integration","A practical RAG eval setup — a small gold set, recall@k and MRR, an LLM judge you can actually trust, and the dashboard that catches regressions before users do.","ragEvals",{},"\u002Fwriting\u002Frag-retrieval-evals","8 min read",{"title":774,"description":1643},"writing\u002Frag-retrieval-evals","OAGCuh06m-oICzVYz7yMaw8ltBf8N-LQ1eDoF6HjEv8",{"id":1652,"title":1653,"body":1654,"category":3246,"date":235,"description":3247,"extension":237,"i18nKey":2149,"meta":3248,"navigation":240,"path":3249,"readingTime":3250,"seo":3251,"stem":3252,"__hash__":3253},"writing\u002Fwriting\u002Fstreaming-responses-vue.md","Designing streaming responses in Vue",{"type":8,"value":1655,"toc":3238},[1656,1663,1666,1669,1673,1676,2058,2061,2065,2076,2876,2879,2897,2901,2904,2912,2915,2918,2924,3071,3074,3084,3098,3102,3109,3112,3171,3178,3182,3224,3226,3229,3235],[11,1657,1658,1659,1662],{},"Streaming is the single highest-leverage UX decision in an AI product. The same model, the same latency, the same answer — but if the first token shows up in 400ms instead of the full answer showing up in 9 seconds, the product ",[78,1660,1661],{},"feels"," an order of magnitude faster.",[11,1664,1665],{},"It's also where most chat UIs quietly fall apart: flickering markdown, layouts that jump, a scroll position that fights the user, and tool calls that render as raw JSON. None of these are model problems. They're frontend problems, and they're all fixable.",[11,1667,1668],{},"This is the setup I use in Nuxt 4 for Claude-powered chat features — from the server route to the last CSS detail.",[22,1670,1672],{"id":1671},"the-transport-a-nitro-route-that-re-streams","The transport: a Nitro route that re-streams",[11,1674,1675],{},"Never call the model API from the browser. The key stays on the server, and a Nitro route re-streams the response to the client:",[285,1677,1679],{"className":851,"code":1678,"language":503,"meta":226,"style":226},"\u002F\u002F server\u002Fapi\u002Fchat.post.ts\nexport default defineEventHandler(async (event) => {\n  const { messages } = await readBody(event)\n  const { apiKey } = useRuntimeConfig().anthropic\n\n  const upstream = await fetch('https:\u002F\u002Fapi.anthropic.com\u002Fv1\u002Fmessages', {\n    method: 'POST',\n    headers: {\n      'x-api-key': apiKey,\n      'anthropic-version': '2023-06-01',\n      'content-type': 'application\u002Fjson'\n    },\n    body: JSON.stringify({\n      model: 'claude-sonnet-5',\n      max_tokens: 1024,\n      stream: true,\n      messages\n    })\n  })\n\n  setHeader(event, 'content-type', 'text\u002Fevent-stream')\n  setHeader(event, 'cache-control', 'no-cache')\n  return sendStream(event, upstream.body!)\n})\n",[115,1680,1681,1686,1712,1737,1761,1765,1792,1808,1817,1833,1853,1871,1876,1895,1912,1925,1939,1945,1953,1961,1966,1995,2024,2050],{"__ignoreMap":226},[293,1682,1683],{"class":295,"line":296},[293,1684,1685],{"class":299},"\u002F\u002F server\u002Fapi\u002Fchat.post.ts\n",[293,1687,1688,1690,1693,1696,1698,1701,1703,1706,1708,1710],{"class":295,"line":227},[293,1689,864],{"class":305},[293,1691,1692],{"class":305}," default",[293,1694,1695],{"class":513}," defineEventHandler",[293,1697,1114],{"class":309},[293,1699,1700],{"class":489},"async",[293,1702,1027],{"class":313},[293,1704,1705],{"class":1030},"event",[293,1707,1053],{"class":313},[293,1709,1056],{"class":489},[293,1711,1059],{"class":313},[293,1713,1714,1716,1719,1722,1724,1726,1728,1731,1733,1735],{"class":295,"line":317},[293,1715,1064],{"class":489},[293,1717,1718],{"class":313}," {",[293,1720,1721],{"class":309}," messages",[293,1723,1256],{"class":313},[293,1725,1046],{"class":313},[293,1727,1108],{"class":305},[293,1729,1730],{"class":513}," readBody",[293,1732,1114],{"class":485},[293,1734,1705],{"class":309},[293,1736,1129],{"class":485},[293,1738,1739,1741,1743,1746,1748,1750,1753,1756,1758],{"class":295,"line":333},[293,1740,1064],{"class":489},[293,1742,1718],{"class":313},[293,1744,1745],{"class":309}," apiKey",[293,1747,1256],{"class":313},[293,1749,1046],{"class":313},[293,1751,1752],{"class":513}," useRuntimeConfig",[293,1754,1755],{"class":485},"()",[293,1757,224],{"class":313},[293,1759,1760],{"class":309},"anthropic\n",[293,1762,1763],{"class":295,"line":344},[293,1764,347],{"emptyLinePlaceholder":240},[293,1766,1767,1769,1772,1774,1776,1779,1781,1783,1786,1788,1790],{"class":295,"line":350},[293,1768,1064],{"class":489},[293,1770,1771],{"class":309}," upstream",[293,1773,1046],{"class":313},[293,1775,1108],{"class":305},[293,1777,1778],{"class":513}," fetch",[293,1780,1114],{"class":485},[293,1782,534],{"class":313},[293,1784,1785],{"class":502},"https:\u002F\u002Fapi.anthropic.com\u002Fv1\u002Fmessages",[293,1787,534],{"class":313},[293,1789,323],{"class":313},[293,1791,1059],{"class":313},[293,1793,1794,1797,1799,1801,1804,1806],{"class":295,"line":362},[293,1795,1796],{"class":485},"    method",[293,1798,170],{"class":313},[293,1800,528],{"class":313},[293,1802,1803],{"class":502},"POST",[293,1805,534],{"class":313},[293,1807,905],{"class":313},[293,1809,1810,1813,1815],{"class":295,"line":373},[293,1811,1812],{"class":485},"    headers",[293,1814,170],{"class":313},[293,1816,1059],{"class":313},[293,1818,1819,1822,1825,1827,1829,1831],{"class":295,"line":384},[293,1820,1821],{"class":313},"      '",[293,1823,1824],{"class":485},"x-api-key",[293,1826,534],{"class":313},[293,1828,170],{"class":313},[293,1830,1745],{"class":309},[293,1832,905],{"class":313},[293,1834,1835,1837,1840,1842,1844,1846,1849,1851],{"class":295,"line":395},[293,1836,1821],{"class":313},[293,1838,1839],{"class":485},"anthropic-version",[293,1841,534],{"class":313},[293,1843,170],{"class":313},[293,1845,528],{"class":313},[293,1847,1848],{"class":502},"2023-06-01",[293,1850,534],{"class":313},[293,1852,905],{"class":313},[293,1854,1855,1857,1860,1862,1864,1866,1869],{"class":295,"line":400},[293,1856,1821],{"class":313},[293,1858,1859],{"class":485},"content-type",[293,1861,534],{"class":313},[293,1863,170],{"class":313},[293,1865,528],{"class":313},[293,1867,1868],{"class":502},"application\u002Fjson",[293,1870,544],{"class":313},[293,1872,1873],{"class":295,"line":406},[293,1874,1875],{"class":313},"    },\n",[293,1877,1878,1881,1883,1886,1888,1891,1893],{"class":295,"line":1433},[293,1879,1880],{"class":485},"    body",[293,1882,170],{"class":313},[293,1884,1885],{"class":309}," JSON",[293,1887,224],{"class":313},[293,1889,1890],{"class":513},"stringify",[293,1892,1114],{"class":485},[293,1894,314],{"class":313},[293,1896,1898,1901,1903,1905,1908,1910],{"class":295,"line":1897},14,[293,1899,1900],{"class":485},"      model",[293,1902,170],{"class":313},[293,1904,528],{"class":313},[293,1906,1907],{"class":502},"claude-sonnet-5",[293,1909,534],{"class":313},[293,1911,905],{"class":313},[293,1913,1915,1918,1920,1923],{"class":295,"line":1914},15,[293,1916,1917],{"class":485},"      max_tokens",[293,1919,170],{"class":313},[293,1921,1922],{"class":1049}," 1024",[293,1924,905],{"class":313},[293,1926,1928,1931,1933,1937],{"class":295,"line":1927},16,[293,1929,1930],{"class":485},"      stream",[293,1932,170],{"class":313},[293,1934,1936],{"class":1935},"sfNiH"," true",[293,1938,905],{"class":313},[293,1940,1942],{"class":295,"line":1941},17,[293,1943,1944],{"class":309},"      messages\n",[293,1946,1948,1951],{"class":295,"line":1947},18,[293,1949,1950],{"class":313},"    }",[293,1952,1129],{"class":485},[293,1954,1956,1959],{"class":295,"line":1955},19,[293,1957,1958],{"class":313},"  }",[293,1960,1129],{"class":485},[293,1962,1964],{"class":295,"line":1963},20,[293,1965,347],{"emptyLinePlaceholder":240},[293,1967,1969,1972,1974,1976,1978,1980,1982,1984,1986,1988,1991,1993],{"class":295,"line":1968},21,[293,1970,1971],{"class":513},"  setHeader",[293,1973,1114],{"class":485},[293,1975,1705],{"class":309},[293,1977,323],{"class":313},[293,1979,528],{"class":313},[293,1981,1859],{"class":502},[293,1983,534],{"class":313},[293,1985,323],{"class":313},[293,1987,528],{"class":313},[293,1989,1990],{"class":502},"text\u002Fevent-stream",[293,1992,534],{"class":313},[293,1994,1129],{"class":485},[293,1996,1998,2000,2002,2004,2006,2008,2011,2013,2015,2017,2020,2022],{"class":295,"line":1997},22,[293,1999,1971],{"class":513},[293,2001,1114],{"class":485},[293,2003,1705],{"class":309},[293,2005,323],{"class":313},[293,2007,528],{"class":313},[293,2009,2010],{"class":502},"cache-control",[293,2012,534],{"class":313},[293,2014,323],{"class":313},[293,2016,528],{"class":313},[293,2018,2019],{"class":502},"no-cache",[293,2021,534],{"class":313},[293,2023,1129],{"class":485},[293,2025,2027,2029,2032,2034,2036,2038,2040,2042,2045,2048],{"class":295,"line":2026},23,[293,2028,1267],{"class":305},[293,2030,2031],{"class":513}," sendStream",[293,2033,1114],{"class":485},[293,2035,1705],{"class":309},[293,2037,323],{"class":313},[293,2039,1771],{"class":309},[293,2041,224],{"class":313},[293,2043,2044],{"class":309},"body",[293,2046,2047],{"class":313},"!",[293,2049,1129],{"class":485},[293,2051,2053,2056],{"class":295,"line":2052},24,[293,2054,2055],{"class":313},"}",[293,2057,1129],{"class":309},[11,2059,2060],{},"That's it on the server. The interesting work is on the client.",[22,2062,2064],{"id":2063},"parsing-the-stream-without-tears","Parsing the stream without tears",[11,2066,2067,2068,2071,2072,2075],{},"Server-sent events arrive as text chunks, and a chunk boundary can land ",[78,2069,2070],{},"in the middle of a line",". If you split each chunk on ",[115,2073,2074],{},"\\n"," independently, you'll eventually parse half a JSON object and crash mid-answer. The fix is a persistent buffer: append every chunk, only consume complete lines.",[285,2077,2079],{"className":851,"code":2078,"language":503,"meta":226,"style":226},"\u002F\u002F app\u002Fcomposables\u002FuseChatStream.ts\nexport const useChatStream = () => {\n  const text = ref('')\n  const status = ref\u003C'idle' | 'streaming' | 'done' | 'error'>('idle')\n  let controller: AbortController | undefined\n\n  const send = async (messages: ChatMessage[]) => {\n    controller = new AbortController()\n    status.value = 'streaming'\n    text.value = ''\n\n    const res = await fetch('\u002Fapi\u002Fchat', {\n      method: 'POST',\n      body: JSON.stringify({ messages }),\n      signal: controller.signal\n    })\n\n    const reader = res.body!.getReader()\n    const decoder = new TextDecoder()\n    let buffer = ''\n\n    while (true) {\n      const { done, value } = await reader.read()\n      if (done) break\n\n      buffer += decoder.decode(value, { stream: true })\n      const lines = buffer.split('\\n')\n      buffer = lines.pop() ?? '' \u002F\u002F keep the incomplete tail for the next chunk\n\n      for (const line of lines) {\n        if (!line.startsWith('data: ')) continue\n        const event = JSON.parse(line.slice(6))\n        if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') {\n          text.value += event.delta.text\n        }\n      }\n    }\n    status.value = 'done'\n  }\n\n  const stop = () => {\n    controller?.abort()\n    status.value = 'done'\n  }\n\n  return { text, status, send, stop }\n}\n",[115,2080,2081,2086,2104,2123,2183,2201,2205,2234,2248,2266,2280,2284,2310,2325,2350,2364,2370,2374,2397,2413,2425,2429,2443,2473,2487,2492,2527,2554,2580,2585,2606,2637,2672,2723,2746,2752,2758,2764,2781,2786,2791,2807,2820,2837,2842,2847,2871],{"__ignoreMap":226},[293,2082,2083],{"class":295,"line":296},[293,2084,2085],{"class":299},"\u002F\u002F app\u002Fcomposables\u002FuseChatStream.ts\n",[293,2087,2088,2090,2092,2095,2097,2100,2102],{"class":295,"line":227},[293,2089,864],{"class":305},[293,2091,867],{"class":489},[293,2093,2094],{"class":309}," useChatStream ",[293,2096,496],{"class":313},[293,2098,2099],{"class":313}," ()",[293,2101,1056],{"class":489},[293,2103,1059],{"class":313},[293,2105,2106,2108,2111,2113,2116,2118,2121],{"class":295,"line":317},[293,2107,1064],{"class":489},[293,2109,2110],{"class":309}," text",[293,2112,1046],{"class":313},[293,2114,2115],{"class":513}," ref",[293,2117,1114],{"class":485},[293,2119,2120],{"class":313},"''",[293,2122,1129],{"class":485},[293,2124,2125,2127,2130,2132,2134,2136,2138,2141,2143,2145,2147,2150,2152,2154,2156,2159,2161,2163,2165,2168,2170,2173,2175,2177,2179,2181],{"class":295,"line":333},[293,2126,1064],{"class":489},[293,2128,2129],{"class":309}," status",[293,2131,1046],{"class":313},[293,2133,2115],{"class":513},[293,2135,482],{"class":313},[293,2137,534],{"class":313},[293,2139,2140],{"class":502},"idle",[293,2142,534],{"class":313},[293,2144,537],{"class":313},[293,2146,528],{"class":313},[293,2148,2149],{"class":502},"streaming",[293,2151,534],{"class":313},[293,2153,537],{"class":313},[293,2155,528],{"class":313},[293,2157,2158],{"class":502},"done",[293,2160,534],{"class":313},[293,2162,537],{"class":313},[293,2164,528],{"class":313},[293,2166,2167],{"class":502},"error",[293,2169,534],{"class":313},[293,2171,2172],{"class":313},">",[293,2174,1114],{"class":485},[293,2176,534],{"class":313},[293,2178,2140],{"class":502},[293,2180,534],{"class":313},[293,2182,1129],{"class":485},[293,2184,2185,2188,2191,2193,2196,2198],{"class":295,"line":344},[293,2186,2187],{"class":489},"  let",[293,2189,2190],{"class":309}," controller",[293,2192,170],{"class":313},[293,2194,2195],{"class":326}," AbortController",[293,2197,537],{"class":313},[293,2199,2200],{"class":326}," undefined\n",[293,2202,2203],{"class":295,"line":350},[293,2204,347],{"emptyLinePlaceholder":240},[293,2206,2207,2209,2212,2214,2216,2218,2221,2223,2226,2228,2230,2232],{"class":295,"line":362},[293,2208,1064],{"class":489},[293,2210,2211],{"class":309}," send",[293,2213,1046],{"class":313},[293,2215,1024],{"class":489},[293,2217,1027],{"class":313},[293,2219,2220],{"class":1030},"messages",[293,2222,170],{"class":313},[293,2224,2225],{"class":326}," ChatMessage",[293,2227,1038],{"class":485},[293,2229,1053],{"class":313},[293,2231,1056],{"class":489},[293,2233,1059],{"class":313},[293,2235,2236,2239,2241,2244,2246],{"class":295,"line":373},[293,2237,2238],{"class":309},"    controller",[293,2240,1046],{"class":313},[293,2242,2243],{"class":313}," new",[293,2245,2195],{"class":513},[293,2247,575],{"class":485},[293,2249,2250,2253,2255,2258,2260,2262,2264],{"class":295,"line":384},[293,2251,2252],{"class":309},"    status",[293,2254,224],{"class":313},[293,2256,2257],{"class":309},"value",[293,2259,1046],{"class":313},[293,2261,528],{"class":313},[293,2263,2149],{"class":502},[293,2265,544],{"class":313},[293,2267,2268,2271,2273,2275,2277],{"class":295,"line":395},[293,2269,2270],{"class":309},"    text",[293,2272,224],{"class":313},[293,2274,2257],{"class":309},[293,2276,1046],{"class":313},[293,2278,2279],{"class":313}," ''\n",[293,2281,2282],{"class":295,"line":400},[293,2283,347],{"emptyLinePlaceholder":240},[293,2285,2286,2288,2291,2293,2295,2297,2299,2301,2304,2306,2308],{"class":295,"line":406},[293,2287,1100],{"class":489},[293,2289,2290],{"class":309}," res",[293,2292,1046],{"class":313},[293,2294,1108],{"class":305},[293,2296,1778],{"class":513},[293,2298,1114],{"class":485},[293,2300,534],{"class":313},[293,2302,2303],{"class":502},"\u002Fapi\u002Fchat",[293,2305,534],{"class":313},[293,2307,323],{"class":313},[293,2309,1059],{"class":313},[293,2311,2312,2315,2317,2319,2321,2323],{"class":295,"line":1433},[293,2313,2314],{"class":485},"      method",[293,2316,170],{"class":313},[293,2318,528],{"class":313},[293,2320,1803],{"class":502},[293,2322,534],{"class":313},[293,2324,905],{"class":313},[293,2326,2327,2330,2332,2334,2336,2338,2340,2342,2344,2346,2348],{"class":295,"line":1897},[293,2328,2329],{"class":485},"      body",[293,2331,170],{"class":313},[293,2333,1885],{"class":309},[293,2335,224],{"class":313},[293,2337,1890],{"class":513},[293,2339,1114],{"class":485},[293,2341,1197],{"class":313},[293,2343,1721],{"class":309},[293,2345,1256],{"class":313},[293,2347,1053],{"class":485},[293,2349,905],{"class":313},[293,2351,2352,2355,2357,2359,2361],{"class":295,"line":1914},[293,2353,2354],{"class":485},"      signal",[293,2356,170],{"class":313},[293,2358,2190],{"class":309},[293,2360,224],{"class":313},[293,2362,2363],{"class":309},"signal\n",[293,2365,2366,2368],{"class":295,"line":1927},[293,2367,1950],{"class":313},[293,2369,1129],{"class":485},[293,2371,2372],{"class":295,"line":1941},[293,2373,347],{"emptyLinePlaceholder":240},[293,2375,2376,2378,2381,2383,2385,2387,2389,2392,2395],{"class":295,"line":1947},[293,2377,1100],{"class":489},[293,2379,2380],{"class":309}," reader",[293,2382,1046],{"class":313},[293,2384,2290],{"class":309},[293,2386,224],{"class":313},[293,2388,2044],{"class":309},[293,2390,2391],{"class":313},"!.",[293,2393,2394],{"class":513},"getReader",[293,2396,575],{"class":485},[293,2398,2399,2401,2404,2406,2408,2411],{"class":295,"line":1955},[293,2400,1100],{"class":489},[293,2402,2403],{"class":309}," decoder",[293,2405,1046],{"class":313},[293,2407,2243],{"class":313},[293,2409,2410],{"class":513}," TextDecoder",[293,2412,575],{"class":485},[293,2414,2415,2418,2421,2423],{"class":295,"line":1963},[293,2416,2417],{"class":489},"    let",[293,2419,2420],{"class":309}," buffer",[293,2422,1046],{"class":313},[293,2424,2279],{"class":313},[293,2426,2427],{"class":295,"line":1968},[293,2428,347],{"emptyLinePlaceholder":240},[293,2430,2431,2434,2436,2439,2441],{"class":295,"line":1997},[293,2432,2433],{"class":305},"    while",[293,2435,1027],{"class":485},[293,2437,2438],{"class":1935},"true",[293,2440,1093],{"class":485},[293,2442,314],{"class":313},[293,2444,2445,2448,2450,2453,2455,2458,2460,2462,2464,2466,2468,2471],{"class":295,"line":2026},[293,2446,2447],{"class":489},"      const",[293,2449,1718],{"class":313},[293,2451,2452],{"class":309}," done",[293,2454,323],{"class":313},[293,2456,2457],{"class":309}," value",[293,2459,1256],{"class":313},[293,2461,1046],{"class":313},[293,2463,1108],{"class":305},[293,2465,2380],{"class":309},[293,2467,224],{"class":313},[293,2469,2470],{"class":513},"read",[293,2472,575],{"class":485},[293,2474,2475,2478,2480,2482,2484],{"class":295,"line":2052},[293,2476,2477],{"class":305},"      if",[293,2479,1027],{"class":485},[293,2481,2158],{"class":309},[293,2483,1093],{"class":485},[293,2485,2486],{"class":305},"break\n",[293,2488,2490],{"class":295,"line":2489},25,[293,2491,347],{"emptyLinePlaceholder":240},[293,2493,2495,2498,2501,2503,2505,2508,2510,2512,2514,2516,2519,2521,2523,2525],{"class":295,"line":2494},26,[293,2496,2497],{"class":309},"      buffer",[293,2499,2500],{"class":313}," +=",[293,2502,2403],{"class":309},[293,2504,224],{"class":313},[293,2506,2507],{"class":513},"decode",[293,2509,1114],{"class":485},[293,2511,2257],{"class":309},[293,2513,323],{"class":313},[293,2515,1718],{"class":313},[293,2517,2518],{"class":485}," stream",[293,2520,170],{"class":313},[293,2522,1936],{"class":1935},[293,2524,1256],{"class":313},[293,2526,1129],{"class":485},[293,2528,2530,2532,2535,2537,2539,2541,2544,2546,2548,2550,2552],{"class":295,"line":2529},27,[293,2531,2447],{"class":489},[293,2533,2534],{"class":309}," lines",[293,2536,1046],{"class":313},[293,2538,2420],{"class":309},[293,2540,224],{"class":313},[293,2542,2543],{"class":513},"split",[293,2545,1114],{"class":485},[293,2547,534],{"class":313},[293,2549,2074],{"class":309},[293,2551,534],{"class":313},[293,2553,1129],{"class":485},[293,2555,2557,2559,2561,2563,2565,2568,2571,2574,2577],{"class":295,"line":2556},28,[293,2558,2497],{"class":309},[293,2560,1046],{"class":313},[293,2562,2534],{"class":309},[293,2564,224],{"class":313},[293,2566,2567],{"class":513},"pop",[293,2569,2570],{"class":485},"() ",[293,2572,2573],{"class":313},"??",[293,2575,2576],{"class":313}," ''",[293,2578,2579],{"class":299}," \u002F\u002F keep the incomplete tail for the next chunk\n",[293,2581,2583],{"class":295,"line":2582},29,[293,2584,347],{"emptyLinePlaceholder":240},[293,2586,2588,2591,2593,2595,2598,2600,2602,2604],{"class":295,"line":2587},30,[293,2589,2590],{"class":305},"      for",[293,2592,1027],{"class":485},[293,2594,1016],{"class":489},[293,2596,2597],{"class":309}," line",[293,2599,1087],{"class":313},[293,2601,2534],{"class":309},[293,2603,1093],{"class":485},[293,2605,314],{"class":313},[293,2607,2609,2612,2614,2616,2618,2620,2623,2625,2627,2630,2632,2634],{"class":295,"line":2608},31,[293,2610,2611],{"class":305},"        if",[293,2613,1027],{"class":485},[293,2615,2047],{"class":313},[293,2617,295],{"class":309},[293,2619,224],{"class":313},[293,2621,2622],{"class":513},"startsWith",[293,2624,1114],{"class":485},[293,2626,534],{"class":313},[293,2628,2629],{"class":502},"data: ",[293,2631,534],{"class":313},[293,2633,1176],{"class":485},[293,2635,2636],{"class":305},"continue\n",[293,2638,2640,2643,2646,2648,2650,2652,2655,2657,2659,2661,2664,2666,2669],{"class":295,"line":2639},32,[293,2641,2642],{"class":489},"        const",[293,2644,2645],{"class":309}," event",[293,2647,1046],{"class":313},[293,2649,1885],{"class":309},[293,2651,224],{"class":313},[293,2653,2654],{"class":513},"parse",[293,2656,1114],{"class":485},[293,2658,295],{"class":309},[293,2660,224],{"class":313},[293,2662,2663],{"class":513},"slice",[293,2665,1114],{"class":485},[293,2667,2668],{"class":1049},"6",[293,2670,2671],{"class":485},"))\n",[293,2673,2675,2677,2679,2681,2683,2686,2689,2691,2694,2696,2699,2701,2703,2706,2708,2710,2712,2714,2717,2719,2721],{"class":295,"line":2674},33,[293,2676,2611],{"class":305},[293,2678,1027],{"class":485},[293,2680,1705],{"class":309},[293,2682,224],{"class":313},[293,2684,2685],{"class":309},"type",[293,2687,2688],{"class":313}," ===",[293,2690,528],{"class":313},[293,2692,2693],{"class":502},"content_block_delta",[293,2695,534],{"class":313},[293,2697,2698],{"class":313}," &&",[293,2700,2645],{"class":309},[293,2702,224],{"class":313},[293,2704,2705],{"class":309},"delta",[293,2707,224],{"class":313},[293,2709,2685],{"class":309},[293,2711,2688],{"class":313},[293,2713,528],{"class":313},[293,2715,2716],{"class":502},"text_delta",[293,2718,534],{"class":313},[293,2720,1093],{"class":485},[293,2722,314],{"class":313},[293,2724,2726,2729,2731,2733,2735,2737,2739,2741,2743],{"class":295,"line":2725},34,[293,2727,2728],{"class":309},"          text",[293,2730,224],{"class":313},[293,2732,2257],{"class":309},[293,2734,2500],{"class":313},[293,2736,2645],{"class":309},[293,2738,224],{"class":313},[293,2740,2705],{"class":309},[293,2742,224],{"class":313},[293,2744,2745],{"class":309},"text\n",[293,2747,2749],{"class":295,"line":2748},35,[293,2750,2751],{"class":313},"        }\n",[293,2753,2755],{"class":295,"line":2754},36,[293,2756,2757],{"class":313},"      }\n",[293,2759,2761],{"class":295,"line":2760},37,[293,2762,2763],{"class":313},"    }\n",[293,2765,2767,2769,2771,2773,2775,2777,2779],{"class":295,"line":2766},38,[293,2768,2252],{"class":309},[293,2770,224],{"class":313},[293,2772,2257],{"class":309},[293,2774,1046],{"class":313},[293,2776,528],{"class":313},[293,2778,2158],{"class":502},[293,2780,544],{"class":313},[293,2782,2784],{"class":295,"line":2783},39,[293,2785,967],{"class":313},[293,2787,2789],{"class":295,"line":2788},40,[293,2790,347],{"emptyLinePlaceholder":240},[293,2792,2794,2796,2799,2801,2803,2805],{"class":295,"line":2793},41,[293,2795,1064],{"class":489},[293,2797,2798],{"class":309}," stop",[293,2800,1046],{"class":313},[293,2802,2099],{"class":313},[293,2804,1056],{"class":489},[293,2806,1059],{"class":313},[293,2808,2810,2812,2815,2818],{"class":295,"line":2809},42,[293,2811,2238],{"class":309},[293,2813,2814],{"class":313},"?.",[293,2816,2817],{"class":513},"abort",[293,2819,575],{"class":485},[293,2821,2823,2825,2827,2829,2831,2833,2835],{"class":295,"line":2822},43,[293,2824,2252],{"class":309},[293,2826,224],{"class":313},[293,2828,2257],{"class":309},[293,2830,1046],{"class":313},[293,2832,528],{"class":313},[293,2834,2158],{"class":502},[293,2836,544],{"class":313},[293,2838,2840],{"class":295,"line":2839},44,[293,2841,967],{"class":313},[293,2843,2845],{"class":295,"line":2844},45,[293,2846,347],{"emptyLinePlaceholder":240},[293,2848,2850,2852,2854,2856,2858,2860,2862,2864,2866,2868],{"class":295,"line":2849},46,[293,2851,1267],{"class":305},[293,2853,1718],{"class":313},[293,2855,2110],{"class":309},[293,2857,323],{"class":313},[293,2859,2129],{"class":309},[293,2861,323],{"class":313},[293,2863,2211],{"class":309},[293,2865,323],{"class":313},[293,2867,2798],{"class":309},[293,2869,2870],{"class":313}," }\n",[293,2872,2874],{"class":295,"line":2873},47,[293,2875,409],{"class":313},[11,2877,2878],{},"Two details that matter here:",[37,2880,2881,2889],{},[40,2882,2883,2888],{},[18,2884,2885],{},[115,2886,2887],{},"decoder.decode(value, { stream: true })"," — without the flag, a multi-byte character (an emoji, an accented á) split across chunks becomes garbage.",[40,2890,2891,2896],{},[18,2892,2893],{},[115,2894,2895],{},"buffer = lines.pop()"," — the incomplete last line waits for the rest of itself. This is the line that separates a demo from a product.",[22,2898,2900],{"id":2899},"rendering-why-streamed-markdown-flickers","Rendering: why streamed markdown flickers",[11,2902,2903],{},"The naive approach re-parses the whole markdown string on every token:",[285,2905,2910],{"className":2906,"code":2908,"language":2909},[2907],"language-text","token arrives → text.value += token → markdown-it re-parses everything → v-html swaps the DOM\n","text",[115,2911,2908],{"__ignoreMap":226},[11,2913,2914],{},"At 60–100 tokens per second this means the entire answer's DOM is torn down and rebuilt dozens of times a second. Code blocks lose their syntax highlighting for a frame, images re-request, and the page visibly shimmers.",[11,2916,2917],{},"Three fixes, in order of effort:",[11,2919,2920,2923],{},[18,2921,2922],{},"1. Throttle renders to animation frames."," Tokens can arrive faster than the screen refreshes. Accumulate them and flush once per frame:",[285,2925,2927],{"className":851,"code":2926,"language":503,"meta":226,"style":226},"let pending = ''\nlet scheduled = false\n\nconst push = (token: string) => {\n  pending += token\n  if (scheduled) return\n  scheduled = true\n  requestAnimationFrame(() => {\n    text.value += pending\n    pending = ''\n    scheduled = false\n  })\n}\n",[115,2928,2929,2941,2953,2957,2982,2992,3007,3017,3030,3043,3052,3061,3067],{"__ignoreMap":226},[293,2930,2931,2934,2937,2939],{"class":295,"line":296},[293,2932,2933],{"class":489},"let",[293,2935,2936],{"class":309}," pending ",[293,2938,496],{"class":313},[293,2940,2279],{"class":313},[293,2942,2943,2945,2948,2950],{"class":295,"line":227},[293,2944,2933],{"class":489},[293,2946,2947],{"class":309}," scheduled ",[293,2949,496],{"class":313},[293,2951,2952],{"class":1935}," false\n",[293,2954,2955],{"class":295,"line":317},[293,2956,347],{"emptyLinePlaceholder":240},[293,2958,2959,2961,2964,2966,2968,2971,2973,2976,2978,2980],{"class":295,"line":333},[293,2960,1016],{"class":489},[293,2962,2963],{"class":309}," push ",[293,2965,496],{"class":313},[293,2967,1027],{"class":313},[293,2969,2970],{"class":1030},"token",[293,2972,170],{"class":313},[293,2974,2975],{"class":326}," string",[293,2977,1053],{"class":313},[293,2979,1056],{"class":489},[293,2981,1059],{"class":313},[293,2983,2984,2987,2989],{"class":295,"line":344},[293,2985,2986],{"class":309},"  pending",[293,2988,2500],{"class":313},[293,2990,2991],{"class":309}," token\n",[293,2993,2994,2997,2999,3002,3004],{"class":295,"line":350},[293,2995,2996],{"class":305},"  if",[293,2998,1027],{"class":485},[293,3000,3001],{"class":309},"scheduled",[293,3003,1093],{"class":485},[293,3005,3006],{"class":305},"return\n",[293,3008,3009,3012,3014],{"class":295,"line":362},[293,3010,3011],{"class":309},"  scheduled",[293,3013,1046],{"class":313},[293,3015,3016],{"class":1935}," true\n",[293,3018,3019,3022,3024,3026,3028],{"class":295,"line":373},[293,3020,3021],{"class":513},"  requestAnimationFrame",[293,3023,1114],{"class":485},[293,3025,1755],{"class":313},[293,3027,1056],{"class":489},[293,3029,1059],{"class":313},[293,3031,3032,3034,3036,3038,3040],{"class":295,"line":384},[293,3033,2270],{"class":309},[293,3035,224],{"class":313},[293,3037,2257],{"class":309},[293,3039,2500],{"class":313},[293,3041,3042],{"class":309}," pending\n",[293,3044,3045,3048,3050],{"class":295,"line":395},[293,3046,3047],{"class":309},"    pending",[293,3049,1046],{"class":313},[293,3051,2279],{"class":313},[293,3053,3054,3057,3059],{"class":295,"line":400},[293,3055,3056],{"class":309},"    scheduled",[293,3058,1046],{"class":313},[293,3060,2952],{"class":1935},[293,3062,3063,3065],{"class":295,"line":406},[293,3064,1958],{"class":313},[293,3066,1129],{"class":485},[293,3068,3069],{"class":295,"line":1433},[293,3070,409],{"class":313},[11,3072,3073],{},"The stream reads identically to the user, but you've cut renders from ~100\u002Fs to at most 60\u002Fs — and in practice far fewer.",[11,3075,3076,3079,3080,3083],{},[18,3077,3078],{},"2. Only re-parse the tail."," Completed markdown blocks (separated by ",[115,3081,3082],{},"\\n\\n",") never change once closed. Parse them once, cache the HTML, and only re-parse the still-open final block. This drops re-parse cost from O(whole answer) to O(current paragraph).",[11,3085,3086,3089,3090,3093,3094,3097],{},[18,3087,3088],{},"3. Never animate layout."," The blinking caret at the end of streamed text should be a CSS pseudo-element (",[115,3091,3092],{},"&::after"," with an opacity keyframe), not a character you append and remove — appending a real ",[115,3095,3096],{},"▍"," character to the string means every flush also shifts text.",[22,3099,3101],{"id":3100},"tool-calls-are-part-of-the-conversation","Tool calls are part of the conversation",[11,3103,3104,3105,3108],{},"When Claude decides to call a tool mid-answer, the stream switches from text deltas to ",[115,3106,3107],{},"tool_use"," blocks. The worst thing you can do is print raw JSON; the second worst is hiding it entirely, leaving a mysterious 3-second silence.",[11,3110,3111],{},"Treat the tool call as a first-class message segment with its own component:",[285,3113,3115],{"className":473,"code":3114,"language":475,"meta":226,"style":226},"\u003CToolCallChip\n  :name=\"segment.toolName\"\n  :status=\"segment.status\"\n\u002F>\n",[115,3116,3117,3124,3147,3166],{"__ignoreMap":226},[293,3118,3119,3121],{"class":295,"line":296},[293,3120,482],{"class":313},[293,3122,3123],{"class":485},"ToolCallChip\n",[293,3125,3126,3129,3132,3134,3136,3139,3141,3144],{"class":295,"line":227},[293,3127,3128],{"class":313},"  :",[293,3130,3131],{"class":489},"name",[293,3133,496],{"class":313},[293,3135,499],{"class":313},[293,3137,3138],{"class":309},"segment",[293,3140,224],{"class":313},[293,3142,3143],{"class":309},"toolName",[293,3145,3146],{"class":313},"\"\n",[293,3148,3149,3151,3154,3156,3158,3160,3162,3164],{"class":295,"line":317},[293,3150,3128],{"class":313},[293,3152,3153],{"class":489},"status",[293,3155,496],{"class":313},[293,3157,499],{"class":313},[293,3159,3138],{"class":309},[293,3161,224],{"class":313},[293,3163,3153],{"class":309},[293,3165,3146],{"class":313},[293,3167,3168],{"class":295,"line":333},[293,3169,3170],{"class":309},"\u002F>\n",[11,3172,3173,3174,3177],{},"A small chip — spinner while running, checkmark when done, expandable if the user wants the details. The mental model you're building for the user: ",[78,3175,3176],{},"the assistant is doing something, here's what, it's under control."," In my segments array, text blocks and tool blocks interleave in order, so the transcript reads like what actually happened.",[22,3179,3181],{"id":3180},"the-details-that-make-it-feel-calm","The details that make it feel calm",[37,3183,3184,3190,3199,3208,3218],{},[40,3185,3186,3189],{},[18,3187,3188],{},"Auto-scroll with an escape hatch."," Follow the bottom while the user is at the bottom; the moment they scroll up, stop following and show a \"jump to latest\" pill. Fighting the user's scroll is the fastest way to make streaming feel hostile.",[40,3191,3192,3198],{},[18,3193,3194,3197],{},[115,3195,3196],{},"aria-live=\"polite\""," on the transcript"," — screen readers announce streamed content when the user is idle, instead of on every token.",[40,3200,3201,3207],{},[18,3202,3203,3204,224],{},"Respect ",[115,3205,3206],{},"prefers-reduced-motion"," Skip the caret blink and any typing embellishments; the text simply appears.",[40,3209,3210,3213,3214,3217],{},[18,3211,3212],{},"Design the stop button in from day one."," An ",[115,3215,3216],{},"AbortController"," on the fetch, a visible \"Stop\" affordance, and a state that renders the partial answer as valid — because a half-answer the user chose to keep is a feature, not an error.",[40,3219,3220,3223],{},[18,3221,3222],{},"Errors mid-stream keep the partial text."," Append a retry affordance under what already arrived. Throwing away 400 streamed words because the connection hiccuped at word 401 is rage-inducing.",[22,3225,1617],{"id":1616},[11,3227,3228],{},"Streaming UIs live or die on four things: a chunk parser that survives arbitrary split points, renders throttled to frames, markdown parsed incrementally instead of wholesale, and tool activity shown as status instead of silence. None of it is glamorous — and that's exactly why getting it right is a competitive advantage. Most teams ship the naive version.",[11,3230,3231,3232,224],{},"If you're building a streamed AI feature in Vue or Nuxt and want it to feel like a product instead of a demo, ",[220,3233,3234],{"href":222},"that's exactly the work I do",[754,3236,3237],{},"html pre.shiki code .sHwdD, html code.shiki .sHwdD{--shiki-light:#90A4AE;--shiki-light-font-style:italic;--shiki-default:#546E7A;--shiki-default-font-style:italic;--shiki-dark:#676E95;--shiki-dark-font-style:italic}html pre.shiki code .s7zQu, html code.shiki .s7zQu{--shiki-light:#39ADB5;--shiki-light-font-style:italic;--shiki-default:#89DDFF;--shiki-default-font-style:italic;--shiki-dark:#89DDFF;--shiki-dark-font-style:italic}html pre.shiki code .s2Zo4, html code.shiki .s2Zo4{--shiki-light:#6182B8;--shiki-default:#82AAFF;--shiki-dark:#82AAFF}html pre.shiki code .sTEyZ, html code.shiki .sTEyZ{--shiki-light:#90A4AE;--shiki-default:#EEFFFF;--shiki-dark:#BABED8}html pre.shiki code .spNyl, html code.shiki .spNyl{--shiki-light:#9C3EDA;--shiki-default:#C792EA;--shiki-dark:#C792EA}html pre.shiki code .sMK4o, html code.shiki .sMK4o{--shiki-light:#39ADB5;--shiki-default:#89DDFF;--shiki-dark:#89DDFF}html pre.shiki code .sHdIc, html code.shiki .sHdIc{--shiki-light:#90A4AE;--shiki-light-font-style:italic;--shiki-default:#EEFFFF;--shiki-default-font-style:italic;--shiki-dark:#BABED8;--shiki-dark-font-style:italic}html pre.shiki code .swJcz, html code.shiki .swJcz{--shiki-light:#E53935;--shiki-default:#F07178;--shiki-dark:#F07178}html pre.shiki code .sfazB, html code.shiki .sfazB{--shiki-light:#91B859;--shiki-default:#C3E88D;--shiki-dark:#C3E88D}html pre.shiki code .sbssI, html code.shiki .sbssI{--shiki-light:#F76D47;--shiki-default:#F78C6C;--shiki-dark:#F78C6C}html pre.shiki code .sfNiH, html code.shiki .sfNiH{--shiki-light:#FF5370;--shiki-default:#FF9CAC;--shiki-dark:#FF9CAC}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 .sBMFI, html code.shiki .sBMFI{--shiki-light:#E2931D;--shiki-default:#FFCB6B;--shiki-dark:#FFCB6B}",{"title":226,"searchDepth":227,"depth":227,"links":3239},[3240,3241,3242,3243,3244,3245],{"id":1671,"depth":227,"text":1672},{"id":2063,"depth":227,"text":2064},{"id":2899,"depth":227,"text":2900},{"id":3100,"depth":227,"text":3101},{"id":3180,"depth":227,"text":3181},{"id":1616,"depth":227,"text":1617},"AI Product","A no-flicker streaming chat UI in Nuxt — chunk parsing, smooth typing, graceful tool-call rendering, and the small decisions that make streamed AI output feel calm instead of jittery.",{},"\u002Fwriting\u002Fstreaming-responses-vue","9 min read",{"title":1653,"description":3247},"writing\u002Fstreaming-responses-vue","Oinve955MFq48xXZLNazkgiFNsuQuWVOSy4HWiyiikQ",1785786125813]