<?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: OuterSpaceHobo</title>
    <description>The latest articles on DEV Community by OuterSpaceHobo (@spacehobo).</description>
    <link>https://dev.to/spacehobo</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%2F1060198%2F7c9e588a-f270-4961-b7bb-d2e8dcf32aee.png</url>
      <title>DEV Community: OuterSpaceHobo</title>
      <link>https://dev.to/spacehobo</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/spacehobo"/>
    <language>en</language>
    <item>
      <title>Dry-run permissions: a button that knows why it's disabled</title>
      <dc:creator>OuterSpaceHobo</dc:creator>
      <pubDate>Mon, 12 Oct 2026 06:42:17 +0000</pubDate>
      <link>https://dev.to/spacehobo/dry-run-permissions-a-button-that-knows-why-its-disabled-ago</link>
      <guid>https://dev.to/spacehobo/dry-run-permissions-a-button-that-knows-why-its-disabled-ago</guid>
      <description>&lt;p&gt;In this post I talk about a small pattern from my side project that turned out to be the useful piece of architecture in it: asking the server "could I do this?" before doing it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Some context.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I'm building &lt;a href="https://daihon.app" rel="noopener noreferrer"&gt;daihon.app&lt;/a&gt;, a trainer for spoken Japanese: you go through a dialogue line by line, say your lines out loud, and the browser checks your pronunciation.&lt;/p&gt;

&lt;p&gt;Shadowing apps already exist, so the honest reason for building another one was different. I wanted real full stack experience on a stack I'd pick for a serious product: Next.js 16 with the App Router, strict TypeScript, PostgreSQL with Drizzle, my own JWT auth with httpOnly cookies, Zod on every boundary, and a lot of tests. A product with roles, limits and an admin panel is a good excuse to get all of that right.&lt;/p&gt;

&lt;p&gt;And roles plus limits is exactly where UI code usually goes bad.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The problem.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;There are four kinds of visitors: an anonymous visitor, a guest (no sign up, three trial dialogues), a registered user with a monthly quota, and an admin. Plus states: a banned user, an unverified email, a quota that resets on the 1st.&lt;/p&gt;

&lt;p&gt;The default way to handle that is to sprinkle checks over components:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;AdminLink&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt; &lt;span class="na"&gt;disabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isGuest&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;trialsUsed&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Start&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every one of these is a copy of a rule that also lives on the server. Copies drift. One day the server lets a guest start a dialogue and the button says no, or worse, the button says yes and the request fails with a 403 nobody explains.&lt;/p&gt;

&lt;p&gt;So I set one hard rule for the project: &lt;strong&gt;no component decides by itself whether an action is available. It asks the server.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dry-run.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every protected route in the app is declared with a chain of guards: is the actor authenticated, not banned, within quota, and so on. The idea of dry-run is simple: run the same chain, but stop before the handler.&lt;/p&gt;

&lt;p&gt;There are two ways in:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;an &lt;code&gt;X-Dry-Run&lt;/code&gt; header on any route, which returns the verdict instead of executing;&lt;/li&gt;
&lt;li&gt;a batch endpoint, &lt;code&gt;POST /api/dry-run/batch&lt;/code&gt;, that answers up to 20 checks in one request.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both call the same function. That's important, because if the batch endpoint had its own logic, "direct check says yes, batch says no" would become possible.&lt;/p&gt;

&lt;p&gt;The core is short:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;evaluateDryRun&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;denial&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;guardMeta&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;guards&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;guard&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;allowed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;derived&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;deniedStateFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nx"&gt;denial&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stateOf&lt;/span&gt;&lt;span class="p"&gt;?.(&lt;/span&gt;&lt;span class="nx"&gt;derived&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;derived&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nx"&gt;guardMeta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// the dry-run itself failed: disabled, never enabled&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;result&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;shape&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;allowed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;DRY_RUN_UNAVAILABLE&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="na"&gt;failed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;denial&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;result&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;shape&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;allowed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;enabled&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="na"&gt;failed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;result&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;shape&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;allowed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;denial&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;guardMeta&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="na"&gt;failed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The guards in &lt;code&gt;entry.guards&lt;/code&gt; are not "similar to" the ones on the real route. They are the same array. The route declares an action key from a registry instead of its own access rules, and takes its chain from there. So "dry-run said yes, the real call said no" can't be expressed in code at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What the UI gets back.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd128ruazlrbu16rlybns.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd128ruazlrbu16rlybns.png" alt=" " width="560" height="440"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Not just a boolean. The response is a state plus a reason:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST /api/attempts
X-Dry-Run: state

