<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Next Intl on ICE-ICE-BEAR-BLOG</title><link>https://ice-ice-bear.github.io/tags/next-intl/</link><description>Recent content in Next Intl on ICE-ICE-BEAR-BLOG</description><generator>Hugo -- gohugo.io</generator><language>en</language><lastBuildDate>Thu, 07 May 2026 00:00:00 +0900</lastBuildDate><atom:link href="https://ice-ice-bear.github.io/tags/next-intl/index.xml" rel="self" type="application/rss+xml"/><item><title>popcon Dev Log #11 — Credits System, R2 Migration, ToonOut, and a Brutal Redesign</title><link>https://ice-ice-bear.github.io/posts/2026-05-07-popcon-dev11/</link><pubDate>Thu, 07 May 2026 00:00:00 +0900</pubDate><guid>https://ice-ice-bear.github.io/posts/2026-05-07-popcon-dev11/</guid><description>&lt;img src="https://ice-ice-bear.github.io/" alt="Featured image of post popcon Dev Log #11 — Credits System, R2 Migration, ToonOut, and a Brutal Redesign" /&gt;&lt;h2 id="overview"&gt;Overview
&lt;/h2&gt;&lt;p&gt;Since &lt;a class="link" href="https://ice-ice-bear.github.io/posts/2026-04-22-popcon-dev10/" &gt;#10 — beta signups, balloon indicator, countdown&lt;/a&gt;, fifteen days have rolled in too much for a single dev log. Matting model swap, payments (credits), Cloudflare R2 cutover, brutal redesign, and Korean i18n — 156 commits across five effectively independent milestones.&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;graph TD
 Start["popcon dev #10 (594cceb)"] --&gt; M1["Matting model swap &amp;lt;br/&amp;gt; ToonOut on gray bg"]
 Start --&gt; M2["Credits system &amp;lt;br/&amp;gt; Credits/CreditCode/CreditLedger"]
 Start --&gt; M3["D1 brutal redesign &amp;lt;br/&amp;gt; tokens/fonts/primitives rewrite"]
 Start --&gt; M4["Cloudflare R2 cutover &amp;lt;br/&amp;gt; dual-write → backfill → drop"]
 Start --&gt; M5["Korean i18n &amp;lt;br/&amp;gt; next-intl + locale prefix"]
 M1 --&gt; End["popcon dev #11 (411c5ec)"]
 M2 --&gt; End
 M3 --&gt; End
 M4 --&gt; End
 M5 --&gt; End&lt;/pre&gt;&lt;p&gt;This post covers all five at once, but the same question echoes through every track — &lt;strong&gt;&amp;ldquo;how do we hop onto a new rail without stopping the existing system.&amp;rdquo;&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="matting-model-birefnet--toonout"&gt;Matting model: BiRefNet → ToonOut
&lt;/h2&gt;&lt;p&gt;popcon separates the character from its background and composites that mask into 12 emoji actions. The previous matting model was trained on photographs and broke down on anime hair and translucent regions.&lt;/p&gt;
&lt;p&gt;&lt;a class="link" href="https://github.com/MatteoKartoon/BiRefNet" target="_blank" rel="noopener"
 &gt;ToonOut&lt;/a&gt; is &lt;a class="link" href="https://github.com/zhengpeng7/birefnet" target="_blank" rel="noopener"
 &gt;BiRefNet&lt;/a&gt; fine-tuned on 1,228 hand-annotated anime images. Pixel accuracy jumps from 95.3% to 99.5% on the test set.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# gpu_worker — composite onto gray before feeding ToonOut&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# (ToonOut training-time gray = #808080)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_swap_bg_to_gray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ndarray&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ndarray&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;&amp;#34;&amp;#34;Soft white-key compositor: alpha-blend onto #808080.&amp;#34;&amp;#34;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;alpha&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;255.0&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;rgb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;gray&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;full_like&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rgb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;128&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rgb&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;alpha&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;gray&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;alpha&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;astype&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uint8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Two pre/post details that mattered:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Single source of truth for bg color&lt;/strong&gt; — made &lt;code&gt;bg_color&lt;/code&gt; authoritative on the backend and standardized to &lt;code&gt;#808080&lt;/code&gt; (commit &lt;code&gt;430f985&lt;/code&gt;). The frontend and worker had been drifting on slightly different grays.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Pylette per-character gray pick&lt;/strong&gt; — uses the Rec.709 luminance rule to pick a gray that matches the character&amp;rsquo;s average brightness (commit &lt;code&gt;94544df&lt;/code&gt;). The library I wrote about &lt;a class="link" href="https://ice-ice-bear.github.io/posts/2026-04-22-pylette/" &gt;in the Pylette post&lt;/a&gt; finally has a real consumer.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;While refactoring, a dynamic indirection turned out to be cargo-cult and got removed; the mask-fill threshold finally got a name (&lt;code&gt;081ddd6&lt;/code&gt;).&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="credits-system-a-full-payment-loop-in-five-days"&gt;Credits system: a full payment loop in five days
