<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Kenji Sakuramoto</title>
    <description>The latest articles on DEV Community by Kenji Sakuramoto (@kenji_sakuramoto_d72920b2).</description>
    <link>https://dev.to/kenji_sakuramoto_d72920b2</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F2588616%2F852e6862-979a-4ace-8c82-3ec765a4729f.png</url>
      <title>DEV Community: Kenji Sakuramoto</title>
      <link>https://dev.to/kenji_sakuramoto_d72920b2</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kenji_sakuramoto_d72920b2"/>
    <language>en</language>
    <item>
      <title>How long is my audiobook? Three numbers, and only one measured the audio</title>
      <dc:creator>Kenji Sakuramoto</dc:creator>
      <pubDate>Sun, 25 Oct 2026 12:00:00 +0000</pubDate>
      <link>https://dev.to/kenji_sakuramoto_d72920b2/how-long-is-my-audiobook-three-numbers-and-only-one-measured-the-audio-4l2k</link>
      <guid>https://dev.to/kenji_sakuramoto_d72920b2/how-long-is-my-audiobook-three-numbers-and-only-one-measured-the-audio-4l2k</guid>
      <description>&lt;p&gt;&lt;em&gt;the wanting mind&lt;/em&gt; is a free book I wrote about why people want. It runs to 36 chapters and about 147,000 words, across neuroscience, economics, philosophy and anthropology. The question started when I lived in meritocratic Singapore and then came back to Japan, a country that runs on community and, in places, on a very clear hierarchy.&lt;/p&gt;

&lt;p&gt;Every chapter has English narration in AI voices. The app and its store listing said that narration was about 12 hours long. When I checked in October, the files disagreed, and so did the word-timing data the reader uses.&lt;/p&gt;

&lt;h2&gt;
  
  
  12 hours: the copy
&lt;/h2&gt;

&lt;p&gt;The in-app purchase screen said "Full 12-hour narrated audiobook with word-sync". Both App Store descriptions said "about 12 hours" (約12時間 in Japanese).&lt;/p&gt;

&lt;h2&gt;
  
  
  20.5 hours: the files
&lt;/h2&gt;

&lt;p&gt;There are 36 MP3 files, one per chapter, 1,181,948,756 bytes in total. The first file's frame header says 128 kbps. If every file uses the same bitrate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1,181,948,756 bytes × 8 ÷ 128,000 bit/s ≈ 73,872 s ≈ 20.5 h
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That assumes a constant bitrate, so I checked it with &lt;code&gt;ffprobe&lt;/code&gt; on every live file. It gave 20.52 hours. This is the number that measures the audio.&lt;/p&gt;

&lt;h2&gt;
  
  
  16.6 hours: the word timings
&lt;/h2&gt;

&lt;p&gt;Each chapter has an alignment file with a start and end time for every word. The reader uses it to highlight the word being spoken. If the timings covered the whole file, the last word would end near the end of each file. Summed over all 36, the last end times came to 16.56 hours.&lt;/p&gt;

&lt;p&gt;So I compared them chapter by chapter on 16 October. In 31 of the 36 chapters, the alignment file ended more than a minute before the audio did. The worst was the preface: its last aligned word ended at 2.9 minutes, in a 9.7-minute file. The other five ended within a minute of their audio.&lt;/p&gt;

&lt;p&gt;The highlight is built from these files, so in those 31 chapters it had nothing to follow after the last aligned word. The fix was to regenerate them, and the check was the same sum: it had to come out within a few minutes of 20.5 hours. I re-timed them on 16 October, and on 17 October the sum came to 20.51 hours, with every chapter ending within a minute of its audio. In one chapter and the appendices, though, the audio still held more words than the word list (about 45% and 17% more), and the sum can't see that.&lt;/p&gt;

&lt;h2&gt;
  
  
  And 11.1 hours, which is something else
&lt;/h2&gt;

&lt;p&gt;The chapters also carry a reading-time estimate. Summed, it is 666 minutes, about 11 hours. That is how long the text takes to read, not to hear.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it says now
&lt;/h2&gt;

&lt;p&gt;The purchase screen in the current code says "Full 20-hour narrated audiobook", and my posts say "about 20 hours". On 16 October both App Store descriptions still said 12. On 17 October, version 3.13.0 changed both to about 20 hours (約20時間).&lt;/p&gt;