→ 200 {
  allowed: false,
  state: "disabled",            // enabled | disabled | hidden
  code: "GUEST_LIMIT_REACHED",
  messageKey: "dryRun.guestLimit",
  meta: { length: "short", limit: 3, used: 3, unlockedBy: "registration" }
}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The status is 200 even when the answer is no. The check succeeded, the action is just not available. Otherwise every hidden element on a page would show up as a 403 in the console and in monitoring.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;hidden&lt;/code&gt; vs &lt;code&gt;disabled&lt;/code&gt; follows one rule: &lt;strong&gt;no permission by role means hidden, no possibility by state means disabled.&lt;/strong&gt; A guest never sees the admin link (showing it would leak the structure of the product). But a guest who used up three trial dialogues sees the Start button, disabled, with an explanation: "0 of 3 left, sign up to continue".&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;meta&lt;/code&gt; part is what makes the explanation possible. The registry knows the action, but only the guard knows the actor: how much is left and when it resets. So the guard returns those numbers with its denial, and the client builds the message from them.&lt;/p&gt;

&lt;p&gt;The mapping from error code to state lives in one table on the project, shared by server and client. A second copy on the client would drift silently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The component.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;On the client a component asks by action key, not by URL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Gated&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"attempt.start.short"&lt;/span&gt; &lt;span class="na"&gt;skeleton&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ButtonSkeleton&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;messageKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;meta&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt; &lt;span class="na"&gt;disabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;enabled&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;hint&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;messageKey&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;t&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;messageKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;t&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dialogue.start&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Gated&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;hidden&lt;/code&gt; renders nothing;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;disabled&lt;/code&gt; renders the element inactive, with the reason next to it. For the Start button that's a caption right under it: a tooltip doesn't exist on touch screens or for keyboard users, and this is the one reason people really need to read;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;enabled&lt;/code&gt; is a normal render.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A provider collects all keys requested on the page and sends them in &lt;strong&gt;one&lt;/strong&gt; batch. Two components with the same key share one check. A key that appears later (conditional render) gets added to the next batch with a 50 ms debounce, so late subscribers don't each fire their own request.&lt;/p&gt;

&lt;p&gt;There is no polling. Verdicts get invalidated on events: login, logout, starting an attempt, role change. Without that, the Start button would happily stay enabled after the guest used their last trial.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Skeletons that don't blink.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;While a verdict is pending, &lt;code&gt;Gated&lt;/code&gt; shows a skeleton of the same shape and size as the final element, so the page doesn't jump.&lt;/p&gt;

&lt;p&gt;But a skeleton shown right away has its own problem: on a fast connection it turns into a grey flash for a few dozen milliseconds, which looks broken. So there are two thresholds: if the answer comes faster than 150 ms, no skeleton at all. If the skeleton did appear, it stays for at least 300 ms. One small hook does that for the whole project, not only for dry-run.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fail closed.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;What if the database is down and a guard throws? The catch block above answers that: the dry-run result is &lt;code&gt;disabled&lt;/code&gt; with an "unavailable" message. Never &lt;code&gt;enabled&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The project has a general rule for degradation: fail open for convenience, fail closed for access. A broken check is allowed to make a button inactive. It is never allowed to make it active.&lt;/p&gt;

&lt;p&gt;One more case from the same family: an &lt;code&gt;X-Dry-Run&lt;/code&gt; header with an unknown value returns a validation error. Ignoring an unknown header would mean executing a real mutation where the client asked for a check.&lt;/p&gt;

