[{"data":1,"prerenderedAt":627},["ShallowReactive",2],{"/blog/building-yata-a-translation-app-in-a-couple-of-evenings":3},{"id":4,"title":5,"body":6,"category":611,"date":612,"description":613,"extension":614,"meta":615,"navigation":152,"path":623,"seo":624,"stem":625,"__hash__":626},"blog/blog/building-yata-a-translation-app-in-a-couple-of-evenings.md","Building Yata: A Translation App in a Couple of Evenings",{"type":7,"value":8,"toc":605},"minimark",[9,13,24,27,35,40,43,55,58,61,64,316,319,328,332,335,344,347,350,353,368,371,375,378,381,384,387,396,399,541,544,547,558,561,564,567,571,580,583,586,589,592,595,598,601],[10,11,12],"p",{},"I had already entered my text into DeepL when a Cloudflare Turnstile checkbox appeared over the\npage. It had given me enough time to start, but now I could not copy the Translation or interact\nwith the interface until I passed the check.",[10,14,15,16,23],{},"That interruption was one part of an accumulated frustration with translation products. DeepL\nalso appended a promotional attribution when I copied longer Translations from its free version.\nKagi Translate did not focus the textarea when I opened it, even though the textarea is the\nproduct's main interaction, and ",[17,18,22],"a",{"href":19,"rel":20},"https://blog.kagi.com/translate-update",[21],"nofollow","Kagi later paused free access",".\nGoogle Translate was easy to reach, but its results often lacked the contextual quality I wanted.",[10,25,26],{},"None of those observations is a market analysis, and this is not a comparison of translation\nservices. They simply pushed me toward a personal question: what would a translation product look\nlike if I built the one I wanted to use myself?",[28,29],"theme-picture",{"alt":30,"dark":31,"height":32,"light":33,"width":34},"Yata translating the English pangram The quick brown fox jumps over the lazy dog into Ukrainian","/images/blog/yata-dark.png",1754,"/images/blog/yata-light.png",2858,[36,37,39],"h2",{"id":38},"translation-should-not-feel-like-work","Translation should not feel like work",[10,41,42],{},"The obvious modern answer would have been to use an AI chatbot. I did not want another chat.\nTranslation does not need a conversation, a message history, or context that carries from one\nrequest into the next. Each Translation Request should stand on its own: Source Text, language\nchoices, and the selected Translation Mode go in; a read-only Translation comes back.",[10,44,45,46,50,51,54],{},"That decision made the interface deliberately conventional. Yata has two text areas, language\nselectors, a Copy action, and little else competing for attention. The Source Text editor is\nfocused when the desktop app opens. Live Translation begins after a short pause, while\n",[47,48,49],"code",{},"Ctrl+Enter"," or ",[47,52,53],{},"Command+Enter"," starts it immediately. The result is designed to let me paste,\ntranslate, and copy without leaving the keyboard.",[10,56,57],{},"The product still needed to handle the less visible parts of that simplicity. If I change the\nSource Text while a request is running, the old request becomes obsolete. If I change a language\nor switch between Fast and Thoughtful Translation, the visible result is now out of date. If the\nreplacement fails, discarding the previous successful Translation would turn a recoverable\nfailure into lost work.",[10,59,60],{},"I modeled those cases as one discriminated lifecycle rather than a collection of flags: idle,\ndebouncing, translating, current, out of date, and failed. The previous Translation can remain\nvisible while its status tells the truth. A 600-millisecond debounce keeps ordinary typing from\nissuing a request for every edit, and input-method composition is allowed to finish before that\ntimer starts.",[10,62,63],{},"Cancellation is only best-effort, so the browser also owns a monotonically increasing sequence.\nAn obsolete request may still finish somewhere downstream, but it cannot publish over newer work.\nThe essential shape looks like this, trimmed for the article:",[65,66,71],"pre",{"className":67,"code":68,"language":69,"meta":70,"style":70},"language-ts shiki shiki-themes github-light github-dark-dimmed","function cancelObsoleteWork(): void {\n  requestSequence += 1;\n  activeAbortController?.abort();\n  activeAbortController = undefined;\n}\n\nasync function publishTranslation(\n  sequence: number,\n  abortController: AbortController,\n  translationRequest: TranslationRequest,\n): Promise\u003Cvoid> {\n  const result = await request(translationRequest, abortController.signal);\n\n  if (sequence !== requestSequence) return;\n\n  lastSuccessful = { request: translationRequest, result };\n  lifecycle = { status: 'current', success: lastSuccessful };\n}\n","ts","",[47,72,73,100,115,127,141,147,154,169,184,198,211,231,252,257,277,282,293,311],{"__ignoreMap":70},[74,75,78,82,86,90,93,97],"span",{"class":76,"line":77},"line",1,[74,79,81],{"class":80},"s7YZ4","function",[74,83,85],{"class":84},"sPO5f"," cancelObsoleteWork",[74,87,89],{"class":88},"sYgZi","()",[74,91,92],{"class":80},":",[74,94,96],{"class":95},"sviXB"," void",[74,98,99],{"class":88}," {\n",[74,101,103,106,109,112],{"class":76,"line":102},2,[74,104,105],{"class":88},"  requestSequence ",[74,107,108],{"class":80},"+=",[74,110,111],{"class":95}," 1",[74,113,114],{"class":88},";\n",[74,116,118,121,124],{"class":76,"line":117},3,[74,119,120],{"class":88},"  activeAbortController?.",[74,122,123],{"class":84},"abort",[74,125,126],{"class":88},"();\n",[74,128,130,133,136,139],{"class":76,"line":129},4,[74,131,132],{"class":88},"  activeAbortController ",[74,134,135],{"class":80},"=",[74,137,138],{"class":95}," undefined",[74,140,114],{"class":88},[74,142,144],{"class":76,"line":143},5,[74,145,146],{"class":88},"}\n",[74,148,150],{"class":76,"line":149},6,[74,151,153],{"emptyLinePlaceholder":152},true,"\n",[74,155,157,160,163,166],{"class":76,"line":156},7,[74,158,159],{"class":80},"async",[74,161,162],{"class":80}," function",[74,164,165],{"class":84}," publishTranslation",[74,167,168],{"class":88},"(\n",[74,170,172,176,178,181],{"class":76,"line":171},8,[74,173,175],{"class":174},"stnAF","  sequence",[74,177,92],{"class":80},[74,179,180],{"class":95}," number",[74,182,183],{"class":88},",\n",[74,185,187,190,192,196],{"class":76,"line":186},9,[74,188,189],{"class":174},"  abortController",[74,191,92],{"class":80},[74,193,195],{"class":194},"sOLd2"," AbortController",[74,197,183],{"class":88},[74,199,201,204,206,209],{"class":76,"line":200},10,[74,202,203],{"class":174},"  translationRequest",[74,205,92],{"class":80},[74,207,208],{"class":194}," TranslationRequest",[74,210,183],{"class":88},[74,212,214,217,219,222,225,228],{"class":76,"line":213},11,[74,215,216],{"class":88},")",[74,218,92],{"class":80},[74,220,221],{"class":194}," Promise",[74,223,224],{"class":88},"\u003C",[74,226,227],{"class":95},"void",[74,229,230],{"class":88},"> {\n",[74,232,234,237,240,243,246,249],{"class":76,"line":233},12,[74,235,236],{"class":80},"  const",[74,238,239],{"class":95}," result",[74,241,242],{"class":80}," =",[74,244,245],{"class":80}," await",[74,247,248],{"class":84}," request",[74,250,251],{"class":88},"(translationRequest, abortController.signal);\n",[74,253,255],{"class":76,"line":254},13,[74,256,153],{"emptyLinePlaceholder":152},[74,258,260,263,266,269,272,275],{"class":76,"line":259},14,[74,261,262],{"class":80},"  if",[74,264,265],{"class":88}," (sequence ",[74,267,268],{"class":80},"!==",[74,270,271],{"class":88}," requestSequence) ",[74,273,274],{"class":80},"return",[74,276,114],{"class":88},[74,278,280],{"class":76,"line":279},15,[74,281,153],{"emptyLinePlaceholder":152},[74,283,285,288,290],{"class":76,"line":284},16,[74,286,287],{"class":88},"  lastSuccessful ",[74,289,135],{"class":80},[74,291,292],{"class":88}," { request: translationRequest, result };\n",[74,294,296,299,301,304,308],{"class":76,"line":295},17,[74,297,298],{"class":88},"  lifecycle ",[74,300,135],{"class":80},[74,302,303],{"class":88}," { status: ",[74,305,307],{"class":306},"s-HuK","'current'",[74,309,310],{"class":88},", success: lastSuccessful };\n",[74,312,314],{"class":76,"line":313},18,[74,315,146],{"class":88},[10,317,318],{},"This was the first point where the product philosophy became an engineering constraint.\n\"Immediate\" was not a visual style. It depended on scheduling, cancellation, state ownership, and\neventually the latency of the model itself.",[10,320,321,322,327],{},"Privacy followed the same product-first logic. Yata retains no Translation history or content\nlogs, and it disables AI Gateway payload logging and caching. A provider necessarily receives the\nSource Text, but Yata sends no application account, Cloudflare Access identity, analytics\nidentifier, or conversation history with it. Under\n",[17,323,326],{"href":324,"rel":325},"https://ai.google.dev/gemini-api/docs/zdr",[21],"Google's paid-service terms",", those requests are not\nused to improve Google's products, although Google may still retain content for abuse monitoring.\nI describe Yata as private, not as zero-retention. The distinction matters, but privacy is a\nsupporting property of the product rather than the subject of this case study.",[36,329,331],{"id":330},"an-unfamiliar-frontend-on-familiar-infrastructure","An unfamiliar frontend on familiar infrastructure",[10,333,334],{},"I came to Yata with more than seven years of frontend experience, mostly in Vue and React. Before\nthis project, I had built only something very basic with Svelte. Yata became my first substantial\nSvelteKit application.",[10,336,337,338,343],{},"I had been curious about Svelte for a long time. As Vue's\n",[17,339,342],{"href":340,"rel":341},"https://github.com/vuejs/core/releases/tag/v3.6.0-rc.1",[21],"Vapor Mode"," moved toward a\ncompiler-driven, no-virtual-DOM rendering path, I wanted to experience those ideas from the other\ndirection: start with a framework built around compilation and see how they felt in a complete\nproduct.",[10,345,346],{},"The transition was much smoother than the word \"new\" suggests. Component composition, reactive\nstate, and the separation between application behavior and presentation all transferred naturally\nfrom Vue. Svelte's compiler-first model kept the resulting client lightweight, and Svelte 5 runes\ngave the translator controller an explicit place to own reactive state without adding a state\nlibrary.",[10,348,349],{},"Codex also handled Svelte and SvelteKit well. That does not prove that every unfamiliar framework\nbecomes effortless with an AI coding partner, but it changed the risk calculation for this\nproject. I could review the logic and patterns through the frontend experience I already had while\nCodex supplied much of the framework-specific implementation. The resulting application still\nfelt snappy and native to the stack rather than like Vue code mechanically translated into Svelte.",[10,351,352],{},"For infrastructure, I chose the familiar option. Cloudflare Workers was already a platform I knew,\nand a single SvelteKit Worker was a good fit for a small full-stack application: static application\nshell, dynamic Translation endpoint, secrets, deployment, and owner-only Access protection in one\nplace.",[10,354,355,356,361,362,367],{},"The pleasant discovery was ",[17,357,360],{"href":358,"rel":359},"https://developers.cloudflare.com/ai-gateway/",[21],"Cloudflare AI Gateway",".\nIt gave both inference paths one place for metadata-only observability, rate limiting,\nauthentication, and cache controls. Its\n",[17,363,366],{"href":364,"rel":365},"https://developers.cloudflare.com/ai-gateway/reference/pricing/",[21],"core analytics, caching, and rate-limiting features are currently free",",\nalthough provider inference still has its own price. For developers who already use Cloudflare,\nAI Gateway is remarkably easy to recommend: it adds useful control without requiring a large AI\nframework or a separate operational system.",[10,369,370],{},"The integration does not mean every request remains inside Cloudflare. Fast Translation uses a\nCloudflare-hosted model, while Thoughtful Translation passes through AI Gateway to Google. What\nstays unified is the Cloudflare account, request path, and operational controls—not the underlying\nprovider.",[36,372,374],{"id":373},"responsiveness-runs-through-the-whole-stack","Responsiveness runs through the whole stack",[10,376,377],{},"Yata exposes two Translation Modes. Fast is for ordinary requests where latency matters most.\nThoughtful permits additional provider reasoning for harder text while preserving exactly the\nsame interface and browser-visible response contract.",[10,379,380],{},"The first version used Cloudflare-hosted Gemma 4 for both. That was a reasonable starting point,\nnot an architecture I needed to apologize for later. One model meant one Workers AI binding, one\nGateway, no third-party inference credential, and no speculative provider layer. Fast disabled\nthinking; Thoughtful asked Gemma to reason briefly before publishing the structured Translation.",[10,382,383],{},"Then I watched Thoughtful requests in Chrome's Network panel. For comparable short Source Text,\nthey took approximately five seconds end to end. The request worked, but the pause dominated an\ninteraction designed to disappear into my typing flow.",[10,385,386],{},"I first tried the smaller change: constrain Gemma to LOW reasoning and ask it to use only the\nminimum thought needed to resolve ambiguity. The wait did not materially improve. At that point\nthe question was no longer how to tune one model. It was whether the simplicity of one inference\nstack was worth keeping when it contradicted the product.",[10,388,389,390,395],{},"I considered Gemini 3.5 Flash, Gemini 3.6 Flash, Qwen, and several other models before choosing\n",[17,391,394],{"href":392,"rel":393},"https://ai.google.dev/gemini-api/docs/models/gemini-3.5-flash-lite",[21],"Gemini 3.5 Flash-Lite","\nwith low thinking for Thoughtful Translation. Its combination of speed, cost, structured output,\nand paid-provider data terms fit Yata best. Fast remained on Gemma because its path did not have\nthe same problem.",[10,397,398],{},"The switch revealed differences a model string could not hide. Workers AI publishes through one\nforced tool call; Gemini returns native JSON-schema output. They have different reasoning controls,\nauthentication, token accounting, and error shapes. Those were the variations that finally\njustified a boundary:",[65,400,402],{"className":67,"code":401,"language":69,"meta":70,"style":70},"export type ProviderTranslationRequest = Omit\u003CTranslationRequest, 'mode'>;\n\nexport interface TranslationProvider {\n  model: string;\n  pricing?: ProviderPricing;\n  translate(\n    request: ProviderTranslationRequest,\n    promptVersion: string,\n    signal: AbortSignal,\n  ): Promise\u003CProviderPublication | undefined>;\n}\n",[47,403,404,434,438,450,462,475,482,493,504,516,537],{"__ignoreMap":70},[74,405,406,409,412,415,417,420,422,425,428,431],{"class":76,"line":77},[74,407,408],{"class":80},"export",[74,410,411],{"class":80}," type",[74,413,414],{"class":194}," ProviderTranslationRequest",[74,416,242],{"class":80},[74,418,419],{"class":194}," Omit",[74,421,224],{"class":88},[74,423,424],{"class":194},"TranslationRequest",[74,426,427],{"class":88},", ",[74,429,430],{"class":306},"'mode'",[74,432,433],{"class":88},">;\n",[74,435,436],{"class":76,"line":102},[74,437,153],{"emptyLinePlaceholder":152},[74,439,440,442,445,448],{"class":76,"line":117},[74,441,408],{"class":80},[74,443,444],{"class":80}," interface",[74,446,447],{"class":194}," TranslationProvider",[74,449,99],{"class":88},[74,451,452,455,457,460],{"class":76,"line":129},[74,453,454],{"class":174},"  model",[74,456,92],{"class":80},[74,458,459],{"class":95}," string",[74,461,114],{"class":88},[74,463,464,467,470,473],{"class":76,"line":143},[74,465,466],{"class":174},"  pricing",[74,468,469],{"class":80},"?:",[74,471,472],{"class":194}," ProviderPricing",[74,474,114],{"class":88},[74,476,477,480],{"class":76,"line":149},[74,478,479],{"class":84},"  translate",[74,481,168],{"class":88},[74,483,484,487,489,491],{"class":76,"line":156},[74,485,486],{"class":174},"    request",[74,488,92],{"class":80},[74,490,414],{"class":194},[74,492,183],{"class":88},[74,494,495,498,500,502],{"class":76,"line":171},[74,496,497],{"class":174},"    promptVersion",[74,499,92],{"class":80},[74,501,459],{"class":95},[74,503,183],{"class":88},[74,505,506,509,511,514],{"class":76,"line":186},[74,507,508],{"class":174},"    signal",[74,510,92],{"class":80},[74,512,513],{"class":194}," AbortSignal",[74,515,183],{"class":88},[74,517,518,521,523,525,527,530,533,535],{"class":76,"line":200},[74,519,520],{"class":88},"  )",[74,522,92],{"class":80},[74,524,221],{"class":194},[74,526,224],{"class":88},[74,528,529],{"class":194},"ProviderPublication",[74,531,532],{"class":80}," |",[74,534,138],{"class":95},[74,536,433],{"class":88},[74,538,539],{"class":76,"line":213},[74,540,146],{"class":88},[10,542,543],{},"The interface is intentionally narrow. It does not promise arbitrary portability or expose a\nprovider selector. Fast always names one configured path and Thoughtful names the other. The\nTranslation Service continues to own the stable product contract: validation, deadlines, language\npolicy, safe errors, request IDs, and content-free diagnostics. Provider-specific adapters remain\nconcrete behind it.",[10,545,546],{},"Using the same informal Network-panel method and comparable text, I now see Thoughtful responses\naround one second. In one five-request capture, every request completed between 874 milliseconds\nand 1.35 seconds:",[548,549],"nuxt-picture",{"alt":550,"decoding":159,"densities":551,"format":552,"height":553,"loading":554,"sizes":555,"src":556,"width":557},"Chrome Network panel showing five successful Gemini Translation Requests completing between 874 milliseconds and 1.35 seconds","x1","avif,webp",364,"lazy","xs:100vw sm:92vw md:704px","/images/blog/yata-gemini-thoughtful-network-timings.png",988,[10,559,560],{},"That is enough to change the feel of the product. It is not a controlled benchmark: I did not\nrecord a fixed corpus, sample size, cold-versus-warm runs, or a breakdown of browser, network,\nGateway, and model time.",[10,562,563],{},"The quality result is similarly bounded. I saw no obvious regression for popular languages and\nnoticeably better results for Ukrainian, Hutsul, and Surzhyk, but I have not completed a thorough\nprovider-backed evaluation. Deterministic tests prove that the adapters preserve Yata's structured\ncontract and failure behavior. They do not prove linguistic quality.",[10,565,566],{},"That distinction is useful. The model change solved the interaction problem I had observed and\nmade the architecture more honest about its two providers. It did not establish a universal model\nranking.",[36,568,570],{"id":569},"from-sixteen-issues-to-a-closed-beta","From sixteen issues to a closed beta",[10,572,573,574,579],{},"The development speed came from making decisions before generating code. I used a grilling\nworkflow from ",[17,575,578],{"href":576,"rel":577},"https://github.com/mattpocock/skills",[21],"Matt Pocock's skills"," to force the product\nquestions into the open. The result became a PRD, then sixteen initial vertical slices that Codex\ncould implement one by one.",[10,581,582],{},"That sequence mattered. \"Build a translation app\" leaves hundreds of decisions implicit. An issue\nsuch as Live Translation had a much tighter boundary: debounce after 600 milliseconds, respect\ncomposition events, cancel obsolete work, and prevent older requests from publishing. The issue\ndescribed what had to be true; Codex could concentrate on making it true.",[10,584,585],{},"I defined the idea, UI/UX philosophy, constraints, and architecture. Codex implemented the\napplication issue by issue. I reviewed every commit—primarily its logic, patterns, and\narchitectural direction—then exercised the result and redirected it when necessary. This was\nactive oversight, although not a meticulous line-by-line review.",[10,587,588],{},"The first usable version took shape in a couple of evenings. Deployment, the Gemini migration, and\npolish continued over the next few days. The repository records the aftermath of the process: one\nPRD, sixteen initial implementation issues, issue-scoped commits, and verification notes. It does\nnot turn those days into proof that anyone can build any production system instantly. I brought\nyears of frontend judgment, a familiar infrastructure platform, and a deliberately narrow product\nscope to the work.",[10,590,591],{},"I then put Yata in an Access-protected closed beta with three people from my circle of friends and\nfamily. The goal was not to validate Translation quality. It was to make the application usable\nenough for my inner circle before widening access. Their feedback found the kind of problems a\nproduct needs real use to expose: labels that felt wrong, sizing inconsistencies, and missing\nworkflow details such as a Clear action. I fixed those issues without changing the basic direction\nof the product.",[10,593,594],{},"Yata is still a small application. It has no conversation, history, database, provider marketplace,\nor attempt to support every language a model might recognize. That restraint is part of why it\nworks for me.",[10,596,597],{},"Three lessons remain after the implementation details. First, chat is not a silver bullet for AI\nproducts. A focused task often deserves a focused interface. Second, responsiveness runs through\nthe whole system: component state, request ownership, network behavior, and model choice all shape\nthe same user experience. Third, an AI coding partner can compress implementation dramatically\nwhen the product decisions and acceptance boundaries are explicit.",[10,599,600],{},"With a narrow problem, explicit product decisions, a familiar deployment platform, and an\nissue-by-issue AI-assisted workflow, I could build a genuinely usable application in a couple of\nevenings. That does not remove the need for engineering judgment; it changes where that judgment\nis spent.",[602,603,604],"style",{},"html pre.shiki code .s7YZ4, html code.shiki .s7YZ4{--shiki-default:#D73A49;--shiki-dark:#F47067}html pre.shiki code .sPO5f, html code.shiki .sPO5f{--shiki-default:#6F42C1;--shiki-dark:#DCBDFB}html pre.shiki code .sYgZi, html code.shiki .sYgZi{--shiki-default:#24292E;--shiki-dark:#ADBAC7}html pre.shiki code .sviXB, html code.shiki .sviXB{--shiki-default:#005CC5;--shiki-dark:#6CB6FF}html pre.shiki code .stnAF, html code.shiki .stnAF{--shiki-default:#E36209;--shiki-dark:#F69D50}html pre.shiki code .sOLd2, html code.shiki .sOLd2{--shiki-default:#6F42C1;--shiki-dark:#F69D50}html pre.shiki code .s-HuK, html code.shiki .s-HuK{--shiki-default:#032F62;--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);}",{"title":70,"searchDepth":102,"depth":102,"links":606},[607,608,609,610],{"id":38,"depth":102,"text":39},{"id":330,"depth":102,"text":331},{"id":373,"depth":102,"text":374},{"id":569,"depth":102,"text":570},"Engineering","2026-08-08","How I used SvelteKit, Cloudflare, and an issue-by-issue Codex workflow to build a focused translation app, then changed models when latency broke the experience.","md",{"tags":616},[617,618,619,620,621,622],"svelte","sveltekit","cloudflare","ai","translation","codex","/blog/building-yata-a-translation-app-in-a-couple-of-evenings",{"title":5,"description":613},"blog/building-yata-a-translation-app-in-a-couple-of-evenings","OEaZnhXaPNXZbxpSMvpfiKnG1-7OnYkiz58fIVcX1L8",1787063340385]