&lt;p&gt;The book, and the other ways into it (the concept map, the simulations, the references you can open from the line that cites them), is here: &lt;a href="https://kensaur.us/notes/the-wanting-mind/en/?utm_source=devto&amp;amp;utm_medium=post&amp;amp;utm_campaign=gtm-2026q4&amp;amp;utm_content=the-wanting-mind" rel="noopener noreferrer"&gt;the wanting mind&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I wrote this post with help from an AI assistant. The figures come from the project's repository and the live audio files.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>audio</category>
      <category>webdev</category>
      <category>ai</category>
      <category>debugging</category>
    </item>
    <item>
      <title>From "this is broken" to a draft PR: building Mushi Mushi</title>
      <dc:creator>Kenji Sakuramoto</dc:creator>
      <pubDate>Thu, 15 Oct 2026 04:18:01 +0000</pubDate>
      <link>https://dev.to/kenji_sakuramoto_d72920b2/from-this-is-broken-to-a-fix-pr-i-merge-by-hand-cbh</link>
      <guid>https://dev.to/kenji_sakuramoto_d72920b2/from-this-is-broken-to-a-fix-pr-i-merge-by-hand-cbh</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Correction, 2026-10-29.&lt;/strong&gt; This post first promised "60 seconds" from report to draft PR. We never measured that, so the number is gone. What we do measure is on the &lt;a href="https://kensaur.us/mushi-mushi/docs/proof" rel="noopener noreferrer"&gt;proof page&lt;/a&gt;: where the owner's bugs came from (almost all from the owner's own checks, very few from users), fixes merged, and the median time from bug found to fix merged. The pipeline details below are corrected to match the code, and an unsourced judge-accuracy figure was removed. Since this post, Mushi Mushi added an &lt;a href="https://kensaur.us/mushi-mushi/docs/concepts/agent-pr-review" rel="noopener noreferrer"&gt;AI review of agent PRs&lt;/a&gt;, which runs on the app's own AI key, and is building verified merge (&lt;a href="https://kensaur.us/mushi-mushi/docs/roadmap" rel="noopener noreferrer"&gt;roadmap&lt;/a&gt;): an agent PR merging without a click, only when you switch it on (off by default) and the PR passes a fail-then-pass test and two AI reviews.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Correction, 2026-11-03.&lt;/strong&gt; Checked against the code again. The default models, the fix steps and the review step below now match it, and the merge gate has moved since this was written: see the update at the end of "Why we kept the human in the loop".&lt;/p&gt;

&lt;p&gt;Sentry sees what your code throws. Mushi Mushi sees what your users &lt;em&gt;feel&lt;/em&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is the post I would have wanted to read a year ago, when I was staring at a Sentry dashboard that was perfectly green while three people in a row told me the login button on iPad Safari "did nothing." No exception. No 5xx. No console error. Just a user, frustrated.&lt;/p&gt;

&lt;p&gt;That gap — between &lt;strong&gt;errors that throw&lt;/strong&gt; and &lt;strong&gt;bugs that users feel&lt;/strong&gt; — is what &lt;a href="https://github.com/kensaurus/mushi-mushi" rel="noopener noreferrer"&gt;Mushi Mushi (虫虫)&lt;/a&gt; tries to close.&lt;/p&gt;

&lt;p&gt;This post walks through the loop end-to-end, what each stage actually does, and why we kept a human in the loop for the parts that should not be automated.&lt;/p&gt;

&lt;h2&gt;
  
  
  The loop
&lt;/h2&gt;

&lt;p&gt;A user opens our SDK widget on the site they're frustrated with. They type one sentence: &lt;em&gt;"login button does nothing on my iPad."&lt;/em&gt; They hit send.&lt;/p&gt;