&lt;/h2&gt;&lt;p&gt;Beta is wrapping, and we needed a credits system from scratch — SQLAlchemy ORM through to a frontend 402 handler — before flipping to paid.&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;graph TD
 Code["Admin CLI mint &amp;lt;br/&amp;gt; CreditCode (POPxxxxx)"] --&gt; Redeem["Redeem modal &amp;lt;br/&amp;gt; code → balance"]
 Redeem --&gt; Ledger["CreditLedger &amp;lt;br/&amp;gt; charge / refund / grant"]
 Action["Editor action &amp;lt;br/&amp;gt; (generate/refine/animate)"] --&gt; Quote["Pre-flight quote &amp;lt;br/&amp;gt; gate if balance low"]
 Quote --&gt; Ledger
 Ledger -- "402 emit" --&gt; Pill["Header balance pill &amp;lt;br/&amp;gt; global redeem modal"]
 Ledger --&gt; Account["/account page &amp;lt;br/&amp;gt; balance / redeem / history"]&lt;/pre&gt;&lt;p&gt;Three core decisions:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Ledger pattern&lt;/strong&gt; — &lt;code&gt;CreditLedger&lt;/code&gt; is append-only; &lt;code&gt;Credits.balance&lt;/code&gt; is a cached column. Every charge/refund runs in a strict transaction (&lt;code&gt;e28b100&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Global 402 event&lt;/strong&gt; — when the backend throws HTTP 402 for insufficient balance, the frontend &lt;code&gt;useCredits()&lt;/code&gt; hook auto-refreshes and surfaces a global redeem modal (&lt;code&gt;d25739e&lt;/code&gt;, &lt;code&gt;1a32900&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Stage-failure refund&lt;/strong&gt; — if emoji generation fails partway, that stage&amp;rsquo;s credits auto-refund (&lt;code&gt;6d7cc7f&lt;/code&gt;). No manual support tickets.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A small mishap in the middle: I tried sending Gemini&amp;rsquo;s &lt;code&gt;image_size&lt;/code&gt; as &lt;code&gt;&amp;quot;0.5K&amp;quot;&lt;/code&gt; to match the pricing tier — Gemini rejects that with INVALID_ARGUMENT (&lt;code&gt;b1ac23f&lt;/code&gt; revert → &lt;code&gt;55eda01&lt;/code&gt; corrects to &lt;code&gt;&amp;quot;512&amp;quot;&lt;/code&gt;). The pricing-table notation and the API input notation aren&amp;rsquo;t the same value space. I assumed they were.&lt;/p&gt;
&lt;p&gt;Commit &lt;code&gt;360115e&lt;/code&gt; is the funny one. During a refactor, the &lt;code&gt;POP&lt;/code&gt; brand prefix got auto-changed to &lt;code&gt;P0P&lt;/code&gt; (zero instead of letter O). Reverted. AI was being a little too eager about &amp;ldquo;consistency.&amp;rdquo;&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="d1-brutal-redesign-tokens-up"&gt;D1 brutal redesign: tokens up
&lt;/h2&gt;&lt;p&gt;popcon was running a generic Tailwind look. To match the flyer/branding, the whole UI got a brutal overhaul — chunky black borders, hard shadows, a 5-tone palette, bold sans-serifs.&lt;/p&gt;
&lt;p&gt;New font stack:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Archivo Black&lt;/strong&gt; — English headlines&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Black Han Sans&lt;/strong&gt; — Korean headlines&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Jua&lt;/strong&gt; — Korean body&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;JetBrains Mono&lt;/strong&gt; — code/numerics&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Pretendard&lt;/strong&gt; — Korean fallback&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-css" data-lang="css"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c"&gt;/* tokens.css — 5-tone brutal palette */&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nd"&gt;root&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nv"&gt;--paper&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mh"&gt;#fafaf7&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c"&gt;/* page bg */&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nv"&gt;--ink&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mh"&gt;#1a1a1a&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c"&gt;/* body text + borders */&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nv"&gt;--violet&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mh"&gt;#7c3aed&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c"&gt;/* brand (P logo, actions) */&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nv"&gt;--yellow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mh"&gt;#fbbf24&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c"&gt;/* active emphasis (ZIP button etc) */&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nv"&gt;--pink&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mh"&gt;#ec4899&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c"&gt;/* erase / warning */&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nv"&gt;--mint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mh"&gt;#10b981&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c"&gt;/* success */&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Primitives got rewritten — &lt;code&gt;Card&lt;/code&gt;, &lt;code&gt;Chip&lt;/code&gt; (5 tones × 2 sizes), &lt;code&gt;StatusDot&lt;/code&gt;, &lt;code&gt;Input&lt;/code&gt;, &lt;code&gt;Textarea&lt;/code&gt;, &lt;code&gt;Button&lt;/code&gt; (5 variants × 3 sizes), &lt;code&gt;StepIndicator&lt;/code&gt;. All in brutal style (&lt;code&gt;769df10&lt;/code&gt; ~ &lt;code&gt;0e013a8&lt;/code&gt;).&lt;/p&gt;
&lt;p&gt;Pages were swapped one at a time — landing → editor panels → archive → account → auth modal → header. Each commit is one page or panel, so reviews stayed readable.&lt;/p&gt;
&lt;p&gt;The trickiest part was &lt;strong&gt;scrim handling&lt;/strong&gt;. The old design used a white veil; brutal demanded an ink scrim (semi-transparent black). But on the SAM2 / matte refine modal the ink scrim was so heavy you couldn&amp;rsquo;t see the reference image — so scrim became per-modal (&lt;code&gt;99b1908&lt;/code&gt;, &lt;code&gt;4096ba7&lt;/code&gt;).&lt;/p&gt;
&lt;p&gt;A WCAG AA pass caught one issue too: white text on the pink Erase active state was sub-AA, swapped to ink (&lt;code&gt;4827ed4&lt;/code&gt;).&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="cloudflare-r2-cutover-phased-in-four-steps"&gt;Cloudflare R2 cutover: phased in four steps
&lt;/h2&gt;&lt;p&gt;popcon was writing emoji zips/APNGs/videos to the local disk of a fly.io machine. As we scale to multiple machines, assets fragment across disks and download routing breaks. Time to move to R2 (Cloudflare&amp;rsquo;s S3-compatible object store).&lt;/p&gt;
&lt;p&gt;To do this without downtime, I split it into four phases:&lt;/p&gt;
&lt;table&gt;
 &lt;thead&gt;
 &lt;tr&gt;
 &lt;th&gt;Phase&lt;/th&gt;
 &lt;th&gt;Content&lt;/th&gt;
 &lt;th&gt;PR&lt;/th&gt;
 &lt;/tr&gt;
 &lt;/thead&gt;
 &lt;tbody&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;strong&gt;A&lt;/strong&gt;&lt;/td&gt;
 &lt;td&gt;R2 client wrapper + &lt;code&gt;blob_key&lt;/code&gt; DB columns&lt;/td&gt;
 &lt;td&gt;#5&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;strong&gt;B&lt;/strong&gt;&lt;/td&gt;
 &lt;td&gt;Worker dual-writes — local disk &lt;strong&gt;and&lt;/strong&gt; R2&lt;/td&gt;
 &lt;td&gt;#6&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;strong&gt;C&lt;/strong&gt;&lt;/td&gt;
 &lt;td&gt;Backfill script + frontend passes through absolute R2 URLs&lt;/td&gt;
 &lt;td&gt;#7&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;strong&gt;D&lt;/strong&gt;&lt;/td&gt;
 &lt;td&gt;Drop legacy file routes; &lt;code&gt;/download_job&lt;/code&gt; 302 redirect; scratch GC&lt;/td&gt;
 &lt;td&gt;#8&lt;/td&gt;
 &lt;/tr&gt;
 &lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;I waited between each phase to confirm traffic looked clean. The dual-write phase costs more (writing to both backends) but bought rollback safety — if anything broke, I could just turn off the R2 path and disk was still truth.&lt;/p&gt;
&lt;p&gt;Two follow-ups:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Rehydrate URLs from R2 keys&lt;/strong&gt; (&lt;code&gt;b43e802&lt;/code&gt;) — instead of storing absolute R2 URLs, derive them from &lt;code&gt;blob_key&lt;/code&gt; every time. Endpoint changes don&amp;rsquo;t require migrations.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Restore legacy asset routes&lt;/strong&gt; (&lt;code&gt;1e08937&lt;/code&gt;) — for users with in-flight jobs from before the cutover. Caught a bonus bug along the way: R2 URLs were being mistakenly mirrored into filesystem-path columns (&lt;code&gt;83d62c4&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2 id="korean-i18n-next-intl--locale-prefixed-routes"&gt;Korean i18n: next-intl + locale-prefixed routes
&lt;/h2&gt;&lt;pre class="mermaid" style="visibility:hidden"&gt;graph LR
 URL1["/editor"] --&gt; Proxy["proxy.ts &amp;lt;br/&amp;gt; Next 16-style"]
 URL2["/ko/editor"] --&gt; Proxy
 URL3["/en/editor"] --&gt; Proxy
 Proxy --&gt; Locale{"extract locale"}
 Locale --&gt; Layout["[locale]/layout.tsx &amp;lt;br/&amp;gt; getMessages()"]
 Layout --&gt; Page["page render &amp;lt;br/&amp;gt; useTranslations()"]&lt;/pre&gt;&lt;p&gt;Korean was added with next-intl + locale-prefixed routes. Two key decisions:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Move pages under a &lt;code&gt;[locale]&lt;/code&gt; segment&lt;/strong&gt; — &lt;code&gt;app/page.tsx&lt;/code&gt; → &lt;code&gt;app/[locale]/page.tsx&lt;/code&gt;. The layout splits into a root layout and a locale layout (&lt;code&gt;fe1eaa3&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Use Next 16&amp;rsquo;s &lt;code&gt;proxy.ts&lt;/code&gt; for locale routing&lt;/strong&gt; — instead of middleware (&lt;code&gt;4f322e2&lt;/code&gt;). Static routing means caching works.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Translations are split by namespace — &lt;code&gt;home&lt;/code&gt;, &lt;code&gt;editor&lt;/code&gt;, &lt;code&gt;archive&lt;/code&gt;, &lt;code&gt;account&lt;/code&gt;, &lt;code&gt;redeem&lt;/code&gt;, &lt;code&gt;actions&lt;/code&gt;, &lt;code&gt;picker&lt;/code&gt;, etc. Each page/panel has its own commit, which makes greps clean.&lt;/p&gt;
&lt;p&gt;One bug surfaced in the language switcher: switching language dropped search params, killing in-progress editor jobs. Replaced both &lt;code&gt;Link&lt;/code&gt; and &lt;code&gt;router&lt;/code&gt; with locale-aware wrappers that preserve search params (&lt;code&gt;d644b1b&lt;/code&gt;, PR #12).&lt;/p&gt;
&lt;p&gt;Also caught: in-app browsers (KakaoTalk, Instagram) block Google sign-in. Added an escape-to-external-browser guard (&lt;code&gt;29cd743&lt;/code&gt;).&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="ops-skip_runpod-guard-and-sync-pod-id"&gt;Ops: SKIP_RUNPOD guard and sync-pod-id
&lt;/h2&gt;&lt;p&gt;Deploys are fly.io (API + frontend) + RunPod (GPU worker) + a GitHub Actions cron scheduler. The scheduler shuts down the RunPod pod overnight to save money. But manually-spawned dev pods were getting killed by the same scheduler.&lt;/p&gt;
&lt;p&gt;Fix: &lt;code&gt;SKIP_RUNPOD&lt;/code&gt; env-var guard (&lt;code&gt;e3fa9fa&lt;/code&gt;). When set, the scheduler leaves pods alone. An escape hatch for manual ops.&lt;/p&gt;
&lt;p&gt;Also added &lt;code&gt;sync-pod-id&lt;/code&gt; (&lt;code&gt;783238b&lt;/code&gt;) — auto-syncs a new RunPod ID into fly secrets. Used to be a manual fly secrets update that I&amp;rsquo;d forget.&lt;/p&gt;
&lt;p&gt;One more line that mattered: &lt;code&gt;fly(frontend)&lt;/code&gt; warm-machine config (&lt;code&gt;edf3d18&lt;/code&gt;, PR #9). Keep one frontend machine warm at 512 MB. Cold start dropped from 1.5s → 200ms.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="insights"&gt;Insights
&lt;/h2&gt;&lt;p&gt;Looking back over the 156 commits, the surprising thing is how &lt;strong&gt;parallel&lt;/strong&gt; these tracks ran. Matting/R2 were backend/worker. Brutal redesign was frontend. Credits and i18n were full-stack. Five tracks ran simultaneously and merge conflicts stayed minor — module boundaries were sharp enough to keep them apart.&lt;/p&gt;
&lt;p&gt;The R2 phased cutover pattern is the one I&amp;rsquo;d reuse first. The dual-write phase costs a little — writing to both backends — but it buys a clean rollback. If phase B had broken anything, we could&amp;rsquo;ve just disabled the R2 path and disk would still be truth.&lt;/p&gt;
&lt;p&gt;The credit ledger pattern is also a keeper. Cache &lt;code&gt;Credits.balance&lt;/code&gt; on the row, but keep &lt;code&gt;CreditLedger&lt;/code&gt; append-only. If anyone questions a balance, you re-derive from the ledger. This is exactly Stripe&amp;rsquo;s model.&lt;/p&gt;
&lt;p&gt;For redesigns, rebuilding the tokens and primitives &lt;strong&gt;before&lt;/strong&gt; touching pages was decisive. Touch pages first and you end up with old components that don&amp;rsquo;t pick up the new tokens, lingering forever.&lt;/p&gt;
&lt;p&gt;Coming up in dev #12: payment gateway integration (KG Inicis / PortOne), ToonOut matting quality A/B against the previous model, and the i18n micro-gaps left over (error toasts, admin CLI strings).&lt;/p&gt;</description></item></channel></rss>