&lt;p&gt;On the client a network error is not the same as a denial either. The hook returns a tagged union, &lt;code&gt;pending | ready | error&lt;/code&gt;, and on &lt;code&gt;error&lt;/code&gt; the element is disabled with a connection message instead of silently disappearing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Whole sections.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For the admin panel there is a boundary around the whole section:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;DryRunBoundary&lt;/span&gt;
  &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"admin.summary.view"&lt;/span&gt;
  &lt;span class="na"&gt;skeleton&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;PageSkeleton&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;denied&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;messageKey&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;AccessDenied&lt;/span&gt; &lt;span class="na"&gt;messageKey&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;messageKey&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;DryRunBoundary&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;hidden&lt;/code&gt; turns into a 404 (the section doesn't confirm it exists), &lt;code&gt;disabled&lt;/code&gt; into an "access denied" screen.&lt;/p&gt;

&lt;p&gt;And here is the part I want to underline: &lt;strong&gt;dry-run is UX, not security.&lt;/strong&gt; The admin layout has its own server check, and every admin endpoint has its guards. Dry-run only makes sure the UI tells the truth about what those guards will say.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cheap on purpose.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Dry-run checks are not written to the activity log. Every user action in the app is logged (it's a product requirement), but a log row per check would make the fastest growing table grow on every page view.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Testing it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Because everything goes through one registry, the tests are generic:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;for every action, a dry-run doesn't change the database (snapshot before and after, through both the header and the batch);&lt;/li&gt;
&lt;li&gt;dry-run and the real call give consistent verdicts. This is generated from the registry, not written by hand;&lt;/li&gt;
&lt;li&gt;completeness: an action key without a route, or a route that isn't in the registry or the explicit public list, fails the test run.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The first two prove the mechanism works for what is in the registry. The third proves the registry has everything it should.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Was it worth it?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Mostly yes. Adding a role or a limit now means touching guards and the registry, not hunting through components. The UI can always say &lt;em&gt;why&lt;/em&gt; something isn't available, and that's the part users notice.&lt;/p&gt;

&lt;p&gt;The cost is one extra request per page (batched) and some discipline: every new action needs a registry entry. Writing a test that fails when you forget it is what keeps that discipline from being a promise.&lt;/p&gt;

&lt;p&gt;The app is live at &lt;a href="https://daihon.app" rel="noopener noreferrer"&gt;daihon.app&lt;/a&gt;: open a dialogue as a guest, finish three, and you'll see the disabled Start button explaining itself.&lt;/p&gt;

&lt;p&gt;Thank you for reading. Feedback is highly appreciated!&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>webdev</category>
      <category>architecture</category>
      <category>typescript</category>
    </item>
    <item>
      <title>Why the dialogues in my Japanese speaking trainer aren't made up</title>
      <dc:creator>OuterSpaceHobo</dc:creator>
      <pubDate>Tue, 06 Oct 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/spacehobo/why-the-dialogues-in-my-japanese-speaking-trainer-arent-made-up-21o3</link>
      <guid>https://dev.to/spacehobo/why-the-dialogues-in-my-japanese-speaking-trainer-arent-made-up-21o3</guid>
      <description>&lt;p&gt;In this post I talk about how dialogues for my Japanese speaking trainer are put together, and why "just ask a model to write a conversation" was a no go.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Some context.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For a few years now I have been learning Japanese and even passed JLPT N2 this summer. At some point speaking became the weakest part. Reading and listening can be done alone with any amount of material. Speaking needs a partner, or at least a script worth repeating out loud.&lt;/p&gt;

&lt;p&gt;Shadowing apps exist, and so do AI chat partners. With the chat option I kept running into the same thing: you talk with a model about who knows what, and you have no idea how natural its Japanese is. Plus, as a learner, you are the last person who could tell.&lt;/p&gt;

&lt;p&gt;What I wanted was boring in a good way: short model dialogues, like the ones in a decent textbook, for everyday situations, at N5 to N3 level. One role is yours. You read it out loud, the browser recognizes your speech, and you only move on once the line is said correctly.&lt;/p&gt;

&lt;p&gt;So I built &lt;a href="https://daihon.app" rel="noopener noreferrer"&gt;daihon.app&lt;/a&gt; (台本, "script"). The trainer itself is a separate story. This one is about the content.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The problem with dialogues.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;There are no native speakers on the project, and no native proofreading is coming. In a speaking trainer an unnatural line isn't just a typo: you drill it until it's automatic, so you memorize the mistake together with the pronunciation.&lt;/p&gt;

&lt;p&gt;So "sounds natural to me" can't be the quality bar. Naturalness has to rest on something you can check.&lt;/p&gt;

&lt;p&gt;And it breaks on two separate levels:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;the line:&lt;/strong&gt; do people actually say this?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;the flow:&lt;/strong&gt; do people say this &lt;em&gt;at this point&lt;/em&gt; of a conversation?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Borrowing good sentences only fixes the first one. A dialogue stitched together from perfect sentences is a Frankenstein: every part is real, the whole thing is dead. Which is why most of the work goes into the skeleton, not into the lines.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Start from a real conversation.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A dialogue starts as a sequence of moves taken from an actual conversation. Roughly like this, for a restaurant call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;staff:    greeting + asks which area
customer: area and number of people, in one go
staff:    two options with prices
customer: picks one + asks about the time
staff:    confirms + asks about smoking
customer: short answer, doesn't repeat the question
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is exactly the kind of thing you don't get by inventing. On move four a textbook author writes a long polite question. A real customer says 六時でも大丈夫ですか and stops.&lt;/p&gt;

&lt;p&gt;The sources are public research corpora of everyday Japanese and open-licensed sentence collections. All of them are credited. Some of them don't allow taking text at all, and that's fine: the &lt;em&gt;structure&lt;/em&gt; of a conversation (who speaks when, what kind of move, how long) is a fact, not someone's text. So the closed corpora work where they matter most, on the skeleton, and actual wording only comes from sources whose license allows it.&lt;/p&gt;

&lt;p&gt;One thing learned the hard way: &lt;strong&gt;a drill is not a conversation.&lt;/strong&gt; Textbook exercises train one move, so they all have the same shape: question, answer, clarification, thanks. My first batch used them as skeletons and came out as ten dialogues you could not tell apart. Textbooks are still useful as a list of situations and vocabulary, just not as a source of flow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What a line has to prove.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;There is no such thing as an unchecked line. Each one carries a note of where it came from, and there are three kinds:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Taken as is&lt;/strong&gt; from a source that allows it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A slot swap&lt;/strong&gt; in a real sentence pattern. 何名様ですか becomes 何時からですか: the construction is real, only the slot changes. This is how a line gets adjusted to the level without breaking grammar.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Written by me&lt;/strong&gt;, but the phrase has to show up in a corpus search. This catches calques and "textbook Japanese": if the phrase doesn't occur anywhere, people probably don't say it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The third kind is a necessity. At N5 about half the lines need simplifying, and the simplified version still has to be checkable.&lt;/p&gt;

&lt;p&gt;Zero hits doesn't always mean "wrong", though. Sometimes the construction is fine and only a specific word pair is rare. Sometimes you searched for the wrong spelling: one of the corpora writes numbers with digits, so 三十分 gives zero and ３０分 gives over a hundred. And sometimes zero means exactly what it looks like. One line had every word backed by a source, and still the whole frame (a medicine that 咳が止まる) wasn't something people say, while the neighbouring frame with に効く was common. Checking words one by one can't see that. Checking the whole frame sees it immediately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rules a text has to pass.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A few rules came out of this that I now check every dialogue against:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Short reactions are real lines.&lt;/strong&gt; そうですか, なるほど, ええ make up a big share of real conversation, so they stay. (With one technical catch: the speech synthesizer returns silence for a lonely ええ。, so a reaction always comes with something after it.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ellipsis where Japanese uses ellipsis.&lt;/strong&gt; The answer doesn't repeat the question, and 私は only appears for contrast. These two give away textbook Japanese from the very first line. The echo is surprisingly easy to miss by eye, because you only see it in the pair, not in the line. Once you strip the final か from the question and compare the predicates, it shows up right away.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not every partner line is a question.&lt;/strong&gt; Strictly alternating question/answer is the shape of an exercise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A turn is a move, not an answer.&lt;/strong&gt; In real talk people usually say two things per turn: they answer and add a reason, a caveat, a counter question.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The rule I didn't see coming: one reading only.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This one is specific to a trainer that checks your speech.&lt;/p&gt;

&lt;p&gt;Your spoken line is compared against the expected reading. If a word has two accepted readings, you can say the second one, be completely right, and still fail the line.&lt;/p&gt;

&lt;p&gt;Numbers are the main suspects. 四十分 is fine as both よんじっぷん and よんじゅっぷん. 七時 is both しちじ and ななじ. 船便 is both ふなびん and せんびん. Destination 〜行き is both ゆき and いき, and that one sneaks in with any place name.&lt;/p&gt;

&lt;p&gt;Fix: move the time to a neighbour that has one reading (六時半 instead of 七時). The scene doesn't change at all, and the line becomes passable for everyone who says it correctly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Writing in several passes.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Drafts are written with an LLM, but not in one go. Each dialogue goes through several separate passes: collect the material and the skeleton, write the text, review, translate. Each pass starts fresh.&lt;/p&gt;

&lt;p&gt;The fresh start for the review is the whole point. A session that just wrote a line also remembers why it wrote it, and it won't argue with itself. A clean pass reads the dialogue the way a reader does, and checks the flow, the tone and whether the scene is believable. The material from the first pass stays as the reference, so the review can still answer "was this intended?".&lt;/p&gt;

&lt;p&gt;Translations come last and only touch translations. The Japanese text is final by then.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Audio.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The partner's lines are voiced with an open source offline TTS, generated once and stored, not requested on the fly. Two speakers, so the roles are easy to tell apart by ear. Getting the loudness even was its own small adventure: the standard one-step normalization didn't reach the target on lines under three seconds, and neighbouring lines differed by about 6 LU. Measuring each file and applying the exact gain fixed it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where it is now.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;30 dialogues so far, 10 each for N5, N4 and N3. The pipeline is slow on purpose: a dialogue that takes a few sessions to make is still cheaper than a learner memorizing a sentence nobody says.&lt;/p&gt;

&lt;p&gt;It's still a portfolio project, and I'm sure some lines can be better. If you're a native speaker or a teacher and something sounds off, I'd really like to hear it.&lt;/p&gt;

&lt;p&gt;Try it here: &lt;a href="https://daihon.app" rel="noopener noreferrer"&gt;daihon.app&lt;/a&gt;. No sign up needed for the first three dialogues.&lt;/p&gt;

&lt;p&gt;Thank you for reading. Feedback is highly appreciated!&lt;/p&gt;

</description>
      <category>japanese</category>
      <category>languagelearning</category>
      <category>nlp</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How I made free OCR chrome extension for Japanese language learners.</title>
      <dc:creator>OuterSpaceHobo</dc:creator>
      <pubDate>Thu, 06 Apr 2023 14:26:51 +0000</pubDate>
      <link>https://dev.to/spacehobo/how-i-made-free-ocr-chrome-extension-for-japanese-language-learners-41bm</link>
      <guid>https://dev.to/spacehobo/how-i-made-free-ocr-chrome-extension-for-japanese-language-learners-41bm</guid>
      <description>&lt;p&gt;In this article I talk about how I decided to create my first open source project after completing JS &amp;amp; React courses.&lt;/p&gt;

&lt;p&gt;For some years I have been casually learning Japanese language and in my studies I heavily preferred immersive approach.&lt;/p&gt;

&lt;p&gt;But Japanese language (as well as Chinese &amp;amp; partially Korean) have a pretty high bar for casual reading because not only one have to learn two alphabets, but also memorize at least few hundreds kanji.&lt;/p&gt;

&lt;p&gt;The regular need switch from reading light text/manga (or watching youtube, dorama etc.) to some dictionary tab just to find this one unknown or forgotten kanji was really ruining the experience.&lt;/p&gt;

&lt;p&gt;I discovered some awesome resources like Yomichan (highly recommend checking it out), which recognize text under mouse cursor and provide translation and annotation from given dictionaries.&lt;/p&gt;

&lt;p&gt;Unfortunately none of free options used OCR so that one could check any pic be it manga panel or subtitles in paused video right from the same tab. So I decided to make one myself!&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/SU9TsNvd_OA" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ScanLingua&lt;/strong&gt;(project name proudly accepted from chatGPT suggestions).&lt;/p&gt;

&lt;p&gt;I decided to start with chrome extension and used knowledge, gained from selfstudy and Jad Joubran JS &amp;amp; React courses (cool courses btw).&lt;/p&gt;

&lt;p&gt;This is my of first/experimental and kind of portfolio project, so I tested different approaches and thus it may seem inconsistent, but thats the point.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Project structure.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Features two main parts: servise_worker and content_script, which are separate React Apps.&lt;/p&gt;

&lt;p&gt;servise_worker is responsible for manifest, fetch and main popup render and content_script provides logic and render overlay &amp;amp; popup within current (active) tab.&lt;/p&gt;

&lt;p&gt;content_script is bundled in single file inside servise_worker/public.&lt;/p&gt;

&lt;p&gt;Apps exchange necessary data via chrome messaging with unique IDs like so:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// content_script

async function requestTranslation () {
    const requestId = getRandomInt(100000) 
    const response = await chrome.runtime.sendMessage({type: "request-translation", requestId}); 
    const res = await new Promise((resolve, reject) =&amp;gt; {
        requests.set(requestId, resolve) 
        setTimeout(reject, 60*1000) //60sec
    })
    return res
}

// service_worker

chrome.runtime.onMessage.addListener(gotMessage)

async function gotMessage(request, sendResponse) {
    if (request.type == "request-translation") {
        const rawTranslation = await translateZone(visionText)
        const translation = await unEscape(rawTranslation)
        await sendTranslation(request.requestId, translation)
    }
}

async function sendTranslation(requestId, translation) {
    const [tab] = await chrome.tabs.query({active: true, lastFocusedWindow: true});
    const response = await chrome.tabs.sendMessage(tab.id, {type: "your-translation", requestId, translation});
} 
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;strong&gt;Active tab overlay and canvas.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I started to implement crop zone selection functionality with creating main overlay div and five static children (top, bottom, right, left, zone).&lt;/p&gt;

&lt;p&gt;And then passed on mousedown and on mousemove coords (x1, y1, x2, y2) to children divs sides so they change dynamically.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fx5nt4bpmbrdb351044dx.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fx5nt4bpmbrdb351044dx.jpeg" alt=" " width="799" height="499"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;To capture selected zone on mouseup I used chrome captureVisibleTab and drew OffscreenCanvas on it with zone div coords.&lt;/p&gt;

&lt;p&gt;I rushed to convert data to base64 and finally call OCR API, but the results were incorrect, and visualized zone image showed this:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0jvi8opcud5avwt4csb1.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0jvi8opcud5avwt4csb1.jpeg" alt=" " width="799" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fa1ae5lepjanr5zlssd0m.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fa1ae5lepjanr5zlssd0m.jpeg" alt=" " width="796" height="296"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The zone was off and looked zoomed in. At first it was frustrating but thanks to that Window.devicePixelRatio was discovered along with the source of problem — display ratio 2 on Mac. I’m kinda glad I found this early because be my display ratio 1 it would pass the radar just to appear later on other displays.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Styling.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Another problem was the need isolate active tab popup styles from the page in was injected into. Upon some research I decided between creating an iFrame or manually overriding conflicting styles.&lt;/p&gt;

&lt;p&gt;Considering the active tab popup will host only one button and some styled text inside, iFrame looked like overkill so I went with override option. I also tried using React Styled Components since it is mandatory to bundle content_script in one .js file.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Used APIs.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For OCR &amp;amp; translation ScanLingua uses Google vision and translation APIs.&lt;/p&gt;

&lt;p&gt;This APIs are not free, but provide 1000 free vision requests and 500 000 free character translation rep month which should suffice for personal use. One just have to create appropriate API key and pay attention for APIs usage to not exceed free monthly limits (key setup instruction provided).&lt;/p&gt;

&lt;p&gt;To help User track key usage simple hotkey counter is set at extension Home tab.&lt;/p&gt;

&lt;p&gt;For Kanji annotation I picked awesome free open source dictionary Jotoba (it also takes data from various resources, such as KanjiDic, Jisho and many other; since Jotoba API is free its availability is not guaranteed).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Next steps.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It is still raw &amp;amp; buggy, but already usable in general. Proof of concept release pushed to store:&lt;br&gt;
&lt;/p&gt;
&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
        &lt;div class="c-embed__cover"&gt;
          &lt;a href="https://chromewebstore.google.com/detail/scanlingua/mkkiocfjoeoolffbpldkglpfclmdiafn" class="c-link align-middle" rel="noopener noreferrer"&gt;
            &lt;img alt="" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Flh3.googleusercontent.com%2FtNIkh7lltPVRocVw1UaLbIKmHDXewSiKeBoxWa6mwi7MT_evXCplMGQM-3LozgvReEe7DpeEoWS1-0F-OxN0e61J3A%3Ds128-rj-sc0x00ffffff" height="128" class="m-0" width="128"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="c-embed__body"&gt;
        &lt;h2 class="fs-xl lh-tight"&gt;
          &lt;a href="https://chromewebstore.google.com/detail/scanlingua/mkkiocfjoeoolffbpldkglpfclmdiafn" rel="noopener noreferrer" class="c-link"&gt;
            ScanLingua - Chrome Web Store
          &lt;/a&gt;
        &lt;/h2&gt;
          &lt;p class="truncate-at-3"&gt;
            Scan, recognize and translate parts of your screen.
          &lt;/p&gt;
        &lt;div class="color-secondary fs-s flex items-center"&gt;
            &lt;img alt="favicon" class="c-embed__favicon m-0 mr-2 radius-0" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fssl.gstatic.com%2Fchrome%2Fwebstore%2Fimages%2Ficon_48px.png" width="48" height="48"&gt;
          chromewebstore.google.com
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;



&lt;p&gt;Repo:&lt;br&gt;
&lt;/p&gt;
&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/OuterSpaceHobo" rel="noopener noreferrer"&gt;
        OuterSpaceHobo
      &lt;/a&gt; / &lt;a href="https://github.com/OuterSpaceHobo/ScanLingua" rel="noopener noreferrer"&gt;
        ScanLingua
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Free open source chrome extension for immersive japanese, chinese and korean language learners.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;ScanLingua &lt;a rel="noopener noreferrer nofollow" href="https://user-images.githubusercontent.com/116193464/229344421-e508bdde-c1dd-428a-85f2-58578a05060c.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fuser-images.githubusercontent.com%2F116193464%2F229344421-e508bdde-c1dd-428a-85f2-58578a05060c.png" alt="icon-32"&gt;&lt;/a&gt;
&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;ScanLingua&amp;nbsp;provides Japanese, Chinese and Korean text recognition of selected screen areas with translation, kanji annotation and audio.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Check on subtitles, manga panels or any CJK text;&lt;/li&gt;
&lt;li&gt;Edit recognized text and alter annotations;&lt;/li&gt;
&lt;li&gt;Create and export cards to Anki via Anki-connect;&lt;/li&gt;
&lt;li&gt;Free and open source, secure by design;&lt;/li&gt;
&lt;li&gt;Packed with local OCR engine and dictionaries.&lt;/li&gt;
&lt;li&gt;Interface localization for en, es, fr, ja, ko, pt, th, tr, vi, zh.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Demo video: &lt;a href="https://www.youtube.com/watch?v=u-YYc9RSiXo" rel="nofollow noopener noreferrer"&gt;https://www.youtube.com/watch?v=u-YYc9RSiXo&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;ScanLingua&amp;nbsp;uses Baidu's PaggleOCR (PP-OCRv5) for recognition and KANJIDIC2, CC-CEDICT, CC-KEDICT dictionaries, licensed under 'CC BY-SA 4.0', for characters annotation. Translation and audio (TTS) are handled by free native chrome APIs.&lt;/p&gt;
&lt;p&gt;To-Do list:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Advanced setting - local model endpoins option;&lt;/li&gt;
&lt;li&gt;Advanced setting - dictionary annotation translation;&lt;/li&gt;
&lt;li&gt;Local usage statistics page and crop history;&lt;/li&gt;
&lt;li&gt;Style themes;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Feedback is highly appreciated!&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Troubleshooting&lt;/h1&gt;
&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;If screenshot hotkey don't work try to set it manually at chrome://extensions/shortcuts for "take-screenshot" command;&lt;/li&gt;
&lt;li&gt;If you just…&lt;/li&gt;
&lt;/ul&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/OuterSpaceHobo/ScanLingua" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;I plan to work on UI, fix errors and expand existing functionality.&lt;/p&gt;

&lt;p&gt;Thank you for reading. Feedback is highly appreciated!&lt;/p&gt;

</description>
      <category>react</category>
      <category>typescript</category>
      <category>beginners</category>
      <category>javascript</category>
    </item>
  </channel>
</rss>