&lt;p&gt;What happens next, in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Capture.&lt;/strong&gt; The report lands in Mushi Mushi's &lt;code&gt;reports&lt;/code&gt; table with the user's text, the page URL, the viewport, the browser, and the last console and network events. If the app captures screenshots, one comes along, and the reporter sees it and can remove it before sending. If the app turned session replay on, up to the last 30 seconds of it come too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Filter.&lt;/strong&gt; Claude Haiku 4.5 makes a cheap first pass and drops spam and noise. GPT-6 Luna is its fallback.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Group.&lt;/strong&gt; Right after that first pass, the report is embedded and compared with earlier ones by cosine similarity in pgvector. Above 0.82 (the default; each project can change it in Settings), it joins the existing group instead of becoming a new row. Once the group has a diagnosis, a new report in it reuses that diagnosis, so it costs no new model call and does not count toward the monthly diagnosis allowance. The report list shows each group's blast radius: how many reports, distinct reporters and sessions it has. Until October 2026 the per-project threshold was saved but never read, so every project got 0.82.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Diagnose.&lt;/strong&gt; Reports that pass go to a second model: Claude Sonnet 5.5 by default, with GPT-6.1 Sol as the fallback when there is no usable Claude key, or Claude refuses or returns unusable output. It writes a category, a severity, the likely component, a plain-English summary and a confidence score. If the repo is indexed, it reads code retrieved from it while it does this, so it is more likely to name a file it actually saw than to guess one. An organization can also switch its AI to OpenAI only. Mine has run that way since 31 October 2026, when my Claude credits ran low, so my own reports are diagnosed by GPT-6.1 Sol.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fix.&lt;/strong&gt; Auto-fix is off for each app until someone turns it on. Then &lt;strong&gt;Dispatch fix&lt;/strong&gt; on the report starts the fix worker. It reads whole files from the repo at the commit the PR will branch from, and asks the model (Sonnet 5.5 by default, GPT-6.1 Sol as the fallback) for find-and-replace edits through a typed schema. The model gets no tools and no shell. If the edits don't apply, the worker retries once with the exact error, then stops. If any one file's diff would pass 200 changed lines (a per-project setting), the run aborts. Otherwise it opens a &lt;strong&gt;draft&lt;/strong&gt; PR. The model calls are traced in Langfuse. Instead of the built-in worker, the fix can also go to a cloud coding agent (Cursor's, or GitHub's), which opens the PR itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Review.&lt;/strong&gt; If the app has an AI key of its own, a second model reviews the PR: Claude Opus 5.5 on a Claude key, otherwise GPT-6.1 Sol on an OpenAI key. It never runs on Mushi Mushi's platform key; without the app's own key the review is skipped. The verdict is merge, fix first or hold. On merge or fix first the draft is marked ready, so a repo that skips CI on drafts runs its checks then. A hold stays a draft.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Merge.&lt;/strong&gt; A person merges from the console, or adds the PR to the merge queue, which updates the branch and waits for CI. By default the queue batches an app's approved PRs into a release train and merges them together; an app can switch to one PR at a time. On merge, the report is marked fixed, a linked Sentry issue is resolved when the report came from Sentry, and the person who reported the bug gets a "fixed" notice when their report can receive one.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Separately, an LLM-as-Judge job re-reads classified reports in batches. It scores category accuracy, severity calibration, component tagging and repro quality from 0 to 1, and records whether it agrees overall. The scores are stored on the report and decide which candidate classifier prompt gets promoted. They never change a report's status.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why we kept the human in the loop
&lt;/h2&gt;

&lt;p&gt;The full PDCA loop &lt;em&gt;could&lt;/em&gt; be fully autonomous. The agent has the context, the judge has the receipts, and a draft PR is technically reversible.&lt;/p&gt;

&lt;p&gt;We chose not to do that, for two reasons.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. The judge is good. The judge is not infallible.
&lt;/h3&gt;

&lt;p&gt;Our LLM-as-Judge mostly agrees with the classifier. Mostly is not "auto-merge" good. The disagreements include the exact cases that an autonomous loop would silently ship wrong fixes for: ambiguous severity, mis-routed component paths, and duplicate-fix attempts that mask the real bug.&lt;/p&gt;

&lt;p&gt;Keeping the operator as the merge gate let the rest of the loop get faster without removing the safety net.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. The user's trust is the moat.
&lt;/h3&gt;

&lt;p&gt;A user who reports a bug and watches the team respond — even with a one-line "we shipped this" — develops a kind of operational trust that tells them "this app is alive." That trust survives outages and slow weeks. It does not survive an automated reply that ships the wrong fix and silently breaks something else.&lt;/p&gt;

&lt;p&gt;So the human stays in the loop. The loop just gets shorter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Update (2026-11-03).&lt;/strong&gt; The gate moved. On 30 October my eight apps had 607 open agent fix PRs for 584 reports. About 250 had a "merge" verdict from the review, and none of them was in the queue, because the queue waited for a person to add each one. That person was me, and I didn't. So each app now has a &lt;strong&gt;Decide for me&lt;/strong&gt; switch, off by default. When it is on, Mushi Mushi queues the PRs the review approved and closes the ones it will not merge. The release train still requires the "merge" verdict on the exact commit and green CI before anything merges. A hold can only be overridden by a person in the console, with a reason that goes in the audit log. Verified merge, where an agent PR merges only with a test that fails without the fix and passes with it, is still in progress (&lt;a href="https://kensaur.us/mushi-mushi/docs/roadmap" rel="noopener noreferrer"&gt;roadmap&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  What's actually open source
&lt;/h2&gt;

&lt;p&gt;Every SDK is MIT: web, React, Vue, Svelte, Angular, React Native, Capacitor and Node on npm, plus the CLI and the MCP server. iOS, Android and Flutter SDKs are previews in the repo. It is all on &lt;a href="https://github.com/kensaurus/mushi-mushi" rel="noopener noreferrer"&gt;github.com/kensaurus/mushi-mushi&lt;/a&gt;. The backend (Supabase Edge Functions + Postgres + pgvector for the embeddings) and the admin console are AGPLv3, which is OSI open source: self-host it free, fork it, modify it for your own org. Offer a modified server as a hosted service and you publish your changes (§13) or take a commercial license.&lt;/p&gt;

&lt;p&gt;It's open-core: the core is open source (AGPLv3). What costs money is the hosted service beyond its free plan, and a small source-available enterprise edition (SSO, audit export, retention policies, region pinning) that a solo builder never needs. The hosted Free plan covers one app and 100 diagnoses a month; Builder is $29 a month and Studio $79 (&lt;a href="https://kensaur.us/mushi-mushi/docs/pricing" rel="noopener noreferrer"&gt;pricing&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;If you'd rather see than read:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sample diagnosis&lt;/strong&gt; (no signup): &lt;a href="https://kensaur.us/mushi-mushi/docs/demo/diagnosis" rel="noopener noreferrer"&gt;kensaur.us/mushi-mushi/docs/demo/diagnosis&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One-command install&lt;/strong&gt; (any web framework):
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;  npx mushi-mushi
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Source + self-host docs&lt;/strong&gt;: &lt;a href="https://github.com/kensaurus/mushi-mushi" rel="noopener noreferrer"&gt;github.com/kensaurus/mushi-mushi&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If Mushi Mushi's voice (slightly Japanese, slightly bug-themed, &lt;em&gt;very&lt;/em&gt; opinionated about the difference between an error and a bug) clicks for you, the easiest way to support the project is to &lt;a href="https://github.com/kensaurus/mushi-mushi/stargazers" rel="noopener noreferrer"&gt;star the repo&lt;/a&gt;. The next dev who needs this finds it that way.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>ai</category>
      <category>devtools</category>
      <category>productivity</category>
    </item>
    <item>
      <title>A 147,000-word interactive book as a free SPA, and the stack behind it</title>
      <dc:creator>Kenji Sakuramoto</dc:creator>
      <pubDate>Thu, 23 Apr 2026 07:48:48 +0000</pubDate>
      <link>https://dev.to/kenji_sakuramoto_d72920b2/i-shipped-a-147000-word-interactive-book-as-a-free-spa-heres-the-stack-55i0</link>
      <guid>https://dev.to/kenji_sakuramoto_d72920b2/i-shipped-a-147000-word-interactive-book-as-a-free-spa-heres-the-stack-55i0</guid>
      <description>&lt;p&gt;&lt;em&gt;&lt;a href="https://kensaur.us/the-wanting-mind/" rel="noopener noreferrer"&gt;the wanting mind&lt;/a&gt;&lt;/em&gt; is a free 147,000-word interactive book: no ads, no account, in English, Japanese, Chinese and Thai. It is a React 19 SPA with Supabase behind it and Capacitor for the mobile apps. ElevenLabs reads the chapters, fal.ai made the chapter art, Veo made the hero videos, and Claude translated the chapters. This post covers what worked, what I'd do again, and the stack.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it is
&lt;/h2&gt;

&lt;p&gt;A long-form book about &lt;em&gt;why we're never satisfied&lt;/em&gt;: the economics, neuroscience and Buddhist philosophy of &lt;code&gt;taṇhā&lt;/code&gt; ("thirst"). At 147,000 words, reading it cover to cover takes about eleven hours. Most people won't do that, so the app gives them other ways in:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Narrated chapters.&lt;/strong&gt; ElevenLabs voices, with 12 narrator personas.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A 3D knowledge graph&lt;/strong&gt; of the concepts, people and chapters, built with &lt;code&gt;react-three-fiber&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;22 simulations&lt;/strong&gt; you can play with: the Easterlin paradox, Minsky moments, &lt;code&gt;r &amp;gt; g&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Four languages&lt;/strong&gt;, switchable while you read.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An "essence" view&lt;/strong&gt;: each chapter compressed to one quote over its chapter image, shareable as a card.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Read it: &lt;a href="https://kensaur.us/the-wanting-mind/" rel="noopener noreferrer"&gt;kensaur.us/the-wanting-mind&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Choice&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Framework&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;React 19 + Vite 8&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Fast dev loop; a prerender step after the build for SEO&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Language&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;TypeScript 6&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The book's content lives in typed data files, so a typo in a chapter reference fails the build&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Styling&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Tailwind CSS 4&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Design tokens in one file, dark mode with &lt;code&gt;dark:&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3D&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;reagraph&lt;/strong&gt; on react-three-fiber&lt;/td&gt;
&lt;td&gt;The knowledge graph and the closing constellation scene&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mobile&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Capacitor 8&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The same React build for iOS and Android&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auth + DB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Supabase&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Accounts and cloud sync, with row-level security&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Audio&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;ElevenLabs&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;English narration of all 36 chapters, about 20 hours&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Images&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;fal.ai&lt;/strong&gt; (nano-banana-2)&lt;/td&gt;
&lt;td&gt;Chapter essence images, batch-generated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Video&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Veo 3.1&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;35 looping hero videos and a cover video&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Translation&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Claude&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Chapter by chapter, into Japanese, Chinese and Thai&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hosting&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;S3 + CloudFront&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Static files; no server to keep running&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Errors&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Sentry&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Errors on readers' devices reach me without anyone having to report them&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Push&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Firebase Cloud Messaging&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Notifications to signed-in readers' devices&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Three things that mattered more than I expected
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Prerendering chapter HTML
&lt;/h3&gt;

&lt;p&gt;An SPA serves one HTML shell for every route. Google can render JavaScript, but other crawlers and link previews often read only that shell. So &lt;code&gt;scripts/prerender-chapter-html.mjs&lt;/code&gt; runs after &lt;code&gt;vite build&lt;/code&gt;. It copies &lt;code&gt;dist/index.html&lt;/code&gt; once for every chapter and feature page in all four locales, and gives each copy its own &lt;code&gt;&amp;lt;title&amp;gt;&lt;/code&gt;, description, canonical URL and OpenGraph tags. The deploy uploads each file to the matching S3 key, so a crawler or link preview asking for &lt;code&gt;/ja/ch-4&lt;/code&gt; gets that chapter's title and description in the first response. The chapter text itself still renders in the browser.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Quote cards drawn in the browser
&lt;/h3&gt;

&lt;p&gt;Every chapter has an "essence", one sentence that sums it up. &lt;code&gt;generateQuoteCardBlob&lt;/code&gt; in &lt;code&gt;src/components/QuoteCard.tsx&lt;/code&gt; draws it as a 1080×1080 card on a Canvas: the chapter image as the backdrop, a translucent overlay, the quote, and the book's name. It runs in the browser with no server. Readers can save the card or share it with &lt;code&gt;navigator.share()&lt;/code&gt; on mobile.&lt;/p&gt;

&lt;p&gt;For social posts I call the same function from a Playwright script (&lt;code&gt;scripts/generate-quote-cards.mjs&lt;/code&gt;), so the cards I post are the cards readers get.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Store listings from one file
&lt;/h3&gt;

&lt;p&gt;The store listings exist in four locales on both stores, and editing them by hand is slow and easy to get wrong. Two scripts do it now:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;scripts/asc-update-listing.mjs&lt;/code&gt; signs a JWT with the App Store Connect API key, reads the listing copy from &lt;code&gt;docs/store-listings.md&lt;/code&gt;, and pushes name, subtitle, promotional text, description and keywords for each locale.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;scripts/play-update-listing.mjs&lt;/code&gt; does the same through the Google Play Publisher API with a service account.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;docs/store-listings.md&lt;/code&gt; is the one place the listing copy is edited.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you build something similar
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Pick one mobile shell (Capacitor or Tauri) and stay with it.&lt;/li&gt;
&lt;li&gt;Prerender before you write growth posts. The posts link to pages, and the pages should show their content to whatever opens them.&lt;/li&gt;
&lt;li&gt;Add error reporting on the first day, even for a personal project.&lt;/li&gt;
&lt;li&gt;Generate marketing images from the app's own code, so they match what readers see.&lt;/li&gt;
&lt;li&gt;Keep the content (chapters, essences, graph nodes) as plain TypeScript files in git. For a single author, that is enough of a CMS.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;The source isn't public yet. If a translation reads wrong, the feedback button in the app reaches me directly.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I wrote this post with help from an AI assistant. The figures come from the project's repository.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>react</category>
      <category>webdev</category>
      <category>ai</category>
      <category>javascript</category>
    </item>
  </channel>
</rss>
