{
    "componentChunkName": "component---src-templates-blog-post-jsx",
    "path": "/en/blog/coding/website-or-app-yes-hacky-expo-router-guide/",
    "result": {"data":{"site":{"siteMetadata":{"siteUrl":"https://pablo.gg"}},"markdownRemark":{"id":"46c52258-c9b4-535c-9c88-3758ac8a0cc5","excerpt":"One of the annoying little side quests of shipping an app is that Google occasionally forces you to become a website owner too. Back when I first wrote about…","html":"<p>One of the annoying little side quests of shipping an app is that Google occasionally forces you to become a website owner too.</p>\n<p>Back when I <a href=\"/en/blog/coding/musclog-leveraging-my-reactjs-experience-to-build-a-react-native-app/\">first wrote about Musclog in 2024</a>, the website mostly existed because Google wanted it to exist. I needed the privacy policy, the public pages, the whole respectable app developer starter pack, so I quickly spun up a small Next.js site, got the app approved, and moved on with my life.</p>\n<p>That worked right until I had to touch the website again.</p>\n<p>New screenshots? Other repo. New feature copy? Other repo. Legal page update? Other repo. Translation tweak? Other repo.</p>\n<p>None of that was actually hard, which almost made it worse. It was just annoying enough to keep reminding me that I had split one product into two codebases for a reason I no longer respected.</p>\n<p>So now I don’t.</p>\n<p>Musclog’s website lives inside the app repo. Same Expo Router project. Same deploy. <a href=\"https://musclog.app/\" target=\"_blank\" rel=\"noreferrer\">musclog.app</a> is the public site, <a href=\"https://musclog.app/app\" target=\"_blank\" rel=\"noreferrer\">musclog.app/app</a> is the actual web app, and Android + iOS still come from the same codebase.</p>\n<p>Best practice? Questionable. Convenient? Extremely.</p>\n<p><figure class=\"gatsby-resp-image-figure\" style=\"\">\n    <span\n      class=\"gatsby-resp-image-wrapper\"\n      style=\"position: relative; display: block; margin-left: auto; margin-right: auto; max-width: 1024px; \"\n    >\n      <span\n    class=\"gatsby-resp-image-background-image\"\n    style=\"padding-bottom: 61.71875%; position: relative; bottom: 0; left: 0; background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAMCAIAAADtbgqsAAAACXBIWXMAAAsTAAALEwEAmpwYAAAB0ElEQVQoz2WRTW7bMBCF1YVjcSiL4j8pkbRkxQri2G4c20mzyC5IWyDdNAVa9Bg9Q7e9StGz9EQFZcVNUeDbzOA9vuFMkjIS4YQEgwVDrECHTg8SNDMCK4E3Dl9XEy0mTqeKprxIGUlAUJAUBB3l2QnNxyxHfTmgOJQyDZKvVvrtx1fvb9C6wV6B5SBZctSdPV6fPWxn3dw4N1GibzJQDDs9qsn89uv+x+/k13d43OFKQiWfzbzAkq0/v1t8ul89PdC7dWo48H4ixcAp8Ao+vB7//DK+36XLJmstlBLUMZnTfNmQN4ti09GbBa4UiGIwVxJ7nd2d4m/7UefR3GWnJVgBkg5mJGjpvbHW1VMm46ugOdYcDMelhKCKrhbL9eiihc5ltQbD/5ohblWClVAZmFY4GOxlJMgsqCyoSaPRbX2y9RBU5iVoBuKFOWIkrh1uA24qPDNZayMzndUKnCCdy9sSLOtj/zdLBkaAldhKsBz1wAHDkKLximo4bTQjyY5AzwkjZjY9314udpuL3dX51aVqwpgX8EJ5IIkzPIM0Q7oP15w4w0LJQplXuk/msT8w6JO4dBtrMve8DcRbpNnEKeotMSrXEktG9jWZlv2P/uEPhuJAcZJWdDcAAAAASUVORK5CYII='); background-size: cover; display: block;\"\n  ></span>\n  <img\n        class=\"gatsby-resp-image-image\"\n        alt=\"Musclog public website rendered from the same Expo Router codebase as the app\"\n        title=\"Musclog public website rendered from the same Expo Router codebase as the app\"\n        src=\"/static/f71a459f3034e47544de2f3ce309dd61/42a19/musclog-shared-router-website-home-1.png\"\n        srcset=\"/static/f71a459f3034e47544de2f3ce309dd61/e3135/musclog-shared-router-website-home-1.png 256w,\n/static/f71a459f3034e47544de2f3ce309dd61/06341/musclog-shared-router-website-home-1.png 512w,\n/static/f71a459f3034e47544de2f3ce309dd61/42a19/musclog-shared-router-website-home-1.png 1024w,\n/static/f71a459f3034e47544de2f3ce309dd61/e8464/musclog-shared-router-website-home-1.png 1536w,\n/static/f71a459f3034e47544de2f3ce309dd61/2eb59/musclog-shared-router-website-home-1.png 2048w,\n/static/f71a459f3034e47544de2f3ce309dd61/d7fb2/musclog-shared-router-website-home-1.png 2184w\"\n        sizes=\"(max-width: 1024px) 100vw, 1024px\"\n        style=\"width:100%;height:100%;margin:0;vertical-align:middle;position:absolute;top:0;left:0;\"\n        loading=\"lazy\"\n        decoding=\"async\"\n      />\n    </span>\n    <figcaption class=\"gatsby-resp-image-figcaption\">Musclog public website rendered from the same Expo Router codebase as the app</figcaption>\n  </figure></p>\n<h2 id=\"ok-but-why\" style=\"position:relative;\"><a href=\"#ok-but-why\" aria-label=\"ok but why permalink\" class=\"post-headers-link before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Ok but why?</h2>\n<p>Basically because Expo Router removed my last excuse.</p>\n<p>Musclog already used <a href=\"https://expo.github.io/router/docs\" target=\"_blank\" rel=\"noreferrer\">Expo Router</a>, so the app was already organized as file-based routes inside <code class=\"language-text\">app/</code>. Then I realized I could just create a route group called <code class=\"language-text\">(website)</code>, move the public pages there, keep the actual app under <code class=\"language-text\">app/app/*</code>, and stop pretending these were two different products.</p>\n<p>The separate repo had become one of those architecture decisions that sounds clean in theory and then quietly charges you maintenance tax forever:</p>\n<ul>\n<li>two PRs for one product change</li>\n<li>two deployments</li>\n<li>two places for translations</li>\n<li>two places for legal pages</li>\n<li>two places to forget something</li>\n</ul>\n<p>No thanks.</p>\n<h2 id=\"the-split\" style=\"position:relative;\"><a href=\"#the-split\" aria-label=\"the split permalink\" class=\"post-headers-link before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The split</h2>\n<p>The funny part is that the routing itself ended up being the least cursed part of the whole thing:</p>\n<ul>\n<li><code class=\"language-text\">app/app/*</code> is the actual Musclog app. Workouts, nutrition, AI coach, all of it.</li>\n<li><code class=\"language-text\">app/(website)/*</code> is the public website. Landing page, privacy policy, terms, contact page, calculator.</li>\n</ul>\n<p>The root route just checks the platform and sends you where you belong:</p>\n<div class=\"gatsby-highlight\" data-language=\"tsx\"><pre class=\"language-tsx\"><code class=\"language-tsx\"><span class=\"token comment\">// app/index.tsx</span>\r\n<span class=\"token keyword\">import</span> <span class=\"token punctuation\">{</span> Redirect<span class=\"token punctuation\">,</span> useRouter <span class=\"token punctuation\">}</span> <span class=\"token keyword\">from</span> <span class=\"token string\">'expo-router'</span><span class=\"token punctuation\">;</span>\r\n<span class=\"token keyword\">import</span> <span class=\"token punctuation\">{</span> useEffect <span class=\"token punctuation\">}</span> <span class=\"token keyword\">from</span> <span class=\"token string\">'react'</span><span class=\"token punctuation\">;</span>\r\n<span class=\"token keyword\">import</span> <span class=\"token punctuation\">{</span> Platform <span class=\"token punctuation\">}</span> <span class=\"token keyword\">from</span> <span class=\"token string\">'react-native'</span><span class=\"token punctuation\">;</span>\r\n\r\n<span class=\"token keyword\">export</span> <span class=\"token keyword\">default</span> <span class=\"token keyword\">function</span> <span class=\"token function\">Index</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\r\n  <span class=\"token keyword\">const</span> router <span class=\"token operator\">=</span> <span class=\"token function\">useRouter</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n\r\n  <span class=\"token function\">useEffect</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">=></span> <span class=\"token punctuation\">{</span>\r\n    <span class=\"token keyword\">if</span> <span class=\"token punctuation\">(</span>Platform<span class=\"token punctuation\">.</span><span class=\"token constant\">OS</span> <span class=\"token operator\">===</span> <span class=\"token string\">'web'</span><span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\r\n      router<span class=\"token punctuation\">.</span><span class=\"token function\">replace</span><span class=\"token punctuation\">(</span><span class=\"token string\">'/home'</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n    <span class=\"token punctuation\">}</span>\r\n  <span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span> <span class=\"token punctuation\">[</span>router<span class=\"token punctuation\">]</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n\r\n  <span class=\"token keyword\">if</span> <span class=\"token punctuation\">(</span>Platform<span class=\"token punctuation\">.</span><span class=\"token constant\">OS</span> <span class=\"token operator\">===</span> <span class=\"token string\">'web'</span><span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\r\n    <span class=\"token keyword\">return</span> <span class=\"token keyword\">null</span><span class=\"token punctuation\">;</span>\r\n  <span class=\"token punctuation\">}</span>\r\n\r\n  <span class=\"token keyword\">return</span> <span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span><span class=\"token class-name\">Redirect</span></span> <span class=\"token attr-name\">href</span><span class=\"token attr-value\"><span class=\"token punctuation attr-equals\">=</span><span class=\"token punctuation\">\"</span>/app<span class=\"token punctuation\">\"</span></span> <span class=\"token punctuation\">/></span></span><span class=\"token punctuation\">;</span>\r\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>That’s it. On web, <code class=\"language-text\">/</code> becomes the website. On native, <code class=\"language-text\">/</code> becomes the app.</p>\n<p>The website also gets its own lighter layout:</p>\n<div class=\"gatsby-highlight\" data-language=\"tsx\"><pre class=\"language-tsx\"><code class=\"language-tsx\"><span class=\"token comment\">// app/(website)/_layout.web.tsx</span>\r\n<span class=\"token keyword\">import</span> <span class=\"token punctuation\">{</span> Slot <span class=\"token punctuation\">}</span> <span class=\"token keyword\">from</span> <span class=\"token string\">'expo-router'</span><span class=\"token punctuation\">;</span>\r\n\r\n<span class=\"token keyword\">import</span> <span class=\"token punctuation\">{</span> WebsiteChrome <span class=\"token punctuation\">}</span> <span class=\"token keyword\">from</span> <span class=\"token string\">'@/components/website/WebsiteChrome'</span><span class=\"token punctuation\">;</span>\r\n<span class=\"token keyword\">import</span> <span class=\"token punctuation\">{</span> WebsiteProviders <span class=\"token punctuation\">}</span> <span class=\"token keyword\">from</span> <span class=\"token string\">'@/components/website/WebsiteProviders'</span><span class=\"token punctuation\">;</span>\r\n\r\n<span class=\"token keyword\">export</span> <span class=\"token keyword\">default</span> <span class=\"token keyword\">function</span> <span class=\"token function\">WebsiteLayout</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\r\n  <span class=\"token keyword\">return</span> <span class=\"token punctuation\">(</span>\r\n    <span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span><span class=\"token class-name\">WebsiteProviders</span></span><span class=\"token punctuation\">></span></span><span class=\"token plain-text\">\r\n      </span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span><span class=\"token class-name\">WebsiteChrome</span></span><span class=\"token punctuation\">></span></span><span class=\"token plain-text\">\r\n        </span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span><span class=\"token class-name\">Slot</span></span> <span class=\"token punctuation\">/></span></span><span class=\"token plain-text\">\r\n      </span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;/</span><span class=\"token class-name\">WebsiteChrome</span></span><span class=\"token punctuation\">></span></span><span class=\"token plain-text\">\r\n    </span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;/</span><span class=\"token class-name\">WebsiteProviders</span></span><span class=\"token punctuation\">></span></span>\r\n  <span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>That matters because the real app layout had already grown into an actual app layout. Database boot, migrations, React Query, safe area, gestures, modals, snackbars, camera, coach context, all the usual “small side project” lies. The website does not need any of that just to explain what the app does.</p>\n<p>And if a native user somehow lands on a website-only route, the fix is beautifully blunt:</p>\n<div class=\"gatsby-highlight\" data-language=\"tsx\"><pre class=\"language-tsx\"><code class=\"language-tsx\"><span class=\"token comment\">// app/(website)/home.tsx</span>\r\n<span class=\"token keyword\">import</span> <span class=\"token punctuation\">{</span> Redirect <span class=\"token punctuation\">}</span> <span class=\"token keyword\">from</span> <span class=\"token string\">'expo-router'</span><span class=\"token punctuation\">;</span>\r\n\r\n<span class=\"token keyword\">export</span> <span class=\"token keyword\">default</span> <span class=\"token keyword\">function</span> <span class=\"token function\">Home</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\r\n  <span class=\"token keyword\">return</span> <span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span><span class=\"token class-name\">Redirect</span></span> <span class=\"token attr-name\">href</span><span class=\"token attr-value\"><span class=\"token punctuation attr-equals\">=</span><span class=\"token punctuation\">\"</span>/app<span class=\"token punctuation\">\"</span></span> <span class=\"token punctuation\">/></span></span><span class=\"token punctuation\">;</span>\r\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>Done. Back to your macros.</p>\n<p><figure class=\"gatsby-resp-image-figure\" style=\"\">\n    <span\n      class=\"gatsby-resp-image-wrapper\"\n      style=\"position: relative; display: block; margin-left: auto; margin-right: auto; max-width: 1024px; \"\n    >\n      <span\n    class=\"gatsby-resp-image-background-image\"\n    style=\"padding-bottom: 61.71875%; position: relative; bottom: 0; left: 0; background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAMCAIAAADtbgqsAAAACXBIWXMAAAsTAAALEwEAmpwYAAABt0lEQVQoz02SWW/jMAyE/RaLdqyLkiXfpxznstOi6S6w//93LZS4aYB5IuYDhkMGO554iWRfpSBZyJNQUoIslDREClrEBkHweLbxRxkpGWuxITwJQIuniGSgOaQcjOyW03Rfp++bu6/ua2mWmShOOCWaE8Uh3ZAAUvlUlKvIyCjDqFBxY5gr+VyzU8Omcl+bqNRRoSKDkUUwGxKARbBIrMwvU309qKHSc9v9XextEktb/LvUf67q2KlD236es0MvuwIyfFIbDBaxr/RY41iLsTTXIb05eevxo09vTp97PhQ4VGqoRJu/kMBneAinWg0VqywfC7M6PHf83OivSS8DHwoxFubUyyanpXkhW2FEczM0xTyqrsSuqNdjvjicG3t15XpIp1a2RXV02dSJOnt1HBBkT4XICE9AcaJFiAwUi6wkRhAtIBVEiVBSEA/DDxIQyV6CPvcpWAKlhrnyQ0EhV9udUkmm8t3/CwNyc3GsMDu6T+pMXR2RLBQU+4pm6Y4lsUU8D0S8wz8ZfHKeEP9e3GOc+mdI/Qp+ovyQsOTd//th8NjtKWJkUlt3X6bvlTaW+G7fDD+F/QccPj1gi9mJrAAAAABJRU5ErkJggg=='); background-size: cover; display: block;\"\n  ></span>\n  <img\n        class=\"gatsby-resp-image-image\"\n        alt=\"Musclog public website rendered from the same Expo Router codebase as the app\"\n        title=\"Musclog public website rendered from the same Expo Router codebase as the app\"\n        src=\"/static/c50b08ecc21b0e02ed7774578063a5f6/42a19/musclog-shared-router-website-home-2.png\"\n        srcset=\"/static/c50b08ecc21b0e02ed7774578063a5f6/e3135/musclog-shared-router-website-home-2.png 256w,\n/static/c50b08ecc21b0e02ed7774578063a5f6/06341/musclog-shared-router-website-home-2.png 512w,\n/static/c50b08ecc21b0e02ed7774578063a5f6/42a19/musclog-shared-router-website-home-2.png 1024w,\n/static/c50b08ecc21b0e02ed7774578063a5f6/e8464/musclog-shared-router-website-home-2.png 1536w,\n/static/c50b08ecc21b0e02ed7774578063a5f6/2eb59/musclog-shared-router-website-home-2.png 2048w,\n/static/c50b08ecc21b0e02ed7774578063a5f6/d7fb2/musclog-shared-router-website-home-2.png 2184w\"\n        sizes=\"(max-width: 1024px) 100vw, 1024px\"\n        style=\"width:100%;height:100%;margin:0;vertical-align:middle;position:absolute;top:0;left:0;\"\n        loading=\"lazy\"\n        decoding=\"async\"\n      />\n    </span>\n    <figcaption class=\"gatsby-resp-image-figcaption\">Musclog public website rendered from the same Expo Router codebase as the app</figcaption>\n  </figure></p>\n<h2 id=\"the-desktop-phone-nonsense\" style=\"position:relative;\"><a href=\"#the-desktop-phone-nonsense\" aria-label=\"the desktop phone nonsense permalink\" class=\"post-headers-link before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The desktop phone nonsense</h2>\n<p>This is where I stopped behaving like a normal person.</p>\n<p>I wanted <a href=\"https://musclog.app/app/\" target=\"_blank\" rel=\"noreferrer\">musclog.app/app</a> on desktop to show the actual working app inside a phone frame. Partly because it looks nice. Partly because it doubles as a product preview. Mostly because once the idea entered my head, every less ridiculous option started feeling wrong.</p>\n<p>The annoying detail was that <code class=\"language-text\">/app</code> should look like a phone, but <code class=\"language-text\">/home</code> absolutely should not. So routing alone was not enough. I needed the raw HTML shell to know, before hydration, whether the browser should render the fake phone wrapper or not.</p>\n<p>That logic lives in <code class=\"language-text\">app/+html.tsx</code>, inside a script in the document <code class=\"language-text\">&lt;head></code>:</p>\n<div class=\"gatsby-highlight\" data-language=\"tsx\"><pre class=\"language-tsx\"><code class=\"language-tsx\"><span class=\"token keyword\">function</span> <span class=\"token function\">landingPanelGate</span><span class=\"token punctuation\">(</span>base<span class=\"token operator\">:</span> <span class=\"token builtin\">string</span><span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\r\n  <span class=\"token keyword\">try</span> <span class=\"token punctuation\">{</span>\r\n    <span class=\"token keyword\">function</span> <span class=\"token function\">update</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\r\n      <span class=\"token keyword\">const</span> raw <span class=\"token operator\">=</span> window<span class=\"token punctuation\">.</span>location<span class=\"token punctuation\">.</span>pathname<span class=\"token punctuation\">;</span>\r\n      <span class=\"token keyword\">const</span> path <span class=\"token operator\">=</span> <span class=\"token punctuation\">(</span>base <span class=\"token operator\">&amp;&amp;</span> raw<span class=\"token punctuation\">.</span><span class=\"token function\">startsWith</span><span class=\"token punctuation\">(</span>base<span class=\"token punctuation\">)</span> <span class=\"token operator\">?</span> raw<span class=\"token punctuation\">.</span><span class=\"token function\">slice</span><span class=\"token punctuation\">(</span>base<span class=\"token punctuation\">.</span>length<span class=\"token punctuation\">)</span> <span class=\"token operator\">:</span> raw<span class=\"token punctuation\">)</span> <span class=\"token operator\">||</span> <span class=\"token string\">'/'</span><span class=\"token punctuation\">;</span>\r\n\r\n      <span class=\"token keyword\">if</span> <span class=\"token punctuation\">(</span><span class=\"token operator\">!</span>path<span class=\"token punctuation\">.</span><span class=\"token function\">startsWith</span><span class=\"token punctuation\">(</span><span class=\"token string\">'/app'</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\r\n        document<span class=\"token punctuation\">.</span>documentElement<span class=\"token punctuation\">.</span>classList<span class=\"token punctuation\">.</span><span class=\"token function\">add</span><span class=\"token punctuation\">(</span><span class=\"token string\">'hide-desktop-wrapper'</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n      <span class=\"token punctuation\">}</span> <span class=\"token keyword\">else</span> <span class=\"token punctuation\">{</span>\r\n        document<span class=\"token punctuation\">.</span>documentElement<span class=\"token punctuation\">.</span>classList<span class=\"token punctuation\">.</span><span class=\"token function\">remove</span><span class=\"token punctuation\">(</span><span class=\"token string\">'hide-desktop-wrapper'</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n      <span class=\"token punctuation\">}</span>\r\n    <span class=\"token punctuation\">}</span>\r\n\r\n    <span class=\"token function\">update</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n    window<span class=\"token punctuation\">.</span><span class=\"token function\">addEventListener</span><span class=\"token punctuation\">(</span><span class=\"token string\">'popstate'</span><span class=\"token punctuation\">,</span> update<span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n\r\n    <span class=\"token keyword\">const</span> origPush <span class=\"token operator\">=</span> history<span class=\"token punctuation\">.</span><span class=\"token function\">pushState</span><span class=\"token punctuation\">.</span><span class=\"token function\">bind</span><span class=\"token punctuation\">(</span>history<span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n    history<span class=\"token punctuation\">.</span><span class=\"token function-variable function\">pushState</span> <span class=\"token operator\">=</span> <span class=\"token keyword\">function</span> <span class=\"token punctuation\">(</span><span class=\"token operator\">...</span>args<span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\r\n      <span class=\"token function\">origPush</span><span class=\"token punctuation\">(</span><span class=\"token operator\">...</span>args<span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n      <span class=\"token function\">update</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n    <span class=\"token punctuation\">}</span><span class=\"token punctuation\">;</span>\r\n\r\n    <span class=\"token keyword\">const</span> origReplace <span class=\"token operator\">=</span> history<span class=\"token punctuation\">.</span><span class=\"token function\">replaceState</span><span class=\"token punctuation\">.</span><span class=\"token function\">bind</span><span class=\"token punctuation\">(</span>history<span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n    history<span class=\"token punctuation\">.</span><span class=\"token function-variable function\">replaceState</span> <span class=\"token operator\">=</span> <span class=\"token keyword\">function</span> <span class=\"token punctuation\">(</span><span class=\"token operator\">...</span>args<span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\r\n      <span class=\"token function\">origReplace</span><span class=\"token punctuation\">(</span><span class=\"token operator\">...</span>args<span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n      <span class=\"token function\">update</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n    <span class=\"token punctuation\">}</span><span class=\"token punctuation\">;</span>\r\n  <span class=\"token punctuation\">}</span> <span class=\"token keyword\">catch</span> <span class=\"token punctuation\">(</span>_<span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span><span class=\"token punctuation\">}</span>\r\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>The important part is the <code class=\"language-text\">pushState</code> / <code class=\"language-text\">replaceState</code> monkey patch. First paint was easy. Client-side navigation was the annoying bit. Without this, you could leave <code class=\"language-text\">/app</code> and keep the wrong shell around like Chrome had forgotten what page it was on.</p>\n<p>The HTML shell itself is basically the landing panel, the routed app, and the phone frame:</p>\n<div class=\"gatsby-highlight\" data-language=\"tsx\"><pre class=\"language-tsx\"><code class=\"language-tsx\"><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span>body</span> <span class=\"token attr-name\">className</span><span class=\"token attr-value\"><span class=\"token punctuation attr-equals\">=</span><span class=\"token punctuation\">\"</span>expo-web-body<span class=\"token punctuation\">\"</span></span><span class=\"token punctuation\">></span></span><span class=\"token plain-text\">\r\n  </span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span>div</span> <span class=\"token attr-name\">className</span><span class=\"token attr-value\"><span class=\"token punctuation attr-equals\">=</span><span class=\"token punctuation\">\"</span>expo-web-landing<span class=\"token punctuation\">\"</span></span><span class=\"token punctuation\">></span></span><span class=\"token plain-text\">...</span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;/</span>div</span><span class=\"token punctuation\">></span></span><span class=\"token plain-text\">\r\n  </span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span>script</span> <span class=\"token attr-name\">dangerouslySetInnerHTML</span><span class=\"token script language-javascript\"><span class=\"token script-punctuation punctuation\">=</span><span class=\"token punctuation\">{</span><span class=\"token punctuation\">{</span> __html<span class=\"token operator\">:</span> <span class=\"token constant\">LANDING_I18N_SCRIPT</span> <span class=\"token punctuation\">}</span><span class=\"token punctuation\">}</span></span> <span class=\"token punctuation\">/></span></span><span class=\"token plain-text\">\r\n  </span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span>div</span> <span class=\"token attr-name\">className</span><span class=\"token attr-value\"><span class=\"token punctuation attr-equals\">=</span><span class=\"token punctuation\">\"</span>expo-web-root<span class=\"token punctuation\">\"</span></span><span class=\"token punctuation\">></span></span><span class=\"token plain-text\">\r\n    </span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span>div</span> <span class=\"token attr-name\">className</span><span class=\"token attr-value\"><span class=\"token punctuation attr-equals\">=</span><span class=\"token punctuation\">\"</span>expo-web-app-shell<span class=\"token punctuation\">\"</span></span><span class=\"token punctuation\">></span></span><span class=\"token punctuation\">{</span>children<span class=\"token punctuation\">}</span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;/</span>div</span><span class=\"token punctuation\">></span></span><span class=\"token plain-text\">\r\n    </span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span>img</span>\r\n      <span class=\"token attr-name\">className</span><span class=\"token attr-value\"><span class=\"token punctuation attr-equals\">=</span><span class=\"token punctuation\">\"</span>expo-web-phone-frame<span class=\"token punctuation\">\"</span></span>\r\n      <span class=\"token attr-name\">src</span><span class=\"token script language-javascript\"><span class=\"token script-punctuation punctuation\">=</span><span class=\"token punctuation\">{</span><span class=\"token function\">withExpoBaseUrl</span><span class=\"token punctuation\">(</span><span class=\"token constant\">PHONE_FRAME_SRC</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">}</span></span>\r\n      <span class=\"token attr-name\">alt</span><span class=\"token attr-value\"><span class=\"token punctuation attr-equals\">=</span><span class=\"token punctuation\">\"</span><span class=\"token punctuation\">\"</span></span>\r\n      <span class=\"token attr-name\">aria-hidden</span>\r\n    <span class=\"token punctuation\">/></span></span><span class=\"token plain-text\">\r\n  </span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;/</span>div</span><span class=\"token punctuation\">></span></span><span class=\"token plain-text\">\r\n</span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;/</span>body</span><span class=\"token punctuation\">></span></span></code></pre></div>\n<p>Then CSS commits the crime:</p>\n<div class=\"gatsby-highlight\" data-language=\"css\"><pre class=\"language-css\"><code class=\"language-css\"><span class=\"token atrule\"><span class=\"token rule\">@media</span> <span class=\"token punctuation\">(</span><span class=\"token property\">min-width</span><span class=\"token punctuation\">:</span> 1024px<span class=\"token punctuation\">)</span></span> <span class=\"token punctuation\">{</span>\r\n  <span class=\"token selector\">.expo-web-root</span> <span class=\"token punctuation\">{</span>\r\n    <span class=\"token property\">--frame-h</span><span class=\"token punctuation\">:</span> <span class=\"token function\">min</span><span class=\"token punctuation\">(</span>100dvh<span class=\"token punctuation\">,</span> <span class=\"token function\">max</span><span class=\"token punctuation\">(</span><span class=\"token function\">min</span><span class=\"token punctuation\">(</span>360px<span class=\"token punctuation\">,</span> 100dvh<span class=\"token punctuation\">)</span><span class=\"token punctuation\">,</span> 85dvh<span class=\"token punctuation\">)</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n    <span class=\"token property\">aspect-ratio</span><span class=\"token punctuation\">:</span> 1438 / 2976<span class=\"token punctuation\">;</span>\r\n    <span class=\"token property\">width</span><span class=\"token punctuation\">:</span> <span class=\"token function\">min</span><span class=\"token punctuation\">(</span>100vw<span class=\"token punctuation\">,</span> <span class=\"token function\">calc</span><span class=\"token punctuation\">(</span><span class=\"token function\">var</span><span class=\"token punctuation\">(</span>--frame-h<span class=\"token punctuation\">)</span> * 1438 / 2976<span class=\"token punctuation\">)</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n    <span class=\"token property\">max-height</span><span class=\"token punctuation\">:</span> <span class=\"token function\">var</span><span class=\"token punctuation\">(</span>--frame-h<span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n    <span class=\"token property\">overflow</span><span class=\"token punctuation\">:</span> hidden<span class=\"token punctuation\">;</span>\r\n  <span class=\"token punctuation\">}</span>\r\n\r\n  <span class=\"token selector\">.expo-web-app-shell</span> <span class=\"token punctuation\">{</span>\r\n    <span class=\"token property\">position</span><span class=\"token punctuation\">:</span> absolute<span class=\"token punctuation\">;</span>\r\n    <span class=\"token property\">left</span><span class=\"token punctuation\">:</span> 7.4409%<span class=\"token punctuation\">;</span>\r\n    <span class=\"token property\">top</span><span class=\"token punctuation\">:</span> 2.9906%<span class=\"token punctuation\">;</span>\r\n    <span class=\"token property\">right</span><span class=\"token punctuation\">:</span> 6.3282%<span class=\"token punctuation\">;</span>\r\n    <span class=\"token property\">bottom</span><span class=\"token punctuation\">:</span> 3.125%<span class=\"token punctuation\">;</span>\r\n    <span class=\"token property\">zoom</span><span class=\"token punctuation\">:</span> 0.85<span class=\"token punctuation\">;</span>\r\n  <span class=\"token punctuation\">}</span>\r\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>Yes, I measured the transparent screen area inside the PNG until the app lined up correctly. There is no elegant geometry behind those percentages. It was just me, a phone bezel, and a lot of nudging until the app stopped looking crooked.</p>\n<p>On routes that are not <code class=\"language-text\">/app</code>, the whole performance gets disabled:</p>\n<div class=\"gatsby-highlight\" data-language=\"css\"><pre class=\"language-css\"><code class=\"language-css\"><span class=\"token selector\">.hide-desktop-wrapper .expo-web-landing,\r\n.hide-desktop-wrapper .expo-web-phone-frame</span> <span class=\"token punctuation\">{</span>\r\n  <span class=\"token property\">display</span><span class=\"token punctuation\">:</span> none <span class=\"token important\">!important</span><span class=\"token punctuation\">;</span>\r\n<span class=\"token punctuation\">}</span>\r\n\r\n<span class=\"token selector\">.hide-desktop-wrapper .expo-web-app-shell</span> <span class=\"token punctuation\">{</span>\r\n  <span class=\"token property\">position</span><span class=\"token punctuation\">:</span> static <span class=\"token important\">!important</span><span class=\"token punctuation\">;</span>\r\n  <span class=\"token property\">width</span><span class=\"token punctuation\">:</span> 100% <span class=\"token important\">!important</span><span class=\"token punctuation\">;</span>\r\n  <span class=\"token property\">height</span><span class=\"token punctuation\">:</span> auto <span class=\"token important\">!important</span><span class=\"token punctuation\">;</span>\r\n  <span class=\"token property\">zoom</span><span class=\"token punctuation\">:</span> 1 <span class=\"token important\">!important</span><span class=\"token punctuation\">;</span>\r\n  <span class=\"token property\">overflow</span><span class=\"token punctuation\">:</span> visible <span class=\"token important\">!important</span><span class=\"token punctuation\">;</span>\r\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>Yes, it uses <code class=\"language-text\">!important</code>. This is pre-hydration CSS whose entire job is keeping the lie intact. We are not doing refined architecture at this layer.</p>\n<p><figure class=\"gatsby-resp-image-figure\" style=\"\">\n    <span\n      class=\"gatsby-resp-image-wrapper\"\n      style=\"position: relative; display: block; margin-left: auto; margin-right: auto; max-width: 1024px; \"\n    >\n      <span\n    class=\"gatsby-resp-image-background-image\"\n    style=\"padding-bottom: 61.71875%; position: relative; bottom: 0; left: 0; background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAMCAIAAADtbgqsAAAACXBIWXMAAAsTAAALEwEAmpwYAAABS0lEQVQoz42SyUoDQRCGgxOTrq7pfbbuZBKTmTEwWSAgKKLghtvBQy6CHs3Nk94UwZPP4Wv4DD6VqBdhION3Kor6qL+gGrQCfvNbER/bApEDFehXJxvVFhACAEDandHYTbcxG9BJHpQZZwwAVskAoIzWJvTWGvunrwfPn+uPD633F/V0IynC6s0AECZRFMWEeMX4bHR8z04O/dtzc7EjfQYUamIro6WSCDQunN0aJoU1iQptKKWsiU0plVoro6Hhda+Pyo+3dHkpho4PEsF5vYyUEiC0Tewk71/tmVnGXShcyFfLiNhsNufz+XJ5Z7TWUWDTDguUH2kZB/Vyq9Uqy3KxWGilgtT1Z6XNM512w15XiLrYiEgI8TyP+X6Q2rgY2s1cpd14oyel+MfNP/iIUWp13yW7UzPoxH3Hap/kL4wxLjgXXAjBOa++5xcHAzI9yvyvqQAAAABJRU5ErkJggg=='); background-size: cover; display: block;\"\n  ></span>\n  <img\n        class=\"gatsby-resp-image-image\"\n        alt=\"Musclog desktop web app rendered inside a phone frame next to the landing panel\"\n        title=\"Musclog desktop web app rendered inside a phone frame next to the landing panel\"\n        src=\"/static/49361b3283ae34ef30a950b13c7f5594/42a19/musclog-desktop-phone-frame-wrapper.png\"\n        srcset=\"/static/49361b3283ae34ef30a950b13c7f5594/e3135/musclog-desktop-phone-frame-wrapper.png 256w,\n/static/49361b3283ae34ef30a950b13c7f5594/06341/musclog-desktop-phone-frame-wrapper.png 512w,\n/static/49361b3283ae34ef30a950b13c7f5594/42a19/musclog-desktop-phone-frame-wrapper.png 1024w,\n/static/49361b3283ae34ef30a950b13c7f5594/e8464/musclog-desktop-phone-frame-wrapper.png 1536w,\n/static/49361b3283ae34ef30a950b13c7f5594/2eb59/musclog-desktop-phone-frame-wrapper.png 2048w,\n/static/49361b3283ae34ef30a950b13c7f5594/d7fb2/musclog-desktop-phone-frame-wrapper.png 2184w\"\n        sizes=\"(max-width: 1024px) 100vw, 1024px\"\n        style=\"width:100%;height:100%;margin:0;vertical-align:middle;position:absolute;top:0;left:0;\"\n        loading=\"lazy\"\n        decoding=\"async\"\n      />\n    </span>\n    <figcaption class=\"gatsby-resp-image-figcaption\">Musclog desktop web app rendered inside a phone frame next to the landing panel</figcaption>\n  </figure></p>\n<h2 id=\"three-tiny-crimes\" style=\"position:relative;\"><a href=\"#three-tiny-crimes\" aria-label=\"three tiny crimes permalink\" class=\"post-headers-link before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Three tiny crimes</h2>\n<p><img src=\"/377ac4354ce553fccf98c5d8d1017e50/straight-to-jail.gif\" alt=\"Straight to jail. Right away.\"></p>\n<p>Once the shell worked, the smaller annoyances started lining up like they had booked appointments.</p>\n<h3 id=\"1-the-landing-panel-needed-i18n-before-hydration\" style=\"position:relative;\"><a href=\"#1-the-landing-panel-needed-i18n-before-hydration\" aria-label=\"1 the landing panel needed i18n before hydration permalink\" class=\"post-headers-link before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>1. The landing panel needed i18n before hydration</h3>\n<p>The text beside the phone frame lives in raw HTML, which means React and i18n are still sleeping when the page first renders. So if I wanted a Portuguese page to not flash English first, I had to patch it myself from <code class=\"language-text\">localStorage</code>:</p>\n<div class=\"gatsby-highlight\" data-language=\"tsx\"><pre class=\"language-tsx\"><code class=\"language-tsx\"><span class=\"token keyword\">function</span> <span class=\"token function\">landingI18nPatcher</span><span class=\"token punctuation\">(</span>translations<span class=\"token punctuation\">,</span> storageKey<span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\r\n  <span class=\"token keyword\">try</span> <span class=\"token punctuation\">{</span>\r\n    <span class=\"token keyword\">let</span> lang <span class=\"token operator\">=</span> localStorage<span class=\"token punctuation\">.</span><span class=\"token function\">getItem</span><span class=\"token punctuation\">(</span>storageKey<span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n    <span class=\"token keyword\">let</span> s <span class=\"token operator\">=</span> <span class=\"token punctuation\">(</span>lang <span class=\"token operator\">&amp;&amp;</span> translations<span class=\"token punctuation\">[</span>lang<span class=\"token punctuation\">]</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">||</span> translations<span class=\"token punctuation\">[</span><span class=\"token string\">'en-US'</span><span class=\"token punctuation\">]</span><span class=\"token punctuation\">;</span>\r\n\r\n    document<span class=\"token punctuation\">.</span><span class=\"token function\">querySelectorAll</span><span class=\"token punctuation\">(</span><span class=\"token string\">'[data-landing-i18n]'</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">.</span><span class=\"token function\">forEach</span><span class=\"token punctuation\">(</span><span class=\"token keyword\">function</span> <span class=\"token punctuation\">(</span>el<span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\r\n      <span class=\"token keyword\">let</span> k <span class=\"token operator\">=</span> el<span class=\"token punctuation\">.</span><span class=\"token function\">getAttribute</span><span class=\"token punctuation\">(</span><span class=\"token string\">'data-landing-i18n'</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n      <span class=\"token keyword\">if</span> <span class=\"token punctuation\">(</span>k <span class=\"token operator\">&amp;&amp;</span> s<span class=\"token punctuation\">[</span>k<span class=\"token punctuation\">]</span><span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\r\n        el<span class=\"token punctuation\">.</span>textContent <span class=\"token operator\">=</span> s<span class=\"token punctuation\">[</span>k<span class=\"token punctuation\">]</span><span class=\"token punctuation\">;</span>\r\n      <span class=\"token punctuation\">}</span>\r\n    <span class=\"token punctuation\">}</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n  <span class=\"token punctuation\">}</span> <span class=\"token keyword\">catch</span> <span class=\"token punctuation\">(</span>_<span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span><span class=\"token punctuation\">}</span>\r\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>Not elegant. Also not flash-banging a Portuguese page with English text, so I’m keeping it.</p>\n<h3 id=\"2-raw-html-assets-do-not-get-expos-usual-help\" style=\"position:relative;\"><a href=\"#2-raw-html-assets-do-not-get-expos-usual-help\" aria-label=\"2 raw html assets do not get expos usual help permalink\" class=\"post-headers-link before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>2. Raw HTML assets do not get Expo’s usual help</h3>\n<p>The phone frame PNG and QR code are referenced from raw HTML too, which means Expo’s usual asset niceness does not really help me there. So I needed a helper for base URLs:</p>\n<div class=\"gatsby-highlight\" data-language=\"tsx\"><pre class=\"language-tsx\"><code class=\"language-tsx\"><span class=\"token keyword\">function</span> <span class=\"token function\">withExpoBaseUrl</span><span class=\"token punctuation\">(</span>path<span class=\"token operator\">:</span> <span class=\"token builtin\">string</span><span class=\"token punctuation\">)</span><span class=\"token operator\">:</span> <span class=\"token builtin\">string</span> <span class=\"token punctuation\">{</span>\r\n  <span class=\"token keyword\">const</span> base <span class=\"token operator\">=</span> process<span class=\"token punctuation\">.</span>env<span class=\"token punctuation\">.</span><span class=\"token constant\">EXPO_BASE_URL</span><span class=\"token punctuation\">;</span>\r\n  <span class=\"token keyword\">if</span> <span class=\"token punctuation\">(</span>base <span class=\"token operator\">==</span> <span class=\"token keyword\">null</span> <span class=\"token operator\">||</span> base <span class=\"token operator\">===</span> <span class=\"token string\">''</span><span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\r\n    <span class=\"token keyword\">return</span> path<span class=\"token punctuation\">;</span>\r\n  <span class=\"token punctuation\">}</span>\r\n\r\n  <span class=\"token keyword\">const</span> basePath <span class=\"token operator\">=</span> <span class=\"token function\">String</span><span class=\"token punctuation\">(</span>base<span class=\"token punctuation\">)</span><span class=\"token punctuation\">.</span><span class=\"token function\">replace</span><span class=\"token punctuation\">(</span><span class=\"token regex\"><span class=\"token regex-delimiter\">/</span><span class=\"token regex-source language-regex\">^\\/+|\\/+$</span><span class=\"token regex-delimiter\">/</span><span class=\"token regex-flags\">g</span></span><span class=\"token punctuation\">,</span> <span class=\"token string\">''</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n  <span class=\"token keyword\">const</span> normalized <span class=\"token operator\">=</span> path<span class=\"token punctuation\">.</span><span class=\"token function\">startsWith</span><span class=\"token punctuation\">(</span><span class=\"token string\">'/'</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">?</span> path <span class=\"token operator\">:</span> <span class=\"token template-string\"><span class=\"token template-punctuation string\">`</span><span class=\"token string\">/</span><span class=\"token interpolation\"><span class=\"token interpolation-punctuation punctuation\">${</span>path<span class=\"token interpolation-punctuation punctuation\">}</span></span><span class=\"token template-punctuation string\">`</span></span><span class=\"token punctuation\">;</span>\r\n  <span class=\"token keyword\">return</span> <span class=\"token template-string\"><span class=\"token template-punctuation string\">`</span><span class=\"token string\">/</span><span class=\"token interpolation\"><span class=\"token interpolation-punctuation punctuation\">${</span>basePath<span class=\"token interpolation-punctuation punctuation\">}</span></span><span class=\"token interpolation\"><span class=\"token interpolation-punctuation punctuation\">${</span>normalized<span class=\"token interpolation-punctuation punctuation\">}</span></span><span class=\"token template-punctuation string\">`</span></span><span class=\"token punctuation\">;</span>\r\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>And because those files live outside the React Native asset pipeline, I copy them into <code class=\"language-text\">public/</code> before dev and export. Forget that step once and the site immediately reminds you who is in charge.</p>\n<h3 id=\"3-expo-export-occasionally-needed-emotional-support\" style=\"position:relative;\"><a href=\"#3-expo-export-occasionally-needed-emotional-support\" aria-label=\"3 expo export occasionally needed emotional support permalink\" class=\"post-headers-link before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>3. <code class=\"language-text\">expo export</code> occasionally needed emotional support</h3>\n<p>In some environments, <code class=\"language-text\">expo export --platform web</code> would successfully finish and then just stay alive for no reason. Dist folder there. Files generated. Process spiritually complete, technically still hanging around.</p>\n<p>So now there is a wrapper script:</p>\n<div class=\"gatsby-highlight\" data-language=\"js\"><pre class=\"language-js\"><code class=\"language-js\"><span class=\"token comment\">// scripts/export-web-wrapper.js</span>\r\n<span class=\"token keyword\">if</span> <span class=\"token punctuation\">(</span>output<span class=\"token punctuation\">.</span><span class=\"token function\">includes</span><span class=\"token punctuation\">(</span><span class=\"token string\">'Exported: dist'</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\r\n  console<span class=\"token punctuation\">.</span><span class=\"token function\">log</span><span class=\"token punctuation\">(</span><span class=\"token string\">'[export-web-wrapper] Detected successful export. Forcing exit in 5s...'</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n  <span class=\"token function\">setTimeout</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">=></span> process<span class=\"token punctuation\">.</span><span class=\"token function\">exit</span><span class=\"token punctuation\">(</span><span class=\"token number\">0</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">,</span> <span class=\"token number\">5000</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>Not glamorous. Very effective.</p>\n<h2 id=\"then-modals-got-weird\" style=\"position:relative;\"><a href=\"#then-modals-got-weird\" aria-label=\"then modals got weird permalink\" class=\"post-headers-link before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Then modals got weird</h2>\n<p><figure class=\"gatsby-resp-image-figure\" style=\"\">\n    <span\n      class=\"gatsby-resp-image-wrapper\"\n      style=\"position: relative; display: block; margin-left: auto; margin-right: auto; max-width: 1024px; \"\n    >\n      <span\n    class=\"gatsby-resp-image-background-image\"\n    style=\"padding-bottom: 61.71875%; position: relative; bottom: 0; left: 0; background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAMCAIAAADtbgqsAAAACXBIWXMAAAsTAAALEwEAmpwYAAABD0lEQVQoz53SS27CMBCAYUtUIp5x4ldMnAST2E3BcVA3kXqOcgbuf4eKhypoN8DoW4xG+ndDAAARAYDxAoucMnxUjiSj5wFYb4cqeN7Uom0eoXaBKK2lEBTApbRJ+01MF904ddO0HqMbx/UY2xj9lHyK/bi76D4TWdnKGAOIKjj1vhFdK/o7/N/lytVElaUQHBB5dMXQsGBv5aGWW/fnyILFfiXngUitzjEUoSl8XXh70tvLwn2tt4756k6osDNi/iCAmC2XyJj+SnlnWWPy1sBKgrmipfjdb1HJyTzPx+NRac1syWqNlXoQs5pM+/334SCVXIqcavEUkmXZ22IBCM+Wp/j6YYhUvRSfBuGF+AfXMjfjsQh0mgAAAABJRU5ErkJggg=='); background-size: cover; display: block;\"\n  ></span>\n  <img\n        class=\"gatsby-resp-image-image\"\n        alt=\"Musclog desktop modal rendered correctly inside the phone frame\"\n        title=\"Musclog desktop modal rendered correctly inside the phone frame\"\n        src=\"/static/a2af4c3fcf36eee559fddfb48a5143d1/42a19/musclog-desktop-modal-inside-phone-frame.png\"\n        srcset=\"/static/a2af4c3fcf36eee559fddfb48a5143d1/e3135/musclog-desktop-modal-inside-phone-frame.png 256w,\n/static/a2af4c3fcf36eee559fddfb48a5143d1/06341/musclog-desktop-modal-inside-phone-frame.png 512w,\n/static/a2af4c3fcf36eee559fddfb48a5143d1/42a19/musclog-desktop-modal-inside-phone-frame.png 1024w,\n/static/a2af4c3fcf36eee559fddfb48a5143d1/e8464/musclog-desktop-modal-inside-phone-frame.png 1536w,\n/static/a2af4c3fcf36eee559fddfb48a5143d1/2eb59/musclog-desktop-modal-inside-phone-frame.png 2048w,\n/static/a2af4c3fcf36eee559fddfb48a5143d1/d7fb2/musclog-desktop-modal-inside-phone-frame.png 2184w\"\n        sizes=\"(max-width: 1024px) 100vw, 1024px\"\n        style=\"width:100%;height:100%;margin:0;vertical-align:middle;position:absolute;top:0;left:0;\"\n        loading=\"lazy\"\n        decoding=\"async\"\n      />\n    </span>\n    <figcaption class=\"gatsby-resp-image-figcaption\">Musclog desktop modal rendered correctly inside the phone frame</figcaption>\n  </figure></p>\n<p>Of course they did.</p>\n<p>When the web app lives inside a fake phone on desktop, React Native Web’s default <code class=\"language-text\">Modal</code> behavior starts looking ridiculous very quickly. Portaling straight to <code class=\"language-text\">document.body</code> is fine when the whole app owns the page. It is much less fine when the app is visually clipped inside a phone shell and the modal suddenly decides it belongs to the whole browser window instead.</p>\n<p>So I added a <code class=\"language-text\">WebModalShellProvider</code> high in <code class=\"language-text\">app/app/_layout.tsx</code>, with an overlay host inside the phone shell:</p>\n<div class=\"gatsby-highlight\" data-language=\"tsx\"><pre class=\"language-tsx\"><code class=\"language-tsx\"><span class=\"token comment\">// context/WebModalShellContext.web.tsx</span>\r\n<span class=\"token keyword\">return</span> <span class=\"token punctuation\">(</span>\r\n  <span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span><span class=\"token class-name\">WebModalShellContext.Provider</span></span> <span class=\"token attr-name\">value</span><span class=\"token script language-javascript\"><span class=\"token script-punctuation punctuation\">=</span><span class=\"token punctuation\">{</span><span class=\"token punctuation\">{</span> hostElement <span class=\"token punctuation\">}</span><span class=\"token punctuation\">}</span></span><span class=\"token punctuation\">></span></span><span class=\"token plain-text\">\r\n    </span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span><span class=\"token class-name\">View</span></span> <span class=\"token attr-name\">style</span><span class=\"token script language-javascript\"><span class=\"token script-punctuation punctuation\">=</span><span class=\"token punctuation\">{</span><span class=\"token punctuation\">{</span> flex<span class=\"token operator\">:</span> <span class=\"token number\">1</span><span class=\"token punctuation\">,</span> minHeight<span class=\"token operator\">:</span> <span class=\"token number\">0</span><span class=\"token punctuation\">,</span> height<span class=\"token operator\">:</span> <span class=\"token string\">'100%'</span><span class=\"token punctuation\">,</span> width<span class=\"token operator\">:</span> <span class=\"token string\">'100%'</span><span class=\"token punctuation\">,</span> position<span class=\"token operator\">:</span> <span class=\"token string\">'relative'</span> <span class=\"token punctuation\">}</span><span class=\"token punctuation\">}</span></span><span class=\"token punctuation\">></span></span><span class=\"token plain-text\">\r\n      </span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span><span class=\"token class-name\">View</span></span> <span class=\"token attr-name\">style</span><span class=\"token script language-javascript\"><span class=\"token script-punctuation punctuation\">=</span><span class=\"token punctuation\">{</span><span class=\"token punctuation\">{</span> flex<span class=\"token operator\">:</span> <span class=\"token number\">1</span><span class=\"token punctuation\">,</span> minHeight<span class=\"token operator\">:</span> <span class=\"token number\">0</span><span class=\"token punctuation\">,</span> height<span class=\"token operator\">:</span> <span class=\"token string\">'100%'</span><span class=\"token punctuation\">,</span> width<span class=\"token operator\">:</span> <span class=\"token string\">'100%'</span> <span class=\"token punctuation\">}</span><span class=\"token punctuation\">}</span></span> <span class=\"token attr-name\">collapsable</span><span class=\"token script language-javascript\"><span class=\"token script-punctuation punctuation\">=</span><span class=\"token punctuation\">{</span><span class=\"token boolean\">false</span><span class=\"token punctuation\">}</span></span><span class=\"token punctuation\">></span></span><span class=\"token plain-text\">\r\n        </span><span class=\"token punctuation\">{</span>children<span class=\"token punctuation\">}</span><span class=\"token plain-text\">\r\n      </span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;/</span><span class=\"token class-name\">View</span></span><span class=\"token punctuation\">></span></span><span class=\"token plain-text\">\r\n      </span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span><span class=\"token class-name\">View</span></span>\r\n        <span class=\"token attr-name\">id</span><span class=\"token attr-value\"><span class=\"token punctuation attr-equals\">=</span><span class=\"token punctuation\">\"</span>expo-web-modal-shell-host<span class=\"token punctuation\">\"</span></span>\r\n        <span class=\"token attr-name\">ref</span><span class=\"token script language-javascript\"><span class=\"token script-punctuation punctuation\">=</span><span class=\"token punctuation\">{</span>setHostRef<span class=\"token punctuation\">}</span></span>\r\n        <span class=\"token attr-name\">collapsable</span><span class=\"token script language-javascript\"><span class=\"token script-punctuation punctuation\">=</span><span class=\"token punctuation\">{</span><span class=\"token boolean\">false</span><span class=\"token punctuation\">}</span></span>\r\n        <span class=\"token attr-name\">style</span><span class=\"token script language-javascript\"><span class=\"token script-punctuation punctuation\">=</span><span class=\"token punctuation\">{</span><span class=\"token punctuation\">{</span>\r\n          position<span class=\"token operator\">:</span> <span class=\"token string\">'absolute'</span><span class=\"token punctuation\">,</span>\r\n          left<span class=\"token operator\">:</span> <span class=\"token number\">0</span><span class=\"token punctuation\">,</span>\r\n          right<span class=\"token operator\">:</span> <span class=\"token number\">0</span><span class=\"token punctuation\">,</span>\r\n          top<span class=\"token operator\">:</span> <span class=\"token number\">0</span><span class=\"token punctuation\">,</span>\r\n          bottom<span class=\"token operator\">:</span> <span class=\"token number\">0</span><span class=\"token punctuation\">,</span>\r\n          zIndex<span class=\"token operator\">:</span> <span class=\"token number\">1_000_000</span><span class=\"token punctuation\">,</span>\r\n        <span class=\"token punctuation\">}</span><span class=\"token punctuation\">}</span></span>\r\n      <span class=\"token punctuation\">/></span></span><span class=\"token plain-text\">\r\n    </span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;/</span><span class=\"token class-name\">View</span></span><span class=\"token punctuation\">></span></span><span class=\"token plain-text\">\r\n  </span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;/</span><span class=\"token class-name\">WebModalShellContext.Provider</span></span><span class=\"token punctuation\">></span></span>\r\n<span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span></code></pre></div>\n<p>Then <code class=\"language-text\">Modal.web.tsx</code> does the obvious-not-obvious thing and switches between a portal inside that host and the normal RN modal:</p>\n<div class=\"gatsby-highlight\" data-language=\"tsx\"><pre class=\"language-tsx\"><code class=\"language-tsx\"><span class=\"token keyword\">const</span> useShellPortal <span class=\"token operator\">=</span> Platform<span class=\"token punctuation\">.</span><span class=\"token constant\">OS</span> <span class=\"token operator\">===</span> <span class=\"token string\">'web'</span> <span class=\"token operator\">&amp;&amp;</span> isDesktopFrame <span class=\"token operator\">&amp;&amp;</span> hostElement <span class=\"token operator\">!=</span> <span class=\"token keyword\">null</span><span class=\"token punctuation\">;</span>\r\n\r\n<span class=\"token keyword\">if</span> <span class=\"token punctuation\">(</span>useShellPortal<span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\r\n  <span class=\"token keyword\">return</span> <span class=\"token function\">createPortal</span><span class=\"token punctuation\">(</span>\r\n    <span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span><span class=\"token class-name\">View</span></span>\r\n      <span class=\"token attr-name\">style</span><span class=\"token script language-javascript\"><span class=\"token script-punctuation punctuation\">=</span><span class=\"token punctuation\">{</span><span class=\"token punctuation\">{</span>\r\n        position<span class=\"token operator\">:</span> <span class=\"token string\">'absolute'</span><span class=\"token punctuation\">,</span>\r\n        left<span class=\"token operator\">:</span> <span class=\"token number\">0</span><span class=\"token punctuation\">,</span>\r\n        right<span class=\"token operator\">:</span> <span class=\"token number\">0</span><span class=\"token punctuation\">,</span>\r\n        top<span class=\"token operator\">:</span> <span class=\"token number\">0</span><span class=\"token punctuation\">,</span>\r\n        bottom<span class=\"token operator\">:</span> <span class=\"token number\">0</span><span class=\"token punctuation\">,</span>\r\n        width<span class=\"token operator\">:</span> <span class=\"token string\">'100%'</span><span class=\"token punctuation\">,</span>\r\n        height<span class=\"token operator\">:</span> <span class=\"token string\">'100%'</span><span class=\"token punctuation\">,</span>\r\n      <span class=\"token punctuation\">}</span><span class=\"token punctuation\">}</span></span>\r\n    <span class=\"token punctuation\">></span></span><span class=\"token plain-text\">\r\n      </span><span class=\"token punctuation\">{</span>children<span class=\"token punctuation\">}</span><span class=\"token plain-text\">\r\n    </span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;/</span><span class=\"token class-name\">View</span></span><span class=\"token punctuation\">></span></span><span class=\"token punctuation\">,</span>\r\n    hostElement\r\n  <span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n<span class=\"token punctuation\">}</span>\r\n\r\n<span class=\"token keyword\">return</span> <span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span><span class=\"token class-name\">RNModal</span></span> <span class=\"token attr-name\">visible</span><span class=\"token script language-javascript\"><span class=\"token script-punctuation punctuation\">=</span><span class=\"token punctuation\">{</span>visible<span class=\"token punctuation\">}</span></span><span class=\"token punctuation\">></span></span><span class=\"token punctuation\">{</span>children<span class=\"token punctuation\">}</span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;/</span><span class=\"token class-name\">RNModal</span></span><span class=\"token punctuation\">></span></span><span class=\"token punctuation\">;</span></code></pre></div>\n<p>And the shared overlay hook knows whether it should behave like a full viewport modal or a fake-phone viewport modal:</p>\n<div class=\"gatsby-highlight\" data-language=\"tsx\"><pre class=\"language-tsx\"><code class=\"language-tsx\"><span class=\"token keyword\">export</span> <span class=\"token keyword\">function</span> <span class=\"token function\">useWebModalLayerStyle</span><span class=\"token punctuation\">(</span>options <span class=\"token operator\">=</span> <span class=\"token punctuation\">{</span><span class=\"token punctuation\">}</span><span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\r\n  <span class=\"token keyword\">const</span> isDesktopFrame <span class=\"token operator\">=</span> <span class=\"token function\">useWebDesktopPhoneFrame</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n\r\n  <span class=\"token keyword\">if</span> <span class=\"token punctuation\">(</span>isDesktopFrame<span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\r\n    <span class=\"token keyword\">return</span> <span class=\"token punctuation\">{</span>\r\n      position<span class=\"token operator\">:</span> <span class=\"token string\">'absolute'</span><span class=\"token punctuation\">,</span>\r\n      top<span class=\"token operator\">:</span> <span class=\"token number\">0</span><span class=\"token punctuation\">,</span>\r\n      left<span class=\"token operator\">:</span> <span class=\"token number\">0</span><span class=\"token punctuation\">,</span>\r\n      right<span class=\"token operator\">:</span> <span class=\"token number\">0</span><span class=\"token punctuation\">,</span>\r\n      bottom<span class=\"token operator\">:</span> <span class=\"token number\">0</span><span class=\"token punctuation\">,</span>\r\n      width<span class=\"token operator\">:</span> <span class=\"token string\">'100%'</span><span class=\"token punctuation\">,</span>\r\n      height<span class=\"token operator\">:</span> <span class=\"token string\">'100%'</span><span class=\"token punctuation\">,</span>\r\n    <span class=\"token punctuation\">}</span><span class=\"token punctuation\">;</span>\r\n  <span class=\"token punctuation\">}</span>\r\n\r\n  <span class=\"token keyword\">return</span> <span class=\"token punctuation\">{</span>\r\n    position<span class=\"token operator\">:</span> <span class=\"token string\">'fixed'</span><span class=\"token punctuation\">,</span>\r\n    top<span class=\"token operator\">:</span> <span class=\"token number\">0</span><span class=\"token punctuation\">,</span>\r\n    left<span class=\"token operator\">:</span> <span class=\"token number\">0</span><span class=\"token punctuation\">,</span>\r\n    right<span class=\"token operator\">:</span> <span class=\"token number\">0</span><span class=\"token punctuation\">,</span>\r\n    bottom<span class=\"token operator\">:</span> <span class=\"token number\">0</span><span class=\"token punctuation\">,</span>\r\n    width<span class=\"token operator\">:</span> <span class=\"token string\">'100vw'</span><span class=\"token punctuation\">,</span>\r\n    height<span class=\"token operator\">:</span> <span class=\"token string\">'100dvh'</span><span class=\"token punctuation\">,</span>\r\n  <span class=\"token punctuation\">}</span><span class=\"token punctuation\">;</span>\r\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>That fixed the visual part. Then pointer events decided they wanted attention too.</p>\n<p>On native, <code class=\"language-text\">pointerEvents=\"box-none\"</code> is normal. On HTML, that turns into <code class=\"language-text\">pointer-events: box-none</code>, which is not real CSS, which means the browser gets to improvise. So there is now a defensive rule for the overlay host:</p>\n<div class=\"gatsby-highlight\" data-language=\"css\"><pre class=\"language-css\"><code class=\"language-css\"><span class=\"token selector\">.expo-web-app-shell #expo-web-modal-shell-host</span> <span class=\"token punctuation\">{</span>\r\n  <span class=\"token property\">pointer-events</span><span class=\"token punctuation\">:</span> none <span class=\"token important\">!important</span><span class=\"token punctuation\">;</span>\r\n<span class=\"token punctuation\">}</span>\r\n\r\n<span class=\"token selector\">.expo-web-app-shell #expo-web-modal-shell-host *</span> <span class=\"token punctuation\">{</span>\r\n  <span class=\"token property\">pointer-events</span><span class=\"token punctuation\">:</span> auto <span class=\"token important\">!important</span><span class=\"token punctuation\">;</span>\r\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>There is also a <code class=\"language-text\">useLayoutEffect</code> forcing the same thing inline, because by that point I was no longer interested in finding out how polite the correct solution was.</p>\n<h2 id=\"look-i-have-a-type\" style=\"position:relative;\"><a href=\"#look-i-have-a-type\" aria-label=\"look i have a type permalink\" class=\"post-headers-link before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Look, I have a type</h2>\n<p>If you’ve been reading this blog for a while, none of this should surprise you.</p>\n<p>I already <a href=\"/en/blog/coding/i-made-a-top-down-game-version-of-my-blog-with-phaser-and-react/\">turned this blog into a top-down RPG</a> because the Konami Code deserved a bigger payoff than just a background effect.</p>\n<p>Then I <a href=\"/en/blog/coding/my-blog-now-has-stories-and-im-not-sure-why/\">added Stories to the blog</a> because my internet provider’s app had Stories, and something about that annoyed me deeply enough that I made it everybody else’s problem.</p>\n<p>So yes, of course my fitness app now ships its website from the same Expo Router repo, and of course the desktop web app runs inside a giant picture of a phone. This is not a surprising escalation. This is just continuity.</p>\n<h2 id=\"why-im-keeping-it\" style=\"position:relative;\"><a href=\"#why-im-keeping-it\" aria-label=\"why im keeping it permalink\" class=\"post-headers-link before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Why I’m keeping it</h2>\n<p>Past the comedy value, this fixed a very real maintenance tax.</p>\n<p>Translations are shared. Design tokens are shared. Components are shared. Legal pages live next to the product they are legally defending. When I change copy, screenshots, or feature positioning, I don’t need to remember which repo holds the respectable public version of the same app.</p>\n<p>Deploy also got nicer. One build. One export. One artifact. The website and the web app cannot drift apart unless I deliberately make them drift apart, which is much harder when they are literally shipped together.</p>\n<p>And when I shipped the <a href=\"/en/blog/coding/musclog-redesign-nutrition-tracking-and-why-your-fitness-app-subscription-is-a-scam/\">Musclog redesign</a>, the website basically came along for the ride. Same spacing tokens, same colors, updated screenshots, same PR. Exactly the kind of boring maintenance win that makes a slightly weird architecture decision feel smart six months later.</p>\n<h2 id=\"conclusion\" style=\"position:relative;\"><a href=\"#conclusion\" aria-label=\"conclusion permalink\" class=\"post-headers-link before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Conclusion</h2>\n<p>Could I have kept the website in a separate Next.js repo like a more emotionally stable person? Sure.</p>\n<p>Was I ever going to keep doing that once Expo Router made this possible? No.</p>\n<p>The funny part is that the routing was the boring part. Expo Router handled that just fine. The annoying work lived in all the little browser lies around it: route gating before hydration, raw HTML i18n, asset syncing, shell-aware modals, pointer-event weirdness, and convincing desktop Chrome to temporarily believe it was a phone.</p>\n<p>Still worth it.</p>\n<p>If you want to poke around the code, <a href=\"https://github.com/blopa/musclog-app\" target=\"_blank\" rel=\"noreferrer\">Musclog is open-source on GitHub</a>. If you want to use it, it’s at <a href=\"https://musclog.app\" target=\"_blank\" rel=\"noreferrer\">musclog.app</a>. The website lives in the app repo now.</p>\n<p>Everybody lives here now.</p>","fields":{"postHashId":"Y29kaW5ndHJ1ZW51bGwyMDI2LTA0LTI1VDAwOjAwOjAwLjAwMFo=","slug":"/2026/2026-04-25-website-or-app-yes-hacky-expo-router-guide.en/","path":"/blog/coding/website-or-app-yes-hacky-expo-router-guide/","locale":"en"},"readingTime":{"minutes":10.545},"frontmatter":{"path":"website-or-app-yes-hacky-expo-router-guide","allowComments":true,"title":"Website or App? Yes! The hacky guide to building sites with Expo Router","date":"2026-04-25T00:00:00.000Z","categories":["coding"],"tags":["expo","expo router","react native","musclog","website","monorepo","javascript","typescript"],"hideExcerpt":false,"subtitle":"The routing part was easy. The fake phone in the browser was not."}},"categoryImage":{"childImageSharp":{"original":{"width":1920,"height":1080,"src":"/static/categories_coding-aa17d098a6a0d8e0a764a345865f5ffb.jpg"}}}},"pageContext":{"postHashId":"Y29kaW5ndHJ1ZW51bGwyMDI2LTA0LTI1VDAwOjAwOjAwLjAwMFo=","relatedPosts":[{"fields":{"postHashId":"Y29kaW5ndHJ1ZW51bGwyMDI2LTAzLTIyVDAwOjAwOjAwLjAwMFo=","slug":"/2026/2026-03-22-musclog-redesign-nutrition-tracking-and-why-your-fitness-app-subscription-is-a-scam.en/","path":"/blog/coding/musclog-redesign-nutrition-tracking-and-why-your-fitness-app-subscription-is-a-scam/","locale":"en"},"frontmatter":{"tags":["react","react native","expo","android","fitness","musclog","nutrition","usda","open food facts","stitch","design","offline","watermelondb","nativewind"],"categories":["coding"],"allowComments":true,"publishOnMedium":false,"cover":null,"date":"2026-03-22T00:00:00.000Z","id":null,"path":"musclog-redesign-nutrition-tracking-and-why-your-fitness-app-subscription-is-a-scam","show":true,"title":"Musclog: Redesign nutrition tracking and why your fitness app subscription is a scam","hideExcerpt":false,"subtitle":"New UI, real food databases, and a rant about fitness app subscriptions I've been sitting on for two years"}},{"fields":{"postHashId":"Y29kaW5ndHJ1ZW51bGwyMDI0LTA5LTIxVDAwOjAwOjAwLjAwMFo=","slug":"/2024/2024-09-21-musclog-leveraging-my-reactjs-experience-to-build-a-react-native-app.en/","path":"/blog/coding/musclog-leveraging-my-reactjs-experience-to-build-a-react-native-app/","locale":"en"},"frontmatter":{"tags":["react","react native","expo","android","workout","tracker","logger","musclog"],"categories":["coding"],"allowComments":true,"publishOnMedium":false,"cover":null,"date":"2024-09-21T00:00:00.000Z","id":null,"path":"musclog-leveraging-my-reactjs-experience-to-build-a-react-native-app","show":true,"title":"Musclog: Leveraging my React.js experience to build a React Native App","hideExcerpt":false,"subtitle":"From Bodybuilding to Codebuilding: Crafting a comprehensive fitness App using React Native and Expo"}},{"fields":{"postHashId":"Y29kaW5ndHJ1ZW51bGwyMDI2LTAyLTEyVDAwOjAwOjAwLjAwMFo=","slug":"/2026/2026-02-12-why-the-7700-calorie-rule-is-broken-and-how-i-fixed-it-in-my-app.en/","path":"/blog/coding/why-the-7700-calorie-rule-is-broken-and-how-i-fixed-it-in-my-app/","locale":"en"},"frontmatter":{"tags":["typescript","musclog","algorithms","fitness","nutrition","bodybuilding"],"categories":["coding"],"allowComments":true,"publishOnMedium":false,"cover":null,"date":"2026-02-12T00:00:00.000Z","id":null,"path":"why-the-7700-calorie-rule-is-broken-and-how-i-fixed-it-in-my-app","show":true,"title":"Why the 7700 calorie rule is broken (and how I fixed it in my app)","hideExcerpt":false,"subtitle":"Spoiler: your body did not read the rulebook"}},{"fields":{"postHashId":"Y29kaW5ndHJ1ZW51bGwyMDI0LTEwLTI1VDAwOjAwOjAwLjAwMFo=","slug":"/2024/2024-10-25-sharing-encrypted-laravel-cookies-with-next-js.en/","path":"/blog/coding/sharing-encrypted-laravel-cookies-with-next-js/","locale":"en"},"frontmatter":{"tags":["laravel","next.js","cookies","encryption","synchronization","web development","php","javascript"],"categories":["coding"],"allowComments":true,"publishOnMedium":false,"cover":null,"date":"2024-10-25T00:00:00.000Z","id":null,"path":"sharing-encrypted-laravel-cookies-with-next-js","show":true,"title":"Sharing encrypted Laravel cookies with Next.js","hideExcerpt":false,"subtitle":"Bridging Laravel and Next.js: How we synchronized encrypted cookies between two different frameworks."}},{"fields":{"postHashId":"Y29kaW5ndHJ1ZW51bGwyMDI0LTA2LTE2VDAwOjAwOjAwLjAwMFo=","slug":"/2024/2024-06-16-tracking-my-working-hours-on-personal-projects-ssing-nodejs.en/","path":"/blog/coding/tracking-my-working-hours-on-personal-projects-ssing-nodejs/","locale":"en"},"frontmatter":{"tags":["nodejs","git","foss","javascript","simple-git","math"],"categories":["coding"],"allowComments":true,"publishOnMedium":false,"cover":null,"date":"2024-06-16T00:00:00.000Z","id":null,"path":"tracking-my-working-hours-on-personal-projects-using-nodejs","show":true,"title":"Tracking my working hours on personal projects using Node.js","hideExcerpt":false,"subtitle":"Finding out the hard way that some information are not supposed to be known"}},{"fields":{"postHashId":"Y29kaW5ndHJ1ZW51bGwyMDIzLTEyLTIwVDAwOjAwOjAwLjAwMFo=","slug":"/2023/2023-12-20-creating-instagram-reels-coding-tutorials-automatically-with-openais-gpt.en/","path":"/blog/coding/creating-instagram-reels-coding-tutorials-automatically-with-openais-gpt/","locale":"en"},"frontmatter":{"tags":["openai","gpt","javascript","node","nodejs","instagram"],"categories":["coding"],"allowComments":true,"publishOnMedium":false,"cover":null,"date":"2023-12-20T00:00:00.000Z","id":null,"path":"creating-instagram-reels-coding-tutorials-automatically-with-openais-gpt","show":true,"title":"Creating Instagram Reels coding tutorials automatically with OpenAI's GPT","hideExcerpt":false,"subtitle":"Node.js and OpenAI: auto-generating coding tutorial videos."}},{"fields":{"postHashId":"Y29kaW5ndHJ1ZW51bGwyMDIzLTEwLTI1VDAwOjAwOjAwLjAwMFo=","slug":"/2023/2023-10-25-how-to-automatically-create-thumbnails-for-your-videos-with-nodejs.en/","path":"/blog/coding/how-to-automatically-create-thumbnails-for-your-videos-with-nodejs/","locale":"en"},"frontmatter":{"tags":["instagram","thumbnail","node","nodejs","javascript","canvas","image","video"],"categories":["coding"],"allowComments":true,"publishOnMedium":false,"cover":null,"date":"2023-10-25T00:00:00.000Z","id":null,"path":"how-to-automatically-create-thumbnails-for-your-videos-with-nodejs","show":true,"title":"How to automatically create thumbnails for your videos with Node.js","hideExcerpt":false,"subtitle":"The best way to create thumbnails for your videos on Instagram"}},{"fields":{"postHashId":"Y29kaW5ndHJ1ZW51bGwyMDIzLTEwLTE0VDAwOjAwOjAwLjAwMFo=","slug":"/2023/2023-10-14-posting-instagram-reels-using-nodejs-and-the-art-of-overcoming-limitations.en/","path":"/blog/coding/posting-instagram-reels-using-nodejs-and-the-art-of-overcoming-limitations/","locale":"en"},"frontmatter":{"tags":["instagram","node","nodejs","javascript","axios","ngrok","ffmpeg","automation"],"categories":["coding"],"allowComments":true,"publishOnMedium":false,"cover":null,"date":"2023-10-14T00:00:00.000Z","id":null,"path":"posting-instagram-reels-using-nodejs-and-the-art-of-overcoming-limitations","show":true,"title":"Posting Instagram Reels using Node.js and the art of overcoming limitations","hideExcerpt":false,"subtitle":"After all, why spend 30 seconds doing something manually when you can spend 30 hours automating it?"}},{"fields":{"postHashId":"Y29kaW5ndHJ1ZW51bGwyMDIzLTA5LTA0VDAwOjAwOjAwLjAwMFo=","slug":"/2023/2023-09-04-my-blog-now-has-stories-and-im-not-sure-why.en/","path":"/blog/coding/my-blog-now-has-stories-and-im-not-sure-why/","locale":"en"},"frontmatter":{"tags":["instagram","instagram stories","unnecessary inventions","gatsby","react","javascript"],"categories":["coding"],"allowComments":true,"publishOnMedium":false,"cover":null,"date":"2023-09-04T00:00:00.000Z","id":null,"path":"my-blog-now-has-stories-and-im-not-sure-why","show":true,"title":"My blog now has Stories, and I'm not sure why","hideExcerpt":false,"subtitle":"The saga of \"Well, It's here now\""}},{"fields":{"postHashId":"Y29kaW5ndHJ1ZW51bGwyMDIzLTA4LTAyVDAwOjAwOjAwLjAwMFo=","slug":"/2023/2023-08-02-using-google-fitness-api-to-calculate-my-tdee-and-more.en/","path":"/blog/coding/using-google-fitness-api-to-calculate-my-tdee-and-more/","locale":"en"},"frontmatter":{"tags":["google cloud","fitness api","google fit","nodejs","node","javascript"],"categories":["coding"],"allowComments":true,"publishOnMedium":false,"cover":null,"date":"2023-08-02T00:00:00.000Z","id":null,"path":"using-google-fitness-api-to-calculate-my-tdee-and-more","show":true,"title":"Using Google Fitness API to calculate my TDEE and more","hideExcerpt":false,"subtitle":"From calories to code: Integrating Google Fitness API for personalized health insights"}}],"alternativeHtml":"<p>One of the annoying little side quests of shipping an app is that Google occasionally forces you to become a website owner too.</p>\n<p>Back when I <a href=\"/en/blog/coding/musclog-leveraging-my-reactjs-experience-to-build-a-react-native-app/\">first wrote about Musclog in 2024</a>, the website mostly existed because Google wanted it to exist. I needed the privacy policy, the public pages, the whole respectable app developer starter pack, so I quickly spun up a small Next.js site, got the app approved, and moved on with my life.</p>\n<p>That worked right until I had to touch the website again.</p>\n<p>New screenshots? Other repo. New feature copy? Other repo. Legal page update? Other repo. Translation tweak? Other repo.</p>\n<p>None of that was actually hard, which almost made it worse. It was just annoying enough to keep reminding me that I had split one product into two codebases for a reason I no longer respected.</p>\n<p>So now I don’t.</p>\n<p>Musclog’s website lives inside the app repo. Same Expo Router project. Same deploy. <a href=\"https://musclog.app/\" target=\"_blank\" rel=\"noreferrer\">musclog.app</a> is the public site, <a href=\"https://musclog.app/app\" target=\"_blank\" rel=\"noreferrer\">musclog.app/app</a> is the actual web app, and Android + iOS still come from the same codebase.</p>\n<p>Best practice? Questionable. Convenient? Extremely.</p>\n<p></p><figure class=\"gatsby-resp-image-figure\" style=\"\">\n    <span class=\"gatsby-resp-image-wrapper\" style=\"position: relative; display: block; margin-left: auto; margin-right: auto; max-width: 1024px; \">\n      <span class=\"gatsby-resp-image-background-image\" style=\"padding-bottom: 61.71875%; position: relative; bottom: 0; left: 0; background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAMCAIAAADtbgqsAAAACXBIWXMAAAsTAAALEwEAmpwYAAAB0ElEQVQoz2WRTW7bMBCF1YVjcSiL4j8pkbRkxQri2G4c20mzyC5IWyDdNAVa9Bg9Q7e9StGz9EQFZcVNUeDbzOA9vuFMkjIS4YQEgwVDrECHTg8SNDMCK4E3Dl9XEy0mTqeKprxIGUlAUJAUBB3l2QnNxyxHfTmgOJQyDZKvVvrtx1fvb9C6wV6B5SBZctSdPV6fPWxn3dw4N1GibzJQDDs9qsn89uv+x+/k13d43OFKQiWfzbzAkq0/v1t8ul89PdC7dWo48H4ixcAp8Ao+vB7//DK+36XLJmstlBLUMZnTfNmQN4ti09GbBa4UiGIwVxJ7nd2d4m/7UefR3GWnJVgBkg5mJGjpvbHW1VMm46ugOdYcDMelhKCKrhbL9eiihc5ltQbD/5ohblWClVAZmFY4GOxlJMgsqCyoSaPRbX2y9RBU5iVoBuKFOWIkrh1uA24qPDNZayMzndUKnCCdy9sSLOtj/zdLBkaAldhKsBz1wAHDkKLximo4bTQjyY5AzwkjZjY9314udpuL3dX51aVqwpgX8EJ5IIkzPIM0Q7oP15w4w0LJQplXuk/msT8w6JO4dBtrMve8DcRbpNnEKeotMSrXEktG9jWZlv2P/uEPhuJAcZJWdDcAAAAASUVORK5CYII='); background-size: cover; display: block;\"></span>\n  <img class=\"gatsby-resp-image-image\" alt=\"Musclog public website rendered from the same Expo Router codebase as the app\" title=\"Musclog public website rendered from the same Expo Router codebase as the app\" src=\"/static/f71a459f3034e47544de2f3ce309dd61/42a19/musclog-shared-router-website-home-1.png\" srcset=\"/static/f71a459f3034e47544de2f3ce309dd61/e3135/musclog-shared-router-website-home-1.png 256w,\n/static/f71a459f3034e47544de2f3ce309dd61/06341/musclog-shared-router-website-home-1.png 512w,\n/static/f71a459f3034e47544de2f3ce309dd61/42a19/musclog-shared-router-website-home-1.png 1024w,\n/static/f71a459f3034e47544de2f3ce309dd61/e8464/musclog-shared-router-website-home-1.png 1536w,\n/static/f71a459f3034e47544de2f3ce309dd61/2eb59/musclog-shared-router-website-home-1.png 2048w,\n/static/f71a459f3034e47544de2f3ce309dd61/d7fb2/musclog-shared-router-website-home-1.png 2184w\" sizes=\"(max-width: 1024px) 100vw, 1024px\" style=\"width:100%;height:100%;margin:0;vertical-align:middle;position:absolute;top:0;left:0;\" loading=\"lazy\" decoding=\"async\">\n    </span>\n    <figcaption class=\"gatsby-resp-image-figcaption\">Musclog public website rendered from the same Expo Router codebase as the app</figcaption>\n  </figure><p></p>\n<h2 id=\"ok-but-why\" style=\"position:relative;\"><a href=\"#ok-but-why\" aria-label=\"ok but why permalink\" class=\"post-headers-link before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Ok but why?</h2>\n<p>Basically because Expo Router removed my last excuse.</p>\n<p>Musclog already used <a href=\"https://expo.github.io/router/docs\" target=\"_blank\" rel=\"noreferrer\">Expo Router</a>, so the app was already organized as file-based routes inside <code class=\"language-text\">app/</code>. Then I realized I could just create a route group called <code class=\"language-text\">(website)</code>, move the public pages there, keep the actual app under <code class=\"language-text\">app/app/*</code>, and stop pretending these were two different products.</p>\n<p>The separate repo had become one of those architecture decisions that sounds clean in theory and then quietly charges you maintenance tax forever:</p>\n<ul>\n<li>two PRs for one product change</li>\n<li>two deployments</li>\n<li>two places for translations</li>\n<li>two places for legal pages</li>\n<li>two places to forget something</li>\n</ul>\n<p>No thanks.</p>\n<h2 id=\"the-split\" style=\"position:relative;\"><a href=\"#the-split\" aria-label=\"the split permalink\" class=\"post-headers-link before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The split</h2>\n<p>The funny part is that the routing itself ended up being the least cursed part of the whole thing:</p>\n<ul>\n<li><code class=\"language-text\">app/app/*</code> is the actual Musclog app. Workouts, nutrition, AI coach, all of it.</li>\n<li><code class=\"language-text\">app/(website)/*</code> is the public website. Landing page, privacy policy, terms, contact page, calculator.</li>\n</ul>\n<p>The root route just checks the platform and sends you where you belong:</p>\n<div class=\"copy-code-block\"><button tabindex=\"0\" type=\"button\" class=\"MuiButtonBase-root MuiButton-root MuiButton-contained MuiButton-containedPrimary copy-code-button\"><span class=\"MuiButton-label\">Copy</span></button><div class=\"gatsby-highlight\" data-language=\"tsx\"><pre class=\"language-tsx\"><code class=\"language-tsx\"><span class=\"token comment\">// app/index.tsx</span>\n<span class=\"token keyword\">import</span> <span class=\"token punctuation\">{</span> Redirect<span class=\"token punctuation\">,</span> useRouter <span class=\"token punctuation\">}</span> <span class=\"token keyword\">from</span> <span class=\"token string\">'expo-router'</span><span class=\"token punctuation\">;</span>\n<span class=\"token keyword\">import</span> <span class=\"token punctuation\">{</span> useEffect <span class=\"token punctuation\">}</span> <span class=\"token keyword\">from</span> <span class=\"token string\">'react'</span><span class=\"token punctuation\">;</span>\n<span class=\"token keyword\">import</span> <span class=\"token punctuation\">{</span> Platform <span class=\"token punctuation\">}</span> <span class=\"token keyword\">from</span> <span class=\"token string\">'react-native'</span><span class=\"token punctuation\">;</span>\n\n<span class=\"token keyword\">export</span> <span class=\"token keyword\">default</span> <span class=\"token keyword\">function</span> <span class=\"token function\">Index</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\n  <span class=\"token keyword\">const</span> router <span class=\"token operator\">=</span> <span class=\"token function\">useRouter</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n\n  <span class=\"token function\">useEffect</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">=&gt;</span> <span class=\"token punctuation\">{</span>\n    <span class=\"token keyword\">if</span> <span class=\"token punctuation\">(</span>Platform<span class=\"token punctuation\">.</span><span class=\"token constant\">OS</span> <span class=\"token operator\">===</span> <span class=\"token string\">'web'</span><span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\n      router<span class=\"token punctuation\">.</span><span class=\"token function\">replace</span><span class=\"token punctuation\">(</span><span class=\"token string\">'/home'</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n    <span class=\"token punctuation\">}</span>\n  <span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span> <span class=\"token punctuation\">[</span>router<span class=\"token punctuation\">]</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n\n  <span class=\"token keyword\">if</span> <span class=\"token punctuation\">(</span>Platform<span class=\"token punctuation\">.</span><span class=\"token constant\">OS</span> <span class=\"token operator\">===</span> <span class=\"token string\">'web'</span><span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\n    <span class=\"token keyword\">return</span> <span class=\"token keyword\">null</span><span class=\"token punctuation\">;</span>\n  <span class=\"token punctuation\">}</span>\n\n  <span class=\"token keyword\">return</span> <span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span><span class=\"token class-name\">Redirect</span></span> <span class=\"token attr-name\">href</span><span class=\"token attr-value\"><span class=\"token punctuation attr-equals\">=</span><span class=\"token punctuation\">\"</span>/app<span class=\"token punctuation\">\"</span></span> <span class=\"token punctuation\">/&gt;</span></span><span class=\"token punctuation\">;</span>\n<span class=\"token punctuation\">}</span></code></pre></div></div>\n<p>That’s it. On web, <code class=\"language-text\">/</code> becomes the website. On native, <code class=\"language-text\">/</code> becomes the app.</p>\n<p>The website also gets its own lighter layout:</p>\n<div class=\"copy-code-block\"><button tabindex=\"1\" type=\"button\" class=\"MuiButtonBase-root MuiButton-root MuiButton-contained MuiButton-containedPrimary copy-code-button\"><span class=\"MuiButton-label\">Copy</span></button><div class=\"gatsby-highlight\" data-language=\"tsx\"><pre class=\"language-tsx\"><code class=\"language-tsx\"><span class=\"token comment\">// app/(website)/_layout.web.tsx</span>\n<span class=\"token keyword\">import</span> <span class=\"token punctuation\">{</span> Slot <span class=\"token punctuation\">}</span> <span class=\"token keyword\">from</span> <span class=\"token string\">'expo-router'</span><span class=\"token punctuation\">;</span>\n\n<span class=\"token keyword\">import</span> <span class=\"token punctuation\">{</span> WebsiteChrome <span class=\"token punctuation\">}</span> <span class=\"token keyword\">from</span> <span class=\"token string\">'@/components/website/WebsiteChrome'</span><span class=\"token punctuation\">;</span>\n<span class=\"token keyword\">import</span> <span class=\"token punctuation\">{</span> WebsiteProviders <span class=\"token punctuation\">}</span> <span class=\"token keyword\">from</span> <span class=\"token string\">'@/components/website/WebsiteProviders'</span><span class=\"token punctuation\">;</span>\n\n<span class=\"token keyword\">export</span> <span class=\"token keyword\">default</span> <span class=\"token keyword\">function</span> <span class=\"token function\">WebsiteLayout</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\n  <span class=\"token keyword\">return</span> <span class=\"token punctuation\">(</span>\n    <span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span><span class=\"token class-name\">WebsiteProviders</span></span><span class=\"token punctuation\">&gt;</span></span><span class=\"token plain-text\">\n      </span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span><span class=\"token class-name\">WebsiteChrome</span></span><span class=\"token punctuation\">&gt;</span></span><span class=\"token plain-text\">\n        </span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span><span class=\"token class-name\">Slot</span></span> <span class=\"token punctuation\">/&gt;</span></span><span class=\"token plain-text\">\n      </span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;/</span><span class=\"token class-name\">WebsiteChrome</span></span><span class=\"token punctuation\">&gt;</span></span><span class=\"token plain-text\">\n    </span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;/</span><span class=\"token class-name\">WebsiteProviders</span></span><span class=\"token punctuation\">&gt;</span></span>\n  <span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n<span class=\"token punctuation\">}</span></code></pre></div></div>\n<p>That matters because the real app layout had already grown into an actual app layout. Database boot, migrations, React Query, safe area, gestures, modals, snackbars, camera, coach context, all the usual “small side project” lies. The website does not need any of that just to explain what the app does.</p>\n<p>And if a native user somehow lands on a website-only route, the fix is beautifully blunt:</p>\n<div class=\"copy-code-block\"><button tabindex=\"2\" type=\"button\" class=\"MuiButtonBase-root MuiButton-root MuiButton-contained MuiButton-containedPrimary copy-code-button\"><span class=\"MuiButton-label\">Copy</span></button><div class=\"gatsby-highlight\" data-language=\"tsx\"><pre class=\"language-tsx\"><code class=\"language-tsx\"><span class=\"token comment\">// app/(website)/home.tsx</span>\n<span class=\"token keyword\">import</span> <span class=\"token punctuation\">{</span> Redirect <span class=\"token punctuation\">}</span> <span class=\"token keyword\">from</span> <span class=\"token string\">'expo-router'</span><span class=\"token punctuation\">;</span>\n\n<span class=\"token keyword\">export</span> <span class=\"token keyword\">default</span> <span class=\"token keyword\">function</span> <span class=\"token function\">Home</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\n  <span class=\"token keyword\">return</span> <span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span><span class=\"token class-name\">Redirect</span></span> <span class=\"token attr-name\">href</span><span class=\"token attr-value\"><span class=\"token punctuation attr-equals\">=</span><span class=\"token punctuation\">\"</span>/app<span class=\"token punctuation\">\"</span></span> <span class=\"token punctuation\">/&gt;</span></span><span class=\"token punctuation\">;</span>\n<span class=\"token punctuation\">}</span></code></pre></div></div>\n<p>Done. Back to your macros.</p>\n<p></p><figure class=\"gatsby-resp-image-figure\" style=\"\">\n    <span class=\"gatsby-resp-image-wrapper\" style=\"position: relative; display: block; margin-left: auto; margin-right: auto; max-width: 1024px; \">\n      <span class=\"gatsby-resp-image-background-image\" style=\"padding-bottom: 61.71875%; position: relative; bottom: 0; left: 0; background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAMCAIAAADtbgqsAAAACXBIWXMAAAsTAAALEwEAmpwYAAABt0lEQVQoz02SWW/jMAyE/RaLdqyLkiXfpxznstOi6S6w//93LZS4aYB5IuYDhkMGO554iWRfpSBZyJNQUoIslDREClrEBkHweLbxRxkpGWuxITwJQIuniGSgOaQcjOyW03Rfp++bu6/ua2mWmShOOCWaE8Uh3ZAAUvlUlKvIyCjDqFBxY5gr+VyzU8Omcl+bqNRRoSKDkUUwGxKARbBIrMwvU309qKHSc9v9XextEktb/LvUf67q2KlD236es0MvuwIyfFIbDBaxr/RY41iLsTTXIb05eevxo09vTp97PhQ4VGqoRJu/kMBneAinWg0VqywfC7M6PHf83OivSS8DHwoxFubUyyanpXkhW2FEczM0xTyqrsSuqNdjvjicG3t15XpIp1a2RXV02dSJOnt1HBBkT4XICE9AcaJFiAwUi6wkRhAtIBVEiVBSEA/DDxIQyV6CPvcpWAKlhrnyQ0EhV9udUkmm8t3/CwNyc3GsMDu6T+pMXR2RLBQU+4pm6Y4lsUU8D0S8wz8ZfHKeEP9e3GOc+mdI/Qp+ovyQsOTd//th8NjtKWJkUlt3X6bvlTaW+G7fDD+F/QccPj1gi9mJrAAAAABJRU5ErkJggg=='); background-size: cover; display: block;\"></span>\n  <img class=\"gatsby-resp-image-image\" alt=\"Musclog public website rendered from the same Expo Router codebase as the app\" title=\"Musclog public website rendered from the same Expo Router codebase as the app\" src=\"/static/c50b08ecc21b0e02ed7774578063a5f6/42a19/musclog-shared-router-website-home-2.png\" srcset=\"/static/c50b08ecc21b0e02ed7774578063a5f6/e3135/musclog-shared-router-website-home-2.png 256w,\n/static/c50b08ecc21b0e02ed7774578063a5f6/06341/musclog-shared-router-website-home-2.png 512w,\n/static/c50b08ecc21b0e02ed7774578063a5f6/42a19/musclog-shared-router-website-home-2.png 1024w,\n/static/c50b08ecc21b0e02ed7774578063a5f6/e8464/musclog-shared-router-website-home-2.png 1536w,\n/static/c50b08ecc21b0e02ed7774578063a5f6/2eb59/musclog-shared-router-website-home-2.png 2048w,\n/static/c50b08ecc21b0e02ed7774578063a5f6/d7fb2/musclog-shared-router-website-home-2.png 2184w\" sizes=\"(max-width: 1024px) 100vw, 1024px\" style=\"width:100%;height:100%;margin:0;vertical-align:middle;position:absolute;top:0;left:0;\" loading=\"lazy\" decoding=\"async\">\n    </span>\n    <figcaption class=\"gatsby-resp-image-figcaption\">Musclog public website rendered from the same Expo Router codebase as the app</figcaption>\n  </figure><p></p>\n<h2 id=\"the-desktop-phone-nonsense\" style=\"position:relative;\"><a href=\"#the-desktop-phone-nonsense\" aria-label=\"the desktop phone nonsense permalink\" class=\"post-headers-link before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>The desktop phone nonsense</h2>\n<p>This is where I stopped behaving like a normal person.</p>\n<p>I wanted <a href=\"https://musclog.app/app/\" target=\"_blank\" rel=\"noreferrer\">musclog.app/app</a> on desktop to show the actual working app inside a phone frame. Partly because it looks nice. Partly because it doubles as a product preview. Mostly because once the idea entered my head, every less ridiculous option started feeling wrong.</p>\n<p>The annoying detail was that <code class=\"language-text\">/app</code> should look like a phone, but <code class=\"language-text\">/home</code> absolutely should not. So routing alone was not enough. I needed the raw HTML shell to know, before hydration, whether the browser should render the fake phone wrapper or not.</p>\n<p>That logic lives in <code class=\"language-text\">app/+html.tsx</code>, inside a script in the document <code class=\"language-text\">&lt;head&gt;</code>:</p>\n<div class=\"copy-code-block\"><button tabindex=\"3\" type=\"button\" class=\"MuiButtonBase-root MuiButton-root MuiButton-contained MuiButton-containedPrimary copy-code-button\"><span class=\"MuiButton-label\">Copy</span></button><div class=\"gatsby-highlight\" data-language=\"tsx\"><pre class=\"language-tsx\"><code class=\"language-tsx\"><span class=\"token keyword\">function</span> <span class=\"token function\">landingPanelGate</span><span class=\"token punctuation\">(</span>base<span class=\"token operator\">:</span> <span class=\"token builtin\">string</span><span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\n  <span class=\"token keyword\">try</span> <span class=\"token punctuation\">{</span>\n    <span class=\"token keyword\">function</span> <span class=\"token function\">update</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\n      <span class=\"token keyword\">const</span> raw <span class=\"token operator\">=</span> window<span class=\"token punctuation\">.</span>location<span class=\"token punctuation\">.</span>pathname<span class=\"token punctuation\">;</span>\n      <span class=\"token keyword\">const</span> path <span class=\"token operator\">=</span> <span class=\"token punctuation\">(</span>base <span class=\"token operator\">&amp;&amp;</span> raw<span class=\"token punctuation\">.</span><span class=\"token function\">startsWith</span><span class=\"token punctuation\">(</span>base<span class=\"token punctuation\">)</span> <span class=\"token operator\">?</span> raw<span class=\"token punctuation\">.</span><span class=\"token function\">slice</span><span class=\"token punctuation\">(</span>base<span class=\"token punctuation\">.</span>length<span class=\"token punctuation\">)</span> <span class=\"token operator\">:</span> raw<span class=\"token punctuation\">)</span> <span class=\"token operator\">||</span> <span class=\"token string\">'/'</span><span class=\"token punctuation\">;</span>\n\n      <span class=\"token keyword\">if</span> <span class=\"token punctuation\">(</span><span class=\"token operator\">!</span>path<span class=\"token punctuation\">.</span><span class=\"token function\">startsWith</span><span class=\"token punctuation\">(</span><span class=\"token string\">'/app'</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\n        document<span class=\"token punctuation\">.</span>documentElement<span class=\"token punctuation\">.</span>classList<span class=\"token punctuation\">.</span><span class=\"token function\">add</span><span class=\"token punctuation\">(</span><span class=\"token string\">'hide-desktop-wrapper'</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n      <span class=\"token punctuation\">}</span> <span class=\"token keyword\">else</span> <span class=\"token punctuation\">{</span>\n        document<span class=\"token punctuation\">.</span>documentElement<span class=\"token punctuation\">.</span>classList<span class=\"token punctuation\">.</span><span class=\"token function\">remove</span><span class=\"token punctuation\">(</span><span class=\"token string\">'hide-desktop-wrapper'</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n      <span class=\"token punctuation\">}</span>\n    <span class=\"token punctuation\">}</span>\n\n    <span class=\"token function\">update</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n    window<span class=\"token punctuation\">.</span><span class=\"token function\">addEventListener</span><span class=\"token punctuation\">(</span><span class=\"token string\">'popstate'</span><span class=\"token punctuation\">,</span> update<span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n\n    <span class=\"token keyword\">const</span> origPush <span class=\"token operator\">=</span> history<span class=\"token punctuation\">.</span><span class=\"token function\">pushState</span><span class=\"token punctuation\">.</span><span class=\"token function\">bind</span><span class=\"token punctuation\">(</span>history<span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n    history<span class=\"token punctuation\">.</span><span class=\"token function-variable function\">pushState</span> <span class=\"token operator\">=</span> <span class=\"token keyword\">function</span> <span class=\"token punctuation\">(</span><span class=\"token operator\">...</span>args<span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\n      <span class=\"token function\">origPush</span><span class=\"token punctuation\">(</span><span class=\"token operator\">...</span>args<span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n      <span class=\"token function\">update</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n    <span class=\"token punctuation\">}</span><span class=\"token punctuation\">;</span>\n\n    <span class=\"token keyword\">const</span> origReplace <span class=\"token operator\">=</span> history<span class=\"token punctuation\">.</span><span class=\"token function\">replaceState</span><span class=\"token punctuation\">.</span><span class=\"token function\">bind</span><span class=\"token punctuation\">(</span>history<span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n    history<span class=\"token punctuation\">.</span><span class=\"token function-variable function\">replaceState</span> <span class=\"token operator\">=</span> <span class=\"token keyword\">function</span> <span class=\"token punctuation\">(</span><span class=\"token operator\">...</span>args<span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\n      <span class=\"token function\">origReplace</span><span class=\"token punctuation\">(</span><span class=\"token operator\">...</span>args<span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n      <span class=\"token function\">update</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n    <span class=\"token punctuation\">}</span><span class=\"token punctuation\">;</span>\n  <span class=\"token punctuation\">}</span> <span class=\"token keyword\">catch</span> <span class=\"token punctuation\">(</span>_<span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span><span class=\"token punctuation\">}</span>\n<span class=\"token punctuation\">}</span></code></pre></div></div>\n<p>The important part is the <code class=\"language-text\">pushState</code> / <code class=\"language-text\">replaceState</code> monkey patch. First paint was easy. Client-side navigation was the annoying bit. Without this, you could leave <code class=\"language-text\">/app</code> and keep the wrong shell around like Chrome had forgotten what page it was on.</p>\n<p>The HTML shell itself is basically the landing panel, the routed app, and the phone frame:</p>\n<div class=\"copy-code-block\"><button tabindex=\"4\" type=\"button\" class=\"MuiButtonBase-root MuiButton-root MuiButton-contained MuiButton-containedPrimary copy-code-button\"><span class=\"MuiButton-label\">Copy</span></button><div class=\"gatsby-highlight\" data-language=\"tsx\"><pre class=\"language-tsx\"><code class=\"language-tsx\"><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span>body</span> <span class=\"token attr-name\">className</span><span class=\"token attr-value\"><span class=\"token punctuation attr-equals\">=</span><span class=\"token punctuation\">\"</span>expo-web-body<span class=\"token punctuation\">\"</span></span><span class=\"token punctuation\">&gt;</span></span><span class=\"token plain-text\">\n  </span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span>div</span> <span class=\"token attr-name\">className</span><span class=\"token attr-value\"><span class=\"token punctuation attr-equals\">=</span><span class=\"token punctuation\">\"</span>expo-web-landing<span class=\"token punctuation\">\"</span></span><span class=\"token punctuation\">&gt;</span></span><span class=\"token plain-text\">...</span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;/</span>div</span><span class=\"token punctuation\">&gt;</span></span><span class=\"token plain-text\">\n  </span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span>script</span> <span class=\"token attr-name\">dangerouslySetInnerHTML</span><span class=\"token script language-javascript\"><span class=\"token script-punctuation punctuation\">=</span><span class=\"token punctuation\">{</span><span class=\"token punctuation\">{</span> __html<span class=\"token operator\">:</span> <span class=\"token constant\">LANDING_I18N_SCRIPT</span> <span class=\"token punctuation\">}</span><span class=\"token punctuation\">}</span></span> <span class=\"token punctuation\">/&gt;</span></span><span class=\"token plain-text\">\n  </span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span>div</span> <span class=\"token attr-name\">className</span><span class=\"token attr-value\"><span class=\"token punctuation attr-equals\">=</span><span class=\"token punctuation\">\"</span>expo-web-root<span class=\"token punctuation\">\"</span></span><span class=\"token punctuation\">&gt;</span></span><span class=\"token plain-text\">\n    </span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span>div</span> <span class=\"token attr-name\">className</span><span class=\"token attr-value\"><span class=\"token punctuation attr-equals\">=</span><span class=\"token punctuation\">\"</span>expo-web-app-shell<span class=\"token punctuation\">\"</span></span><span class=\"token punctuation\">&gt;</span></span><span class=\"token punctuation\">{</span>children<span class=\"token punctuation\">}</span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;/</span>div</span><span class=\"token punctuation\">&gt;</span></span><span class=\"token plain-text\">\n    </span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span>img</span>\n      <span class=\"token attr-name\">className</span><span class=\"token attr-value\"><span class=\"token punctuation attr-equals\">=</span><span class=\"token punctuation\">\"</span>expo-web-phone-frame<span class=\"token punctuation\">\"</span></span>\n      <span class=\"token attr-name\">src</span><span class=\"token script language-javascript\"><span class=\"token script-punctuation punctuation\">=</span><span class=\"token punctuation\">{</span><span class=\"token function\">withExpoBaseUrl</span><span class=\"token punctuation\">(</span><span class=\"token constant\">PHONE_FRAME_SRC</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">}</span></span>\n      <span class=\"token attr-name\">alt</span><span class=\"token attr-value\"><span class=\"token punctuation attr-equals\">=</span><span class=\"token punctuation\">\"</span><span class=\"token punctuation\">\"</span></span>\n      <span class=\"token attr-name\">aria-hidden</span>\n    <span class=\"token punctuation\">/&gt;</span></span><span class=\"token plain-text\">\n  </span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;/</span>div</span><span class=\"token punctuation\">&gt;</span></span><span class=\"token plain-text\">\n</span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;/</span>body</span><span class=\"token punctuation\">&gt;</span></span></code></pre></div></div>\n<p>Then CSS commits the crime:</p>\n<div class=\"copy-code-block\"><button tabindex=\"5\" type=\"button\" class=\"MuiButtonBase-root MuiButton-root MuiButton-contained MuiButton-containedPrimary copy-code-button\"><span class=\"MuiButton-label\">Copy</span></button><div class=\"gatsby-highlight\" data-language=\"css\"><pre class=\"language-css\"><code class=\"language-css\"><span class=\"token atrule\"><span class=\"token rule\">@media</span> <span class=\"token punctuation\">(</span><span class=\"token property\">min-width</span><span class=\"token punctuation\">:</span> 1024px<span class=\"token punctuation\">)</span></span> <span class=\"token punctuation\">{</span>\n  <span class=\"token selector\">.expo-web-root</span> <span class=\"token punctuation\">{</span>\n    <span class=\"token property\">--frame-h</span><span class=\"token punctuation\">:</span> <span class=\"token function\">min</span><span class=\"token punctuation\">(</span>100dvh<span class=\"token punctuation\">,</span> <span class=\"token function\">max</span><span class=\"token punctuation\">(</span><span class=\"token function\">min</span><span class=\"token punctuation\">(</span>360px<span class=\"token punctuation\">,</span> 100dvh<span class=\"token punctuation\">)</span><span class=\"token punctuation\">,</span> 85dvh<span class=\"token punctuation\">)</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n    <span class=\"token property\">aspect-ratio</span><span class=\"token punctuation\">:</span> 1438 / 2976<span class=\"token punctuation\">;</span>\n    <span class=\"token property\">width</span><span class=\"token punctuation\">:</span> <span class=\"token function\">min</span><span class=\"token punctuation\">(</span>100vw<span class=\"token punctuation\">,</span> <span class=\"token function\">calc</span><span class=\"token punctuation\">(</span><span class=\"token function\">var</span><span class=\"token punctuation\">(</span>--frame-h<span class=\"token punctuation\">)</span> * 1438 / 2976<span class=\"token punctuation\">)</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n    <span class=\"token property\">max-height</span><span class=\"token punctuation\">:</span> <span class=\"token function\">var</span><span class=\"token punctuation\">(</span>--frame-h<span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n    <span class=\"token property\">overflow</span><span class=\"token punctuation\">:</span> hidden<span class=\"token punctuation\">;</span>\n  <span class=\"token punctuation\">}</span>\n\n  <span class=\"token selector\">.expo-web-app-shell</span> <span class=\"token punctuation\">{</span>\n    <span class=\"token property\">position</span><span class=\"token punctuation\">:</span> absolute<span class=\"token punctuation\">;</span>\n    <span class=\"token property\">left</span><span class=\"token punctuation\">:</span> 7.4409%<span class=\"token punctuation\">;</span>\n    <span class=\"token property\">top</span><span class=\"token punctuation\">:</span> 2.9906%<span class=\"token punctuation\">;</span>\n    <span class=\"token property\">right</span><span class=\"token punctuation\">:</span> 6.3282%<span class=\"token punctuation\">;</span>\n    <span class=\"token property\">bottom</span><span class=\"token punctuation\">:</span> 3.125%<span class=\"token punctuation\">;</span>\n    <span class=\"token property\">zoom</span><span class=\"token punctuation\">:</span> 0.85<span class=\"token punctuation\">;</span>\n  <span class=\"token punctuation\">}</span>\n<span class=\"token punctuation\">}</span></code></pre></div></div>\n<p>Yes, I measured the transparent screen area inside the PNG until the app lined up correctly. There is no elegant geometry behind those percentages. It was just me, a phone bezel, and a lot of nudging until the app stopped looking crooked.</p>\n<p>On routes that are not <code class=\"language-text\">/app</code>, the whole performance gets disabled:</p>\n<div class=\"copy-code-block\"><button tabindex=\"6\" type=\"button\" class=\"MuiButtonBase-root MuiButton-root MuiButton-contained MuiButton-containedPrimary copy-code-button\"><span class=\"MuiButton-label\">Copy</span></button><div class=\"gatsby-highlight\" data-language=\"css\"><pre class=\"language-css\"><code class=\"language-css\"><span class=\"token selector\">.hide-desktop-wrapper .expo-web-landing,\n.hide-desktop-wrapper .expo-web-phone-frame</span> <span class=\"token punctuation\">{</span>\n  <span class=\"token property\">display</span><span class=\"token punctuation\">:</span> none <span class=\"token important\">!important</span><span class=\"token punctuation\">;</span>\n<span class=\"token punctuation\">}</span>\n\n<span class=\"token selector\">.hide-desktop-wrapper .expo-web-app-shell</span> <span class=\"token punctuation\">{</span>\n  <span class=\"token property\">position</span><span class=\"token punctuation\">:</span> static <span class=\"token important\">!important</span><span class=\"token punctuation\">;</span>\n  <span class=\"token property\">width</span><span class=\"token punctuation\">:</span> 100% <span class=\"token important\">!important</span><span class=\"token punctuation\">;</span>\n  <span class=\"token property\">height</span><span class=\"token punctuation\">:</span> auto <span class=\"token important\">!important</span><span class=\"token punctuation\">;</span>\n  <span class=\"token property\">zoom</span><span class=\"token punctuation\">:</span> 1 <span class=\"token important\">!important</span><span class=\"token punctuation\">;</span>\n  <span class=\"token property\">overflow</span><span class=\"token punctuation\">:</span> visible <span class=\"token important\">!important</span><span class=\"token punctuation\">;</span>\n<span class=\"token punctuation\">}</span></code></pre></div></div>\n<p>Yes, it uses <code class=\"language-text\">!important</code>. This is pre-hydration CSS whose entire job is keeping the lie intact. We are not doing refined architecture at this layer.</p>\n<p></p><figure class=\"gatsby-resp-image-figure\" style=\"\">\n    <span class=\"gatsby-resp-image-wrapper\" style=\"position: relative; display: block; margin-left: auto; margin-right: auto; max-width: 1024px; \">\n      <span class=\"gatsby-resp-image-background-image\" style=\"padding-bottom: 61.71875%; position: relative; bottom: 0; left: 0; background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAMCAIAAADtbgqsAAAACXBIWXMAAAsTAAALEwEAmpwYAAABS0lEQVQoz42SyUoDQRCGgxOTrq7pfbbuZBKTmTEwWSAgKKLghtvBQy6CHs3Nk94UwZPP4Wv4DD6VqBdhION3Kor6qL+gGrQCfvNbER/bApEDFehXJxvVFhACAEDandHYTbcxG9BJHpQZZwwAVskAoIzWJvTWGvunrwfPn+uPD633F/V0IynC6s0AECZRFMWEeMX4bHR8z04O/dtzc7EjfQYUamIro6WSCDQunN0aJoU1iQptKKWsiU0plVoro6Hhda+Pyo+3dHkpho4PEsF5vYyUEiC0Tewk71/tmVnGXShcyFfLiNhsNufz+XJ5Z7TWUWDTDguUH2kZB/Vyq9Uqy3KxWGilgtT1Z6XNM512w15XiLrYiEgI8TyP+X6Q2rgY2s1cpd14oyel+MfNP/iIUWp13yW7UzPoxH3Hap/kL4wxLjgXXAjBOa++5xcHAzI9yvyvqQAAAABJRU5ErkJggg=='); background-size: cover; display: block;\"></span>\n  <img class=\"gatsby-resp-image-image\" alt=\"Musclog desktop web app rendered inside a phone frame next to the landing panel\" title=\"Musclog desktop web app rendered inside a phone frame next to the landing panel\" src=\"/static/49361b3283ae34ef30a950b13c7f5594/42a19/musclog-desktop-phone-frame-wrapper.png\" srcset=\"/static/49361b3283ae34ef30a950b13c7f5594/e3135/musclog-desktop-phone-frame-wrapper.png 256w,\n/static/49361b3283ae34ef30a950b13c7f5594/06341/musclog-desktop-phone-frame-wrapper.png 512w,\n/static/49361b3283ae34ef30a950b13c7f5594/42a19/musclog-desktop-phone-frame-wrapper.png 1024w,\n/static/49361b3283ae34ef30a950b13c7f5594/e8464/musclog-desktop-phone-frame-wrapper.png 1536w,\n/static/49361b3283ae34ef30a950b13c7f5594/2eb59/musclog-desktop-phone-frame-wrapper.png 2048w,\n/static/49361b3283ae34ef30a950b13c7f5594/d7fb2/musclog-desktop-phone-frame-wrapper.png 2184w\" sizes=\"(max-width: 1024px) 100vw, 1024px\" style=\"width:100%;height:100%;margin:0;vertical-align:middle;position:absolute;top:0;left:0;\" loading=\"lazy\" decoding=\"async\">\n    </span>\n    <figcaption class=\"gatsby-resp-image-figcaption\">Musclog desktop web app rendered inside a phone frame next to the landing panel</figcaption>\n  </figure><p></p>\n<h2 id=\"three-tiny-crimes\" style=\"position:relative;\"><a href=\"#three-tiny-crimes\" aria-label=\"three tiny crimes permalink\" class=\"post-headers-link before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Three tiny crimes</h2>\n<p><img src=\"/377ac4354ce553fccf98c5d8d1017e50/straight-to-jail.gif\" alt=\"Straight to jail. Right away.\"></p>\n<p>Once the shell worked, the smaller annoyances started lining up like they had booked appointments.</p>\n<h3 id=\"1-the-landing-panel-needed-i18n-before-hydration\" style=\"position:relative;\"><a href=\"#1-the-landing-panel-needed-i18n-before-hydration\" aria-label=\"1 the landing panel needed i18n before hydration permalink\" class=\"post-headers-link before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>1. The landing panel needed i18n before hydration</h3>\n<p>The text beside the phone frame lives in raw HTML, which means React and i18n are still sleeping when the page first renders. So if I wanted a Portuguese page to not flash English first, I had to patch it myself from <code class=\"language-text\">localStorage</code>:</p>\n<div class=\"copy-code-block\"><button tabindex=\"7\" type=\"button\" class=\"MuiButtonBase-root MuiButton-root MuiButton-contained MuiButton-containedPrimary copy-code-button\"><span class=\"MuiButton-label\">Copy</span></button><div class=\"gatsby-highlight\" data-language=\"tsx\"><pre class=\"language-tsx\"><code class=\"language-tsx\"><span class=\"token keyword\">function</span> <span class=\"token function\">landingI18nPatcher</span><span class=\"token punctuation\">(</span>translations<span class=\"token punctuation\">,</span> storageKey<span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\n  <span class=\"token keyword\">try</span> <span class=\"token punctuation\">{</span>\n    <span class=\"token keyword\">let</span> lang <span class=\"token operator\">=</span> localStorage<span class=\"token punctuation\">.</span><span class=\"token function\">getItem</span><span class=\"token punctuation\">(</span>storageKey<span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n    <span class=\"token keyword\">let</span> s <span class=\"token operator\">=</span> <span class=\"token punctuation\">(</span>lang <span class=\"token operator\">&amp;&amp;</span> translations<span class=\"token punctuation\">[</span>lang<span class=\"token punctuation\">]</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">||</span> translations<span class=\"token punctuation\">[</span><span class=\"token string\">'en-US'</span><span class=\"token punctuation\">]</span><span class=\"token punctuation\">;</span>\n\n    document<span class=\"token punctuation\">.</span><span class=\"token function\">querySelectorAll</span><span class=\"token punctuation\">(</span><span class=\"token string\">'[data-landing-i18n]'</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">.</span><span class=\"token function\">forEach</span><span class=\"token punctuation\">(</span><span class=\"token keyword\">function</span> <span class=\"token punctuation\">(</span>el<span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\n      <span class=\"token keyword\">let</span> k <span class=\"token operator\">=</span> el<span class=\"token punctuation\">.</span><span class=\"token function\">getAttribute</span><span class=\"token punctuation\">(</span><span class=\"token string\">'data-landing-i18n'</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n      <span class=\"token keyword\">if</span> <span class=\"token punctuation\">(</span>k <span class=\"token operator\">&amp;&amp;</span> s<span class=\"token punctuation\">[</span>k<span class=\"token punctuation\">]</span><span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\n        el<span class=\"token punctuation\">.</span>textContent <span class=\"token operator\">=</span> s<span class=\"token punctuation\">[</span>k<span class=\"token punctuation\">]</span><span class=\"token punctuation\">;</span>\n      <span class=\"token punctuation\">}</span>\n    <span class=\"token punctuation\">}</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n  <span class=\"token punctuation\">}</span> <span class=\"token keyword\">catch</span> <span class=\"token punctuation\">(</span>_<span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span><span class=\"token punctuation\">}</span>\n<span class=\"token punctuation\">}</span></code></pre></div></div>\n<p>Not elegant. Also not flash-banging a Portuguese page with English text, so I’m keeping it.</p>\n<h3 id=\"2-raw-html-assets-do-not-get-expos-usual-help\" style=\"position:relative;\"><a href=\"#2-raw-html-assets-do-not-get-expos-usual-help\" aria-label=\"2 raw html assets do not get expos usual help permalink\" class=\"post-headers-link before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>2. Raw HTML assets do not get Expo’s usual help</h3>\n<p>The phone frame PNG and QR code are referenced from raw HTML too, which means Expo’s usual asset niceness does not really help me there. So I needed a helper for base URLs:</p>\n<div class=\"copy-code-block\"><button tabindex=\"8\" type=\"button\" class=\"MuiButtonBase-root MuiButton-root MuiButton-contained MuiButton-containedPrimary copy-code-button\"><span class=\"MuiButton-label\">Copy</span></button><div class=\"gatsby-highlight\" data-language=\"tsx\"><pre class=\"language-tsx\"><code class=\"language-tsx\"><span class=\"token keyword\">function</span> <span class=\"token function\">withExpoBaseUrl</span><span class=\"token punctuation\">(</span>path<span class=\"token operator\">:</span> <span class=\"token builtin\">string</span><span class=\"token punctuation\">)</span><span class=\"token operator\">:</span> <span class=\"token builtin\">string</span> <span class=\"token punctuation\">{</span>\n  <span class=\"token keyword\">const</span> base <span class=\"token operator\">=</span> process<span class=\"token punctuation\">.</span>env<span class=\"token punctuation\">.</span><span class=\"token constant\">EXPO_BASE_URL</span><span class=\"token punctuation\">;</span>\n  <span class=\"token keyword\">if</span> <span class=\"token punctuation\">(</span>base <span class=\"token operator\">==</span> <span class=\"token keyword\">null</span> <span class=\"token operator\">||</span> base <span class=\"token operator\">===</span> <span class=\"token string\">''</span><span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\n    <span class=\"token keyword\">return</span> path<span class=\"token punctuation\">;</span>\n  <span class=\"token punctuation\">}</span>\n\n  <span class=\"token keyword\">const</span> basePath <span class=\"token operator\">=</span> <span class=\"token function\">String</span><span class=\"token punctuation\">(</span>base<span class=\"token punctuation\">)</span><span class=\"token punctuation\">.</span><span class=\"token function\">replace</span><span class=\"token punctuation\">(</span><span class=\"token regex\"><span class=\"token regex-delimiter\">/</span><span class=\"token regex-source language-regex\">^\\/+|\\/+$</span><span class=\"token regex-delimiter\">/</span><span class=\"token regex-flags\">g</span></span><span class=\"token punctuation\">,</span> <span class=\"token string\">''</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n  <span class=\"token keyword\">const</span> normalized <span class=\"token operator\">=</span> path<span class=\"token punctuation\">.</span><span class=\"token function\">startsWith</span><span class=\"token punctuation\">(</span><span class=\"token string\">'/'</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">?</span> path <span class=\"token operator\">:</span> <span class=\"token template-string\"><span class=\"token template-punctuation string\">`</span><span class=\"token string\">/</span><span class=\"token interpolation\"><span class=\"token interpolation-punctuation punctuation\">${</span>path<span class=\"token interpolation-punctuation punctuation\">}</span></span><span class=\"token template-punctuation string\">`</span></span><span class=\"token punctuation\">;</span>\n  <span class=\"token keyword\">return</span> <span class=\"token template-string\"><span class=\"token template-punctuation string\">`</span><span class=\"token string\">/</span><span class=\"token interpolation\"><span class=\"token interpolation-punctuation punctuation\">${</span>basePath<span class=\"token interpolation-punctuation punctuation\">}</span></span><span class=\"token interpolation\"><span class=\"token interpolation-punctuation punctuation\">${</span>normalized<span class=\"token interpolation-punctuation punctuation\">}</span></span><span class=\"token template-punctuation string\">`</span></span><span class=\"token punctuation\">;</span>\n<span class=\"token punctuation\">}</span></code></pre></div></div>\n<p>And because those files live outside the React Native asset pipeline, I copy them into <code class=\"language-text\">public/</code> before dev and export. Forget that step once and the site immediately reminds you who is in charge.</p>\n<h3 id=\"3-expo-export-occasionally-needed-emotional-support\" style=\"position:relative;\"><a href=\"#3-expo-export-occasionally-needed-emotional-support\" aria-label=\"3 expo export occasionally needed emotional support permalink\" class=\"post-headers-link before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>3. <code class=\"language-text\">expo export</code> occasionally needed emotional support</h3>\n<p>In some environments, <code class=\"language-text\">expo export --platform web</code> would successfully finish and then just stay alive for no reason. Dist folder there. Files generated. Process spiritually complete, technically still hanging around.</p>\n<p>So now there is a wrapper script:</p>\n<div class=\"copy-code-block\"><button tabindex=\"9\" type=\"button\" class=\"MuiButtonBase-root MuiButton-root MuiButton-contained MuiButton-containedPrimary copy-code-button\"><span class=\"MuiButton-label\">Copy</span></button><div class=\"gatsby-highlight\" data-language=\"js\"><pre class=\"language-js\"><code class=\"language-js\"><span class=\"token comment\">// scripts/export-web-wrapper.js</span>\n<span class=\"token keyword\">if</span> <span class=\"token punctuation\">(</span>output<span class=\"token punctuation\">.</span><span class=\"token function\">includes</span><span class=\"token punctuation\">(</span><span class=\"token string\">'Exported: dist'</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\n  console<span class=\"token punctuation\">.</span><span class=\"token function\">log</span><span class=\"token punctuation\">(</span><span class=\"token string\">'[export-web-wrapper] Detected successful export. Forcing exit in 5s...'</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n  <span class=\"token function\">setTimeout</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">=&gt;</span> process<span class=\"token punctuation\">.</span><span class=\"token function\">exit</span><span class=\"token punctuation\">(</span><span class=\"token number\">0</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">,</span> <span class=\"token number\">5000</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n<span class=\"token punctuation\">}</span></code></pre></div></div>\n<p>Not glamorous. Very effective.</p>\n<h2 id=\"then-modals-got-weird\" style=\"position:relative;\"><a href=\"#then-modals-got-weird\" aria-label=\"then modals got weird permalink\" class=\"post-headers-link before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Then modals got weird</h2>\n<p></p><figure class=\"gatsby-resp-image-figure\" style=\"\">\n    <span class=\"gatsby-resp-image-wrapper\" style=\"position: relative; display: block; margin-left: auto; margin-right: auto; max-width: 1024px; \">\n      <span class=\"gatsby-resp-image-background-image\" style=\"padding-bottom: 61.71875%; position: relative; bottom: 0; left: 0; background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAMCAIAAADtbgqsAAAACXBIWXMAAAsTAAALEwEAmpwYAAABD0lEQVQoz53SS27CMBCAYUtUIp5x4ldMnAST2E3BcVA3kXqOcgbuf4eKhypoN8DoW4xG+ndDAAARAYDxAoucMnxUjiSj5wFYb4cqeN7Uom0eoXaBKK2lEBTApbRJ+01MF904ddO0HqMbx/UY2xj9lHyK/bi76D4TWdnKGAOIKjj1vhFdK/o7/N/lytVElaUQHBB5dMXQsGBv5aGWW/fnyILFfiXngUitzjEUoSl8XXh70tvLwn2tt4756k6osDNi/iCAmC2XyJj+SnlnWWPy1sBKgrmipfjdb1HJyTzPx+NRac1syWqNlXoQs5pM+/334SCVXIqcavEUkmXZ22IBCM+Wp/j6YYhUvRSfBuGF+AfXMjfjsQh0mgAAAABJRU5ErkJggg=='); background-size: cover; display: block;\"></span>\n  <img class=\"gatsby-resp-image-image\" alt=\"Musclog desktop modal rendered correctly inside the phone frame\" title=\"Musclog desktop modal rendered correctly inside the phone frame\" src=\"/static/a2af4c3fcf36eee559fddfb48a5143d1/42a19/musclog-desktop-modal-inside-phone-frame.png\" srcset=\"/static/a2af4c3fcf36eee559fddfb48a5143d1/e3135/musclog-desktop-modal-inside-phone-frame.png 256w,\n/static/a2af4c3fcf36eee559fddfb48a5143d1/06341/musclog-desktop-modal-inside-phone-frame.png 512w,\n/static/a2af4c3fcf36eee559fddfb48a5143d1/42a19/musclog-desktop-modal-inside-phone-frame.png 1024w,\n/static/a2af4c3fcf36eee559fddfb48a5143d1/e8464/musclog-desktop-modal-inside-phone-frame.png 1536w,\n/static/a2af4c3fcf36eee559fddfb48a5143d1/2eb59/musclog-desktop-modal-inside-phone-frame.png 2048w,\n/static/a2af4c3fcf36eee559fddfb48a5143d1/d7fb2/musclog-desktop-modal-inside-phone-frame.png 2184w\" sizes=\"(max-width: 1024px) 100vw, 1024px\" style=\"width:100%;height:100%;margin:0;vertical-align:middle;position:absolute;top:0;left:0;\" loading=\"lazy\" decoding=\"async\">\n    </span>\n    <figcaption class=\"gatsby-resp-image-figcaption\">Musclog desktop modal rendered correctly inside the phone frame</figcaption>\n  </figure><p></p>\n<p>Of course they did.</p>\n<p>When the web app lives inside a fake phone on desktop, React Native Web’s default <code class=\"language-text\">Modal</code> behavior starts looking ridiculous very quickly. Portaling straight to <code class=\"language-text\">document.body</code> is fine when the whole app owns the page. It is much less fine when the app is visually clipped inside a phone shell and the modal suddenly decides it belongs to the whole browser window instead.</p>\n<p>So I added a <code class=\"language-text\">WebModalShellProvider</code> high in <code class=\"language-text\">app/app/_layout.tsx</code>, with an overlay host inside the phone shell:</p>\n<div class=\"copy-code-block\"><button tabindex=\"10\" type=\"button\" class=\"MuiButtonBase-root MuiButton-root MuiButton-contained MuiButton-containedPrimary copy-code-button\"><span class=\"MuiButton-label\">Copy</span></button><div class=\"gatsby-highlight\" data-language=\"tsx\"><pre class=\"language-tsx\"><code class=\"language-tsx\"><span class=\"token comment\">// context/WebModalShellContext.web.tsx</span>\n<span class=\"token keyword\">return</span> <span class=\"token punctuation\">(</span>\n  <span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span><span class=\"token class-name\">WebModalShellContext.Provider</span></span> <span class=\"token attr-name\">value</span><span class=\"token script language-javascript\"><span class=\"token script-punctuation punctuation\">=</span><span class=\"token punctuation\">{</span><span class=\"token punctuation\">{</span> hostElement <span class=\"token punctuation\">}</span><span class=\"token punctuation\">}</span></span><span class=\"token punctuation\">&gt;</span></span><span class=\"token plain-text\">\n    </span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span><span class=\"token class-name\">View</span></span> <span class=\"token attr-name\">style</span><span class=\"token script language-javascript\"><span class=\"token script-punctuation punctuation\">=</span><span class=\"token punctuation\">{</span><span class=\"token punctuation\">{</span> flex<span class=\"token operator\">:</span> <span class=\"token number\">1</span><span class=\"token punctuation\">,</span> minHeight<span class=\"token operator\">:</span> <span class=\"token number\">0</span><span class=\"token punctuation\">,</span> height<span class=\"token operator\">:</span> <span class=\"token string\">'100%'</span><span class=\"token punctuation\">,</span> width<span class=\"token operator\">:</span> <span class=\"token string\">'100%'</span><span class=\"token punctuation\">,</span> position<span class=\"token operator\">:</span> <span class=\"token string\">'relative'</span> <span class=\"token punctuation\">}</span><span class=\"token punctuation\">}</span></span><span class=\"token punctuation\">&gt;</span></span><span class=\"token plain-text\">\n      </span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span><span class=\"token class-name\">View</span></span> <span class=\"token attr-name\">style</span><span class=\"token script language-javascript\"><span class=\"token script-punctuation punctuation\">=</span><span class=\"token punctuation\">{</span><span class=\"token punctuation\">{</span> flex<span class=\"token operator\">:</span> <span class=\"token number\">1</span><span class=\"token punctuation\">,</span> minHeight<span class=\"token operator\">:</span> <span class=\"token number\">0</span><span class=\"token punctuation\">,</span> height<span class=\"token operator\">:</span> <span class=\"token string\">'100%'</span><span class=\"token punctuation\">,</span> width<span class=\"token operator\">:</span> <span class=\"token string\">'100%'</span> <span class=\"token punctuation\">}</span><span class=\"token punctuation\">}</span></span> <span class=\"token attr-name\">collapsable</span><span class=\"token script language-javascript\"><span class=\"token script-punctuation punctuation\">=</span><span class=\"token punctuation\">{</span><span class=\"token boolean\">false</span><span class=\"token punctuation\">}</span></span><span class=\"token punctuation\">&gt;</span></span><span class=\"token plain-text\">\n        </span><span class=\"token punctuation\">{</span>children<span class=\"token punctuation\">}</span><span class=\"token plain-text\">\n      </span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;/</span><span class=\"token class-name\">View</span></span><span class=\"token punctuation\">&gt;</span></span><span class=\"token plain-text\">\n      </span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span><span class=\"token class-name\">View</span></span>\n        <span class=\"token attr-name\">id</span><span class=\"token attr-value\"><span class=\"token punctuation attr-equals\">=</span><span class=\"token punctuation\">\"</span>expo-web-modal-shell-host<span class=\"token punctuation\">\"</span></span>\n        <span class=\"token attr-name\">ref</span><span class=\"token script language-javascript\"><span class=\"token script-punctuation punctuation\">=</span><span class=\"token punctuation\">{</span>setHostRef<span class=\"token punctuation\">}</span></span>\n        <span class=\"token attr-name\">collapsable</span><span class=\"token script language-javascript\"><span class=\"token script-punctuation punctuation\">=</span><span class=\"token punctuation\">{</span><span class=\"token boolean\">false</span><span class=\"token punctuation\">}</span></span>\n        <span class=\"token attr-name\">style</span><span class=\"token script language-javascript\"><span class=\"token script-punctuation punctuation\">=</span><span class=\"token punctuation\">{</span><span class=\"token punctuation\">{</span>\n          position<span class=\"token operator\">:</span> <span class=\"token string\">'absolute'</span><span class=\"token punctuation\">,</span>\n          left<span class=\"token operator\">:</span> <span class=\"token number\">0</span><span class=\"token punctuation\">,</span>\n          right<span class=\"token operator\">:</span> <span class=\"token number\">0</span><span class=\"token punctuation\">,</span>\n          top<span class=\"token operator\">:</span> <span class=\"token number\">0</span><span class=\"token punctuation\">,</span>\n          bottom<span class=\"token operator\">:</span> <span class=\"token number\">0</span><span class=\"token punctuation\">,</span>\n          zIndex<span class=\"token operator\">:</span> <span class=\"token number\">1_000_000</span><span class=\"token punctuation\">,</span>\n        <span class=\"token punctuation\">}</span><span class=\"token punctuation\">}</span></span>\n      <span class=\"token punctuation\">/&gt;</span></span><span class=\"token plain-text\">\n    </span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;/</span><span class=\"token class-name\">View</span></span><span class=\"token punctuation\">&gt;</span></span><span class=\"token plain-text\">\n  </span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;/</span><span class=\"token class-name\">WebModalShellContext.Provider</span></span><span class=\"token punctuation\">&gt;</span></span>\n<span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span></code></pre></div></div>\n<p>Then <code class=\"language-text\">Modal.web.tsx</code> does the obvious-not-obvious thing and switches between a portal inside that host and the normal RN modal:</p>\n<div class=\"copy-code-block\"><button tabindex=\"11\" type=\"button\" class=\"MuiButtonBase-root MuiButton-root MuiButton-contained MuiButton-containedPrimary copy-code-button\"><span class=\"MuiButton-label\">Copy</span></button><div class=\"gatsby-highlight\" data-language=\"tsx\"><pre class=\"language-tsx\"><code class=\"language-tsx\"><span class=\"token keyword\">const</span> useShellPortal <span class=\"token operator\">=</span> Platform<span class=\"token punctuation\">.</span><span class=\"token constant\">OS</span> <span class=\"token operator\">===</span> <span class=\"token string\">'web'</span> <span class=\"token operator\">&amp;&amp;</span> isDesktopFrame <span class=\"token operator\">&amp;&amp;</span> hostElement <span class=\"token operator\">!=</span> <span class=\"token keyword\">null</span><span class=\"token punctuation\">;</span>\n\n<span class=\"token keyword\">if</span> <span class=\"token punctuation\">(</span>useShellPortal<span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\n  <span class=\"token keyword\">return</span> <span class=\"token function\">createPortal</span><span class=\"token punctuation\">(</span>\n    <span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span><span class=\"token class-name\">View</span></span>\n      <span class=\"token attr-name\">style</span><span class=\"token script language-javascript\"><span class=\"token script-punctuation punctuation\">=</span><span class=\"token punctuation\">{</span><span class=\"token punctuation\">{</span>\n        position<span class=\"token operator\">:</span> <span class=\"token string\">'absolute'</span><span class=\"token punctuation\">,</span>\n        left<span class=\"token operator\">:</span> <span class=\"token number\">0</span><span class=\"token punctuation\">,</span>\n        right<span class=\"token operator\">:</span> <span class=\"token number\">0</span><span class=\"token punctuation\">,</span>\n        top<span class=\"token operator\">:</span> <span class=\"token number\">0</span><span class=\"token punctuation\">,</span>\n        bottom<span class=\"token operator\">:</span> <span class=\"token number\">0</span><span class=\"token punctuation\">,</span>\n        width<span class=\"token operator\">:</span> <span class=\"token string\">'100%'</span><span class=\"token punctuation\">,</span>\n        height<span class=\"token operator\">:</span> <span class=\"token string\">'100%'</span><span class=\"token punctuation\">,</span>\n      <span class=\"token punctuation\">}</span><span class=\"token punctuation\">}</span></span>\n    <span class=\"token punctuation\">&gt;</span></span><span class=\"token plain-text\">\n      </span><span class=\"token punctuation\">{</span>children<span class=\"token punctuation\">}</span><span class=\"token plain-text\">\n    </span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;/</span><span class=\"token class-name\">View</span></span><span class=\"token punctuation\">&gt;</span></span><span class=\"token punctuation\">,</span>\n    hostElement\n  <span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n<span class=\"token punctuation\">}</span>\n\n<span class=\"token keyword\">return</span> <span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span><span class=\"token class-name\">RNModal</span></span> <span class=\"token attr-name\">visible</span><span class=\"token script language-javascript\"><span class=\"token script-punctuation punctuation\">=</span><span class=\"token punctuation\">{</span>visible<span class=\"token punctuation\">}</span></span><span class=\"token punctuation\">&gt;</span></span><span class=\"token punctuation\">{</span>children<span class=\"token punctuation\">}</span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;/</span><span class=\"token class-name\">RNModal</span></span><span class=\"token punctuation\">&gt;</span></span><span class=\"token punctuation\">;</span></code></pre></div></div>\n<p>And the shared overlay hook knows whether it should behave like a full viewport modal or a fake-phone viewport modal:</p>\n<div class=\"copy-code-block\"><button tabindex=\"12\" type=\"button\" class=\"MuiButtonBase-root MuiButton-root MuiButton-contained MuiButton-containedPrimary copy-code-button\"><span class=\"MuiButton-label\">Copy</span></button><div class=\"gatsby-highlight\" data-language=\"tsx\"><pre class=\"language-tsx\"><code class=\"language-tsx\"><span class=\"token keyword\">export</span> <span class=\"token keyword\">function</span> <span class=\"token function\">useWebModalLayerStyle</span><span class=\"token punctuation\">(</span>options <span class=\"token operator\">=</span> <span class=\"token punctuation\">{</span><span class=\"token punctuation\">}</span><span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\n  <span class=\"token keyword\">const</span> isDesktopFrame <span class=\"token operator\">=</span> <span class=\"token function\">useWebDesktopPhoneFrame</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\n\n  <span class=\"token keyword\">if</span> <span class=\"token punctuation\">(</span>isDesktopFrame<span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\n    <span class=\"token keyword\">return</span> <span class=\"token punctuation\">{</span>\n      position<span class=\"token operator\">:</span> <span class=\"token string\">'absolute'</span><span class=\"token punctuation\">,</span>\n      top<span class=\"token operator\">:</span> <span class=\"token number\">0</span><span class=\"token punctuation\">,</span>\n      left<span class=\"token operator\">:</span> <span class=\"token number\">0</span><span class=\"token punctuation\">,</span>\n      right<span class=\"token operator\">:</span> <span class=\"token number\">0</span><span class=\"token punctuation\">,</span>\n      bottom<span class=\"token operator\">:</span> <span class=\"token number\">0</span><span class=\"token punctuation\">,</span>\n      width<span class=\"token operator\">:</span> <span class=\"token string\">'100%'</span><span class=\"token punctuation\">,</span>\n      height<span class=\"token operator\">:</span> <span class=\"token string\">'100%'</span><span class=\"token punctuation\">,</span>\n    <span class=\"token punctuation\">}</span><span class=\"token punctuation\">;</span>\n  <span class=\"token punctuation\">}</span>\n\n  <span class=\"token keyword\">return</span> <span class=\"token punctuation\">{</span>\n    position<span class=\"token operator\">:</span> <span class=\"token string\">'fixed'</span><span class=\"token punctuation\">,</span>\n    top<span class=\"token operator\">:</span> <span class=\"token number\">0</span><span class=\"token punctuation\">,</span>\n    left<span class=\"token operator\">:</span> <span class=\"token number\">0</span><span class=\"token punctuation\">,</span>\n    right<span class=\"token operator\">:</span> <span class=\"token number\">0</span><span class=\"token punctuation\">,</span>\n    bottom<span class=\"token operator\">:</span> <span class=\"token number\">0</span><span class=\"token punctuation\">,</span>\n    width<span class=\"token operator\">:</span> <span class=\"token string\">'100vw'</span><span class=\"token punctuation\">,</span>\n    height<span class=\"token operator\">:</span> <span class=\"token string\">'100dvh'</span><span class=\"token punctuation\">,</span>\n  <span class=\"token punctuation\">}</span><span class=\"token punctuation\">;</span>\n<span class=\"token punctuation\">}</span></code></pre></div></div>\n<p>That fixed the visual part. Then pointer events decided they wanted attention too.</p>\n<p>On native, <code class=\"language-text\">pointerEvents=\"box-none\"</code> is normal. On HTML, that turns into <code class=\"language-text\">pointer-events: box-none</code>, which is not real CSS, which means the browser gets to improvise. So there is now a defensive rule for the overlay host:</p>\n<div class=\"copy-code-block\"><button tabindex=\"13\" type=\"button\" class=\"MuiButtonBase-root MuiButton-root MuiButton-contained MuiButton-containedPrimary copy-code-button\"><span class=\"MuiButton-label\">Copy</span></button><div class=\"gatsby-highlight\" data-language=\"css\"><pre class=\"language-css\"><code class=\"language-css\"><span class=\"token selector\">.expo-web-app-shell #expo-web-modal-shell-host</span> <span class=\"token punctuation\">{</span>\n  <span class=\"token property\">pointer-events</span><span class=\"token punctuation\">:</span> none <span class=\"token important\">!important</span><span class=\"token punctuation\">;</span>\n<span class=\"token punctuation\">}</span>\n\n<span class=\"token selector\">.expo-web-app-shell #expo-web-modal-shell-host *</span> <span class=\"token punctuation\">{</span>\n  <span class=\"token property\">pointer-events</span><span class=\"token punctuation\">:</span> auto <span class=\"token important\">!important</span><span class=\"token punctuation\">;</span>\n<span class=\"token punctuation\">}</span></code></pre></div></div>\n<p>There is also a <code class=\"language-text\">useLayoutEffect</code> forcing the same thing inline, because by that point I was no longer interested in finding out how polite the correct solution was.</p>\n<h2 id=\"look-i-have-a-type\" style=\"position:relative;\"><a href=\"#look-i-have-a-type\" aria-label=\"look i have a type permalink\" class=\"post-headers-link before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Look, I have a type</h2>\n<p>If you’ve been reading this blog for a while, none of this should surprise you.</p>\n<p>I already <a href=\"/en/blog/coding/i-made-a-top-down-game-version-of-my-blog-with-phaser-and-react/\">turned this blog into a top-down RPG</a> because the Konami Code deserved a bigger payoff than just a background effect.</p>\n<p>Then I <a href=\"/en/blog/coding/my-blog-now-has-stories-and-im-not-sure-why/\">added Stories to the blog</a> because my internet provider’s app had Stories, and something about that annoyed me deeply enough that I made it everybody else’s problem.</p>\n<p>So yes, of course my fitness app now ships its website from the same Expo Router repo, and of course the desktop web app runs inside a giant picture of a phone. This is not a surprising escalation. This is just continuity.</p>\n<h2 id=\"why-im-keeping-it\" style=\"position:relative;\"><a href=\"#why-im-keeping-it\" aria-label=\"why im keeping it permalink\" class=\"post-headers-link before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Why I’m keeping it</h2>\n<p>Past the comedy value, this fixed a very real maintenance tax.</p>\n<p>Translations are shared. Design tokens are shared. Components are shared. Legal pages live next to the product they are legally defending. When I change copy, screenshots, or feature positioning, I don’t need to remember which repo holds the respectable public version of the same app.</p>\n<p>Deploy also got nicer. One build. One export. One artifact. The website and the web app cannot drift apart unless I deliberately make them drift apart, which is much harder when they are literally shipped together.</p>\n<p>And when I shipped the <a href=\"/en/blog/coding/musclog-redesign-nutrition-tracking-and-why-your-fitness-app-subscription-is-a-scam/\">Musclog redesign</a>, the website basically came along for the ride. Same spacing tokens, same colors, updated screenshots, same PR. Exactly the kind of boring maintenance win that makes a slightly weird architecture decision feel smart six months later.</p>\n<h2 id=\"conclusion\" style=\"position:relative;\"><a href=\"#conclusion\" aria-label=\"conclusion permalink\" class=\"post-headers-link before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Conclusion</h2>\n<p>Could I have kept the website in a separate Next.js repo like a more emotionally stable person? Sure.</p>\n<p>Was I ever going to keep doing that once Expo Router made this possible? No.</p>\n<p>The funny part is that the routing was the boring part. Expo Router handled that just fine. The annoying work lived in all the little browser lies around it: route gating before hydration, raw HTML i18n, asset syncing, shell-aware modals, pointer-event weirdness, and convincing desktop Chrome to temporarily believe it was a phone.</p>\n<p>Still worth it.</p>\n<p>If you want to poke around the code, <a href=\"https://github.com/blopa/musclog-app\" target=\"_blank\" rel=\"noreferrer\">Musclog is open-source on GitHub</a>. If you want to use it, it’s at <a href=\"https://musclog.app\" target=\"_blank\" rel=\"noreferrer\">musclog.app</a>. The website lives in the app repo now.</p>\n<p>Everybody lives here now.</p>","otherLanguagesUrl":["/pt-br/blog/coding/site-ou-app-sim-o-guia-gambiarrístico-para-sites-feitos-em-expo-router/"],"rss":{"title":"Website or App? Yes! The hacky guide to building sites with Expo Router","description":"Website or App? Yes! The hacky guide to building sites with Expo Router","date":"2026-04-25T00:00:00.000Z"},"images":["/static/f71a459f3034e47544de2f3ce309dd61/42a19/musclog-shared-router-website-home-1.png","/static/c50b08ecc21b0e02ed7774578063a5f6/42a19/musclog-shared-router-website-home-2.png","/static/49361b3283ae34ef30a950b13c7f5594/42a19/musclog-desktop-phone-frame-wrapper.png","/377ac4354ce553fccf98c5d8d1017e50/straight-to-jail.gif","/static/a2af4c3fcf36eee559fddfb48a5143d1/42a19/musclog-desktop-modal-inside-phone-frame.png"],"videos":[],"comments":[],"googleFormData":{"fvv":1,"pageHistory":0,"fbzx":"2341154569390836157","action":"e/1FAIpQLSeST_0jBnLKkEkXGpyx9LWrvV2a1-1F5dr-AcA4wn0BSRyPCw","title":"blog_comment","description":null,"fields":[{"label":"name","description":null,"type":"SHORT_ANSWER","id":"1953327618","required":true},{"label":"email","description":null,"type":"SHORT_ANSWER","id":"1309141965","required":false},{"label":"twitter","description":null,"type":"SHORT_ANSWER","id":"740186305","required":false},{"label":"comment","description":null,"type":"LONG_ANSWER","id":"1663940054","required":true},{"label":"post_path","description":null,"type":"SHORT_ANSWER","id":"1852628638","required":true}],"fieldsOrder":{"740186305":2,"1309141965":1,"1663940054":3,"1852628638":4,"1953327618":0}},"pageType":"blogPost","categoryImage":"/categories_coding.jpg/","slug":"/2026/2026-04-25-website-or-app-yes-hacky-expo-router-guide.en/","locale":"en","title":"Website or App? Yes! The hacky guide to building sites with Expo Router","previous":{"excerpt":"Imagina a cena: você tá na academia. Entre séries. Suado…","html":"<p>Imagina a cena: você tá na academia. Entre séries. Suado. Com uns 90 segundos antes de ter que pegar a barra de novo, e você tá ali cutucando o seu próprio aplicativo igual ao pai de alguém tentando navegar num site de 2008. Quatro toques pra registrar uma série. Quatro toques. Num app que você mesmo escreveu. Que você poderia mudar. E mesmo assim, toda vez, você faz os quatro toques porque sempre tem outra coisa mais urgente pra fazer antes.</p>\n<p>Era o Musclog. Meu app de treino. Meu projeto de “300 horas que não vou recuperar” que eu <a href=\"/en/blog/coding/musclog-leveraging-my-reactjs-experience-to-build-a-react-native-app/\">escrevi lá em 2024</a>. Funcionava. Meus amigos usavam. Eu registrei cada treino e cada grama de comida por meses com ele.</p>\n<p>Também era, objetivamente, um dos apps mais feios que eu já tinha colocado no meu celular.</p>\n<p><figure class=\"gatsby-resp-image-figure\" style=\"\">\n    <span\n      class=\"gatsby-resp-image-wrapper\"\n      style=\"position: relative; display: block; margin-left: auto; margin-right: auto; max-width: 1024px; \"\n    >\n      <span\n    class=\"gatsby-resp-image-background-image\"\n    style=\"padding-bottom: 222.265625%; position: relative; bottom: 0; left: 0; background-image: url('data:image/png;base64,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'); background-size: cover; display: block;\"\n  ></span>\n  <img\n        class=\"gatsby-resp-image-image\"\n        alt=\"Design original do Musclog - a foto de antes que ninguém pediu\"\n        title=\"Design original do Musclog - a foto de antes que ninguém pediu\"\n        src=\"/static/d41801c4cd3ed5901fa8893f146a8c94/42a19/musclog-workout-screenshot.png\"\n        srcset=\"/static/d41801c4cd3ed5901fa8893f146a8c94/e3135/musclog-workout-screenshot.png 256w,\n/static/d41801c4cd3ed5901fa8893f146a8c94/06341/musclog-workout-screenshot.png 512w,\n/static/d41801c4cd3ed5901fa8893f146a8c94/42a19/musclog-workout-screenshot.png 1024w,\n/static/d41801c4cd3ed5901fa8893f146a8c94/b4ad3/musclog-workout-screenshot.png 1080w\"\n        sizes=\"(max-width: 1024px) 100vw, 1024px\"\n        style=\"width:100%;height:100%;margin:0;vertical-align:middle;position:absolute;top:0;left:0;\"\n        loading=\"lazy\"\n        decoding=\"async\"\n      />\n    </span>\n    <figcaption class=\"gatsby-resp-image-figcaption\">Design original do Musclog - a foto de antes que ninguém pediu</figcaption>\n  </figure></p>\n<p>Eu sabia. Meus amigos sabiam. O amigo honesto o suficiente pra falar “cara, isso parece um site” sabia, e falou na minha cara sem a menor cerimônia. Mas funcionava, e por um tempo isso era suficiente. “Funciona” é o equivalente do desenvolvedor ao “tá bom” - tecnicamente verdade, emocionalmente uma mentira completa com a qual você concordou em viver.</p>\n<p>Aí a dívida de UX foi rendendo juros na minha vida. Registrar um treino parecia preencher papelada. Adicionar comida exigia pular entre três telas diferentes quando deveria ser uma. Navegar no app na academia, já suado e com tempo contado, era um exercício de fricção que eu tinha projetado pra mim mesmo sem querer. Fui empilhando features em cima desse caos visual, que é um jeito excelente de deixar a casa do acumulador ainda mais difícil de navegar. Sem falar nos bugs, que às vezes simplesmente crashavam o app e ficavam lá no Sentry, sem solução.</p>\n<p>Tinha que mudar alguma coisa. E por “alguma coisa” eu quero dizer tudo.</p>\n<h2 id=\"eu-nao-dou-pra-um-bom-designer\" style=\"position:relative;\"><a href=\"#eu-nao-dou-pra-um-bom-designer\" aria-label=\"eu nao dou pra um bom designer permalink\" class=\"post-headers-link before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Eu não dou pra um bom designer</h2>\n<p>Eu tenho muitos amigos que dão pra um bom designer. Muitos. Não é o meu caso. O app original foi feito com <a href=\"https://reactnativepaper.com/\" target=\"_blank\" rel=\"noreferrer\">react-native-paper</a>, que é uma biblioteca perfeitamente decente. O problema não era o react-native-paper. O problema era eu. Fui jogando componentes juntos na ordem que fazia sentido na hora, escolhendo cores no feeling, e shippando sem nenhum design system de verdade. Sem escala de espaçamento. Sem paleta de cores semântica. Puro instinto de desenvolvedor aplicado diretamente numa UI, que, como se descobriu, produz exatamente o tipo de app que eu terminei tendo.</p>\n<p>O mesmo conceito parecia diferente em três telas diferentes porque cada tela foi construída num momento diferente da minha compreensão do app. Botões primários tinham alturas diferentes dependendo de onde você os encontrava. Cards tinham três raios de canto diferentes que eu provavelmente escolhi com grande intenção e depois esqueci completamente. Cores eram hardcoded em todo lugar. A coisa toda era mantida unida por confiança e fita adesiva.</p>\n<p>O plano de migração: arrancar o react-native-paper, substituir pelo <a href=\"https://www.nativewind.dev/\" target=\"_blank\" rel=\"noreferrer\">NativeWind</a> (Tailwind CSS pro React Native), e construir um design system de verdade do zero. Simples. Direto. Completamente insano dado o tamanho do codebase nessa altura.</p>\n<p>Fui e fiz mesmo assim, porque não tenho nenhum instinto de autopreservação quando se trata de side projects.</p>\n<h2 id=\"entra-o-stitch\" style=\"position:relative;\"><a href=\"#entra-o-stitch\" aria-label=\"entra o stitch permalink\" class=\"post-headers-link before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Entra o Stitch</h2>\n<p>Eu estava de olho no <a href=\"https://stitch.withgoogle.com/\" target=\"_blank\" rel=\"noreferrer\">Google Stitch</a> desde que lançou. A versão curta: é uma ferramenta de IA que gera UI de app mobile a partir de prompts e screenshots. Você descreve a estética que quer, joga umas telas de referência, e ele gera componentes React coerentes. Pra um desenvolvedor que genuinamente tentou aprender design três vezes separadas e falhou cada vez por razões fundamentalmente diferentes, isso pareceu cola no melhor sentido possível.</p>\n<p>Alimentei com as telas existentes do Musclog e descrevi o que queria: tema escuro, estética de fitness e performance, algo que parecesse estar olhando pra dados sérios ao invés de uma lista de tarefas. Queria que parecesse o dashboard de algo que faz sentido, não um app de bem-estar que vai sugerir que você “respire fundo” antes de registrar seu deadlift.</p>\n<p><figure class=\"gatsby-resp-image-figure\" style=\"\">\n    <span\n      class=\"gatsby-resp-image-wrapper\"\n      style=\"position: relative; display: block; margin-left: auto; margin-right: auto; max-width: 537px; \"\n    >\n      <span\n    class=\"gatsby-resp-image-background-image\"\n    style=\"padding-bottom: 216.796875%; position: relative; bottom: 0; left: 0; background-image: url('data:image/png;base64,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'); background-size: cover; display: block;\"\n  ></span>\n  <img\n        class=\"gatsby-resp-image-image\"\n        alt=\"Nova tela inicial do Musclog\"\n        title=\"Nova tela inicial do Musclog\"\n        src=\"/static/e506b300918526ea840241afb0a13ded/673a2/musclog-home-screen.png\"\n        srcset=\"/static/e506b300918526ea840241afb0a13ded/e3135/musclog-home-screen.png 256w,\n/static/e506b300918526ea840241afb0a13ded/06341/musclog-home-screen.png 512w,\n/static/e506b300918526ea840241afb0a13ded/673a2/musclog-home-screen.png 537w\"\n        sizes=\"(max-width: 537px) 100vw, 537px\"\n        style=\"width:100%;height:100%;margin:0;vertical-align:middle;position:absolute;top:0;left:0;\"\n        loading=\"lazy\"\n        decoding=\"async\"\n      />\n    </span>\n    <figcaption class=\"gatsby-resp-image-figcaption\">Nova tela inicial do Musclog</figcaption>\n  </figure></p>\n<p>A parte inesperada foi o quanto esse processo me forçou a pensar claramente sobre o produto em si. Pra gerar uma boa tela no Stitch, eu precisava articular pra que aquela tela <em>servia</em> antes de poder descrever como deveria parecer. Esse processo expôs problemas de UX que não tinham nada a ver com cores. Um passo no fluxo de registro de treino existia puramente por causa de uma limitação técnica que eu tinha contornado com UI ao invés de resolver o problema de verdade. <del>Resolvido. Fingi que nunca aconteceu.</del> Resolvido, depois refatorei o serviço por baixo pra que nunca voltasse.</p>\n<p><del>Eu escrevi</del> O Stitch também escreveu um documento de design de verdade pela primeira vez na vida desse projeto: cores de superfície com nomes, cores semânticas pra macros (proteína é sempre índigo <code class=\"language-text\">#6366f1</code>, gordura é sempre âmbar <code class=\"language-text\">#f59e0b</code>, carbo é esmeralda <code class=\"language-text\">#10b981</code>, fibra é rosa <code class=\"language-text\">#ec4899</code>), escala de espaçamento em 4/8/12/16/20/24/32px, padrões de border radius em 12px pra inputs e 16px pra cards primários. Coisas que eu deveria ter definido antes de escrever uma única linha de código de UI. A conta do “manda ver” chegou, como sempre chega, com juros.</p>\n<h2 id=\"a-arquitetura-que-ninguem-pediu\" style=\"position:relative;\"><a href=\"#a-arquitetura-que-ninguem-pediu\" aria-label=\"a arquitetura que ninguem pediu permalink\" class=\"post-headers-link before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>A arquitetura que ninguém pediu</h2>\n<p>Já que eu estava queimando a UI até os alicerces, aproveitei pra apertar a arquitetura por baixo também. Essa é a parte onde a maioria das pessoas desliga, mas se você tá construindo algo com armazenamento local-first em React Native, algumas dessas decisões podem te poupar tempo.</p>\n<p>O Musclog usa <a href=\"https://watermelondb.dev/\" target=\"_blank\" rel=\"noreferrer\">WatermelonDB</a> pro armazenamento local. Não SQLite puro, não MMKV, não AsyncStorage. O WatermelonDB fica em cima do SQLite no native e <a href=\"https://github.com/techfort/LokiJS\" target=\"_blank\" rel=\"noreferrer\">LokiJS</a> no web, e te dá uma camada de modelo com queries reativas. Quando o dado muda, qualquer componente observando aquela query re-renderiza automaticamente. Sem sync de estado manual. Sem bug de “lembrei de atualizar a lista depois de salvar?“. O mesmo codebase roda no Android e no browser sem tocar na camada de dados, o que importa quando você quer testar algo rápido sem subir um device.</p>\n<p>A estrutura em camadas vai assim: definição de schema na base, depois models, depois services que cuidam de CRUD e lógica de negócio. Serviços que não são de banco (IA, notificações, sync do Health Connect) vivem separados no próprio dir <code class=\"language-text\">services/</code>. Todo write passa por <code class=\"language-text\">database.write(async () => { ... })</code>. Sem exceções. Blocos de write aninhados causam deadlocks no WatermelonDB, e são uma dor de cabeça pra debugar, então a regra é simples: nunca aninhe, e se você quiser fazer isso, você projetou algo errado lá atrás.</p>\n<p>É assim que fica no <code class=\"language-text\">NutritionCheckinService.ts</code> criando um lote de check-ins semanais:</p>\n<div class=\"gatsby-highlight\" data-language=\"typescript\"><pre class=\"language-typescript\"><code class=\"language-typescript\"><span class=\"token keyword\">static</span> <span class=\"token keyword\">async</span> <span class=\"token function\">createBatch</span><span class=\"token punctuation\">(</span>\r\n    nutritionGoalId<span class=\"token operator\">:</span> <span class=\"token builtin\">string</span><span class=\"token punctuation\">,</span>\r\n    checkins<span class=\"token operator\">:</span> NutritionCheckinInput<span class=\"token punctuation\">[</span><span class=\"token punctuation\">]</span>\r\n<span class=\"token punctuation\">)</span><span class=\"token operator\">:</span> <span class=\"token builtin\">Promise</span><span class=\"token operator\">&lt;</span>NutritionCheckin<span class=\"token punctuation\">[</span><span class=\"token punctuation\">]</span><span class=\"token operator\">></span> <span class=\"token punctuation\">{</span>\r\n    <span class=\"token keyword\">return</span> <span class=\"token keyword\">await</span> database<span class=\"token punctuation\">.</span><span class=\"token function\">write</span><span class=\"token punctuation\">(</span><span class=\"token keyword\">async</span> <span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">=></span> <span class=\"token punctuation\">{</span>\r\n        <span class=\"token keyword\">const</span> collection <span class=\"token operator\">=</span> database<span class=\"token punctuation\">.</span><span class=\"token generic-function\"><span class=\"token function\">get</span><span class=\"token generic class-name\"><span class=\"token operator\">&lt;</span>NutritionCheckin<span class=\"token operator\">></span></span></span><span class=\"token punctuation\">(</span><span class=\"token string\">'nutrition_checkins'</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n\r\n        <span class=\"token keyword\">const</span> preparedRecords <span class=\"token operator\">=</span> checkins<span class=\"token punctuation\">.</span><span class=\"token function\">map</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">(</span>data<span class=\"token punctuation\">)</span> <span class=\"token operator\">=></span>\r\n            collection<span class=\"token punctuation\">.</span><span class=\"token function\">prepareCreate</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">(</span>record<span class=\"token punctuation\">)</span> <span class=\"token operator\">=></span> <span class=\"token punctuation\">{</span>\r\n                record<span class=\"token punctuation\">.</span>nutritionGoalId <span class=\"token operator\">=</span> nutritionGoalId<span class=\"token punctuation\">;</span>\r\n                record<span class=\"token punctuation\">.</span>checkinDate <span class=\"token operator\">=</span> data<span class=\"token punctuation\">.</span>checkinDate<span class=\"token punctuation\">;</span>\r\n                record<span class=\"token punctuation\">.</span>targetWeight <span class=\"token operator\">=</span> data<span class=\"token punctuation\">.</span>targetWeight<span class=\"token punctuation\">;</span>\r\n                record<span class=\"token punctuation\">.</span>status <span class=\"token operator\">=</span> data<span class=\"token punctuation\">.</span>status <span class=\"token operator\">??</span> <span class=\"token string\">'pending'</span><span class=\"token punctuation\">;</span>\r\n            <span class=\"token punctuation\">}</span><span class=\"token punctuation\">)</span>\r\n        <span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n\r\n        <span class=\"token keyword\">await</span> database<span class=\"token punctuation\">.</span><span class=\"token function\">batch</span><span class=\"token punctuation\">(</span><span class=\"token operator\">...</span>preparedRecords<span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n        <span class=\"token keyword\">return</span> preparedRecords<span class=\"token punctuation\">;</span>\r\n    <span class=\"token punctuation\">}</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>A combinação <code class=\"language-text\">prepareCreate</code> + <code class=\"language-text\">database.batch()</code> é como o WatermelonDB lida com múltiplos inserts atomicamente sem múltiplas viagens de ida e volta. Um bloco <code class=\"language-text\">write()</code>, um batch, feito. Você nunca chama o <code class=\"language-text\">database.write()</code> de outro service lá de dentro, porque é assim que você consegue um deadlock às 23h que misteriosamente só reproduz no device.</p>\n<p><figure class=\"gatsby-resp-image-figure\" style=\"\">\n    <span\n      class=\"gatsby-resp-image-wrapper\"\n      style=\"position: relative; display: block; margin-left: auto; margin-right: auto; max-width: 537px; \"\n    >\n      <span\n    class=\"gatsby-resp-image-background-image\"\n    style=\"padding-bottom: 216.796875%; position: relative; bottom: 0; left: 0; background-image: url('data:image/png;base64,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'); background-size: cover; display: block;\"\n  ></span>\n  <img\n        class=\"gatsby-resp-image-image\"\n        alt=\"Tela de registro de treino do Musclog\"\n        title=\"Tela de registro de treino do Musclog\"\n        src=\"/static/e50a22c9cedb095800c16037fcda6fee/673a2/musclog-workout-logging.png\"\n        srcset=\"/static/e50a22c9cedb095800c16037fcda6fee/e3135/musclog-workout-logging.png 256w,\n/static/e50a22c9cedb095800c16037fcda6fee/06341/musclog-workout-logging.png 512w,\n/static/e50a22c9cedb095800c16037fcda6fee/673a2/musclog-workout-logging.png 537w\"\n        sizes=\"(max-width: 537px) 100vw, 537px\"\n        style=\"width:100%;height:100%;margin:0;vertical-align:middle;position:absolute;top:0;left:0;\"\n        loading=\"lazy\"\n        decoding=\"async\"\n      />\n    </span>\n    <figcaption class=\"gatsby-resp-image-figcaption\">Tela de registro de treino do Musclog</figcaption>\n  </figure></p>\n<p>Dados sensíveis, especificamente seu histórico de peso, percentuais de gordura corporal e logs de nutrição, são criptografados com AES antes de entrar no banco. Não porque eu espero que alguém invada um arquivo SQLite local, mas porque são dados de saúde. Merecem ser tratados como tal. O arquivo <code class=\"language-text\">encryptionHelpers.ts</code> cuida de encrypt/decrypt de forma transparente pela camada de service. Você nunca pensa nisso como usuário. Nem precisa pensar nisso como contribuidor, porque tá em um lugar e tudo passa por ele.</p>\n<p>Então quando você loga uma refeição, é isso que realmente fica armazenado:</p>\n<div class=\"gatsby-highlight\" data-language=\"typescript\"><pre class=\"language-typescript\"><code class=\"language-typescript\"><span class=\"token comment\">// database/encryptionHelpers.ts</span>\r\n<span class=\"token keyword\">export</span> <span class=\"token keyword\">async</span> <span class=\"token keyword\">function</span> <span class=\"token function\">encryptNutritionLogSnapshot</span><span class=\"token punctuation\">(</span>plain<span class=\"token operator\">:</span> <span class=\"token punctuation\">{</span>\r\n    loggedFoodName<span class=\"token operator\">?</span><span class=\"token operator\">:</span> <span class=\"token builtin\">string</span><span class=\"token punctuation\">;</span>\r\n    loggedCalories<span class=\"token operator\">:</span> <span class=\"token builtin\">number</span><span class=\"token punctuation\">;</span>\r\n    loggedProtein<span class=\"token operator\">:</span> <span class=\"token builtin\">number</span><span class=\"token punctuation\">;</span>\r\n    loggedCarbs<span class=\"token operator\">:</span> <span class=\"token builtin\">number</span><span class=\"token punctuation\">;</span>\r\n    loggedFat<span class=\"token operator\">:</span> <span class=\"token builtin\">number</span><span class=\"token punctuation\">;</span>\r\n    loggedFiber<span class=\"token operator\">:</span> <span class=\"token builtin\">number</span><span class=\"token punctuation\">;</span>\r\n    loggedMicros<span class=\"token operator\">?</span><span class=\"token operator\">:</span> Record<span class=\"token operator\">&lt;</span><span class=\"token builtin\">string</span><span class=\"token punctuation\">,</span> <span class=\"token builtin\">number</span> <span class=\"token operator\">|</span> <span class=\"token keyword\">undefined</span><span class=\"token operator\">></span><span class=\"token punctuation\">;</span>\r\n<span class=\"token punctuation\">}</span><span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\r\n    <span class=\"token keyword\">const</span> <span class=\"token punctuation\">[</span>\r\n        loggedFoodName<span class=\"token punctuation\">,</span>\r\n        loggedCalories<span class=\"token punctuation\">,</span>\r\n        loggedProtein<span class=\"token punctuation\">,</span>\r\n        loggedCarbs<span class=\"token punctuation\">,</span>\r\n        loggedFat<span class=\"token punctuation\">,</span>\r\n        loggedFiber<span class=\"token punctuation\">,</span>\r\n        loggedMicrosJson<span class=\"token punctuation\">,</span>\r\n    <span class=\"token punctuation\">]</span> <span class=\"token operator\">=</span> <span class=\"token keyword\">await</span> <span class=\"token builtin\">Promise</span><span class=\"token punctuation\">.</span><span class=\"token function\">all</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">[</span>\r\n        <span class=\"token function\">encryptOptionalString</span><span class=\"token punctuation\">(</span>plain<span class=\"token punctuation\">.</span>loggedFoodName<span class=\"token punctuation\">)</span><span class=\"token punctuation\">,</span>\r\n        <span class=\"token function\">encryptNumber</span><span class=\"token punctuation\">(</span>plain<span class=\"token punctuation\">.</span>loggedCalories<span class=\"token punctuation\">)</span><span class=\"token punctuation\">,</span>\r\n        <span class=\"token function\">encryptNumber</span><span class=\"token punctuation\">(</span>plain<span class=\"token punctuation\">.</span>loggedProtein<span class=\"token punctuation\">)</span><span class=\"token punctuation\">,</span>\r\n        <span class=\"token function\">encryptNumber</span><span class=\"token punctuation\">(</span>plain<span class=\"token punctuation\">.</span>loggedCarbs<span class=\"token punctuation\">)</span><span class=\"token punctuation\">,</span>\r\n        <span class=\"token function\">encryptNumber</span><span class=\"token punctuation\">(</span>plain<span class=\"token punctuation\">.</span>loggedFat<span class=\"token punctuation\">)</span><span class=\"token punctuation\">,</span>\r\n        <span class=\"token function\">encryptNumber</span><span class=\"token punctuation\">(</span>plain<span class=\"token punctuation\">.</span>loggedFiber<span class=\"token punctuation\">)</span><span class=\"token punctuation\">,</span>\r\n        <span class=\"token function\">encryptJson</span><span class=\"token punctuation\">(</span>plain<span class=\"token punctuation\">.</span>loggedMicros<span class=\"token punctuation\">)</span><span class=\"token punctuation\">,</span>\r\n    <span class=\"token punctuation\">]</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n\r\n    <span class=\"token keyword\">return</span> <span class=\"token punctuation\">{</span> loggedFoodName<span class=\"token punctuation\">,</span> loggedCalories<span class=\"token punctuation\">,</span> loggedProtein<span class=\"token punctuation\">,</span> loggedCarbs<span class=\"token punctuation\">,</span> loggedFat<span class=\"token punctuation\">,</span> loggedFiber<span class=\"token punctuation\">,</span> loggedMicrosJson <span class=\"token punctuation\">}</span><span class=\"token punctuation\">;</span>\r\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>Aquele <code class=\"language-text\">loggedCalories: 165</code> que você digitou? É uma string criptografada com AES no banco. <code class=\"language-text\">loggedFoodName: \"Chicken breast\"</code> também é criptografado. Até o blob de JSON dos micronutrientes é criptografado. Tudo descriptografa na hora da leitura pela camada de service. A chave de criptografia é derivada por device e nunca sai dele.</p>\n<p>O sistema de gráficos tem uma peculiaridade que vale mencionar. O Musclog usa <a href=\"https://commerce.nearform.com/open-source/victory-native/\" target=\"_blank\" rel=\"noreferrer\">Victory Native</a> pra gráficos no mobile, que usa Skia pra renderizar. Skia não funciona no web. Então todo componente de gráfico tem um par <code class=\"language-text\">.web.tsx</code> usando <a href=\"https://commerce.nearform.com/open-source/victory/\" target=\"_blank\" rel=\"noreferrer\">Victory</a> normal com SVG no lugar. O bundler do Expo pega o arquivo certo automaticamente baseado na extensão. Parece o dobro de trabalho e é meio que isso mesmo, mas a alternativa são gráficos que quebram silenciosamente no web, e eu uso a versão web bastante durante o desenvolvimento.</p>\n<p>O mesmo <code class=\"language-text\">LineChart</code>, dois runtimes:</p>\n<div class=\"gatsby-highlight\" data-language=\"typescript\"><pre class=\"language-typescript\"><code class=\"language-typescript\"><span class=\"token comment\">// components/charts/LineChart.tsx - native (Skia)</span>\r\n<span class=\"token keyword\">import</span> <span class=\"token punctuation\">{</span> Area<span class=\"token punctuation\">,</span> CartesianChart<span class=\"token punctuation\">,</span> Line<span class=\"token punctuation\">,</span> Scatter <span class=\"token punctuation\">}</span> <span class=\"token keyword\">from</span> <span class=\"token string\">'victory-native'</span><span class=\"token punctuation\">;</span>\r\n<span class=\"token keyword\">import</span> Animated<span class=\"token punctuation\">,</span> <span class=\"token punctuation\">{</span> useAnimatedStyle<span class=\"token punctuation\">,</span> useSharedValue <span class=\"token punctuation\">}</span> <span class=\"token keyword\">from</span> <span class=\"token string\">'react-native-reanimated'</span><span class=\"token punctuation\">;</span>\r\n\r\n<span class=\"token comment\">// components/charts/LineChart.web.tsx - web (SVG)</span>\r\n<span class=\"token keyword\">import</span> <span class=\"token punctuation\">{</span> VictoryArea<span class=\"token punctuation\">,</span> VictoryAxis<span class=\"token punctuation\">,</span> VictoryChart<span class=\"token punctuation\">,</span> VictoryLine<span class=\"token punctuation\">,</span> VictoryScatter <span class=\"token punctuation\">}</span> <span class=\"token keyword\">from</span> <span class=\"token string\">'victory'</span><span class=\"token punctuation\">;</span></code></pre></div>\n<p>Mesma interface de props, mesmo comportamento, backends de renderização diferentes. O bundler do Expo resolve <code class=\"language-text\">LineChart</code> pro arquivo <code class=\"language-text\">.web.tsx</code> no web e pro <code class=\"language-text\">.tsx</code> em todo o resto. Zero condicionais no componente que realmente o usa.</p>\n<p>O tracking de volume funciona da mesma forma na infraestrutura, exceto que o problema interessante aí é a matemática. O Musclog rastreia volume como máximo estimado de uma repetição, não peso x reps bruto, porque 5 reps em 80kg e 12 reps em 60kg são estímulos de treino diferentes que produzem o mesmo número num gráfico de volume ingênuo. O problema é que não existe uma fórmula de 1RM aceita universalmente. Brzycki, Epley, Lander, Mayhew - todas dão um número ligeiramente diferente pro mesmo set, e a pesquisa não elege um vencedor claro. Então o Musclog roda as sete e tira a média:</p>\n<div class=\"gatsby-highlight\" data-language=\"typescript\"><pre class=\"language-typescript\"><code class=\"language-typescript\"><span class=\"token comment\">// utils/workoutCalculator.ts</span>\r\n<span class=\"token keyword\">export</span> <span class=\"token keyword\">function</span> <span class=\"token function\">calculateAverage1RM</span><span class=\"token punctuation\">(</span>weight<span class=\"token operator\">:</span> <span class=\"token builtin\">number</span><span class=\"token punctuation\">,</span> reps<span class=\"token operator\">:</span> <span class=\"token builtin\">number</span><span class=\"token punctuation\">,</span> rir<span class=\"token operator\">:</span> <span class=\"token builtin\">number</span> <span class=\"token operator\">=</span> <span class=\"token number\">0</span><span class=\"token punctuation\">)</span><span class=\"token operator\">:</span> <span class=\"token builtin\">number</span> <span class=\"token punctuation\">{</span>\r\n    <span class=\"token keyword\">const</span> formulas<span class=\"token operator\">:</span> FormulaType<span class=\"token punctuation\">[</span><span class=\"token punctuation\">]</span> <span class=\"token operator\">=</span> <span class=\"token punctuation\">[</span>\r\n        <span class=\"token string\">'Epley'</span><span class=\"token punctuation\">,</span> <span class=\"token string\">'Brzycki'</span><span class=\"token punctuation\">,</span> <span class=\"token string\">'Lander'</span><span class=\"token punctuation\">,</span> <span class=\"token string\">'Lombardi'</span><span class=\"token punctuation\">,</span> <span class=\"token string\">'Mayhew'</span><span class=\"token punctuation\">,</span> <span class=\"token string\">'OConner'</span><span class=\"token punctuation\">,</span> <span class=\"token string\">'Wathan'</span><span class=\"token punctuation\">,</span>\r\n    <span class=\"token punctuation\">]</span><span class=\"token punctuation\">;</span>\r\n    <span class=\"token keyword\">let</span> total1RM <span class=\"token operator\">=</span> <span class=\"token number\">0</span><span class=\"token punctuation\">;</span>\r\n    <span class=\"token keyword\">let</span> validFormulas <span class=\"token operator\">=</span> <span class=\"token number\">0</span><span class=\"token punctuation\">;</span>\r\n\r\n    formulas<span class=\"token punctuation\">.</span><span class=\"token function\">forEach</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">(</span>formula<span class=\"token punctuation\">)</span> <span class=\"token operator\">=></span> <span class=\"token punctuation\">{</span>\r\n        <span class=\"token keyword\">const</span> oneRM <span class=\"token operator\">=</span> <span class=\"token function\">calculate1RM</span><span class=\"token punctuation\">(</span>weight<span class=\"token punctuation\">,</span> reps<span class=\"token punctuation\">,</span> formula<span class=\"token punctuation\">,</span> rir<span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n        <span class=\"token keyword\">if</span> <span class=\"token punctuation\">(</span>oneRM <span class=\"token operator\">!==</span> <span class=\"token keyword\">null</span><span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\r\n            total1RM <span class=\"token operator\">+=</span> oneRM<span class=\"token punctuation\">;</span>\r\n            validFormulas<span class=\"token operator\">++</span><span class=\"token punctuation\">;</span>\r\n        <span class=\"token punctuation\">}</span>\r\n    <span class=\"token punctuation\">}</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n\r\n    <span class=\"token keyword\">return</span> validFormulas <span class=\"token operator\">></span> <span class=\"token number\">0</span> <span class=\"token operator\">?</span> total1RM <span class=\"token operator\">/</span> validFormulas <span class=\"token operator\">:</span> <span class=\"token number\">0</span><span class=\"token punctuation\">;</span>\r\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>O parâmetro <code class=\"language-text\">rir</code> é Reps in Reserve: se você parou em 8 mas tinha mais 2 no tanque, <code class=\"language-text\">rir = 2</code> ajusta a estimativa pra cima pra refletir o que você realmente poderia levantar. Registrar seu RIR é opcional. O tipo de pessoa que construiu seu próprio app de fitness e rastreia tudo em planilha geralmente também é o tipo de pessoa que rastreia o RIR. <del>Não tô dizendo que sou eu.</del> Sou eu.</p>\n<h2 id=\"espera-e-os-dados-reais-de-comida\" style=\"position:relative;\"><a href=\"#espera-e-os-dados-reais-de-comida\" aria-label=\"espera e os dados reais de comida permalink\" class=\"post-headers-link before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Espera, e os dados reais de comida?</h2>\n<p>É. O redesign foi a parte visível dessa atualização. A parte mais significativa, pelo menos do ponto de vista de “esse app é realmente útil”, foi reconstruir completamente como o tracking de comida funciona.</p>\n<p>A versão original dependia muito das informações nutricionais vindas do Health Connect. Se você não tinha outro app pra fazer o tracking de nutrição, ficava a deriva. Isso é aceitável pra um projeto de fim de semana. Não é aceitável pra um app que as pessoas usam diariamente pra rastrear calorias, proteína, carbo, gordura, e mais de 40 micronutrientes em múltiplas refeições.</p>\n<p>Pra minha genuína surpresa, descobri que dados de alta qualidade sobre comida não ficam atrás de um portão corporativo. Existem APIs públicas e gratuitas enormes - como USDA e Open Food Facts - que fornecem de quebra de micronutrientes detalhada a consulta de código de barras global sem cobrar um centavo. Encontrar isso foi um grande momento “aha!”: se o dado é público e o processamento acontece direto no seu device, não existe justificativa técnica alguma pra rastreamento de nutrição ser um SaaS baseado em assinatura. A maioria dos apps “premium” basicamente te cobra uma mensalidade pra ser intermediário de dados que nem são deles, mas essa é uma discussão picante que guardei pra outra hora.\r\nO Musclog agora conecta a dois bancos de dados de comida reais.</p>\n<h3 id=\"usda-fooddata-central\" style=\"position:relative;\"><a href=\"#usda-fooddata-central\" aria-label=\"usda fooddata central permalink\" class=\"post-headers-link before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>USDA FoodData Central</h3>\n<p><a href=\"https://fdc.nal.usda.gov/\" target=\"_blank\" rel=\"noreferrer\">USDA FoodData Central</a> é o banco de dados nutricional público do Departamento de Agricultura dos EUA. Centenas de milhares de alimentos, de produtos de marcas a ingredientes brutos, com detalhamento de macro e micronutrientes. São dados governamentais, é gratuito, e a API não requer cartão de crédito, o que como você vai ver é uma consideração não trivial pra mim. A cobertura de produtos de marcas americanos e globais é genuinamente sólida. Essa é a espinha dorsal da busca.</p>\n<p>O USDA identifica nutrientes por códigos numéricos, então mapeá-los pra algo legível por humanos requer uma camada de lookup. É assim que o mapper parece na prática:</p>\n<div class=\"gatsby-highlight\" data-language=\"typescript\"><pre class=\"language-typescript\"><code class=\"language-typescript\"><span class=\"token comment\">// utils/usdaMapper.ts</span>\r\n<span class=\"token keyword\">export</span> <span class=\"token keyword\">function</span> <span class=\"token function\">mapUSDAFoodToUnified</span><span class=\"token punctuation\">(</span>food<span class=\"token operator\">:</span> USDAFood<span class=\"token punctuation\">)</span><span class=\"token operator\">:</span> UnifiedFoodResult <span class=\"token punctuation\">{</span>\r\n    <span class=\"token keyword\">const</span> nutrients <span class=\"token operator\">=</span> food<span class=\"token punctuation\">.</span>foodNutrients<span class=\"token punctuation\">;</span>\r\n\r\n    <span class=\"token comment\">// USDA usa códigos numéricos de nutrientes - 1008/208 é energia, 1003/203 é proteína, etc.</span>\r\n    <span class=\"token keyword\">const</span> calories <span class=\"token operator\">=</span> <span class=\"token function\">mapUSDANutritient</span><span class=\"token punctuation\">(</span>nutrients<span class=\"token punctuation\">,</span> <span class=\"token string\">'1008'</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">??</span> <span class=\"token function\">mapUSDANutritient</span><span class=\"token punctuation\">(</span>nutrients<span class=\"token punctuation\">,</span> <span class=\"token string\">'208'</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n    <span class=\"token keyword\">const</span> protein  <span class=\"token operator\">=</span> <span class=\"token function\">mapUSDANutritient</span><span class=\"token punctuation\">(</span>nutrients<span class=\"token punctuation\">,</span> <span class=\"token string\">'1003'</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">??</span> <span class=\"token function\">mapUSDANutritient</span><span class=\"token punctuation\">(</span>nutrients<span class=\"token punctuation\">,</span> <span class=\"token string\">'203'</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n    <span class=\"token keyword\">const</span> carbs    <span class=\"token operator\">=</span> <span class=\"token function\">mapUSDANutritient</span><span class=\"token punctuation\">(</span>nutrients<span class=\"token punctuation\">,</span> <span class=\"token string\">'1005'</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">??</span> <span class=\"token function\">mapUSDANutritient</span><span class=\"token punctuation\">(</span>nutrients<span class=\"token punctuation\">,</span> <span class=\"token string\">'205'</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n    <span class=\"token keyword\">const</span> fat      <span class=\"token operator\">=</span> <span class=\"token function\">mapUSDANutritient</span><span class=\"token punctuation\">(</span>nutrients<span class=\"token punctuation\">,</span> <span class=\"token string\">'1004'</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">??</span> <span class=\"token function\">mapUSDANutritient</span><span class=\"token punctuation\">(</span>nutrients<span class=\"token punctuation\">,</span> <span class=\"token string\">'204'</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n    <span class=\"token keyword\">const</span> fiber    <span class=\"token operator\">=</span> <span class=\"token function\">mapUSDANutritient</span><span class=\"token punctuation\">(</span>nutrients<span class=\"token punctuation\">,</span> <span class=\"token string\">'1079'</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">??</span> <span class=\"token function\">mapUSDANutritient</span><span class=\"token punctuation\">(</span>nutrients<span class=\"token punctuation\">,</span> <span class=\"token string\">'291'</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n\r\n    <span class=\"token keyword\">return</span> <span class=\"token punctuation\">{</span>\r\n        id<span class=\"token operator\">:</span> <span class=\"token function\">String</span><span class=\"token punctuation\">(</span>food<span class=\"token punctuation\">.</span>fdcId<span class=\"token punctuation\">)</span><span class=\"token punctuation\">,</span>\r\n        name<span class=\"token operator\">:</span> food<span class=\"token punctuation\">.</span>description<span class=\"token punctuation\">,</span>\r\n        brand<span class=\"token operator\">:</span> food<span class=\"token punctuation\">.</span>brandOwner<span class=\"token punctuation\">,</span>\r\n        calories<span class=\"token operator\">:</span> calories <span class=\"token operator\">!==</span> <span class=\"token keyword\">undefined</span> <span class=\"token operator\">?</span> Math<span class=\"token punctuation\">.</span><span class=\"token function\">round</span><span class=\"token punctuation\">(</span>calories<span class=\"token punctuation\">)</span> <span class=\"token operator\">:</span> <span class=\"token keyword\">undefined</span><span class=\"token punctuation\">,</span>\r\n        protein<span class=\"token punctuation\">,</span> carbs<span class=\"token punctuation\">,</span> fat<span class=\"token punctuation\">,</span> fiber<span class=\"token punctuation\">,</span>\r\n        source<span class=\"token operator\">:</span> <span class=\"token string\">'usda'</span><span class=\"token punctuation\">,</span>\r\n    <span class=\"token punctuation\">}</span><span class=\"token punctuation\">;</span>\r\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>O fallback duplo <code class=\"language-text\">??</code> existe porque o USDA tem dois esquemas de numeração de nutrientes diferentes dependendo do tipo de dado (Foundation Foods vs. Branded Foods). Ambos mapeiam pro mesmo formato de saída.</p>\n<h3 id=\"open-food-facts\" style=\"position:relative;\"><a href=\"#open-food-facts\" aria-label=\"open food facts permalink\" class=\"post-headers-link before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Open Food Facts</h3>\n<p><a href=\"https://world.openfoodfacts.org/\" target=\"_blank\" rel=\"noreferrer\">Open Food Facts</a> é um banco de dados comunitário de produtos alimentícios do mundo inteiro. Pensa num Wikipedia mas pra rótulos nutricionais: qualquer um pode adicionar um produto, os dados são abertos sob a licença ODbL, e como é crowdsourced globalmente cobre produtos que o banco do USDA ignora na maior, tipo o negócio de laticínio fermentado que tem no supermercado holandês a cinco minutos do meu apartamento. Quando seu banco de dados nutricional principal assume que você come exclusivamente produtos americanos e você mora na Holanda, você começa a apreciar datasets globais abertos muito rapidamente.</p>\n<p>Fazer queries nele é surpreendentemente simples pra algo tão abrangente:</p>\n<div class=\"gatsby-highlight\" data-language=\"typescript\"><pre class=\"language-typescript\"><code class=\"language-typescript\"><span class=\"token comment\">// hooks/useUnifiedFoodSearch.ts</span>\r\n<span class=\"token keyword\">const</span> url <span class=\"token operator\">=</span> <span class=\"token template-string\"><span class=\"token template-punctuation string\">`</span><span class=\"token string\">https://world.openfoodfacts.org/cgi/search.pl</span><span class=\"token template-punctuation string\">`</span></span> <span class=\"token operator\">+</span>\r\n    <span class=\"token template-string\"><span class=\"token template-punctuation string\">`</span><span class=\"token string\">?search_terms=</span><span class=\"token interpolation\"><span class=\"token interpolation-punctuation punctuation\">${</span><span class=\"token function\">encodeURIComponent</span><span class=\"token punctuation\">(</span>query<span class=\"token punctuation\">)</span><span class=\"token interpolation-punctuation punctuation\">}</span></span><span class=\"token template-punctuation string\">`</span></span> <span class=\"token operator\">+</span>\r\n    <span class=\"token template-string\"><span class=\"token template-punctuation string\">`</span><span class=\"token string\">&amp;json=1</span><span class=\"token template-punctuation string\">`</span></span> <span class=\"token operator\">+</span>\r\n    <span class=\"token template-string\"><span class=\"token template-punctuation string\">`</span><span class=\"token string\">&amp;page_size=20</span><span class=\"token template-punctuation string\">`</span></span> <span class=\"token operator\">+</span>\r\n    <span class=\"token template-string\"><span class=\"token template-punctuation string\">`</span><span class=\"token string\">&amp;fields=code,product_name,brands,nutriments,serving_size,image_url</span><span class=\"token template-punctuation string\">`</span></span><span class=\"token punctuation\">;</span>\r\n\r\n<span class=\"token keyword\">const</span> response <span class=\"token operator\">=</span> <span class=\"token keyword\">await</span> <span class=\"token function\">fetch</span><span class=\"token punctuation\">(</span>url<span class=\"token punctuation\">,</span> <span class=\"token punctuation\">{</span> signal<span class=\"token operator\">:</span> abortController<span class=\"token punctuation\">.</span>signal <span class=\"token punctuation\">}</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n<span class=\"token keyword\">const</span> result <span class=\"token operator\">=</span> <span class=\"token keyword\">await</span> response<span class=\"token punctuation\">.</span><span class=\"token function\">json</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n\r\n<span class=\"token keyword\">const</span> products <span class=\"token operator\">=</span> result<span class=\"token punctuation\">.</span>products<span class=\"token punctuation\">.</span><span class=\"token function\">filter</span><span class=\"token punctuation\">(</span>\r\n    <span class=\"token punctuation\">(</span>product<span class=\"token operator\">:</span> SearchResultProduct<span class=\"token punctuation\">)</span> <span class=\"token operator\">=></span> <span class=\"token function\">getProductName</span><span class=\"token punctuation\">(</span>product<span class=\"token punctuation\">)</span>\r\n<span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span></code></pre></div>\n<p>Ambas as buscas rodam em paralelo (com abort controllers pra requests velhas não brigarem entre si) e os resultados se fundem em uma única lista unificada. O usuário escolhe entre comidas locais, resultados do Open Food Facts, e resultados do USDA, tudo de uma vez, sem saber ou se importar de qual backend cada um veio.</p>\n<p>Entre os dois, você consegue buscar praticamente qualquer comida e obter dados nutricionais reais sem digitar nada manualmente. E porque o Musclog agora rastreia mais de 40 micronutrientes além dos macros padrão, você consegue ver coisas como sua ingestão de magnésio, seus níveis de zinco, sua vitamina D. Acontece que isso importa quando você começa a olhar o que está consistentemente faltando. Pra mim é magnésio. É sempre magnésio.</p>\n<p><figure class=\"gatsby-resp-image-figure\" style=\"\">\n    <span\n      class=\"gatsby-resp-image-wrapper\"\n      style=\"position: relative; display: block; margin-left: auto; margin-right: auto; max-width: 537px; \"\n    >\n      <span\n    class=\"gatsby-resp-image-background-image\"\n    style=\"padding-bottom: 216.796875%; position: relative; bottom: 0; left: 0; background-image: url('data:image/png;base64,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'); background-size: cover; display: block;\"\n  ></span>\n  <img\n        class=\"gatsby-resp-image-image\"\n        alt=\"Tracking de nutrição do Musclog com breakdown completo de macros\"\n        title=\"Tracking de nutrição do Musclog com breakdown completo de macros\"\n        src=\"/static/db58bd04e4b3d987b097905fd5377335/673a2/musclog-nutrition-screen.png\"\n        srcset=\"/static/db58bd04e4b3d987b097905fd5377335/e3135/musclog-nutrition-screen.png 256w,\n/static/db58bd04e4b3d987b097905fd5377335/06341/musclog-nutrition-screen.png 512w,\n/static/db58bd04e4b3d987b097905fd5377335/673a2/musclog-nutrition-screen.png 537w\"\n        sizes=\"(max-width: 537px) 100vw, 537px\"\n        style=\"width:100%;height:100%;margin:0;vertical-align:middle;position:absolute;top:0;left:0;\"\n        loading=\"lazy\"\n        decoding=\"async\"\n      />\n    </span>\n    <figcaption class=\"gatsby-resp-image-figcaption\">Tracking de nutrição do Musclog com breakdown completo de macros</figcaption>\n  </figure></p>\n<p>Tem também um scanner de código de barras. Aponta a câmera pro produto, ele busca o código de barras no Open Food Facts, adiciona ao seu log. Eu escanio minha embalagem de iogurte grego toda manhã por puro hábito a essa altura. Registro sem fricção era o que eu queria quando comecei esse projeto e finalmente tenho, duas versões e uns 500 horas depois.</p>\n<p><figure class=\"gatsby-resp-image-figure\" style=\"\">\n    <span\n      class=\"gatsby-resp-image-wrapper\"\n      style=\"position: relative; display: block; margin-left: auto; margin-right: auto; max-width: 537px; \"\n    >\n      <span\n    class=\"gatsby-resp-image-background-image\"\n    style=\"padding-bottom: 216.796875%; position: relative; bottom: 0; left: 0; background-image: url('data:image/png;base64,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'); background-size: cover; display: block;\"\n  ></span>\n  <img\n        class=\"gatsby-resp-image-image\"\n        alt=\"Scanner de código de barras do Musclog em ação\"\n        title=\"Scanner de código de barras do Musclog em ação\"\n        src=\"/static/6ba0390a71c49bf2c85af708d46f3e67/673a2/musclog-barcode-scanner.png\"\n        srcset=\"/static/6ba0390a71c49bf2c85af708d46f3e67/e3135/musclog-barcode-scanner.png 256w,\n/static/6ba0390a71c49bf2c85af708d46f3e67/06341/musclog-barcode-scanner.png 512w,\n/static/6ba0390a71c49bf2c85af708d46f3e67/673a2/musclog-barcode-scanner.png 537w\"\n        sizes=\"(max-width: 537px) 100vw, 537px\"\n        style=\"width:100%;height:100%;margin:0;vertical-align:middle;position:absolute;top:0;left:0;\"\n        loading=\"lazy\"\n        decoding=\"async\"\n      />\n    </span>\n    <figcaption class=\"gatsby-resp-image-figcaption\">Scanner de código de barras do Musclog em ação</figcaption>\n  </figure></p>\n<p>Pra quando você não consegue escanear nada porque tá comendo num restaurante ou olhando pra um prato de “provavelmente é frango com algum molho,” tem OCR de rótulo (tesseract.js no web, rn-mlkit-ocr no native) e estimativa por foto com IA, que vou chegar lá em um segundo.</p>\n<p>O OCR segue o mesmo padrão de arquivo duplo dos gráficos. Assinatura de função idêntica, engine diferente, o bundler pega o certo na hora do build:</p>\n<div class=\"gatsby-highlight\" data-language=\"typescript\"><pre class=\"language-typescript\"><code class=\"language-typescript\"><span class=\"token comment\">// utils/ocr.ts - native (Google ML Kit, roda no device)</span>\r\n<span class=\"token keyword\">import</span> <span class=\"token punctuation\">{</span> recognizeText <span class=\"token punctuation\">}</span> <span class=\"token keyword\">from</span> <span class=\"token string\">'rn-mlkit-ocr'</span><span class=\"token punctuation\">;</span>\r\n\r\n<span class=\"token keyword\">export</span> <span class=\"token keyword\">async</span> <span class=\"token keyword\">function</span> <span class=\"token function\">performOcr</span><span class=\"token punctuation\">(</span>imageUri<span class=\"token operator\">:</span> <span class=\"token builtin\">string</span><span class=\"token punctuation\">)</span><span class=\"token operator\">:</span> <span class=\"token builtin\">Promise</span><span class=\"token operator\">&lt;</span><span class=\"token builtin\">string</span> <span class=\"token operator\">|</span> <span class=\"token keyword\">null</span><span class=\"token operator\">></span> <span class=\"token punctuation\">{</span>\r\n    <span class=\"token keyword\">const</span> result <span class=\"token operator\">=</span> <span class=\"token keyword\">await</span> <span class=\"token function\">recognizeText</span><span class=\"token punctuation\">(</span>imageUri<span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n    <span class=\"token keyword\">const</span> text <span class=\"token operator\">=</span> result<span class=\"token punctuation\">.</span>text<span class=\"token punctuation\">.</span><span class=\"token function\">trim</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n    <span class=\"token keyword\">return</span> text<span class=\"token punctuation\">.</span>length <span class=\"token operator\">></span> <span class=\"token number\">0</span> <span class=\"token operator\">?</span> text <span class=\"token operator\">:</span> <span class=\"token keyword\">null</span><span class=\"token punctuation\">;</span>\r\n<span class=\"token punctuation\">}</span>\r\n\r\n<span class=\"token comment\">// utils/ocr.web.ts - web (Tesseract.js, roda inteiramente no browser)</span>\r\n<span class=\"token keyword\">import</span> <span class=\"token punctuation\">{</span> createWorker <span class=\"token punctuation\">}</span> <span class=\"token keyword\">from</span> <span class=\"token string\">'tesseract.js'</span><span class=\"token punctuation\">;</span>\r\n\r\n<span class=\"token keyword\">const</span> <span class=\"token constant\">OCR_LANGS</span> <span class=\"token operator\">=</span> <span class=\"token string\">'eng+spa+por+nld+deu+fra'</span><span class=\"token punctuation\">;</span>\r\n\r\n<span class=\"token keyword\">export</span> <span class=\"token keyword\">async</span> <span class=\"token keyword\">function</span> <span class=\"token function\">performOcr</span><span class=\"token punctuation\">(</span>imageUri<span class=\"token operator\">:</span> <span class=\"token builtin\">string</span><span class=\"token punctuation\">)</span><span class=\"token operator\">:</span> <span class=\"token builtin\">Promise</span><span class=\"token operator\">&lt;</span><span class=\"token builtin\">string</span> <span class=\"token operator\">|</span> <span class=\"token keyword\">null</span><span class=\"token operator\">></span> <span class=\"token punctuation\">{</span>\r\n    <span class=\"token keyword\">const</span> worker <span class=\"token operator\">=</span> <span class=\"token keyword\">await</span> <span class=\"token function\">createWorker</span><span class=\"token punctuation\">(</span><span class=\"token constant\">OCR_LANGS</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n    <span class=\"token keyword\">const</span> <span class=\"token punctuation\">{</span> data<span class=\"token operator\">:</span> <span class=\"token punctuation\">{</span> text <span class=\"token punctuation\">}</span> <span class=\"token punctuation\">}</span> <span class=\"token operator\">=</span> <span class=\"token keyword\">await</span> worker<span class=\"token punctuation\">.</span><span class=\"token function\">recognize</span><span class=\"token punctuation\">(</span>imageUri<span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n    <span class=\"token keyword\">await</span> worker<span class=\"token punctuation\">.</span><span class=\"token function\">terminate</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n    <span class=\"token keyword\">return</span> text<span class=\"token punctuation\">.</span><span class=\"token function\">trim</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">||</span> <span class=\"token keyword\">null</span><span class=\"token punctuation\">;</span>\r\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>ML Kit roda no device e é rápido. Tesseract.js sobe e mata um worker completo pra cada scan, o que não é elegante, mas funciona offline e nada sai do browser. O pacote de idiomas cobre inglês, espanhol, português, holandês, alemão e francês. Moro na Holanda, sou brasileiro, e não ia shipar um scanner de rótulo de supermercado que engasgasse com embalagem de queijo holandês.</p>\n<h2 id=\"o-coach-de-ia-cresceu\" style=\"position:relative;\"><a href=\"#o-coach-de-ia-cresceu\" aria-label=\"o coach de ia cresceu permalink\" class=\"post-headers-link before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>O coach de IA cresceu</h2>\n<p>O chatbot original se chamava Chad. Sim, eu sei, não é muito criativo, mas eu tava mais focado em “shipar” features do que em deixá-las atraentes. E tá beleza, né, Chad?</p>\n<p><figure class=\"gatsby-resp-image-figure\" style=\"\">\n    <span\n      class=\"gatsby-resp-image-wrapper\"\n      style=\"position: relative; display: block; margin-left: auto; margin-right: auto; max-width: 1024px; \"\n    >\n      <span\n    class=\"gatsby-resp-image-background-image\"\n    style=\"padding-bottom: 52.34375%; position: relative; bottom: 0; left: 0; background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAKCAIAAAA7N+mxAAAACXBIWXMAAAsTAAALEwEAmpwYAAAArklEQVQoz2P4TwFgwCXx+/fPj+9fffn8kSTN//7////z589TB9ed3Dbh1K45X798AIn++0dYM0TR6xfPD+3YdGbv0odHp717eRciQazmxw/vbV058fTuRR8eXfn9+yexzoba/PLxhiXt6xZPqyovW7p8OQkBBtF/8uTJlrb2DZs23r179/fv36Rp/vDhw40bNz5//vz371+So+rHjx/Pnz9/9uzZr1+/SNZMTCIBADuEQuzgh5oVAAAAAElFTkSuQmCC'); background-size: cover; display: block;\"\n  ></span>\n  <img\n        class=\"gatsby-resp-image-image\"\n        alt=\"sim.\"\n        title=\"sim.\"\n        src=\"/static/0786b7e77bff75acc8234ca8a6cd3f35/42a19/chad-yes.png\"\n        srcset=\"/static/0786b7e77bff75acc8234ca8a6cd3f35/e3135/chad-yes.png 256w,\n/static/0786b7e77bff75acc8234ca8a6cd3f35/06341/chad-yes.png 512w,\n/static/0786b7e77bff75acc8234ca8a6cd3f35/42a19/chad-yes.png 1024w,\n/static/0786b7e77bff75acc8234ca8a6cd3f35/e8464/chad-yes.png 1536w,\n/static/0786b7e77bff75acc8234ca8a6cd3f35/e71fd/chad-yes.png 1910w\"\n        sizes=\"(max-width: 1024px) 100vw, 1024px\"\n        style=\"width:100%;height:100%;margin:0;vertical-align:middle;position:absolute;top:0;left:0;\"\n        loading=\"lazy\"\n        decoding=\"async\"\n      />\n    </span>\n    <figcaption class=\"gatsby-resp-image-figcaption\">sim.</figcaption>\n  </figure></p>\n<p>Agora se chama Loggy, suporta tanto <a href=\"https://openai.com/\" target=\"_blank\" rel=\"noreferrer\">OpenAI</a> quanto <a href=\"https://deepmind.google/technologies/gemini/\" target=\"_blank\" rel=\"noreferrer\">Google Gemini</a>, e tá realmente conectado aos seus dados de forma significativa. Quando você pergunta algo pro Loggy, ele sabe quem você é: seus treinos recentes, seus logs de nutrição, sua tendência de peso, seus objetivos atuais. “Meu volume de treino da semana passada tava no caminho certo?” recebe uma resposta real baseada em dados reais, não uma dica genérica sobre sobrecarga progressiva que você já leu quinze vezes.</p>\n<p>A camada de saída estruturada foi um dos problemas técnicos mais interessantes aqui. Fazer LLMs outputarem dados estruturados de forma confiável (ao invés de prosa que parece dados estruturados mas quebra seu parser de JSON) requer cuidado. O utilitário <code class=\"language-text\">makeSchemaStrict</code> em <code class=\"language-text\">utils/coachAI.ts</code> pega qualquer schema JSON e força <code class=\"language-text\">additionalProperties: false</code> em todo objeto aninhado enquanto marca todos os campos como <code class=\"language-text\">required</code>. Isso vai pra config de function calling do OpenAI e fala pro modelo “retorna exatamente esse formato ou falha limpo.” É a diferença entre uma feature que funciona 95% do tempo e uma que realmente funciona.</p>\n<p>A função em si é simples o suficiente que eu quase não escrevi sobre ela:</p>\n<div class=\"gatsby-highlight\" data-language=\"typescript\"><pre class=\"language-typescript\"><code class=\"language-typescript\"><span class=\"token comment\">// utils/coachAI.ts</span>\r\n<span class=\"token keyword\">function</span> <span class=\"token function\">makeSchemaStrict</span><span class=\"token punctuation\">(</span>schema<span class=\"token operator\">:</span> <span class=\"token builtin\">any</span><span class=\"token punctuation\">)</span><span class=\"token operator\">:</span> <span class=\"token builtin\">any</span> <span class=\"token punctuation\">{</span>\r\n    <span class=\"token keyword\">if</span> <span class=\"token punctuation\">(</span>schema<span class=\"token punctuation\">.</span>type <span class=\"token operator\">===</span> <span class=\"token string\">'object'</span><span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\r\n        <span class=\"token keyword\">const</span> properties <span class=\"token operator\">=</span> schema<span class=\"token punctuation\">.</span>properties <span class=\"token operator\">?</span> <span class=\"token punctuation\">{</span> <span class=\"token operator\">...</span>schema<span class=\"token punctuation\">.</span>properties <span class=\"token punctuation\">}</span> <span class=\"token operator\">:</span> <span class=\"token punctuation\">{</span><span class=\"token punctuation\">}</span><span class=\"token punctuation\">;</span>\r\n\r\n        Object<span class=\"token punctuation\">.</span><span class=\"token function\">keys</span><span class=\"token punctuation\">(</span>properties<span class=\"token punctuation\">)</span><span class=\"token punctuation\">.</span><span class=\"token function\">forEach</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">(</span>key<span class=\"token punctuation\">)</span> <span class=\"token operator\">=></span> <span class=\"token punctuation\">{</span>\r\n            properties<span class=\"token punctuation\">[</span>key<span class=\"token punctuation\">]</span> <span class=\"token operator\">=</span> <span class=\"token function\">makeSchemaStrict</span><span class=\"token punctuation\">(</span>properties<span class=\"token punctuation\">[</span>key<span class=\"token punctuation\">]</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n        <span class=\"token punctuation\">}</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n\r\n        <span class=\"token keyword\">return</span> <span class=\"token punctuation\">{</span>\r\n            <span class=\"token operator\">...</span>schema<span class=\"token punctuation\">,</span>\r\n            properties<span class=\"token punctuation\">,</span>\r\n            additionalProperties<span class=\"token operator\">:</span> <span class=\"token boolean\">false</span><span class=\"token punctuation\">,</span> <span class=\"token comment\">// OpenAI requer isso pro modo estrito</span>\r\n        <span class=\"token punctuation\">}</span><span class=\"token punctuation\">;</span>\r\n    <span class=\"token punctuation\">}</span>\r\n\r\n    <span class=\"token keyword\">if</span> <span class=\"token punctuation\">(</span>schema<span class=\"token punctuation\">.</span>type <span class=\"token operator\">===</span> <span class=\"token string\">'array'</span> <span class=\"token operator\">&amp;&amp;</span> schema<span class=\"token punctuation\">.</span>items<span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\r\n        <span class=\"token keyword\">return</span> <span class=\"token punctuation\">{</span> <span class=\"token operator\">...</span>schema<span class=\"token punctuation\">,</span> items<span class=\"token operator\">:</span> <span class=\"token function\">makeSchemaStrict</span><span class=\"token punctuation\">(</span>schema<span class=\"token punctuation\">.</span>items<span class=\"token punctuation\">)</span> <span class=\"token punctuation\">}</span><span class=\"token punctuation\">;</span>\r\n    <span class=\"token punctuation\">}</span>\r\n\r\n    <span class=\"token keyword\">return</span> schema<span class=\"token punctuation\">;</span>\r\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>Percorre recursivamente cada objeto aninhado no schema e enfia <code class=\"language-text\">additionalProperties: false</code> nele. Sem isso, o function calling estrito do OpenAI rejeita o schema completamente. Com isso, o modelo é restringido exatamente ao formato que você definiu. Sem chaves extras surpresa. Sem campos que existem numa resposta mas não em outra. A IA propõe, o schema impõe.</p>\n<p><figure class=\"gatsby-resp-image-figure\" style=\"\">\n    <span\n      class=\"gatsby-resp-image-wrapper\"\n      style=\"position: relative; display: block; margin-left: auto; margin-right: auto; max-width: 537px; \"\n    >\n      <span\n    class=\"gatsby-resp-image-background-image\"\n    style=\"padding-bottom: 216.796875%; position: relative; bottom: 0; left: 0; background-image: url('data:image/png;base64,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'); background-size: cover; display: block;\"\n  ></span>\n  <img\n        class=\"gatsby-resp-image-image\"\n        alt=\"Chat com o coach de IA do Musclog\"\n        title=\"Chat com o coach de IA do Musclog\"\n        src=\"/static/960530dbeeeeb62bafee9d8e8401f88d/673a2/musclog-ai-coach.png\"\n        srcset=\"/static/960530dbeeeeb62bafee9d8e8401f88d/e3135/musclog-ai-coach.png 256w,\n/static/960530dbeeeeb62bafee9d8e8401f88d/06341/musclog-ai-coach.png 512w,\n/static/960530dbeeeeb62bafee9d8e8401f88d/673a2/musclog-ai-coach.png 537w\"\n        sizes=\"(max-width: 537px) 100vw, 537px\"\n        style=\"width:100%;height:100%;margin:0;vertical-align:middle;position:absolute;top:0;left:0;\"\n        loading=\"lazy\"\n        decoding=\"async\"\n      />\n    </span>\n    <figcaption class=\"gatsby-resp-image-figcaption\">Chat com o coach de IA do Musclog</figcaption>\n  </figure></p>\n<p>A feature de análise por foto é a que faz novos usuários olharem duas vezes. Você tira uma foto da sua refeição, o Loggy estima as porções e o conteúdo nutricional. Não substitui uma balança de alimentos pra tracking de precisão, mas pra comer fora ou em dias que você genuinamente não consegue escanear nada, te deixa na bola certa rápido. O fluxo manda a imagem pro modelo com um schema estruturado pra resposta, extrai as estimativas, e aí te faz confirmar antes de logar. Esse último passo importa: a IA propõe, você decide.</p>\n<p>Os system prompts ficam em <code class=\"language-text\">utils/prompts.ts</code> e puxam instruções customizadas da tabela <code class=\"language-text\">ai_custom_prompts</code>, então o comportamento da IA é configurável sem tocar no código.</p>\n<h2 id=\"check-ins-semanais-a-feature-que-mais-me-orgulha\" style=\"position:relative;\"><a href=\"#check-ins-semanais-a-feature-que-mais-me-orgulha\" aria-label=\"check ins semanais a feature que mais me orgulha permalink\" class=\"post-headers-link before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Check-ins semanais: a feature que mais me orgulha</h2>\n<p>Todo semana, o Musclog roda uma análise automatizada das suas médias móveis de 7 dias pra peso, ingestão calórica e atividade. Você recebe um status: No Caminho, Adiantado ou Atrasado. Se seus números estão divergindo dos seus objetivos, ele pode recalcular suas metas nutricionais baseado no que realmente aconteceu ao invés de te prender num plano que claramente não tá batendo com a realidade.</p>\n<p>O coração disso é <code class=\"language-text\">getCheckinMetrics</code>, que pega um registro de check-in e puxa todos os dados da janela de 7 dias terminando naquela data:</p>\n<div class=\"gatsby-highlight\" data-language=\"typescript\"><pre class=\"language-typescript\"><code class=\"language-typescript\"><span class=\"token comment\">// database/services/NutritionCheckinService.ts</span>\r\n<span class=\"token keyword\">static</span> <span class=\"token keyword\">async</span> <span class=\"token function\">getCheckinMetrics</span><span class=\"token punctuation\">(</span>checkin<span class=\"token operator\">:</span> NutritionCheckin<span class=\"token punctuation\">)</span><span class=\"token operator\">:</span> <span class=\"token builtin\">Promise</span><span class=\"token operator\">&lt;</span>CheckinMetrics<span class=\"token operator\">></span> <span class=\"token punctuation\">{</span>\r\n    <span class=\"token keyword\">const</span> periodEnd <span class=\"token operator\">=</span> checkin<span class=\"token punctuation\">.</span>checkinDate<span class=\"token punctuation\">;</span>\r\n    <span class=\"token keyword\">const</span> periodStart <span class=\"token operator\">=</span> periodEnd <span class=\"token operator\">-</span> <span class=\"token number\">7</span> <span class=\"token operator\">*</span> <span class=\"token number\">24</span> <span class=\"token operator\">*</span> <span class=\"token number\">60</span> <span class=\"token operator\">*</span> <span class=\"token number\">60</span> <span class=\"token operator\">*</span> <span class=\"token number\">1000</span><span class=\"token punctuation\">;</span>\r\n\r\n    <span class=\"token comment\">// Puxa todas as medições de peso da janela de 7 dias</span>\r\n    <span class=\"token keyword\">const</span> weightMetrics <span class=\"token operator\">=</span> <span class=\"token keyword\">await</span> database\r\n        <span class=\"token punctuation\">.</span><span class=\"token generic-function\"><span class=\"token function\">get</span><span class=\"token generic class-name\"><span class=\"token operator\">&lt;</span>UserMetric<span class=\"token operator\">></span></span></span><span class=\"token punctuation\">(</span><span class=\"token string\">'user_metrics'</span><span class=\"token punctuation\">)</span>\r\n        <span class=\"token punctuation\">.</span><span class=\"token function\">query</span><span class=\"token punctuation\">(</span>\r\n            <span class=\"token constant\">Q</span><span class=\"token punctuation\">.</span><span class=\"token function\">where</span><span class=\"token punctuation\">(</span><span class=\"token string\">'type'</span><span class=\"token punctuation\">,</span> <span class=\"token string\">'weight'</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">,</span>\r\n            <span class=\"token constant\">Q</span><span class=\"token punctuation\">.</span><span class=\"token function\">where</span><span class=\"token punctuation\">(</span><span class=\"token string\">'date'</span><span class=\"token punctuation\">,</span> <span class=\"token constant\">Q</span><span class=\"token punctuation\">.</span><span class=\"token function\">between</span><span class=\"token punctuation\">(</span>periodStart<span class=\"token punctuation\">,</span> periodEnd<span class=\"token punctuation\">)</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">,</span>\r\n            <span class=\"token constant\">Q</span><span class=\"token punctuation\">.</span><span class=\"token function\">where</span><span class=\"token punctuation\">(</span><span class=\"token string\">'deleted_at'</span><span class=\"token punctuation\">,</span> <span class=\"token constant\">Q</span><span class=\"token punctuation\">.</span><span class=\"token function\">eq</span><span class=\"token punctuation\">(</span><span class=\"token keyword\">null</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">,</span>\r\n            <span class=\"token constant\">Q</span><span class=\"token punctuation\">.</span><span class=\"token function\">sortBy</span><span class=\"token punctuation\">(</span><span class=\"token string\">'date'</span><span class=\"token punctuation\">,</span> <span class=\"token constant\">Q</span><span class=\"token punctuation\">.</span>asc<span class=\"token punctuation\">)</span>\r\n        <span class=\"token punctuation\">)</span>\r\n        <span class=\"token punctuation\">.</span><span class=\"token function\">fetch</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n\r\n    <span class=\"token comment\">// Descriptografa cada valor (pesos são criptografados com AES no banco)</span>\r\n    <span class=\"token keyword\">const</span> decryptedWeights<span class=\"token operator\">:</span> <span class=\"token builtin\">number</span><span class=\"token punctuation\">[</span><span class=\"token punctuation\">]</span> <span class=\"token operator\">=</span> <span class=\"token punctuation\">[</span><span class=\"token punctuation\">]</span><span class=\"token punctuation\">;</span>\r\n    <span class=\"token keyword\">for</span> <span class=\"token punctuation\">(</span><span class=\"token keyword\">const</span> metric <span class=\"token keyword\">of</span> weightMetrics<span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\r\n        <span class=\"token keyword\">const</span> <span class=\"token punctuation\">{</span> value <span class=\"token punctuation\">}</span> <span class=\"token operator\">=</span> <span class=\"token keyword\">await</span> metric<span class=\"token punctuation\">.</span><span class=\"token function\">getDecrypted</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n        decryptedWeights<span class=\"token punctuation\">.</span><span class=\"token function\">push</span><span class=\"token punctuation\">(</span>value<span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n    <span class=\"token punctuation\">}</span>\r\n\r\n    <span class=\"token keyword\">const</span> avgWeight <span class=\"token operator\">=</span> decryptedWeights<span class=\"token punctuation\">.</span>length <span class=\"token operator\">></span> <span class=\"token number\">0</span>\r\n        <span class=\"token operator\">?</span> decryptedWeights<span class=\"token punctuation\">.</span><span class=\"token function\">reduce</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">(</span>a<span class=\"token punctuation\">,</span> b<span class=\"token punctuation\">)</span> <span class=\"token operator\">=></span> a <span class=\"token operator\">+</span> b<span class=\"token punctuation\">,</span> <span class=\"token number\">0</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">/</span> decryptedWeights<span class=\"token punctuation\">.</span>length\r\n        <span class=\"token operator\">:</span> checkin<span class=\"token punctuation\">.</span>targetWeight<span class=\"token punctuation\">;</span>\r\n\r\n    <span class=\"token comment\">// Quão longe o average real está de onde esperávamos estar?</span>\r\n    <span class=\"token keyword\">const</span> trend <span class=\"token operator\">=</span> avgWeight <span class=\"token operator\">-</span> checkin<span class=\"token punctuation\">.</span>targetWeight<span class=\"token punctuation\">;</span>\r\n\r\n    <span class=\"token comment\">// Nutrição: agrupa logs por dia, calcula média de calorias e consistência</span>\r\n    <span class=\"token keyword\">const</span> nutritionLogs <span class=\"token operator\">=</span> <span class=\"token keyword\">await</span> database\r\n        <span class=\"token punctuation\">.</span><span class=\"token generic-function\"><span class=\"token function\">get</span><span class=\"token generic class-name\"><span class=\"token operator\">&lt;</span>NutritionLog<span class=\"token operator\">></span></span></span><span class=\"token punctuation\">(</span><span class=\"token string\">'nutrition_logs'</span><span class=\"token punctuation\">)</span>\r\n        <span class=\"token punctuation\">.</span><span class=\"token function\">query</span><span class=\"token punctuation\">(</span><span class=\"token constant\">Q</span><span class=\"token punctuation\">.</span><span class=\"token function\">where</span><span class=\"token punctuation\">(</span><span class=\"token string\">'date'</span><span class=\"token punctuation\">,</span> <span class=\"token constant\">Q</span><span class=\"token punctuation\">.</span><span class=\"token function\">between</span><span class=\"token punctuation\">(</span>periodStart<span class=\"token punctuation\">,</span> periodEnd<span class=\"token punctuation\">)</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">,</span> <span class=\"token constant\">Q</span><span class=\"token punctuation\">.</span><span class=\"token function\">where</span><span class=\"token punctuation\">(</span><span class=\"token string\">'deleted_at'</span><span class=\"token punctuation\">,</span> <span class=\"token constant\">Q</span><span class=\"token punctuation\">.</span><span class=\"token function\">eq</span><span class=\"token punctuation\">(</span><span class=\"token keyword\">null</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">)</span>\r\n        <span class=\"token punctuation\">.</span><span class=\"token function\">fetch</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n\r\n    <span class=\"token keyword\">const</span> caloriesByDay <span class=\"token operator\">=</span> <span class=\"token keyword\">new</span> <span class=\"token class-name\">Map<span class=\"token operator\">&lt;</span><span class=\"token builtin\">number</span><span class=\"token punctuation\">,</span> <span class=\"token builtin\">number</span><span class=\"token operator\">></span></span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n    <span class=\"token keyword\">for</span> <span class=\"token punctuation\">(</span><span class=\"token keyword\">const</span> log <span class=\"token keyword\">of</span> nutritionLogs<span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\r\n        <span class=\"token keyword\">const</span> snapshot <span class=\"token operator\">=</span> <span class=\"token keyword\">await</span> log<span class=\"token punctuation\">.</span><span class=\"token function\">getDecryptedSnapshot</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n        <span class=\"token keyword\">const</span> dayKey <span class=\"token operator\">=</span> Math<span class=\"token punctuation\">.</span><span class=\"token function\">floor</span><span class=\"token punctuation\">(</span>log<span class=\"token punctuation\">.</span>date <span class=\"token operator\">/</span> <span class=\"token punctuation\">(</span><span class=\"token number\">24</span> <span class=\"token operator\">*</span> <span class=\"token number\">60</span> <span class=\"token operator\">*</span> <span class=\"token number\">60</span> <span class=\"token operator\">*</span> <span class=\"token number\">1000</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n        caloriesByDay<span class=\"token punctuation\">.</span><span class=\"token function\">set</span><span class=\"token punctuation\">(</span>dayKey<span class=\"token punctuation\">,</span> <span class=\"token punctuation\">(</span>caloriesByDay<span class=\"token punctuation\">.</span><span class=\"token function\">get</span><span class=\"token punctuation\">(</span>dayKey<span class=\"token punctuation\">)</span> <span class=\"token operator\">??</span> <span class=\"token number\">0</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">+</span> snapshot<span class=\"token punctuation\">.</span>loggedCalories<span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n    <span class=\"token punctuation\">}</span>\r\n\r\n    <span class=\"token keyword\">const</span> consistency <span class=\"token operator\">=</span> Math<span class=\"token punctuation\">.</span><span class=\"token function\">round</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">(</span>caloriesByDay<span class=\"token punctuation\">.</span>size <span class=\"token operator\">/</span> <span class=\"token number\">7</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">*</span> <span class=\"token number\">100</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span> <span class=\"token comment\">// % de dias com logs</span>\r\n    <span class=\"token comment\">// ...e assim por diante pra treinos, gordura corporal, minutos ativos</span>\r\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>O <code class=\"language-text\">trend</code> é o número chave: positivo significa que você tá mais pesado do que o alvo previa, negativo significa que você tá à frente. Combinado com <code class=\"language-text\">consistency</code> (que porcentagem dos 7 dias você realmente logou comida), o service consegue inferir se a variância é real ou só dado faltando.</p>\n<p>O cálculo de TDEE é empírico ao invés de baseado em fórmula. O Musclog olha sua mudança real de peso ao longo do tempo combinada com sua ingestão calórica real logada e trabalha ao contrário pra estimar suas calorias reais de manutenção. Se você tá comendo 2.200 calorias por dia e perdendo 0,3kg por semana, a matemática te diz algo sobre seu metabolismo real que a fórmula de Harris-Benedict nunca vai te dizer, especialmente se seu nível de atividade não se encaixa perfeitamente em “sedentário” ou “moderadamente ativo” ou qualquer categoria vaga que você escolheu no onboarding.</p>\n<p>A função que faz isso mora em <code class=\"language-text\">utils/nutritionCalculator.ts</code>. A lógica é direta; as constantes, não:</p>\n<div class=\"gatsby-highlight\" data-language=\"typescript\"><pre class=\"language-typescript\"><code class=\"language-typescript\"><span class=\"token comment\">// utils/nutritionCalculator.ts</span>\r\n<span class=\"token keyword\">export</span> <span class=\"token keyword\">const</span> calculateTDEE <span class=\"token operator\">=</span> <span class=\"token punctuation\">(</span>params<span class=\"token operator\">:</span> TDEEParams<span class=\"token punctuation\">)</span><span class=\"token operator\">:</span> <span class=\"token builtin\">number</span> <span class=\"token operator\">=></span> <span class=\"token punctuation\">{</span>\r\n    <span class=\"token keyword\">const</span> <span class=\"token punctuation\">{</span> totalCalories<span class=\"token punctuation\">,</span> totalDays<span class=\"token punctuation\">,</span> initialWeight<span class=\"token punctuation\">,</span> finalWeight<span class=\"token punctuation\">,</span>\r\n            initialFatPercentage<span class=\"token punctuation\">,</span> finalFatPercentage<span class=\"token punctuation\">,</span> bmr<span class=\"token punctuation\">,</span> activityLevel <span class=\"token punctuation\">}</span> <span class=\"token operator\">=</span> params<span class=\"token punctuation\">;</span>\r\n\r\n    <span class=\"token comment\">// Caminho 1: dados reais de tracking existem - deriva TDEE da Primeira Lei da Termodinâmica</span>\r\n    <span class=\"token keyword\">if</span> <span class=\"token punctuation\">(</span>totalDays <span class=\"token operator\">&amp;&amp;</span> totalCalories <span class=\"token operator\">&amp;&amp;</span> initialWeight <span class=\"token operator\">&amp;&amp;</span> finalWeight<span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\r\n        <span class=\"token keyword\">const</span> weightDifference <span class=\"token operator\">=</span> finalWeight <span class=\"token operator\">-</span> initialWeight<span class=\"token punctuation\">;</span>\r\n\r\n        <span class=\"token comment\">// Divide a mudança de peso em gordura vs massa magra.</span>\r\n        <span class=\"token comment\">// Exato quando temos % de gordura nos dois extremos; estimativa pela curva Hall/Forbes caso contrário.</span>\r\n        <span class=\"token keyword\">let</span> fatDifference<span class=\"token operator\">:</span> <span class=\"token builtin\">number</span><span class=\"token punctuation\">;</span>\r\n        <span class=\"token keyword\">let</span> leanDifference<span class=\"token operator\">:</span> <span class=\"token builtin\">number</span><span class=\"token punctuation\">;</span>\r\n\r\n        <span class=\"token keyword\">if</span> <span class=\"token punctuation\">(</span>initialFatPercentage <span class=\"token operator\">!==</span> <span class=\"token keyword\">undefined</span> <span class=\"token operator\">&amp;&amp;</span> finalFatPercentage <span class=\"token operator\">!==</span> <span class=\"token keyword\">undefined</span><span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\r\n            <span class=\"token keyword\">const</span> initialFatMass <span class=\"token operator\">=</span> <span class=\"token punctuation\">(</span>initialFatPercentage <span class=\"token operator\">*</span> initialWeight<span class=\"token punctuation\">)</span> <span class=\"token operator\">/</span> <span class=\"token number\">100</span><span class=\"token punctuation\">;</span>\r\n            <span class=\"token keyword\">const</span> finalFatMass <span class=\"token operator\">=</span> <span class=\"token punctuation\">(</span>finalFatPercentage <span class=\"token operator\">*</span> finalWeight<span class=\"token punctuation\">)</span> <span class=\"token operator\">/</span> <span class=\"token number\">100</span><span class=\"token punctuation\">;</span>\r\n            fatDifference <span class=\"token operator\">=</span> finalFatMass <span class=\"token operator\">-</span> initialFatMass<span class=\"token punctuation\">;</span>\r\n            leanDifference <span class=\"token operator\">=</span> weightDifference <span class=\"token operator\">-</span> fatDifference<span class=\"token punctuation\">;</span>\r\n        <span class=\"token punctuation\">}</span> <span class=\"token keyword\">else</span> <span class=\"token punctuation\">{</span>\r\n            <span class=\"token keyword\">const</span> comp <span class=\"token operator\">=</span> <span class=\"token function\">getWeightChangeComposition</span><span class=\"token punctuation\">(</span>initialWeight <span class=\"token operator\">*</span> <span class=\"token number\">0.25</span><span class=\"token punctuation\">,</span> weightDifference<span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n            fatDifference <span class=\"token operator\">=</span> comp<span class=\"token punctuation\">.</span>fatChangeKg<span class=\"token punctuation\">;</span>\r\n            leanDifference <span class=\"token operator\">=</span> comp<span class=\"token punctuation\">.</span>leanChangeKg<span class=\"token punctuation\">;</span>\r\n        <span class=\"token punctuation\">}</span>\r\n\r\n        <span class=\"token comment\">// Construir vs queimar gordura e músculo têm custos termodinâmicos diferentes.</span>\r\n        <span class=\"token comment\">// Essas não são constantes intercambiáveis - são valores medidos separados.</span>\r\n        <span class=\"token keyword\">const</span> leanCalories <span class=\"token operator\">=</span> leanDifference <span class=\"token operator\">></span> <span class=\"token number\">0</span>\r\n            <span class=\"token operator\">?</span> leanDifference <span class=\"token operator\">*</span> <span class=\"token constant\">CALORIES_BUILD_KG_MUSCLE</span>   <span class=\"token comment\">// 3900 kcal/kg pra construir</span>\r\n            <span class=\"token operator\">:</span> leanDifference <span class=\"token operator\">*</span> <span class=\"token constant\">CALORIES_STORED_KG_MUSCLE</span><span class=\"token punctuation\">;</span> <span class=\"token comment\">// 1250 kcal/kg armazenados</span>\r\n\r\n        <span class=\"token keyword\">const</span> fatCalories <span class=\"token operator\">=</span> fatDifference <span class=\"token operator\">></span> <span class=\"token number\">0</span>\r\n            <span class=\"token operator\">?</span> fatDifference <span class=\"token operator\">*</span> <span class=\"token constant\">CALORIES_BUILD_KG_FAT</span>   <span class=\"token comment\">// 8840 kcal/kg pra construir</span>\r\n            <span class=\"token operator\">:</span> fatDifference <span class=\"token operator\">*</span> <span class=\"token constant\">CALORIES_STORED_KG_FAT</span><span class=\"token punctuation\">;</span> <span class=\"token comment\">// 7730 kcal/kg armazenados</span>\r\n\r\n        <span class=\"token comment\">// TDEE = (energia consumida − energia presa em mudanças de tecido) / dias</span>\r\n        <span class=\"token keyword\">return</span> Math<span class=\"token punctuation\">.</span><span class=\"token function\">round</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">(</span>totalCalories <span class=\"token operator\">-</span> <span class=\"token punctuation\">(</span>fatCalories <span class=\"token operator\">+</span> leanCalories<span class=\"token punctuation\">)</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">/</span> totalDays<span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n    <span class=\"token punctuation\">}</span>\r\n\r\n    <span class=\"token comment\">// Caminho 2: ainda sem histórico - cai de volta pra BMR x multiplicador de atividade</span>\r\n    <span class=\"token keyword\">if</span> <span class=\"token punctuation\">(</span>bmr <span class=\"token operator\">&amp;&amp;</span> activityLevel<span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\r\n        <span class=\"token keyword\">return</span> Math<span class=\"token punctuation\">.</span><span class=\"token function\">round</span><span class=\"token punctuation\">(</span>bmr <span class=\"token operator\">*</span> <span class=\"token constant\">ACTIVITY_MULTIPLIERS</span><span class=\"token punctuation\">[</span>activityLevel<span class=\"token punctuation\">]</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n    <span class=\"token punctuation\">}</span>\r\n\r\n    <span class=\"token keyword\">return</span> <span class=\"token number\">0</span><span class=\"token punctuation\">;</span>\r\n<span class=\"token punctuation\">}</span><span class=\"token punctuation\">;</span></code></pre></div>\n<p>A assimetria entre construir gordura (8840 kcal/kg) e queimá-la (7730 kcal/kg) é real. A eficiência termodinâmica não é 100%, então criar novo tecido custa mais do que o que acaba sendo armazenado. O mesmo princípio se aplica ao músculo. Se você só hardcoda 7700 e segue em frente você tem algo que é aproximadamente certo, mas você tá tirando a média exatamente do sinal que você construiu o sistema de check-in inteiro pra encontrar.</p>\n<p><figure class=\"gatsby-resp-image-figure\" style=\"\">\n    <span\n      class=\"gatsby-resp-image-wrapper\"\n      style=\"position: relative; display: block; margin-left: auto; margin-right: auto; max-width: 537px; \"\n    >\n      <span\n    class=\"gatsby-resp-image-background-image\"\n    style=\"padding-bottom: 216.796875%; position: relative; bottom: 0; left: 0; background-image: url('data:image/png;base64,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'); background-size: cover; display: block;\"\n  ></span>\n  <img\n        class=\"gatsby-resp-image-image\"\n        alt=\"Tela de check-in semanal do Musclog\"\n        title=\"Tela de check-in semanal do Musclog\"\n        src=\"/static/985f3c03966e36135d3a2b761be56160/673a2/musclog-weekly-checkin.png\"\n        srcset=\"/static/985f3c03966e36135d3a2b761be56160/e3135/musclog-weekly-checkin.png 256w,\n/static/985f3c03966e36135d3a2b761be56160/06341/musclog-weekly-checkin.png 512w,\n/static/985f3c03966e36135d3a2b761be56160/673a2/musclog-weekly-checkin.png 537w\"\n        sizes=\"(max-width: 537px) 100vw, 537px\"\n        style=\"width:100%;height:100%;margin:0;vertical-align:middle;position:absolute;top:0;left:0;\"\n        loading=\"lazy\"\n        decoding=\"async\"\n      />\n    </span>\n    <figcaption class=\"gatsby-resp-image-figcaption\">Tela de check-in semanal do Musclog</figcaption>\n  </figure></p>\n<p>Essa não é uma feature chamativa. Você não percebe até o fim da semana. Mas ter um app que percebe “você ficou abaixo do seu alvo calórico a semana inteira e seu peso ainda não mudou, vamos descobrir por quê” é exatamente o tipo de insight que eu estava construindo quando comecei esse projeto. Só não sabia disso na época. Achei que estava construindo um registrador de treinos.</p>\n<h2 id=\"as-outras-coisas-que-fui-shippando-no-sapatinho\" style=\"position:relative;\"><a href=\"#as-outras-coisas-que-fui-shippando-no-sapatinho\" aria-label=\"as outras coisas que fui shippando no sapatinho permalink\" class=\"post-headers-link before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>As outras coisas que fui shippando no sapatinho</h2>\n<p>Widgets de tela inicial pra registro rápido e resumos diários. Um rastreador de ciclo menstrual com recomendações de intensidade de treino por fase, porque assumir uma base de usuários só masculina é <del>um ponto de partida razoável</del> não tá certo, então o Musclog não usa gênero pra inferir dados de ciclo menstrual. Integração com Health Connect pra sincronizar peso, nutrição e dados de exercício com outros apps de saúde Android. Exportação completa de dados como JSON encriptado (ou não encriptado). Importação de JSON pra migrar entre devices sem perder seu histórico.</p>\n<p>A feature de exportação de dados parece chata até seu celular morrer e você não ter. Nesse ponto você tá simultaneamente grato por ter construído e levemente furioso com o seu eu do passado por não ter testado o fluxo de importação mais a fundo. Pode perguntar como eu sei.</p>\n<p><figure class=\"gatsby-resp-image-figure\" style=\"\">\n    <span\n      class=\"gatsby-resp-image-wrapper\"\n      style=\"position: relative; display: block; margin-left: auto; margin-right: auto; max-width: 537px; \"\n    >\n      <span\n    class=\"gatsby-resp-image-background-image\"\n    style=\"padding-bottom: 216.796875%; position: relative; bottom: 0; left: 0; background-image: url('data:image/png;base64,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'); background-size: cover; display: block;\"\n  ></span>\n  <img\n        class=\"gatsby-resp-image-image\"\n        alt=\"Detalhe do tracking de micronutrientes do Musclog\"\n        title=\"Detalhe do tracking de micronutrientes do Musclog\"\n        src=\"/static/f0ca76a952bf9a9fa74fb16c082e76ac/673a2/musclog-micronutrients.png\"\n        srcset=\"/static/f0ca76a952bf9a9fa74fb16c082e76ac/e3135/musclog-micronutrients.png 256w,\n/static/f0ca76a952bf9a9fa74fb16c082e76ac/06341/musclog-micronutrients.png 512w,\n/static/f0ca76a952bf9a9fa74fb16c082e76ac/673a2/musclog-micronutrients.png 537w\"\n        sizes=\"(max-width: 537px) 100vw, 537px\"\n        style=\"width:100%;height:100%;margin:0;vertical-align:middle;position:absolute;top:0;left:0;\"\n        loading=\"lazy\"\n        decoding=\"async\"\n      />\n    </span>\n    <figcaption class=\"gatsby-resp-image-figcaption\">Detalhe do tracking de micronutrientes do Musclog</figcaption>\n  </figure></p>\n<h2 id=\"por-que-a-assinatura-do-seu-app-de-fitness-e-problema-de-outro\" style=\"position:relative;\"><a href=\"#por-que-a-assinatura-do-seu-app-de-fitness-e-problema-de-outro\" aria-label=\"por que a assinatura do seu app de fitness e problema de outro permalink\" class=\"post-headers-link before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Por que a assinatura do seu app de fitness é problema de outro</h2>\n<p>Quero falar algo sobre o mercado de apps de fitness, porque faz uns dois anos que tô segurando isso e esse é o meu blog.</p>\n<p>A história se repete num cronograma que você pode acertar seu relógio: app lança gratuito, adquire usuários, introduz um nível premium, gradualmente migra features centrais pra trás do paywall, aumenta os preços, usuários reclamam no Reddit, nada muda. Assisti isso acontecer com apps que eu genuinamente gostava de usar. MyFitnessPal passou de realmente bom e gratuito pra uma assinatura onde definir suas próprias metas de macro custa dinheiro. Outros apps introduziram “bancos de dados de alimentos premium” que tiraram a coisa útil e a venderam de volta mensalmente. Um app que usei por um tempo moveu os gráficos de progresso pra trás de um paywall. Os gráficos de progresso. Num app de rastreamento de fitness. A única feature que mostra se o app tá funcionando.</p>\n<blockquote>\n<p>“Mas sem receita de assinatura, como você mantém as luzes acesas?”\r\nAs luzes são um cabo de carregamento e custam zero por mês. Próxima pergunta.</p>\n</blockquote>\n<p>Entendo a economia. Servidores custam dinheiro. Times de engenharia custam dinheiro. Construir um negócio de produto sustentável é genuinamente difícil e alguém tem que pagar por isso. Tudo bem.</p>\n<p>Mas o Musclog não tem servidores. Não tem infraestrutura de nuvem. Nenhum banco de dados que estou pagando pra hospedar em algum lugar. Seus dados vivem no seu celular. Os bancos de dados de alimentos que uso são públicos e gratuitos. As features de IA, se você usá-las, falam diretamente com a OpenAI ou o Google usando sua própria chave de API: você paga pra eles, sem intermediário, sem comissão. O app é gratuito no <a href=\"https://play.google.com/store/apps/details?id=com.werules.logger\" target=\"_blank\" rel=\"noreferrer\">Google Play</a> e open source no <a href=\"https://github.com/blopa/musclog-app\" target=\"_blank\" rel=\"noreferrer\">GitHub</a>.</p>\n<p><figure class=\"gatsby-resp-image-figure\" style=\"\">\n    <span\n      class=\"gatsby-resp-image-wrapper\"\n      style=\"position: relative; display: block; margin-left: auto; margin-right: auto; max-width: 1024px; \"\n    >\n      <span\n    class=\"gatsby-resp-image-background-image\"\n    style=\"padding-bottom: 54.6875%; position: relative; bottom: 0; left: 0; background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAALCAIAAADwazoUAAAACXBIWXMAAAsTAAALEwEAmpwYAAACqklEQVQozwGfAmD9ALmplcO7rNDJupqSg7m2rc3OyeHe1PLw6OTi2+Hf2PPw5erXwqSagoKAcbmgkdqzmtCqkp2Ti4F9eEI7MgC6rpq8t62ZkYyJhYCIgX25trLu6+DZ2NXJysvS1NS8ureRgXWPgG55bmCEdWeQhHmbkIOdlIuGeW2HdWMAuKyaubamdIZ0ZW5iZnRjyMa1+O/e4NnN3NbN1dPMjoR9hVxJnHdgbVpJloh2uaybkIN5i4N5gnlrTUM5AI+Ie2FpWTx2VT5yUj16V4GPdePSvf734+bayK6mnKKUi9+ojd2qiodvWsCynp+Me4lgTI5tWZ6WhlFKQgB3c2djbFxMdV1Zg2lNemGCknvx4c3z7NzTw7S/raKdmY+EZ1Kni2x3e2jGtaGQe27BkHiodFi5o45hW1MAdnBkam9jPmlTUXpmQnFbd4dy7+HM0LijuZJ/wrKpZnpmRGpMeI1sb4xnv7ahb2BZj2VQbkg3c1I9WE5CAJyKd2puZUBgUmB8b05uYWd3aNbIstfHtPjWw8fDtjFSOlNpV3aCdFp4Wp6iiYiAeJyaiJmShJebhGpjUwC5pZBlbGEtSEAwTUYpR0E5TEOxppH28uj9+veQnIgyTzo7WEJggltGaUp5f1+fi3h5emi+pI6cpYeEZlAAvbivc3tyHTg0GDU1Ei8wL0E/u7eu4NzU7eXhiqGNXXliM1c9UntNZoRiaH5me4N7gYZ+uKSTv7KhtaGYAJKPlH18fiY1Ni46Pi87PVBWWpiVmo2LkIuJj36Dd3Z8cTNKRDpUQWNyZ5uCc2Jtc1dtfIiNkbWelJGTmQBYX21eZHNCTVRXW19WWV1WWl1vbndubXVnaneTfHmUe3ROW21DTFVYXGl7bXBaZHFVZ3RyfIZ9cXNra3Tw91qmRwxnZwAAAABJRU5ErkJggg=='); background-size: cover; display: block;\"\n  ></span>\n  <img\n        class=\"gatsby-resp-image-image\"\n        alt=\"O ciclo de assinatura de apps de fitness, ilustrado\"\n        title=\"O ciclo de assinatura de apps de fitness, ilustrado\"\n        src=\"/static/5fcabb7cf250c7994bfc3908d114b28c/42a19/fitness-app-subscription-meme.png\"\n        srcset=\"/static/5fcabb7cf250c7994bfc3908d114b28c/e3135/fitness-app-subscription-meme.png 256w,\n/static/5fcabb7cf250c7994bfc3908d114b28c/06341/fitness-app-subscription-meme.png 512w,\n/static/5fcabb7cf250c7994bfc3908d114b28c/42a19/fitness-app-subscription-meme.png 1024w,\n/static/5fcabb7cf250c7994bfc3908d114b28c/e8464/fitness-app-subscription-meme.png 1536w,\n/static/5fcabb7cf250c7994bfc3908d114b28c/2eb59/fitness-app-subscription-meme.png 2048w,\n/static/5fcabb7cf250c7994bfc3908d114b28c/b4639/fitness-app-subscription-meme.png 2724w\"\n        sizes=\"(max-width: 1024px) 100vw, 1024px\"\n        style=\"width:100%;height:100%;margin:0;vertical-align:middle;position:absolute;top:0;left:0;\"\n        loading=\"lazy\"\n        decoding=\"async\"\n      />\n    </span>\n    <figcaption class=\"gatsby-resp-image-figcaption\">O ciclo de assinatura de apps de fitness, ilustrado</figcaption>\n  </figure></p>\n<p>Isso não é uma posição de princípio contra o capitalismo. É uma decisão de design que faz sentido pro que isso realmente é: uma ferramenta pessoal que construí pra mim mesmo e depois compartilhei com outras pessoas. A arquitetura que escolhi naturalmente não cria custos de servidor, o que significa que não tenho pressão financeira pra monetizar, o que significa que os usuários não têm motivo pra desconfiar do que estou fazendo com os dados deles. Porque não estou fazendo nada com os dados deles. Estão no celular deles.</p>\n<p>O design offline-first é deliberado pelo mesmo motivo. Histórico de peso, composição corporal, o que você come, seu ciclo menstrual, seu histórico de treinos: nada disso deveria ficar no servidor de um estranho por padrão. A maioria dos apps de fitness não explica claramente o que faz com esses dados porque uma resposta transparente seria impopular. O Musclog mantém tudo local. O único dado que sai do seu device é o que você explicitamente manda pra IA, e só quando você escolhe usar essa feature.</p>\n<p>Tem uma população inteira de pessoas treinando em academias com WiFi ruim, em porões, em garagens, em parques, que nunca tiveram o Musclog falhar porque precisava de conexão. Isso importa pra mim mais do que um número de receita recorrente mensal.</p>\n<h2 id=\"o-que-vem-por-ai\" style=\"position:relative;\"><a href=\"#o-que-vem-por-ai\" aria-label=\"o que vem por ai permalink\" class=\"post-headers-link before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>O que vem por aí</h2>\n<p>O onboarding é a parte mais fraca do app agora. O Musclog é significativamente mais útil quanto mais dados tem, mas a experiência de novo usuário ainda tá muito perto de “aqui tá o app inteiro, se vira.” Quero construir um fluxo de onboarding de verdade que deixe alguém configurado com seus objetivos, seu primeiro template de treino, e seu primeiro log de comida em menos de cinco minutos. Você não deveria precisar de 500 horas de contexto pra ter valor desde o primeiro dia.</p>\n<p>Suporte a devices BLE pra balanças inteligentes também está na lista. Entrar o peso e gordura corporal manualmente todo dia é ok. Ter a balança mandar pro app automaticamente quando você sobe nela é melhor, e a infraestrutura não é tão complexa. É principalmente uma questão de conseguir hardware pra testar, o que é um problema muito solucionável e definitivamente algo que vou fazer assim que terminar as outras dezessete coisas que já comecei.</p>\n<h2 id=\"vai-ser-gratuito-pra-sempre\" style=\"position:relative;\"><a href=\"#vai-ser-gratuito-pra-sempre\" aria-label=\"vai ser gratuito pra sempre permalink\" class=\"post-headers-link before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Vai ser gratuito pra sempre?</h2>\n<p>Pergunta justa. A resposta curta é: não faço a menor ideia. A resposta mais longa é mais interessante.</p>\n<p>O código é open source sob a licença Attribution-NonCommercial-NoDerivatives 4.0 International. Você pode ler, fazer fork pra uso pessoal, aprender com ele. O que você não pode é shipar um produto construído em cima dele sem falar comigo primeiro, porque Musclog é uma marca minha, e porque as quinhentas horas de trabalho por trás disso representam algo em torno de €25000 em salário de desenvolvedor europeu. Não tô tocando uma ONG. Tô tocando um side project que ainda não te pediu dinheiro.</p>\n<p>Se isso mudar algum dia, aqui tá minha promessa: não vou fazer assinatura. Se o Musclog algum dia custar dinheiro, vai custar uma vez. Você paga o preço de um café, o app é seu, pra sempre. Sem mensalidade. Sem “seu plano premium foi pausado.” O modelo bom e velho, aquele que a App Store basicamente matou.</p>\n<p>O que tá me empurrando mais perto desse cenário agora é o iOS. Minha namorada quer no iPhone. Alguns amigos próximos querem no iPhone. Há meses escuto “coloca na App Store” como se fosse uma coisa trivial. O programa de desenvolvedor da Apple custa 100 USD por ano. Por ano. Não uma vez. Todo ano. A Apple transformou o direito de distribuir software num SaaS, e falo isso como alguém que acha que a taxa única de 35 USD do Google Play é como deveria ser em todo lugar. Então sim, se o Musclog algum dia chegar no iOS, provavelmente vai ter um preço, porque não vou pagar 100 USD de pedágio anual pra Apple de boa vontade.</p>\n<p>Por enquanto: Android, gratuito, sem planos de mudar isso. Então baixe agora enquanto tá de graça, porque uma vez que você instala no Google Play, ele é seu pra sempre. Mesmo que você não tenha pagado nada. Especialmente se você não pagou nada.</p>\n<h2 id=\"conclusao\" style=\"position:relative;\"><a href=\"#conclusao\" aria-label=\"conclusao permalink\" class=\"post-headers-link before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Conclusão</h2>\n<p>Dois anos, duas versões, 500 horas. O Musclog finalmente parece algo pelo qual eu não me desculpo mentalmente antes de mostrar pra alguém. O redesign limpou anos de dívida de UI do “manda ver”, e fazer direito me forçou a pensar sobre o produto de maneiras que eu deveria ter pensado desde o começo. Acontece que design systems existem por boas razões e não só pra dar aos designers algo pra discutir no Figma.</p>\n<p>É gratuito. É open source. Não tem seus dados. Funciona no modo avião. Bancos de dados de comida reais. IA que realmente sabe quem você é. Check-ins semanais automatizados. E um esquema de cores verde escuro do qual eu me orgulho genuinamente, mesmo que uma ferramenta de IA tenha feito a maior parte do trabalho visual pesado.</p>\n<p>Se quiser experimentar: <a href=\"https://play.google.com/store/apps/details?id=com.werules.logger\" target=\"_blank\" rel=\"noreferrer\">Google Play</a>.</p>\n<p>Se quiser ver como foi construído ou contribuir com algo: <a href=\"https://github.com/blopa/musclog-app\" target=\"_blank\" rel=\"noreferrer\">github.com/blopa/musclog-app</a>.</p>\n<p>Treina, Loga, Repete.</p>\n<p>Até a próxima!</p>","fields":{"postHashId":"Y29kaW5ndHJ1ZW51bGwyMDI2LTAzLTIyVDAwOjAwOjAwLjAwMFo=","slug":"/2026/2026-03-22-musclog-redesign-acompanhamento-nutricional-e-por-que-a-assinatura-do-seu-app-de-fitness-e-uma-enganacao.pt-br/","path":"/blog/coding/musclog-redesign-acompanhamento-nutricional-e-por-que-a-assinatura-do-seu-app-de-fitness-e-uma-enganacao/","locale":"pt-br"},"rawMarkdownBody":"\r\nImagina a cena: você tá na academia. Entre séries. Suado. Com uns 90 segundos antes de ter que pegar a barra de novo, e você tá ali cutucando o seu próprio aplicativo igual ao pai de alguém tentando navegar num site de 2008. Quatro toques pra registrar uma série. Quatro toques. Num app que você mesmo escreveu. Que você poderia mudar. E mesmo assim, toda vez, você faz os quatro toques porque sempre tem outra coisa mais urgente pra fazer antes.\r\n\r\nEra o Musclog. Meu app de treino. Meu projeto de \"300 horas que não vou recuperar\" que eu [escrevi lá em 2024](/en/blog/coding/musclog-leveraging-my-reactjs-experience-to-build-a-react-native-app/). Funcionava. Meus amigos usavam. Eu registrei cada treino e cada grama de comida por meses com ele.\r\n\r\nTambém era, objetivamente, um dos apps mais feios que eu já tinha colocado no meu celular.\r\n\r\n![Design original do Musclog - a foto de antes que ninguém pediu](../../uploads/blog/2024/09/musclog-workout-screenshot.png)\r\n\r\nEu sabia. Meus amigos sabiam. O amigo honesto o suficiente pra falar \"cara, isso parece um site\" sabia, e falou na minha cara sem a menor cerimônia. Mas funcionava, e por um tempo isso era suficiente. \"Funciona\" é o equivalente do desenvolvedor ao \"tá bom\" - tecnicamente verdade, emocionalmente uma mentira completa com a qual você concordou em viver.\r\n\r\nAí a dívida de UX foi rendendo juros na minha vida. Registrar um treino parecia preencher papelada. Adicionar comida exigia pular entre três telas diferentes quando deveria ser uma. Navegar no app na academia, já suado e com tempo contado, era um exercício de fricção que eu tinha projetado pra mim mesmo sem querer. Fui empilhando features em cima desse caos visual, que é um jeito excelente de deixar a casa do acumulador ainda mais difícil de navegar. Sem falar nos bugs, que às vezes simplesmente crashavam o app e ficavam lá no Sentry, sem solução.\r\n\r\nTinha que mudar alguma coisa. E por \"alguma coisa\" eu quero dizer tudo.\r\n\r\n## Eu não dou pra um bom designer\r\n\r\nEu tenho muitos amigos que dão pra um bom designer. Muitos. Não é o meu caso. O app original foi feito com [react-native-paper](https://reactnativepaper.com/), que é uma biblioteca perfeitamente decente. O problema não era o react-native-paper. O problema era eu. Fui jogando componentes juntos na ordem que fazia sentido na hora, escolhendo cores no feeling, e shippando sem nenhum design system de verdade. Sem escala de espaçamento. Sem paleta de cores semântica. Puro instinto de desenvolvedor aplicado diretamente numa UI, que, como se descobriu, produz exatamente o tipo de app que eu terminei tendo.\r\n\r\nO mesmo conceito parecia diferente em três telas diferentes porque cada tela foi construída num momento diferente da minha compreensão do app. Botões primários tinham alturas diferentes dependendo de onde você os encontrava. Cards tinham três raios de canto diferentes que eu provavelmente escolhi com grande intenção e depois esqueci completamente. Cores eram hardcoded em todo lugar. A coisa toda era mantida unida por confiança e fita adesiva.\r\n\r\nO plano de migração: arrancar o react-native-paper, substituir pelo [NativeWind](https://www.nativewind.dev/) (Tailwind CSS pro React Native), e construir um design system de verdade do zero. Simples. Direto. Completamente insano dado o tamanho do codebase nessa altura.\r\n\r\nFui e fiz mesmo assim, porque não tenho nenhum instinto de autopreservação quando se trata de side projects.\r\n\r\n## Entra o Stitch\r\n\r\nEu estava de olho no [Google Stitch](https://stitch.withgoogle.com/) desde que lançou. A versão curta: é uma ferramenta de IA que gera UI de app mobile a partir de prompts e screenshots. Você descreve a estética que quer, joga umas telas de referência, e ele gera componentes React coerentes. Pra um desenvolvedor que genuinamente tentou aprender design três vezes separadas e falhou cada vez por razões fundamentalmente diferentes, isso pareceu cola no melhor sentido possível.\r\n\r\nAlimentei com as telas existentes do Musclog e descrevi o que queria: tema escuro, estética de fitness e performance, algo que parecesse estar olhando pra dados sérios ao invés de uma lista de tarefas. Queria que parecesse o dashboard de algo que faz sentido, não um app de bem-estar que vai sugerir que você \"respire fundo\" antes de registrar seu deadlift.\r\n\r\n![Nova tela inicial do Musclog](../../uploads/blog/2026/03/musclog-home-screen.png)\r\n\r\nA parte inesperada foi o quanto esse processo me forçou a pensar claramente sobre o produto em si. Pra gerar uma boa tela no Stitch, eu precisava articular pra que aquela tela *servia* antes de poder descrever como deveria parecer. Esse processo expôs problemas de UX que não tinham nada a ver com cores. Um passo no fluxo de registro de treino existia puramente por causa de uma limitação técnica que eu tinha contornado com UI ao invés de resolver o problema de verdade. ~~Resolvido. Fingi que nunca aconteceu.~~ Resolvido, depois refatorei o serviço por baixo pra que nunca voltasse.\r\n\r\n~~Eu escrevi~~ O Stitch também escreveu um documento de design de verdade pela primeira vez na vida desse projeto: cores de superfície com nomes, cores semânticas pra macros (proteína é sempre índigo `#6366f1`, gordura é sempre âmbar `#f59e0b`, carbo é esmeralda `#10b981`, fibra é rosa `#ec4899`), escala de espaçamento em 4/8/12/16/20/24/32px, padrões de border radius em 12px pra inputs e 16px pra cards primários. Coisas que eu deveria ter definido antes de escrever uma única linha de código de UI. A conta do \"manda ver\" chegou, como sempre chega, com juros.\r\n\r\n## A arquitetura que ninguém pediu\r\n\r\nJá que eu estava queimando a UI até os alicerces, aproveitei pra apertar a arquitetura por baixo também. Essa é a parte onde a maioria das pessoas desliga, mas se você tá construindo algo com armazenamento local-first em React Native, algumas dessas decisões podem te poupar tempo.\r\n\r\nO Musclog usa [WatermelonDB](https://watermelondb.dev/) pro armazenamento local. Não SQLite puro, não MMKV, não AsyncStorage. O WatermelonDB fica em cima do SQLite no native e [LokiJS](https://github.com/techfort/LokiJS) no web, e te dá uma camada de modelo com queries reativas. Quando o dado muda, qualquer componente observando aquela query re-renderiza automaticamente. Sem sync de estado manual. Sem bug de \"lembrei de atualizar a lista depois de salvar?\". O mesmo codebase roda no Android e no browser sem tocar na camada de dados, o que importa quando você quer testar algo rápido sem subir um device.\r\n\r\nA estrutura em camadas vai assim: definição de schema na base, depois models, depois services que cuidam de CRUD e lógica de negócio. Serviços que não são de banco (IA, notificações, sync do Health Connect) vivem separados no próprio dir `services/`. Todo write passa por `database.write(async () => { ... })`. Sem exceções. Blocos de write aninhados causam deadlocks no WatermelonDB, e são uma dor de cabeça pra debugar, então a regra é simples: nunca aninhe, e se você quiser fazer isso, você projetou algo errado lá atrás.\r\n\r\nÉ assim que fica no `NutritionCheckinService.ts` criando um lote de check-ins semanais:\r\n\r\n```typescript\r\nstatic async createBatch(\r\n    nutritionGoalId: string,\r\n    checkins: NutritionCheckinInput[]\r\n): Promise<NutritionCheckin[]> {\r\n    return await database.write(async () => {\r\n        const collection = database.get<NutritionCheckin>('nutrition_checkins');\r\n\r\n        const preparedRecords = checkins.map((data) =>\r\n            collection.prepareCreate((record) => {\r\n                record.nutritionGoalId = nutritionGoalId;\r\n                record.checkinDate = data.checkinDate;\r\n                record.targetWeight = data.targetWeight;\r\n                record.status = data.status ?? 'pending';\r\n            })\r\n        );\r\n\r\n        await database.batch(...preparedRecords);\r\n        return preparedRecords;\r\n    });\r\n}\r\n```\r\n\r\nA combinação `prepareCreate` + `database.batch()` é como o WatermelonDB lida com múltiplos inserts atomicamente sem múltiplas viagens de ida e volta. Um bloco `write()`, um batch, feito. Você nunca chama o `database.write()` de outro service lá de dentro, porque é assim que você consegue um deadlock às 23h que misteriosamente só reproduz no device.\r\n\r\n![Tela de registro de treino do Musclog](../../uploads/blog/2026/03/musclog-workout-logging.png)\r\n\r\nDados sensíveis, especificamente seu histórico de peso, percentuais de gordura corporal e logs de nutrição, são criptografados com AES antes de entrar no banco. Não porque eu espero que alguém invada um arquivo SQLite local, mas porque são dados de saúde. Merecem ser tratados como tal. O arquivo `encryptionHelpers.ts` cuida de encrypt/decrypt de forma transparente pela camada de service. Você nunca pensa nisso como usuário. Nem precisa pensar nisso como contribuidor, porque tá em um lugar e tudo passa por ele.\r\n\r\nEntão quando você loga uma refeição, é isso que realmente fica armazenado:\r\n\r\n```typescript\r\n// database/encryptionHelpers.ts\r\nexport async function encryptNutritionLogSnapshot(plain: {\r\n    loggedFoodName?: string;\r\n    loggedCalories: number;\r\n    loggedProtein: number;\r\n    loggedCarbs: number;\r\n    loggedFat: number;\r\n    loggedFiber: number;\r\n    loggedMicros?: Record<string, number | undefined>;\r\n}) {\r\n    const [\r\n        loggedFoodName,\r\n        loggedCalories,\r\n        loggedProtein,\r\n        loggedCarbs,\r\n        loggedFat,\r\n        loggedFiber,\r\n        loggedMicrosJson,\r\n    ] = await Promise.all([\r\n        encryptOptionalString(plain.loggedFoodName),\r\n        encryptNumber(plain.loggedCalories),\r\n        encryptNumber(plain.loggedProtein),\r\n        encryptNumber(plain.loggedCarbs),\r\n        encryptNumber(plain.loggedFat),\r\n        encryptNumber(plain.loggedFiber),\r\n        encryptJson(plain.loggedMicros),\r\n    ]);\r\n\r\n    return { loggedFoodName, loggedCalories, loggedProtein, loggedCarbs, loggedFat, loggedFiber, loggedMicrosJson };\r\n}\r\n```\r\n\r\nAquele `loggedCalories: 165` que você digitou? É uma string criptografada com AES no banco. `loggedFoodName: \"Chicken breast\"` também é criptografado. Até o blob de JSON dos micronutrientes é criptografado. Tudo descriptografa na hora da leitura pela camada de service. A chave de criptografia é derivada por device e nunca sai dele.\r\n\r\nO sistema de gráficos tem uma peculiaridade que vale mencionar. O Musclog usa [Victory Native](https://commerce.nearform.com/open-source/victory-native/) pra gráficos no mobile, que usa Skia pra renderizar. Skia não funciona no web. Então todo componente de gráfico tem um par `.web.tsx` usando [Victory](https://commerce.nearform.com/open-source/victory/) normal com SVG no lugar. O bundler do Expo pega o arquivo certo automaticamente baseado na extensão. Parece o dobro de trabalho e é meio que isso mesmo, mas a alternativa são gráficos que quebram silenciosamente no web, e eu uso a versão web bastante durante o desenvolvimento.\r\n\r\nO mesmo `LineChart`, dois runtimes:\r\n\r\n```typescript\r\n// components/charts/LineChart.tsx - native (Skia)\r\nimport { Area, CartesianChart, Line, Scatter } from 'victory-native';\r\nimport Animated, { useAnimatedStyle, useSharedValue } from 'react-native-reanimated';\r\n\r\n// components/charts/LineChart.web.tsx - web (SVG)\r\nimport { VictoryArea, VictoryAxis, VictoryChart, VictoryLine, VictoryScatter } from 'victory';\r\n```\r\n\r\nMesma interface de props, mesmo comportamento, backends de renderização diferentes. O bundler do Expo resolve `LineChart` pro arquivo `.web.tsx` no web e pro `.tsx` em todo o resto. Zero condicionais no componente que realmente o usa.\r\n\r\nO tracking de volume funciona da mesma forma na infraestrutura, exceto que o problema interessante aí é a matemática. O Musclog rastreia volume como máximo estimado de uma repetição, não peso x reps bruto, porque 5 reps em 80kg e 12 reps em 60kg são estímulos de treino diferentes que produzem o mesmo número num gráfico de volume ingênuo. O problema é que não existe uma fórmula de 1RM aceita universalmente. Brzycki, Epley, Lander, Mayhew - todas dão um número ligeiramente diferente pro mesmo set, e a pesquisa não elege um vencedor claro. Então o Musclog roda as sete e tira a média:\r\n\r\n```typescript\r\n// utils/workoutCalculator.ts\r\nexport function calculateAverage1RM(weight: number, reps: number, rir: number = 0): number {\r\n    const formulas: FormulaType[] = [\r\n        'Epley', 'Brzycki', 'Lander', 'Lombardi', 'Mayhew', 'OConner', 'Wathan',\r\n    ];\r\n    let total1RM = 0;\r\n    let validFormulas = 0;\r\n\r\n    formulas.forEach((formula) => {\r\n        const oneRM = calculate1RM(weight, reps, formula, rir);\r\n        if (oneRM !== null) {\r\n            total1RM += oneRM;\r\n            validFormulas++;\r\n        }\r\n    });\r\n\r\n    return validFormulas > 0 ? total1RM / validFormulas : 0;\r\n}\r\n```\r\n\r\nO parâmetro `rir` é Reps in Reserve: se você parou em 8 mas tinha mais 2 no tanque, `rir = 2` ajusta a estimativa pra cima pra refletir o que você realmente poderia levantar. Registrar seu RIR é opcional. O tipo de pessoa que construiu seu próprio app de fitness e rastreia tudo em planilha geralmente também é o tipo de pessoa que rastreia o RIR. ~~Não tô dizendo que sou eu.~~ Sou eu.\r\n\r\n## Espera, e os dados reais de comida?\r\n\r\nÉ. O redesign foi a parte visível dessa atualização. A parte mais significativa, pelo menos do ponto de vista de \"esse app é realmente útil\", foi reconstruir completamente como o tracking de comida funciona.\r\n\r\nA versão original dependia muito das informações nutricionais vindas do Health Connect. Se você não tinha outro app pra fazer o tracking de nutrição, ficava a deriva. Isso é aceitável pra um projeto de fim de semana. Não é aceitável pra um app que as pessoas usam diariamente pra rastrear calorias, proteína, carbo, gordura, e mais de 40 micronutrientes em múltiplas refeições.\r\n\r\nPra minha genuína surpresa, descobri que dados de alta qualidade sobre comida não ficam atrás de um portão corporativo. Existem APIs públicas e gratuitas enormes - como USDA e Open Food Facts - que fornecem de quebra de micronutrientes detalhada a consulta de código de barras global sem cobrar um centavo. Encontrar isso foi um grande momento \"aha!\": se o dado é público e o processamento acontece direto no seu device, não existe justificativa técnica alguma pra rastreamento de nutrição ser um SaaS baseado em assinatura. A maioria dos apps \"premium\" basicamente te cobra uma mensalidade pra ser intermediário de dados que nem são deles, mas essa é uma discussão picante que guardei pra outra hora.\r\nO Musclog agora conecta a dois bancos de dados de comida reais.\r\n\r\n### USDA FoodData Central\r\n\r\n[USDA FoodData Central](https://fdc.nal.usda.gov/) é o banco de dados nutricional público do Departamento de Agricultura dos EUA. Centenas de milhares de alimentos, de produtos de marcas a ingredientes brutos, com detalhamento de macro e micronutrientes. São dados governamentais, é gratuito, e a API não requer cartão de crédito, o que como você vai ver é uma consideração não trivial pra mim. A cobertura de produtos de marcas americanos e globais é genuinamente sólida. Essa é a espinha dorsal da busca.\r\n\r\nO USDA identifica nutrientes por códigos numéricos, então mapeá-los pra algo legível por humanos requer uma camada de lookup. É assim que o mapper parece na prática:\r\n\r\n```typescript\r\n// utils/usdaMapper.ts\r\nexport function mapUSDAFoodToUnified(food: USDAFood): UnifiedFoodResult {\r\n    const nutrients = food.foodNutrients;\r\n\r\n    // USDA usa códigos numéricos de nutrientes - 1008/208 é energia, 1003/203 é proteína, etc.\r\n    const calories = mapUSDANutritient(nutrients, '1008') ?? mapUSDANutritient(nutrients, '208');\r\n    const protein  = mapUSDANutritient(nutrients, '1003') ?? mapUSDANutritient(nutrients, '203');\r\n    const carbs    = mapUSDANutritient(nutrients, '1005') ?? mapUSDANutritient(nutrients, '205');\r\n    const fat      = mapUSDANutritient(nutrients, '1004') ?? mapUSDANutritient(nutrients, '204');\r\n    const fiber    = mapUSDANutritient(nutrients, '1079') ?? mapUSDANutritient(nutrients, '291');\r\n\r\n    return {\r\n        id: String(food.fdcId),\r\n        name: food.description,\r\n        brand: food.brandOwner,\r\n        calories: calories !== undefined ? Math.round(calories) : undefined,\r\n        protein, carbs, fat, fiber,\r\n        source: 'usda',\r\n    };\r\n}\r\n```\r\n\r\nO fallback duplo `??` existe porque o USDA tem dois esquemas de numeração de nutrientes diferentes dependendo do tipo de dado (Foundation Foods vs. Branded Foods). Ambos mapeiam pro mesmo formato de saída.\r\n\r\n### Open Food Facts\r\n\r\n[Open Food Facts](https://world.openfoodfacts.org/) é um banco de dados comunitário de produtos alimentícios do mundo inteiro. Pensa num Wikipedia mas pra rótulos nutricionais: qualquer um pode adicionar um produto, os dados são abertos sob a licença ODbL, e como é crowdsourced globalmente cobre produtos que o banco do USDA ignora na maior, tipo o negócio de laticínio fermentado que tem no supermercado holandês a cinco minutos do meu apartamento. Quando seu banco de dados nutricional principal assume que você come exclusivamente produtos americanos e você mora na Holanda, você começa a apreciar datasets globais abertos muito rapidamente.\r\n\r\nFazer queries nele é surpreendentemente simples pra algo tão abrangente:\r\n\r\n```typescript\r\n// hooks/useUnifiedFoodSearch.ts\r\nconst url = `https://world.openfoodfacts.org/cgi/search.pl` +\r\n    `?search_terms=${encodeURIComponent(query)}` +\r\n    `&json=1` +\r\n    `&page_size=20` +\r\n    `&fields=code,product_name,brands,nutriments,serving_size,image_url`;\r\n\r\nconst response = await fetch(url, { signal: abortController.signal });\r\nconst result = await response.json();\r\n\r\nconst products = result.products.filter(\r\n    (product: SearchResultProduct) => getProductName(product)\r\n);\r\n```\r\n\r\nAmbas as buscas rodam em paralelo (com abort controllers pra requests velhas não brigarem entre si) e os resultados se fundem em uma única lista unificada. O usuário escolhe entre comidas locais, resultados do Open Food Facts, e resultados do USDA, tudo de uma vez, sem saber ou se importar de qual backend cada um veio.\r\n\r\nEntre os dois, você consegue buscar praticamente qualquer comida e obter dados nutricionais reais sem digitar nada manualmente. E porque o Musclog agora rastreia mais de 40 micronutrientes além dos macros padrão, você consegue ver coisas como sua ingestão de magnésio, seus níveis de zinco, sua vitamina D. Acontece que isso importa quando você começa a olhar o que está consistentemente faltando. Pra mim é magnésio. É sempre magnésio.\r\n\r\n![Tracking de nutrição do Musclog com breakdown completo de macros](../../uploads/blog/2026/03/musclog-nutrition-screen.png)\r\n\r\nTem também um scanner de código de barras. Aponta a câmera pro produto, ele busca o código de barras no Open Food Facts, adiciona ao seu log. Eu escanio minha embalagem de iogurte grego toda manhã por puro hábito a essa altura. Registro sem fricção era o que eu queria quando comecei esse projeto e finalmente tenho, duas versões e uns 500 horas depois.\r\n\r\n![Scanner de código de barras do Musclog em ação](../../uploads/blog/2026/03/musclog-barcode-scanner.png)\r\n\r\nPra quando você não consegue escanear nada porque tá comendo num restaurante ou olhando pra um prato de \"provavelmente é frango com algum molho,\" tem OCR de rótulo (tesseract.js no web, rn-mlkit-ocr no native) e estimativa por foto com IA, que vou chegar lá em um segundo.\r\n\r\nO OCR segue o mesmo padrão de arquivo duplo dos gráficos. Assinatura de função idêntica, engine diferente, o bundler pega o certo na hora do build:\r\n\r\n```typescript\r\n// utils/ocr.ts - native (Google ML Kit, roda no device)\r\nimport { recognizeText } from 'rn-mlkit-ocr';\r\n\r\nexport async function performOcr(imageUri: string): Promise<string | null> {\r\n    const result = await recognizeText(imageUri);\r\n    const text = result.text.trim();\r\n    return text.length > 0 ? text : null;\r\n}\r\n\r\n// utils/ocr.web.ts - web (Tesseract.js, roda inteiramente no browser)\r\nimport { createWorker } from 'tesseract.js';\r\n\r\nconst OCR_LANGS = 'eng+spa+por+nld+deu+fra';\r\n\r\nexport async function performOcr(imageUri: string): Promise<string | null> {\r\n    const worker = await createWorker(OCR_LANGS);\r\n    const { data: { text } } = await worker.recognize(imageUri);\r\n    await worker.terminate();\r\n    return text.trim() || null;\r\n}\r\n```\r\n\r\nML Kit roda no device e é rápido. Tesseract.js sobe e mata um worker completo pra cada scan, o que não é elegante, mas funciona offline e nada sai do browser. O pacote de idiomas cobre inglês, espanhol, português, holandês, alemão e francês. Moro na Holanda, sou brasileiro, e não ia shipar um scanner de rótulo de supermercado que engasgasse com embalagem de queijo holandês.\r\n\r\n## O coach de IA cresceu\r\n\r\nO chatbot original se chamava Chad. Sim, eu sei, não é muito criativo, mas eu tava mais focado em \"shipar\" features do que em deixá-las atraentes. E tá beleza, né, Chad?\r\n\r\n![sim.](../../uploads/blog/2026/03/chad-yes.png)\r\n\r\nAgora se chama Loggy, suporta tanto [OpenAI](https://openai.com/) quanto [Google Gemini](https://deepmind.google/technologies/gemini/), e tá realmente conectado aos seus dados de forma significativa. Quando você pergunta algo pro Loggy, ele sabe quem você é: seus treinos recentes, seus logs de nutrição, sua tendência de peso, seus objetivos atuais. \"Meu volume de treino da semana passada tava no caminho certo?\" recebe uma resposta real baseada em dados reais, não uma dica genérica sobre sobrecarga progressiva que você já leu quinze vezes.\r\n\r\nA camada de saída estruturada foi um dos problemas técnicos mais interessantes aqui. Fazer LLMs outputarem dados estruturados de forma confiável (ao invés de prosa que parece dados estruturados mas quebra seu parser de JSON) requer cuidado. O utilitário `makeSchemaStrict` em `utils/coachAI.ts` pega qualquer schema JSON e força `additionalProperties: false` em todo objeto aninhado enquanto marca todos os campos como `required`. Isso vai pra config de function calling do OpenAI e fala pro modelo \"retorna exatamente esse formato ou falha limpo.\" É a diferença entre uma feature que funciona 95% do tempo e uma que realmente funciona.\r\n\r\nA função em si é simples o suficiente que eu quase não escrevi sobre ela:\r\n\r\n```typescript\r\n// utils/coachAI.ts\r\nfunction makeSchemaStrict(schema: any): any {\r\n    if (schema.type === 'object') {\r\n        const properties = schema.properties ? { ...schema.properties } : {};\r\n\r\n        Object.keys(properties).forEach((key) => {\r\n            properties[key] = makeSchemaStrict(properties[key]);\r\n        });\r\n\r\n        return {\r\n            ...schema,\r\n            properties,\r\n            additionalProperties: false, // OpenAI requer isso pro modo estrito\r\n        };\r\n    }\r\n\r\n    if (schema.type === 'array' && schema.items) {\r\n        return { ...schema, items: makeSchemaStrict(schema.items) };\r\n    }\r\n\r\n    return schema;\r\n}\r\n```\r\n\r\nPercorre recursivamente cada objeto aninhado no schema e enfia `additionalProperties: false` nele. Sem isso, o function calling estrito do OpenAI rejeita o schema completamente. Com isso, o modelo é restringido exatamente ao formato que você definiu. Sem chaves extras surpresa. Sem campos que existem numa resposta mas não em outra. A IA propõe, o schema impõe.\r\n\r\n![Chat com o coach de IA do Musclog](../../uploads/blog/2026/03/musclog-ai-coach.png)\r\n\r\nA feature de análise por foto é a que faz novos usuários olharem duas vezes. Você tira uma foto da sua refeição, o Loggy estima as porções e o conteúdo nutricional. Não substitui uma balança de alimentos pra tracking de precisão, mas pra comer fora ou em dias que você genuinamente não consegue escanear nada, te deixa na bola certa rápido. O fluxo manda a imagem pro modelo com um schema estruturado pra resposta, extrai as estimativas, e aí te faz confirmar antes de logar. Esse último passo importa: a IA propõe, você decide.\r\n\r\nOs system prompts ficam em `utils/prompts.ts` e puxam instruções customizadas da tabela `ai_custom_prompts`, então o comportamento da IA é configurável sem tocar no código.\r\n\r\n## Check-ins semanais: a feature que mais me orgulha\r\n\r\nTodo semana, o Musclog roda uma análise automatizada das suas médias móveis de 7 dias pra peso, ingestão calórica e atividade. Você recebe um status: No Caminho, Adiantado ou Atrasado. Se seus números estão divergindo dos seus objetivos, ele pode recalcular suas metas nutricionais baseado no que realmente aconteceu ao invés de te prender num plano que claramente não tá batendo com a realidade.\r\n\r\nO coração disso é `getCheckinMetrics`, que pega um registro de check-in e puxa todos os dados da janela de 7 dias terminando naquela data:\r\n\r\n```typescript\r\n// database/services/NutritionCheckinService.ts\r\nstatic async getCheckinMetrics(checkin: NutritionCheckin): Promise<CheckinMetrics> {\r\n    const periodEnd = checkin.checkinDate;\r\n    const periodStart = periodEnd - 7 * 24 * 60 * 60 * 1000;\r\n\r\n    // Puxa todas as medições de peso da janela de 7 dias\r\n    const weightMetrics = await database\r\n        .get<UserMetric>('user_metrics')\r\n        .query(\r\n            Q.where('type', 'weight'),\r\n            Q.where('date', Q.between(periodStart, periodEnd)),\r\n            Q.where('deleted_at', Q.eq(null)),\r\n            Q.sortBy('date', Q.asc)\r\n        )\r\n        .fetch();\r\n\r\n    // Descriptografa cada valor (pesos são criptografados com AES no banco)\r\n    const decryptedWeights: number[] = [];\r\n    for (const metric of weightMetrics) {\r\n        const { value } = await metric.getDecrypted();\r\n        decryptedWeights.push(value);\r\n    }\r\n\r\n    const avgWeight = decryptedWeights.length > 0\r\n        ? decryptedWeights.reduce((a, b) => a + b, 0) / decryptedWeights.length\r\n        : checkin.targetWeight;\r\n\r\n    // Quão longe o average real está de onde esperávamos estar?\r\n    const trend = avgWeight - checkin.targetWeight;\r\n\r\n    // Nutrição: agrupa logs por dia, calcula média de calorias e consistência\r\n    const nutritionLogs = await database\r\n        .get<NutritionLog>('nutrition_logs')\r\n        .query(Q.where('date', Q.between(periodStart, periodEnd)), Q.where('deleted_at', Q.eq(null)))\r\n        .fetch();\r\n\r\n    const caloriesByDay = new Map<number, number>();\r\n    for (const log of nutritionLogs) {\r\n        const snapshot = await log.getDecryptedSnapshot();\r\n        const dayKey = Math.floor(log.date / (24 * 60 * 60 * 1000));\r\n        caloriesByDay.set(dayKey, (caloriesByDay.get(dayKey) ?? 0) + snapshot.loggedCalories);\r\n    }\r\n\r\n    const consistency = Math.round((caloriesByDay.size / 7) * 100); // % de dias com logs\r\n    // ...e assim por diante pra treinos, gordura corporal, minutos ativos\r\n}\r\n```\r\n\r\nO `trend` é o número chave: positivo significa que você tá mais pesado do que o alvo previa, negativo significa que você tá à frente. Combinado com `consistency` (que porcentagem dos 7 dias você realmente logou comida), o service consegue inferir se a variância é real ou só dado faltando.\r\n\r\nO cálculo de TDEE é empírico ao invés de baseado em fórmula. O Musclog olha sua mudança real de peso ao longo do tempo combinada com sua ingestão calórica real logada e trabalha ao contrário pra estimar suas calorias reais de manutenção. Se você tá comendo 2.200 calorias por dia e perdendo 0,3kg por semana, a matemática te diz algo sobre seu metabolismo real que a fórmula de Harris-Benedict nunca vai te dizer, especialmente se seu nível de atividade não se encaixa perfeitamente em \"sedentário\" ou \"moderadamente ativo\" ou qualquer categoria vaga que você escolheu no onboarding.\r\n\r\nA função que faz isso mora em `utils/nutritionCalculator.ts`. A lógica é direta; as constantes, não:\r\n\r\n```typescript\r\n// utils/nutritionCalculator.ts\r\nexport const calculateTDEE = (params: TDEEParams): number => {\r\n    const { totalCalories, totalDays, initialWeight, finalWeight,\r\n            initialFatPercentage, finalFatPercentage, bmr, activityLevel } = params;\r\n\r\n    // Caminho 1: dados reais de tracking existem - deriva TDEE da Primeira Lei da Termodinâmica\r\n    if (totalDays && totalCalories && initialWeight && finalWeight) {\r\n        const weightDifference = finalWeight - initialWeight;\r\n\r\n        // Divide a mudança de peso em gordura vs massa magra.\r\n        // Exato quando temos % de gordura nos dois extremos; estimativa pela curva Hall/Forbes caso contrário.\r\n        let fatDifference: number;\r\n        let leanDifference: number;\r\n\r\n        if (initialFatPercentage !== undefined && finalFatPercentage !== undefined) {\r\n            const initialFatMass = (initialFatPercentage * initialWeight) / 100;\r\n            const finalFatMass = (finalFatPercentage * finalWeight) / 100;\r\n            fatDifference = finalFatMass - initialFatMass;\r\n            leanDifference = weightDifference - fatDifference;\r\n        } else {\r\n            const comp = getWeightChangeComposition(initialWeight * 0.25, weightDifference);\r\n            fatDifference = comp.fatChangeKg;\r\n            leanDifference = comp.leanChangeKg;\r\n        }\r\n\r\n        // Construir vs queimar gordura e músculo têm custos termodinâmicos diferentes.\r\n        // Essas não são constantes intercambiáveis - são valores medidos separados.\r\n        const leanCalories = leanDifference > 0\r\n            ? leanDifference * CALORIES_BUILD_KG_MUSCLE   // 3900 kcal/kg pra construir\r\n            : leanDifference * CALORIES_STORED_KG_MUSCLE; // 1250 kcal/kg armazenados\r\n\r\n        const fatCalories = fatDifference > 0\r\n            ? fatDifference * CALORIES_BUILD_KG_FAT   // 8840 kcal/kg pra construir\r\n            : fatDifference * CALORIES_STORED_KG_FAT; // 7730 kcal/kg armazenados\r\n\r\n        // TDEE = (energia consumida − energia presa em mudanças de tecido) / dias\r\n        return Math.round((totalCalories - (fatCalories + leanCalories)) / totalDays);\r\n    }\r\n\r\n    // Caminho 2: ainda sem histórico - cai de volta pra BMR x multiplicador de atividade\r\n    if (bmr && activityLevel) {\r\n        return Math.round(bmr * ACTIVITY_MULTIPLIERS[activityLevel]);\r\n    }\r\n\r\n    return 0;\r\n};\r\n```\r\n\r\nA assimetria entre construir gordura (8840 kcal/kg) e queimá-la (7730 kcal/kg) é real. A eficiência termodinâmica não é 100%, então criar novo tecido custa mais do que o que acaba sendo armazenado. O mesmo princípio se aplica ao músculo. Se você só hardcoda 7700 e segue em frente você tem algo que é aproximadamente certo, mas você tá tirando a média exatamente do sinal que você construiu o sistema de check-in inteiro pra encontrar.\r\n\r\n![Tela de check-in semanal do Musclog](../../uploads/blog/2026/03/musclog-weekly-checkin.png)\r\n\r\nEssa não é uma feature chamativa. Você não percebe até o fim da semana. Mas ter um app que percebe \"você ficou abaixo do seu alvo calórico a semana inteira e seu peso ainda não mudou, vamos descobrir por quê\" é exatamente o tipo de insight que eu estava construindo quando comecei esse projeto. Só não sabia disso na época. Achei que estava construindo um registrador de treinos.\r\n\r\n## As outras coisas que fui shippando no sapatinho\r\n\r\nWidgets de tela inicial pra registro rápido e resumos diários. Um rastreador de ciclo menstrual com recomendações de intensidade de treino por fase, porque assumir uma base de usuários só masculina é ~~um ponto de partida razoável~~ não tá certo, então o Musclog não usa gênero pra inferir dados de ciclo menstrual. Integração com Health Connect pra sincronizar peso, nutrição e dados de exercício com outros apps de saúde Android. Exportação completa de dados como JSON encriptado (ou não encriptado). Importação de JSON pra migrar entre devices sem perder seu histórico.\r\n\r\nA feature de exportação de dados parece chata até seu celular morrer e você não ter. Nesse ponto você tá simultaneamente grato por ter construído e levemente furioso com o seu eu do passado por não ter testado o fluxo de importação mais a fundo. Pode perguntar como eu sei.\r\n\r\n![Detalhe do tracking de micronutrientes do Musclog](../../uploads/blog/2026/03/musclog-micronutrients.png)\r\n\r\n## Por que a assinatura do seu app de fitness é problema de outro\r\n\r\nQuero falar algo sobre o mercado de apps de fitness, porque faz uns dois anos que tô segurando isso e esse é o meu blog.\r\n\r\nA história se repete num cronograma que você pode acertar seu relógio: app lança gratuito, adquire usuários, introduz um nível premium, gradualmente migra features centrais pra trás do paywall, aumenta os preços, usuários reclamam no Reddit, nada muda. Assisti isso acontecer com apps que eu genuinamente gostava de usar. MyFitnessPal passou de realmente bom e gratuito pra uma assinatura onde definir suas próprias metas de macro custa dinheiro. Outros apps introduziram \"bancos de dados de alimentos premium\" que tiraram a coisa útil e a venderam de volta mensalmente. Um app que usei por um tempo moveu os gráficos de progresso pra trás de um paywall. Os gráficos de progresso. Num app de rastreamento de fitness. A única feature que mostra se o app tá funcionando.\r\n\r\n> \"Mas sem receita de assinatura, como você mantém as luzes acesas?\"\r\n> As luzes são um cabo de carregamento e custam zero por mês. Próxima pergunta.\r\n\r\nEntendo a economia. Servidores custam dinheiro. Times de engenharia custam dinheiro. Construir um negócio de produto sustentável é genuinamente difícil e alguém tem que pagar por isso. Tudo bem.\r\n\r\nMas o Musclog não tem servidores. Não tem infraestrutura de nuvem. Nenhum banco de dados que estou pagando pra hospedar em algum lugar. Seus dados vivem no seu celular. Os bancos de dados de alimentos que uso são públicos e gratuitos. As features de IA, se você usá-las, falam diretamente com a OpenAI ou o Google usando sua própria chave de API: você paga pra eles, sem intermediário, sem comissão. O app é gratuito no [Google Play](https://play.google.com/store/apps/details?id=com.werules.logger) e open source no [GitHub](https://github.com/blopa/musclog-app).\r\n\r\n![O ciclo de assinatura de apps de fitness, ilustrado](../../uploads/blog/2026/03/fitness-app-subscription-meme.png)\r\n\r\nIsso não é uma posição de princípio contra o capitalismo. É uma decisão de design que faz sentido pro que isso realmente é: uma ferramenta pessoal que construí pra mim mesmo e depois compartilhei com outras pessoas. A arquitetura que escolhi naturalmente não cria custos de servidor, o que significa que não tenho pressão financeira pra monetizar, o que significa que os usuários não têm motivo pra desconfiar do que estou fazendo com os dados deles. Porque não estou fazendo nada com os dados deles. Estão no celular deles.\r\n\r\nO design offline-first é deliberado pelo mesmo motivo. Histórico de peso, composição corporal, o que você come, seu ciclo menstrual, seu histórico de treinos: nada disso deveria ficar no servidor de um estranho por padrão. A maioria dos apps de fitness não explica claramente o que faz com esses dados porque uma resposta transparente seria impopular. O Musclog mantém tudo local. O único dado que sai do seu device é o que você explicitamente manda pra IA, e só quando você escolhe usar essa feature.\r\n\r\nTem uma população inteira de pessoas treinando em academias com WiFi ruim, em porões, em garagens, em parques, que nunca tiveram o Musclog falhar porque precisava de conexão. Isso importa pra mim mais do que um número de receita recorrente mensal.\r\n\r\n## O que vem por aí\r\n\r\nO onboarding é a parte mais fraca do app agora. O Musclog é significativamente mais útil quanto mais dados tem, mas a experiência de novo usuário ainda tá muito perto de \"aqui tá o app inteiro, se vira.\" Quero construir um fluxo de onboarding de verdade que deixe alguém configurado com seus objetivos, seu primeiro template de treino, e seu primeiro log de comida em menos de cinco minutos. Você não deveria precisar de 500 horas de contexto pra ter valor desde o primeiro dia.\r\n\r\nSuporte a devices BLE pra balanças inteligentes também está na lista. Entrar o peso e gordura corporal manualmente todo dia é ok. Ter a balança mandar pro app automaticamente quando você sobe nela é melhor, e a infraestrutura não é tão complexa. É principalmente uma questão de conseguir hardware pra testar, o que é um problema muito solucionável e definitivamente algo que vou fazer assim que terminar as outras dezessete coisas que já comecei.\r\n\r\n## Vai ser gratuito pra sempre?\r\n\r\nPergunta justa. A resposta curta é: não faço a menor ideia. A resposta mais longa é mais interessante.\r\n\r\nO código é open source sob a licença Attribution-NonCommercial-NoDerivatives 4.0 International. Você pode ler, fazer fork pra uso pessoal, aprender com ele. O que você não pode é shipar um produto construído em cima dele sem falar comigo primeiro, porque Musclog é uma marca minha, e porque as quinhentas horas de trabalho por trás disso representam algo em torno de €25000 em salário de desenvolvedor europeu. Não tô tocando uma ONG. Tô tocando um side project que ainda não te pediu dinheiro.\r\n\r\nSe isso mudar algum dia, aqui tá minha promessa: não vou fazer assinatura. Se o Musclog algum dia custar dinheiro, vai custar uma vez. Você paga o preço de um café, o app é seu, pra sempre. Sem mensalidade. Sem \"seu plano premium foi pausado.\" O modelo bom e velho, aquele que a App Store basicamente matou.\r\n\r\nO que tá me empurrando mais perto desse cenário agora é o iOS. Minha namorada quer no iPhone. Alguns amigos próximos querem no iPhone. Há meses escuto \"coloca na App Store\" como se fosse uma coisa trivial. O programa de desenvolvedor da Apple custa 100 USD por ano. Por ano. Não uma vez. Todo ano. A Apple transformou o direito de distribuir software num SaaS, e falo isso como alguém que acha que a taxa única de 35 USD do Google Play é como deveria ser em todo lugar. Então sim, se o Musclog algum dia chegar no iOS, provavelmente vai ter um preço, porque não vou pagar 100 USD de pedágio anual pra Apple de boa vontade.\r\n\r\nPor enquanto: Android, gratuito, sem planos de mudar isso. Então baixe agora enquanto tá de graça, porque uma vez que você instala no Google Play, ele é seu pra sempre. Mesmo que você não tenha pagado nada. Especialmente se você não pagou nada.\r\n\r\n## Conclusão\r\n\r\nDois anos, duas versões, 500 horas. O Musclog finalmente parece algo pelo qual eu não me desculpo mentalmente antes de mostrar pra alguém. O redesign limpou anos de dívida de UI do \"manda ver\", e fazer direito me forçou a pensar sobre o produto de maneiras que eu deveria ter pensado desde o começo. Acontece que design systems existem por boas razões e não só pra dar aos designers algo pra discutir no Figma.\r\n\r\nÉ gratuito. É open source. Não tem seus dados. Funciona no modo avião. Bancos de dados de comida reais. IA que realmente sabe quem você é. Check-ins semanais automatizados. E um esquema de cores verde escuro do qual eu me orgulho genuinamente, mesmo que uma ferramenta de IA tenha feito a maior parte do trabalho visual pesado.\r\n\r\nSe quiser experimentar: [Google Play](https://play.google.com/store/apps/details?id=com.werules.logger).\r\n\r\nSe quiser ver como foi construído ou contribuir com algo: [github.com/blopa/musclog-app](https://github.com/blopa/musclog-app).\r\n\r\nTreina, Loga, Repete.\r\n\r\nAté a próxima!\r\n","frontmatter":{"tags":["react","react native","expo","android","fitness","musclog","nutrition","usda","open food facts","stitch","design","offline","watermelondb","nativewind"],"categories":["coding"],"allowComments":true,"publishOnMedium":false,"cover":null,"date":"2026-03-22T00:00:00.000Z","id":null,"path":"musclog-redesign-acompanhamento-nutricional-e-por-que-a-assinatura-do-seu-app-de-fitness-e-uma-enganacao","show":true,"title":"Musclog: Redesign do acompanhamento nutricional e por que a assinatura do seu app de fitness é uma enganação","hideExcerpt":false,"subtitle":"Nova interface, bancos de dados de alimentos reais e um desabafo sobre assinaturas de apps de fitness que estou guardando há dois anos"}},"next":{"excerpt":"Uma das side quests mais chatas de publicar um app na Google…","html":"<p>Uma das side quests mais chatas de publicar um app na Google Play Store é que o Google às vezes decide que você também virou dono de site.</p>\n<p>Lá em 2024, quando eu <a href=\"/pt-br/blog/coding/musclog-aproveitando-minha-experiencia-com-reactjs-para-criar-um-app-em-react-native/\">escrevi pela primeira vez sobre o Musclog</a>, o site existia basicamente porque o Google queria que ele existisse. Eu precisava da política de privacidade, das páginas públicas, do kit básico do desenvolvedor de app respeitável, então subi rapidinho um site pequeno em Next.js, consegui aprovar o app, e segui com a minha vida.</p>\n<p>Funcionou perfeitamente até o dia em que eu precisei mexer no site de novo.</p>\n<p>Screenshots novas? Outro repo. Texto de feature nova? Outro repo. Atualização de página legal? Outro repo. Ajustezinho de tradução? Outro repo.</p>\n<p>Nada disso era difícil de verdade, o que quase piorava a situação. Era só chato o suficiente pra continuar me lembrando que eu tinha dividido um produto em duas bases de código por um motivo que eu já não respeitava mais.</p>\n<p>Então agora eu não faço mais isso.</p>\n<p>O site do Musclog mora dentro do repo do app. Mesmo projeto com Expo Router. Mesmo deploy. <a href=\"https://musclog.app/\" target=\"_blank\" rel=\"noreferrer\">musclog.app</a> é o site público, <a href=\"https://musclog.app/app\" target=\"_blank\" rel=\"noreferrer\">musclog.app/app</a> é o app web de verdade, e Android + iOS continuam saindo da mesma base de código.</p>\n<p>Boa prática? Questionável. Conveniente? Demais.</p>\n<p><figure class=\"gatsby-resp-image-figure\" style=\"\">\n    <span\n      class=\"gatsby-resp-image-wrapper\"\n      style=\"position: relative; display: block; margin-left: auto; margin-right: auto; max-width: 1024px; \"\n    >\n      <span\n    class=\"gatsby-resp-image-background-image\"\n    style=\"padding-bottom: 61.71875%; position: relative; bottom: 0; left: 0; background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAMCAIAAADtbgqsAAAACXBIWXMAAAsTAAALEwEAmpwYAAAB0ElEQVQoz2WRTW7bMBCF1YVjcSiL4j8pkbRkxQri2G4c20mzyC5IWyDdNAVa9Bg9Q7e9StGz9EQFZcVNUeDbzOA9vuFMkjIS4YQEgwVDrECHTg8SNDMCK4E3Dl9XEy0mTqeKprxIGUlAUJAUBB3l2QnNxyxHfTmgOJQyDZKvVvrtx1fvb9C6wV6B5SBZctSdPV6fPWxn3dw4N1GibzJQDDs9qsn89uv+x+/k13d43OFKQiWfzbzAkq0/v1t8ul89PdC7dWo48H4ixcAp8Ao+vB7//DK+36XLJmstlBLUMZnTfNmQN4ti09GbBa4UiGIwVxJ7nd2d4m/7UefR3GWnJVgBkg5mJGjpvbHW1VMm46ugOdYcDMelhKCKrhbL9eiihc5ltQbD/5ohblWClVAZmFY4GOxlJMgsqCyoSaPRbX2y9RBU5iVoBuKFOWIkrh1uA24qPDNZayMzndUKnCCdy9sSLOtj/zdLBkaAldhKsBz1wAHDkKLximo4bTQjyY5AzwkjZjY9314udpuL3dX51aVqwpgX8EJ5IIkzPIM0Q7oP15w4w0LJQplXuk/msT8w6JO4dBtrMve8DcRbpNnEKeotMSrXEktG9jWZlv2P/uEPhuJAcZJWdDcAAAAASUVORK5CYII='); background-size: cover; display: block;\"\n  ></span>\n  <img\n        class=\"gatsby-resp-image-image\"\n        alt=\"Site público do Musclog renderizado pela mesma base de código com Expo Router do app\"\n        title=\"Site público do Musclog renderizado pela mesma base de código com Expo Router do app\"\n        src=\"/static/f71a459f3034e47544de2f3ce309dd61/42a19/musclog-shared-router-website-home-1.png\"\n        srcset=\"/static/f71a459f3034e47544de2f3ce309dd61/e3135/musclog-shared-router-website-home-1.png 256w,\n/static/f71a459f3034e47544de2f3ce309dd61/06341/musclog-shared-router-website-home-1.png 512w,\n/static/f71a459f3034e47544de2f3ce309dd61/42a19/musclog-shared-router-website-home-1.png 1024w,\n/static/f71a459f3034e47544de2f3ce309dd61/e8464/musclog-shared-router-website-home-1.png 1536w,\n/static/f71a459f3034e47544de2f3ce309dd61/2eb59/musclog-shared-router-website-home-1.png 2048w,\n/static/f71a459f3034e47544de2f3ce309dd61/d7fb2/musclog-shared-router-website-home-1.png 2184w\"\n        sizes=\"(max-width: 1024px) 100vw, 1024px\"\n        style=\"width:100%;height:100%;margin:0;vertical-align:middle;position:absolute;top:0;left:0;\"\n        loading=\"lazy\"\n        decoding=\"async\"\n      />\n    </span>\n    <figcaption class=\"gatsby-resp-image-figcaption\">Site público do Musclog renderizado pela mesma base de código com Expo Router do app</figcaption>\n  </figure></p>\n<h2 id=\"ok-mas-por-que\" style=\"position:relative;\"><a href=\"#ok-mas-por-que\" aria-label=\"ok mas por que permalink\" class=\"post-headers-link before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Ok mas por quê?</h2>\n<p>Basicamente porque o Expo Router arrancou a minha última desculpa.</p>\n<p>O Musclog já usava <a href=\"https://expo.github.io/router/docs\" target=\"_blank\" rel=\"noreferrer\">Expo Router</a>, então o app já estava organizado em rotas baseadas em arquivos dentro da pasta <code class=\"language-text\">app/</code>. Aí eu percebi que podia simplesmente criar um grupo de rotas chamado <code class=\"language-text\">(website)</code>, mover as páginas públicas pra lá, deixar o app de verdade em <code class=\"language-text\">app/app/*</code>, e parar de fingir que eu estava lidando com dois produtos diferentes.</p>\n<p>O repo separado tinha virado uma daquelas decisões de arquitetura que parecem limpas na teoria e depois ficam te cobrando imposto de manutenção pra sempre:</p>\n<ul>\n<li>dois PRs pra uma mudança num produto</li>\n<li>dois deploys</li>\n<li>dois lugares pras traduções</li>\n<li>dois lugares pras páginas legais</li>\n<li>dois lugares pra esquecer alguma coisa</li>\n</ul>\n<p>Sem condições.</p>\n<h2 id=\"a-divisao\" style=\"position:relative;\"><a href=\"#a-divisao\" aria-label=\"a divisao permalink\" class=\"post-headers-link before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>A divisão</h2>\n<p>A parte engraçada é que o roteamento em si acabou sendo a parte menos amaldiçoada dessa história:</p>\n<ul>\n<li><code class=\"language-text\">app/app/*</code> é o Musclog de verdade. Treinos, nutrição, coach com IA, tudo.</li>\n<li><code class=\"language-text\">app/(website)/*</code> é o site público. Landing page, política de privacidade, termos, contato, calculadora.</li>\n</ul>\n<p>A rota raiz só checa a plataforma e te joga pra onde você pertence:</p>\n<div class=\"gatsby-highlight\" data-language=\"tsx\"><pre class=\"language-tsx\"><code class=\"language-tsx\"><span class=\"token comment\">// app/index.tsx</span>\r\n<span class=\"token keyword\">import</span> <span class=\"token punctuation\">{</span> Redirect<span class=\"token punctuation\">,</span> useRouter <span class=\"token punctuation\">}</span> <span class=\"token keyword\">from</span> <span class=\"token string\">'expo-router'</span><span class=\"token punctuation\">;</span>\r\n<span class=\"token keyword\">import</span> <span class=\"token punctuation\">{</span> useEffect <span class=\"token punctuation\">}</span> <span class=\"token keyword\">from</span> <span class=\"token string\">'react'</span><span class=\"token punctuation\">;</span>\r\n<span class=\"token keyword\">import</span> <span class=\"token punctuation\">{</span> Platform <span class=\"token punctuation\">}</span> <span class=\"token keyword\">from</span> <span class=\"token string\">'react-native'</span><span class=\"token punctuation\">;</span>\r\n\r\n<span class=\"token keyword\">export</span> <span class=\"token keyword\">default</span> <span class=\"token keyword\">function</span> <span class=\"token function\">Index</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\r\n  <span class=\"token keyword\">const</span> router <span class=\"token operator\">=</span> <span class=\"token function\">useRouter</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n\r\n  <span class=\"token function\">useEffect</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">=></span> <span class=\"token punctuation\">{</span>\r\n    <span class=\"token keyword\">if</span> <span class=\"token punctuation\">(</span>Platform<span class=\"token punctuation\">.</span><span class=\"token constant\">OS</span> <span class=\"token operator\">===</span> <span class=\"token string\">'web'</span><span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\r\n      router<span class=\"token punctuation\">.</span><span class=\"token function\">replace</span><span class=\"token punctuation\">(</span><span class=\"token string\">'/home'</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n    <span class=\"token punctuation\">}</span>\r\n  <span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span> <span class=\"token punctuation\">[</span>router<span class=\"token punctuation\">]</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n\r\n  <span class=\"token keyword\">if</span> <span class=\"token punctuation\">(</span>Platform<span class=\"token punctuation\">.</span><span class=\"token constant\">OS</span> <span class=\"token operator\">===</span> <span class=\"token string\">'web'</span><span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\r\n    <span class=\"token keyword\">return</span> <span class=\"token keyword\">null</span><span class=\"token punctuation\">;</span>\r\n  <span class=\"token punctuation\">}</span>\r\n\r\n  <span class=\"token keyword\">return</span> <span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span><span class=\"token class-name\">Redirect</span></span> <span class=\"token attr-name\">href</span><span class=\"token attr-value\"><span class=\"token punctuation attr-equals\">=</span><span class=\"token punctuation\">\"</span>/app<span class=\"token punctuation\">\"</span></span> <span class=\"token punctuation\">/></span></span><span class=\"token punctuation\">;</span>\r\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>É isso. No web, <code class=\"language-text\">/</code> vira o site. No native, <code class=\"language-text\">/</code> vira o app.</p>\n<p>O site também ganha o próprio layout mais leve:</p>\n<div class=\"gatsby-highlight\" data-language=\"tsx\"><pre class=\"language-tsx\"><code class=\"language-tsx\"><span class=\"token comment\">// app/(website)/_layout.web.tsx</span>\r\n<span class=\"token keyword\">import</span> <span class=\"token punctuation\">{</span> Slot <span class=\"token punctuation\">}</span> <span class=\"token keyword\">from</span> <span class=\"token string\">'expo-router'</span><span class=\"token punctuation\">;</span>\r\n\r\n<span class=\"token keyword\">import</span> <span class=\"token punctuation\">{</span> WebsiteChrome <span class=\"token punctuation\">}</span> <span class=\"token keyword\">from</span> <span class=\"token string\">'@/components/website/WebsiteChrome'</span><span class=\"token punctuation\">;</span>\r\n<span class=\"token keyword\">import</span> <span class=\"token punctuation\">{</span> WebsiteProviders <span class=\"token punctuation\">}</span> <span class=\"token keyword\">from</span> <span class=\"token string\">'@/components/website/WebsiteProviders'</span><span class=\"token punctuation\">;</span>\r\n\r\n<span class=\"token keyword\">export</span> <span class=\"token keyword\">default</span> <span class=\"token keyword\">function</span> <span class=\"token function\">WebsiteLayout</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\r\n  <span class=\"token keyword\">return</span> <span class=\"token punctuation\">(</span>\r\n    <span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span><span class=\"token class-name\">WebsiteProviders</span></span><span class=\"token punctuation\">></span></span><span class=\"token plain-text\">\r\n      </span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span><span class=\"token class-name\">WebsiteChrome</span></span><span class=\"token punctuation\">></span></span><span class=\"token plain-text\">\r\n        </span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span><span class=\"token class-name\">Slot</span></span> <span class=\"token punctuation\">/></span></span><span class=\"token plain-text\">\r\n      </span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;/</span><span class=\"token class-name\">WebsiteChrome</span></span><span class=\"token punctuation\">></span></span><span class=\"token plain-text\">\r\n    </span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;/</span><span class=\"token class-name\">WebsiteProviders</span></span><span class=\"token punctuation\">></span></span>\r\n  <span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>Isso importa porque o layout do app de verdade já tinha crescido e virado um layout de app mesmo. Boot de banco, migrações, React Query, safe area, gestos, modais, snackbars, câmera, contexto do coach, todas aquelas mentiras clássicas de “é só um projetinho paralelo”. O site não precisa de nada disso só pra explicar o que o app faz.</p>\n<p>E se algum usuário nativo cair numa rota exclusiva do site, a correção é maravilhosamente direta:</p>\n<div class=\"gatsby-highlight\" data-language=\"tsx\"><pre class=\"language-tsx\"><code class=\"language-tsx\"><span class=\"token comment\">// app/(website)/home.tsx</span>\r\n<span class=\"token keyword\">import</span> <span class=\"token punctuation\">{</span> Redirect <span class=\"token punctuation\">}</span> <span class=\"token keyword\">from</span> <span class=\"token string\">'expo-router'</span><span class=\"token punctuation\">;</span>\r\n\r\n<span class=\"token keyword\">export</span> <span class=\"token keyword\">default</span> <span class=\"token keyword\">function</span> <span class=\"token function\">Home</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\r\n  <span class=\"token keyword\">return</span> <span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span><span class=\"token class-name\">Redirect</span></span> <span class=\"token attr-name\">href</span><span class=\"token attr-value\"><span class=\"token punctuation attr-equals\">=</span><span class=\"token punctuation\">\"</span>/app<span class=\"token punctuation\">\"</span></span> <span class=\"token punctuation\">/></span></span><span class=\"token punctuation\">;</span>\r\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>Pronto. Volta pros macros.</p>\n<p><figure class=\"gatsby-resp-image-figure\" style=\"\">\n    <span\n      class=\"gatsby-resp-image-wrapper\"\n      style=\"position: relative; display: block; margin-left: auto; margin-right: auto; max-width: 1024px; \"\n    >\n      <span\n    class=\"gatsby-resp-image-background-image\"\n    style=\"padding-bottom: 61.71875%; position: relative; bottom: 0; left: 0; background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAMCAIAAADtbgqsAAAACXBIWXMAAAsTAAALEwEAmpwYAAABt0lEQVQoz02SWW/jMAyE/RaLdqyLkiXfpxznstOi6S6w//93LZS4aYB5IuYDhkMGO554iWRfpSBZyJNQUoIslDREClrEBkHweLbxRxkpGWuxITwJQIuniGSgOaQcjOyW03Rfp++bu6/ua2mWmShOOCWaE8Uh3ZAAUvlUlKvIyCjDqFBxY5gr+VyzU8Omcl+bqNRRoSKDkUUwGxKARbBIrMwvU309qKHSc9v9XextEktb/LvUf67q2KlD236es0MvuwIyfFIbDBaxr/RY41iLsTTXIb05eevxo09vTp97PhQ4VGqoRJu/kMBneAinWg0VqywfC7M6PHf83OivSS8DHwoxFubUyyanpXkhW2FEczM0xTyqrsSuqNdjvjicG3t15XpIp1a2RXV02dSJOnt1HBBkT4XICE9AcaJFiAwUi6wkRhAtIBVEiVBSEA/DDxIQyV6CPvcpWAKlhrnyQ0EhV9udUkmm8t3/CwNyc3GsMDu6T+pMXR2RLBQU+4pm6Y4lsUU8D0S8wz8ZfHKeEP9e3GOc+mdI/Qp+ovyQsOTd//th8NjtKWJkUlt3X6bvlTaW+G7fDD+F/QccPj1gi9mJrAAAAABJRU5ErkJggg=='); background-size: cover; display: block;\"\n  ></span>\n  <img\n        class=\"gatsby-resp-image-image\"\n        alt=\"Site público do Musclog renderizado pela mesma base de código com Expo Router do app\"\n        title=\"Site público do Musclog renderizado pela mesma base de código com Expo Router do app\"\n        src=\"/static/c50b08ecc21b0e02ed7774578063a5f6/42a19/musclog-shared-router-website-home-2.png\"\n        srcset=\"/static/c50b08ecc21b0e02ed7774578063a5f6/e3135/musclog-shared-router-website-home-2.png 256w,\n/static/c50b08ecc21b0e02ed7774578063a5f6/06341/musclog-shared-router-website-home-2.png 512w,\n/static/c50b08ecc21b0e02ed7774578063a5f6/42a19/musclog-shared-router-website-home-2.png 1024w,\n/static/c50b08ecc21b0e02ed7774578063a5f6/e8464/musclog-shared-router-website-home-2.png 1536w,\n/static/c50b08ecc21b0e02ed7774578063a5f6/2eb59/musclog-shared-router-website-home-2.png 2048w,\n/static/c50b08ecc21b0e02ed7774578063a5f6/d7fb2/musclog-shared-router-website-home-2.png 2184w\"\n        sizes=\"(max-width: 1024px) 100vw, 1024px\"\n        style=\"width:100%;height:100%;margin:0;vertical-align:middle;position:absolute;top:0;left:0;\"\n        loading=\"lazy\"\n        decoding=\"async\"\n      />\n    </span>\n    <figcaption class=\"gatsby-resp-image-figcaption\">Site público do Musclog renderizado pela mesma base de código com Expo Router do app</figcaption>\n  </figure></p>\n<h2 id=\"a-palhacada-do-celular-no-desktop\" style=\"position:relative;\"><a href=\"#a-palhacada-do-celular-no-desktop\" aria-label=\"a palhacada do celular no desktop permalink\" class=\"post-headers-link before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>A palhaçada do celular no desktop</h2>\n<p>Foi aqui que eu parei de me comportar como uma pessoa normal.</p>\n<p>Eu queria que <a href=\"https://musclog.app/app/\" target=\"_blank\" rel=\"noreferrer\">musclog.app/app</a>, no desktop, mostrasse o app funcionando dentro de uma moldura de celular. Um pouco porque fica bonito. Um pouco porque funciona como preview do produto. Mas principalmente porque, depois que a ideia entrou na minha cabeça, qualquer opção menos ridícula começou a parecer errada.</p>\n<p>O detalhe chato era que <code class=\"language-text\">/app</code> precisava parecer um celular, mas <code class=\"language-text\">/home</code> definitivamente não. Então só o roteamento não bastava. Eu precisava que o shell bruto de HTML soubesse, antes da hidratação, se o navegador deveria renderizar a gambiarra do celular fake ou não.</p>\n<p>Essa lógica mora em <code class=\"language-text\">app/+html.tsx</code>, dentro de um script no <code class=\"language-text\">&lt;head></code> do documento:</p>\n<div class=\"gatsby-highlight\" data-language=\"tsx\"><pre class=\"language-tsx\"><code class=\"language-tsx\"><span class=\"token keyword\">function</span> <span class=\"token function\">landingPanelGate</span><span class=\"token punctuation\">(</span>base<span class=\"token operator\">:</span> <span class=\"token builtin\">string</span><span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\r\n  <span class=\"token keyword\">try</span> <span class=\"token punctuation\">{</span>\r\n    <span class=\"token keyword\">function</span> <span class=\"token function\">update</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\r\n      <span class=\"token keyword\">const</span> raw <span class=\"token operator\">=</span> window<span class=\"token punctuation\">.</span>location<span class=\"token punctuation\">.</span>pathname<span class=\"token punctuation\">;</span>\r\n      <span class=\"token keyword\">const</span> path <span class=\"token operator\">=</span> <span class=\"token punctuation\">(</span>base <span class=\"token operator\">&amp;&amp;</span> raw<span class=\"token punctuation\">.</span><span class=\"token function\">startsWith</span><span class=\"token punctuation\">(</span>base<span class=\"token punctuation\">)</span> <span class=\"token operator\">?</span> raw<span class=\"token punctuation\">.</span><span class=\"token function\">slice</span><span class=\"token punctuation\">(</span>base<span class=\"token punctuation\">.</span>length<span class=\"token punctuation\">)</span> <span class=\"token operator\">:</span> raw<span class=\"token punctuation\">)</span> <span class=\"token operator\">||</span> <span class=\"token string\">'/'</span><span class=\"token punctuation\">;</span>\r\n\r\n      <span class=\"token keyword\">if</span> <span class=\"token punctuation\">(</span><span class=\"token operator\">!</span>path<span class=\"token punctuation\">.</span><span class=\"token function\">startsWith</span><span class=\"token punctuation\">(</span><span class=\"token string\">'/app'</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\r\n        document<span class=\"token punctuation\">.</span>documentElement<span class=\"token punctuation\">.</span>classList<span class=\"token punctuation\">.</span><span class=\"token function\">add</span><span class=\"token punctuation\">(</span><span class=\"token string\">'hide-desktop-wrapper'</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n      <span class=\"token punctuation\">}</span> <span class=\"token keyword\">else</span> <span class=\"token punctuation\">{</span>\r\n        document<span class=\"token punctuation\">.</span>documentElement<span class=\"token punctuation\">.</span>classList<span class=\"token punctuation\">.</span><span class=\"token function\">remove</span><span class=\"token punctuation\">(</span><span class=\"token string\">'hide-desktop-wrapper'</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n      <span class=\"token punctuation\">}</span>\r\n    <span class=\"token punctuation\">}</span>\r\n\r\n    <span class=\"token function\">update</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n    window<span class=\"token punctuation\">.</span><span class=\"token function\">addEventListener</span><span class=\"token punctuation\">(</span><span class=\"token string\">'popstate'</span><span class=\"token punctuation\">,</span> update<span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n\r\n    <span class=\"token keyword\">const</span> origPush <span class=\"token operator\">=</span> history<span class=\"token punctuation\">.</span><span class=\"token function\">pushState</span><span class=\"token punctuation\">.</span><span class=\"token function\">bind</span><span class=\"token punctuation\">(</span>history<span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n    history<span class=\"token punctuation\">.</span><span class=\"token function-variable function\">pushState</span> <span class=\"token operator\">=</span> <span class=\"token keyword\">function</span> <span class=\"token punctuation\">(</span><span class=\"token operator\">...</span>args<span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\r\n      <span class=\"token function\">origPush</span><span class=\"token punctuation\">(</span><span class=\"token operator\">...</span>args<span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n      <span class=\"token function\">update</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n    <span class=\"token punctuation\">}</span><span class=\"token punctuation\">;</span>\r\n\r\n    <span class=\"token keyword\">const</span> origReplace <span class=\"token operator\">=</span> history<span class=\"token punctuation\">.</span><span class=\"token function\">replaceState</span><span class=\"token punctuation\">.</span><span class=\"token function\">bind</span><span class=\"token punctuation\">(</span>history<span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n    history<span class=\"token punctuation\">.</span><span class=\"token function-variable function\">replaceState</span> <span class=\"token operator\">=</span> <span class=\"token keyword\">function</span> <span class=\"token punctuation\">(</span><span class=\"token operator\">...</span>args<span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\r\n      <span class=\"token function\">origReplace</span><span class=\"token punctuation\">(</span><span class=\"token operator\">...</span>args<span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n      <span class=\"token function\">update</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n    <span class=\"token punctuation\">}</span><span class=\"token punctuation\">;</span>\r\n  <span class=\"token punctuation\">}</span> <span class=\"token keyword\">catch</span> <span class=\"token punctuation\">(</span>_<span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span><span class=\"token punctuation\">}</span>\r\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>A parte importante é a gambiarra em <code class=\"language-text\">pushState</code> / <code class=\"language-text\">replaceState</code>. A primeira renderização foi tranquila. A navegação client-side é que era a parte chata. Sem isso, dava pra sair de <code class=\"language-text\">/app</code> e continuar com o shell errado ali, como se o Chrome tivesse esquecido em que página estava.</p>\n<p>O shell HTML em si é basicamente o painel da landing, o app roteado, e a moldura do celular:</p>\n<div class=\"gatsby-highlight\" data-language=\"tsx\"><pre class=\"language-tsx\"><code class=\"language-tsx\"><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span>body</span> <span class=\"token attr-name\">className</span><span class=\"token attr-value\"><span class=\"token punctuation attr-equals\">=</span><span class=\"token punctuation\">\"</span>expo-web-body<span class=\"token punctuation\">\"</span></span><span class=\"token punctuation\">></span></span><span class=\"token plain-text\">\r\n  </span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span>div</span> <span class=\"token attr-name\">className</span><span class=\"token attr-value\"><span class=\"token punctuation attr-equals\">=</span><span class=\"token punctuation\">\"</span>expo-web-landing<span class=\"token punctuation\">\"</span></span><span class=\"token punctuation\">></span></span><span class=\"token plain-text\">...</span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;/</span>div</span><span class=\"token punctuation\">></span></span><span class=\"token plain-text\">\r\n  </span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span>script</span> <span class=\"token attr-name\">dangerouslySetInnerHTML</span><span class=\"token script language-javascript\"><span class=\"token script-punctuation punctuation\">=</span><span class=\"token punctuation\">{</span><span class=\"token punctuation\">{</span> __html<span class=\"token operator\">:</span> <span class=\"token constant\">LANDING_I18N_SCRIPT</span> <span class=\"token punctuation\">}</span><span class=\"token punctuation\">}</span></span> <span class=\"token punctuation\">/></span></span><span class=\"token plain-text\">\r\n  </span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span>div</span> <span class=\"token attr-name\">className</span><span class=\"token attr-value\"><span class=\"token punctuation attr-equals\">=</span><span class=\"token punctuation\">\"</span>expo-web-root<span class=\"token punctuation\">\"</span></span><span class=\"token punctuation\">></span></span><span class=\"token plain-text\">\r\n    </span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span>div</span> <span class=\"token attr-name\">className</span><span class=\"token attr-value\"><span class=\"token punctuation attr-equals\">=</span><span class=\"token punctuation\">\"</span>expo-web-app-shell<span class=\"token punctuation\">\"</span></span><span class=\"token punctuation\">></span></span><span class=\"token punctuation\">{</span>children<span class=\"token punctuation\">}</span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;/</span>div</span><span class=\"token punctuation\">></span></span><span class=\"token plain-text\">\r\n    </span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span>img</span>\r\n      <span class=\"token attr-name\">className</span><span class=\"token attr-value\"><span class=\"token punctuation attr-equals\">=</span><span class=\"token punctuation\">\"</span>expo-web-phone-frame<span class=\"token punctuation\">\"</span></span>\r\n      <span class=\"token attr-name\">src</span><span class=\"token script language-javascript\"><span class=\"token script-punctuation punctuation\">=</span><span class=\"token punctuation\">{</span><span class=\"token function\">withExpoBaseUrl</span><span class=\"token punctuation\">(</span><span class=\"token constant\">PHONE_FRAME_SRC</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">}</span></span>\r\n      <span class=\"token attr-name\">alt</span><span class=\"token attr-value\"><span class=\"token punctuation attr-equals\">=</span><span class=\"token punctuation\">\"</span><span class=\"token punctuation\">\"</span></span>\r\n      <span class=\"token attr-name\">aria-hidden</span>\r\n    <span class=\"token punctuation\">/></span></span><span class=\"token plain-text\">\r\n  </span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;/</span>div</span><span class=\"token punctuation\">></span></span><span class=\"token plain-text\">\r\n</span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;/</span>body</span><span class=\"token punctuation\">></span></span></code></pre></div>\n<p>Aí o CSS comete o crime:</p>\n<div class=\"gatsby-highlight\" data-language=\"css\"><pre class=\"language-css\"><code class=\"language-css\"><span class=\"token atrule\"><span class=\"token rule\">@media</span> <span class=\"token punctuation\">(</span><span class=\"token property\">min-width</span><span class=\"token punctuation\">:</span> 1024px<span class=\"token punctuation\">)</span></span> <span class=\"token punctuation\">{</span>\r\n  <span class=\"token selector\">.expo-web-root</span> <span class=\"token punctuation\">{</span>\r\n    <span class=\"token property\">--frame-h</span><span class=\"token punctuation\">:</span> <span class=\"token function\">min</span><span class=\"token punctuation\">(</span>100dvh<span class=\"token punctuation\">,</span> <span class=\"token function\">max</span><span class=\"token punctuation\">(</span><span class=\"token function\">min</span><span class=\"token punctuation\">(</span>360px<span class=\"token punctuation\">,</span> 100dvh<span class=\"token punctuation\">)</span><span class=\"token punctuation\">,</span> 85dvh<span class=\"token punctuation\">)</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n    <span class=\"token property\">aspect-ratio</span><span class=\"token punctuation\">:</span> 1438 / 2976<span class=\"token punctuation\">;</span>\r\n    <span class=\"token property\">width</span><span class=\"token punctuation\">:</span> <span class=\"token function\">min</span><span class=\"token punctuation\">(</span>100vw<span class=\"token punctuation\">,</span> <span class=\"token function\">calc</span><span class=\"token punctuation\">(</span><span class=\"token function\">var</span><span class=\"token punctuation\">(</span>--frame-h<span class=\"token punctuation\">)</span> * 1438 / 2976<span class=\"token punctuation\">)</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n    <span class=\"token property\">max-height</span><span class=\"token punctuation\">:</span> <span class=\"token function\">var</span><span class=\"token punctuation\">(</span>--frame-h<span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n    <span class=\"token property\">overflow</span><span class=\"token punctuation\">:</span> hidden<span class=\"token punctuation\">;</span>\r\n  <span class=\"token punctuation\">}</span>\r\n\r\n  <span class=\"token selector\">.expo-web-app-shell</span> <span class=\"token punctuation\">{</span>\r\n    <span class=\"token property\">position</span><span class=\"token punctuation\">:</span> absolute<span class=\"token punctuation\">;</span>\r\n    <span class=\"token property\">left</span><span class=\"token punctuation\">:</span> 7.4409%<span class=\"token punctuation\">;</span>\r\n    <span class=\"token property\">top</span><span class=\"token punctuation\">:</span> 2.9906%<span class=\"token punctuation\">;</span>\r\n    <span class=\"token property\">right</span><span class=\"token punctuation\">:</span> 6.3282%<span class=\"token punctuation\">;</span>\r\n    <span class=\"token property\">bottom</span><span class=\"token punctuation\">:</span> 3.125%<span class=\"token punctuation\">;</span>\r\n    <span class=\"token property\">zoom</span><span class=\"token punctuation\">:</span> 0.85<span class=\"token punctuation\">;</span>\r\n  <span class=\"token punctuation\">}</span>\r\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>Sim, eu medi a área transparente da tela dentro do PNG até o app encaixar direito. Não tem geometria elegante nenhuma por trás dessas porcentagens. Era só eu, uma moldura de celular, e muito empurra-pra-lá até o app parar de parecer torto.</p>\n<p>Nas rotas que não são <code class=\"language-text\">/app</code>, o espetáculo inteiro é desligado:</p>\n<div class=\"gatsby-highlight\" data-language=\"css\"><pre class=\"language-css\"><code class=\"language-css\"><span class=\"token selector\">.hide-desktop-wrapper .expo-web-landing,\r\n.hide-desktop-wrapper .expo-web-phone-frame</span> <span class=\"token punctuation\">{</span>\r\n  <span class=\"token property\">display</span><span class=\"token punctuation\">:</span> none <span class=\"token important\">!important</span><span class=\"token punctuation\">;</span>\r\n<span class=\"token punctuation\">}</span>\r\n\r\n<span class=\"token selector\">.hide-desktop-wrapper .expo-web-app-shell</span> <span class=\"token punctuation\">{</span>\r\n  <span class=\"token property\">position</span><span class=\"token punctuation\">:</span> static <span class=\"token important\">!important</span><span class=\"token punctuation\">;</span>\r\n  <span class=\"token property\">width</span><span class=\"token punctuation\">:</span> 100% <span class=\"token important\">!important</span><span class=\"token punctuation\">;</span>\r\n  <span class=\"token property\">height</span><span class=\"token punctuation\">:</span> auto <span class=\"token important\">!important</span><span class=\"token punctuation\">;</span>\r\n  <span class=\"token property\">zoom</span><span class=\"token punctuation\">:</span> 1 <span class=\"token important\">!important</span><span class=\"token punctuation\">;</span>\r\n  <span class=\"token property\">overflow</span><span class=\"token punctuation\">:</span> visible <span class=\"token important\">!important</span><span class=\"token punctuation\">;</span>\r\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>Sim, usa <code class=\"language-text\">!important</code>. Isso é CSS de pré-hidratação cujo trabalho inteiro é manter a mentira de pé. Não estamos fazendo arquitetura refinada nessa camada.</p>\n<p><figure class=\"gatsby-resp-image-figure\" style=\"\">\n    <span\n      class=\"gatsby-resp-image-wrapper\"\n      style=\"position: relative; display: block; margin-left: auto; margin-right: auto; max-width: 1024px; \"\n    >\n      <span\n    class=\"gatsby-resp-image-background-image\"\n    style=\"padding-bottom: 61.71875%; position: relative; bottom: 0; left: 0; background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAMCAIAAADtbgqsAAAACXBIWXMAAAsTAAALEwEAmpwYAAABS0lEQVQoz42SyUoDQRCGgxOTrq7pfbbuZBKTmTEwWSAgKKLghtvBQy6CHs3Nk94UwZPP4Wv4DD6VqBdhION3Kor6qL+gGrQCfvNbER/bApEDFehXJxvVFhACAEDandHYTbcxG9BJHpQZZwwAVskAoIzWJvTWGvunrwfPn+uPD633F/V0IynC6s0AECZRFMWEeMX4bHR8z04O/dtzc7EjfQYUamIro6WSCDQunN0aJoU1iQptKKWsiU0plVoro6Hhda+Pyo+3dHkpho4PEsF5vYyUEiC0Tewk71/tmVnGXShcyFfLiNhsNufz+XJ5Z7TWUWDTDguUH2kZB/Vyq9Uqy3KxWGilgtT1Z6XNM512w15XiLrYiEgI8TyP+X6Q2rgY2s1cpd14oyel+MfNP/iIUWp13yW7UzPoxH3Hap/kL4wxLjgXXAjBOa++5xcHAzI9yvyvqQAAAABJRU5ErkJggg=='); background-size: cover; display: block;\"\n  ></span>\n  <img\n        class=\"gatsby-resp-image-image\"\n        alt=\"App web do Musclog no desktop renderizado dentro de uma moldura de celular ao lado da landing\"\n        title=\"App web do Musclog no desktop renderizado dentro de uma moldura de celular ao lado da landing\"\n        src=\"/static/49361b3283ae34ef30a950b13c7f5594/42a19/musclog-desktop-phone-frame-wrapper.png\"\n        srcset=\"/static/49361b3283ae34ef30a950b13c7f5594/e3135/musclog-desktop-phone-frame-wrapper.png 256w,\n/static/49361b3283ae34ef30a950b13c7f5594/06341/musclog-desktop-phone-frame-wrapper.png 512w,\n/static/49361b3283ae34ef30a950b13c7f5594/42a19/musclog-desktop-phone-frame-wrapper.png 1024w,\n/static/49361b3283ae34ef30a950b13c7f5594/e8464/musclog-desktop-phone-frame-wrapper.png 1536w,\n/static/49361b3283ae34ef30a950b13c7f5594/2eb59/musclog-desktop-phone-frame-wrapper.png 2048w,\n/static/49361b3283ae34ef30a950b13c7f5594/d7fb2/musclog-desktop-phone-frame-wrapper.png 2184w\"\n        sizes=\"(max-width: 1024px) 100vw, 1024px\"\n        style=\"width:100%;height:100%;margin:0;vertical-align:middle;position:absolute;top:0;left:0;\"\n        loading=\"lazy\"\n        decoding=\"async\"\n      />\n    </span>\n    <figcaption class=\"gatsby-resp-image-figcaption\">App web do Musclog no desktop renderizado dentro de uma moldura de celular ao lado da landing</figcaption>\n  </figure></p>\n<h2 id=\"tres-pequenos-crimes\" style=\"position:relative;\"><a href=\"#tres-pequenos-crimes\" aria-label=\"tres pequenos crimes permalink\" class=\"post-headers-link before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Três pequenos crimes</h2>\n<p><img src=\"/377ac4354ce553fccf98c5d8d1017e50/straight-to-jail.gif\" alt=\"Direto pra cadeia. Na hora.\"></p>\n<p>Depois que o shell funcionou, os incômodos menores começaram a aparecer como se tivessem marcado horário.</p>\n<h3 id=\"1-o-painel-da-landing-precisava-de-i18n-antes-da-hidratacao\" style=\"position:relative;\"><a href=\"#1-o-painel-da-landing-precisava-de-i18n-antes-da-hidratacao\" aria-label=\"1 o painel da landing precisava de i18n antes da hidratacao permalink\" class=\"post-headers-link before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>1. O painel da landing precisava de i18n antes da hidratação</h3>\n<p>O texto do lado do celular mora em HTML cru, o que significa que React e i18n ainda estão dormindo quando a página renderiza pela primeira vez. Então, se eu quisesse que a página em português não desse um flash de inglês antes, eu tinha que remendar isso manualmente a partir do <code class=\"language-text\">localStorage</code>:</p>\n<div class=\"gatsby-highlight\" data-language=\"tsx\"><pre class=\"language-tsx\"><code class=\"language-tsx\"><span class=\"token keyword\">function</span> <span class=\"token function\">landingI18nPatcher</span><span class=\"token punctuation\">(</span>translations<span class=\"token punctuation\">,</span> storageKey<span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\r\n  <span class=\"token keyword\">try</span> <span class=\"token punctuation\">{</span>\r\n    <span class=\"token keyword\">let</span> lang <span class=\"token operator\">=</span> localStorage<span class=\"token punctuation\">.</span><span class=\"token function\">getItem</span><span class=\"token punctuation\">(</span>storageKey<span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n    <span class=\"token keyword\">let</span> s <span class=\"token operator\">=</span> <span class=\"token punctuation\">(</span>lang <span class=\"token operator\">&amp;&amp;</span> translations<span class=\"token punctuation\">[</span>lang<span class=\"token punctuation\">]</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">||</span> translations<span class=\"token punctuation\">[</span><span class=\"token string\">'en-US'</span><span class=\"token punctuation\">]</span><span class=\"token punctuation\">;</span>\r\n\r\n    document<span class=\"token punctuation\">.</span><span class=\"token function\">querySelectorAll</span><span class=\"token punctuation\">(</span><span class=\"token string\">'[data-landing-i18n]'</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">.</span><span class=\"token function\">forEach</span><span class=\"token punctuation\">(</span><span class=\"token keyword\">function</span> <span class=\"token punctuation\">(</span>el<span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\r\n      <span class=\"token keyword\">let</span> k <span class=\"token operator\">=</span> el<span class=\"token punctuation\">.</span><span class=\"token function\">getAttribute</span><span class=\"token punctuation\">(</span><span class=\"token string\">'data-landing-i18n'</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n      <span class=\"token keyword\">if</span> <span class=\"token punctuation\">(</span>k <span class=\"token operator\">&amp;&amp;</span> s<span class=\"token punctuation\">[</span>k<span class=\"token punctuation\">]</span><span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\r\n        el<span class=\"token punctuation\">.</span>textContent <span class=\"token operator\">=</span> s<span class=\"token punctuation\">[</span>k<span class=\"token punctuation\">]</span><span class=\"token punctuation\">;</span>\r\n      <span class=\"token punctuation\">}</span>\r\n    <span class=\"token punctuation\">}</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n  <span class=\"token punctuation\">}</span> <span class=\"token keyword\">catch</span> <span class=\"token punctuation\">(</span>_<span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span><span class=\"token punctuation\">}</span>\r\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>Não é elegante. Também evita jogar um flashbang de texto em inglês numa página em português, então eu vou manter.</p>\n<h3 id=\"2-assets-em-html-cru-nao-recebem-a-ajuda-de-sempre-do-expo\" style=\"position:relative;\"><a href=\"#2-assets-em-html-cru-nao-recebem-a-ajuda-de-sempre-do-expo\" aria-label=\"2 assets em html cru nao recebem a ajuda de sempre do expo permalink\" class=\"post-headers-link before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>2. Assets em HTML cru não recebem a ajuda de sempre do Expo</h3>\n<p>O PNG da moldura do celular e o QR code também são referenciados a partir de HTML cru, então aquela conveniência normal de assets do Expo não me ajudava muito ali. Resultado: eu precisei de um helper pra base URL:</p>\n<div class=\"gatsby-highlight\" data-language=\"tsx\"><pre class=\"language-tsx\"><code class=\"language-tsx\"><span class=\"token keyword\">function</span> <span class=\"token function\">withExpoBaseUrl</span><span class=\"token punctuation\">(</span>path<span class=\"token operator\">:</span> <span class=\"token builtin\">string</span><span class=\"token punctuation\">)</span><span class=\"token operator\">:</span> <span class=\"token builtin\">string</span> <span class=\"token punctuation\">{</span>\r\n  <span class=\"token keyword\">const</span> base <span class=\"token operator\">=</span> process<span class=\"token punctuation\">.</span>env<span class=\"token punctuation\">.</span><span class=\"token constant\">EXPO_BASE_URL</span><span class=\"token punctuation\">;</span>\r\n  <span class=\"token keyword\">if</span> <span class=\"token punctuation\">(</span>base <span class=\"token operator\">==</span> <span class=\"token keyword\">null</span> <span class=\"token operator\">||</span> base <span class=\"token operator\">===</span> <span class=\"token string\">''</span><span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\r\n    <span class=\"token keyword\">return</span> path<span class=\"token punctuation\">;</span>\r\n  <span class=\"token punctuation\">}</span>\r\n\r\n  <span class=\"token keyword\">const</span> basePath <span class=\"token operator\">=</span> <span class=\"token function\">String</span><span class=\"token punctuation\">(</span>base<span class=\"token punctuation\">)</span><span class=\"token punctuation\">.</span><span class=\"token function\">replace</span><span class=\"token punctuation\">(</span><span class=\"token regex\"><span class=\"token regex-delimiter\">/</span><span class=\"token regex-source language-regex\">^\\/+|\\/+$</span><span class=\"token regex-delimiter\">/</span><span class=\"token regex-flags\">g</span></span><span class=\"token punctuation\">,</span> <span class=\"token string\">''</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n  <span class=\"token keyword\">const</span> normalized <span class=\"token operator\">=</span> path<span class=\"token punctuation\">.</span><span class=\"token function\">startsWith</span><span class=\"token punctuation\">(</span><span class=\"token string\">'/'</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">?</span> path <span class=\"token operator\">:</span> <span class=\"token template-string\"><span class=\"token template-punctuation string\">`</span><span class=\"token string\">/</span><span class=\"token interpolation\"><span class=\"token interpolation-punctuation punctuation\">${</span>path<span class=\"token interpolation-punctuation punctuation\">}</span></span><span class=\"token template-punctuation string\">`</span></span><span class=\"token punctuation\">;</span>\r\n  <span class=\"token keyword\">return</span> <span class=\"token template-string\"><span class=\"token template-punctuation string\">`</span><span class=\"token string\">/</span><span class=\"token interpolation\"><span class=\"token interpolation-punctuation punctuation\">${</span>basePath<span class=\"token interpolation-punctuation punctuation\">}</span></span><span class=\"token interpolation\"><span class=\"token interpolation-punctuation punctuation\">${</span>normalized<span class=\"token interpolation-punctuation punctuation\">}</span></span><span class=\"token template-punctuation string\">`</span></span><span class=\"token punctuation\">;</span>\r\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>E como esses arquivos vivem fora do pipeline normal de assets do React Native, eu copio tudo pra <code class=\"language-text\">public/</code> antes de rodar dev e export. Esquece esse passo uma vez e o site imediatamente te lembra quem manda.</p>\n<h3 id=\"3-o-expo-export-as-vezes-precisava-de-apoio-emocional\" style=\"position:relative;\"><a href=\"#3-o-expo-export-as-vezes-precisava-de-apoio-emocional\" aria-label=\"3 o expo export as vezes precisava de apoio emocional permalink\" class=\"post-headers-link before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>3. O <code class=\"language-text\">expo export</code> às vezes precisava de apoio emocional</h3>\n<p>Em alguns ambientes, <code class=\"language-text\">expo export --platform web</code> terminava com sucesso e depois só… continuava vivo sem motivo nenhum. Pasta <code class=\"language-text\">dist</code> lá. Arquivos gerados. Processo espiritualmente concluído, tecnicamente ainda pendurado.</p>\n<p>Então agora existe um script wrapper:</p>\n<div class=\"gatsby-highlight\" data-language=\"js\"><pre class=\"language-js\"><code class=\"language-js\"><span class=\"token comment\">// scripts/export-web-wrapper.js</span>\r\n<span class=\"token keyword\">if</span> <span class=\"token punctuation\">(</span>output<span class=\"token punctuation\">.</span><span class=\"token function\">includes</span><span class=\"token punctuation\">(</span><span class=\"token string\">'Exported: dist'</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\r\n  console<span class=\"token punctuation\">.</span><span class=\"token function\">log</span><span class=\"token punctuation\">(</span><span class=\"token string\">'[export-web-wrapper] Detected successful export. Forcing exit in 5s...'</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n  <span class=\"token function\">setTimeout</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">=></span> process<span class=\"token punctuation\">.</span><span class=\"token function\">exit</span><span class=\"token punctuation\">(</span><span class=\"token number\">0</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">,</span> <span class=\"token number\">5000</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>Não é glamouroso… mas resolve.</p>\n<h2 id=\"ai-os-modais-ficaram-esquisitos\" style=\"position:relative;\"><a href=\"#ai-os-modais-ficaram-esquisitos\" aria-label=\"ai os modais ficaram esquisitos permalink\" class=\"post-headers-link before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Aí os modais ficaram esquisitos</h2>\n<p><figure class=\"gatsby-resp-image-figure\" style=\"\">\n    <span\n      class=\"gatsby-resp-image-wrapper\"\n      style=\"position: relative; display: block; margin-left: auto; margin-right: auto; max-width: 1024px; \"\n    >\n      <span\n    class=\"gatsby-resp-image-background-image\"\n    style=\"padding-bottom: 61.71875%; position: relative; bottom: 0; left: 0; background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAMCAIAAADtbgqsAAAACXBIWXMAAAsTAAALEwEAmpwYAAABD0lEQVQoz53SS27CMBCAYUtUIp5x4ldMnAST2E3BcVA3kXqOcgbuf4eKhypoN8DoW4xG+ndDAAARAYDxAoucMnxUjiSj5wFYb4cqeN7Uom0eoXaBKK2lEBTApbRJ+01MF904ddO0HqMbx/UY2xj9lHyK/bi76D4TWdnKGAOIKjj1vhFdK/o7/N/lytVElaUQHBB5dMXQsGBv5aGWW/fnyILFfiXngUitzjEUoSl8XXh70tvLwn2tt4756k6osDNi/iCAmC2XyJj+SnlnWWPy1sBKgrmipfjdb1HJyTzPx+NRac1syWqNlXoQs5pM+/334SCVXIqcavEUkmXZ22IBCM+Wp/j6YYhUvRSfBuGF+AfXMjfjsQh0mgAAAABJRU5ErkJggg=='); background-size: cover; display: block;\"\n  ></span>\n  <img\n        class=\"gatsby-resp-image-image\"\n        alt=\"Modal do Musclog no desktop renderizado corretamente dentro da moldura do celular\"\n        title=\"Modal do Musclog no desktop renderizado corretamente dentro da moldura do celular\"\n        src=\"/static/a2af4c3fcf36eee559fddfb48a5143d1/42a19/musclog-desktop-modal-inside-phone-frame.png\"\n        srcset=\"/static/a2af4c3fcf36eee559fddfb48a5143d1/e3135/musclog-desktop-modal-inside-phone-frame.png 256w,\n/static/a2af4c3fcf36eee559fddfb48a5143d1/06341/musclog-desktop-modal-inside-phone-frame.png 512w,\n/static/a2af4c3fcf36eee559fddfb48a5143d1/42a19/musclog-desktop-modal-inside-phone-frame.png 1024w,\n/static/a2af4c3fcf36eee559fddfb48a5143d1/e8464/musclog-desktop-modal-inside-phone-frame.png 1536w,\n/static/a2af4c3fcf36eee559fddfb48a5143d1/2eb59/musclog-desktop-modal-inside-phone-frame.png 2048w,\n/static/a2af4c3fcf36eee559fddfb48a5143d1/d7fb2/musclog-desktop-modal-inside-phone-frame.png 2184w\"\n        sizes=\"(max-width: 1024px) 100vw, 1024px\"\n        style=\"width:100%;height:100%;margin:0;vertical-align:middle;position:absolute;top:0;left:0;\"\n        loading=\"lazy\"\n        decoding=\"async\"\n      />\n    </span>\n    <figcaption class=\"gatsby-resp-image-figcaption\">Modal do Musclog no desktop renderizado corretamente dentro da moldura do celular</figcaption>\n  </figure></p>\n<p>Claro que ficaram.</p>\n<p>Quando o app web mora dentro de um celular fake no desktop, o comportamento padrão de <code class=\"language-text\">Modal</code> no React Native Web começa a ficar ridículo bem rápido. Fazer portal direto pro <code class=\"language-text\">document.body</code> funciona bem quando o app é dono da página inteira. Funciona bem menos quando o app está visualmente recortado dentro de uma carcaça de celular e o modal decide do nada que agora pertence à janela inteira do navegador.</p>\n<p>Então eu adicionei um <code class=\"language-text\">WebModalShellProvider</code> lá em cima em <code class=\"language-text\">app/app/_layout.tsx</code>, com um host de overlay dentro do shell do celular:</p>\n<div class=\"gatsby-highlight\" data-language=\"tsx\"><pre class=\"language-tsx\"><code class=\"language-tsx\"><span class=\"token comment\">// context/WebModalShellContext.web.tsx</span>\r\n<span class=\"token keyword\">return</span> <span class=\"token punctuation\">(</span>\r\n  <span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span><span class=\"token class-name\">WebModalShellContext.Provider</span></span> <span class=\"token attr-name\">value</span><span class=\"token script language-javascript\"><span class=\"token script-punctuation punctuation\">=</span><span class=\"token punctuation\">{</span><span class=\"token punctuation\">{</span> hostElement <span class=\"token punctuation\">}</span><span class=\"token punctuation\">}</span></span><span class=\"token punctuation\">></span></span><span class=\"token plain-text\">\r\n    </span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span><span class=\"token class-name\">View</span></span> <span class=\"token attr-name\">style</span><span class=\"token script language-javascript\"><span class=\"token script-punctuation punctuation\">=</span><span class=\"token punctuation\">{</span><span class=\"token punctuation\">{</span> flex<span class=\"token operator\">:</span> <span class=\"token number\">1</span><span class=\"token punctuation\">,</span> minHeight<span class=\"token operator\">:</span> <span class=\"token number\">0</span><span class=\"token punctuation\">,</span> height<span class=\"token operator\">:</span> <span class=\"token string\">'100%'</span><span class=\"token punctuation\">,</span> width<span class=\"token operator\">:</span> <span class=\"token string\">'100%'</span><span class=\"token punctuation\">,</span> position<span class=\"token operator\">:</span> <span class=\"token string\">'relative'</span> <span class=\"token punctuation\">}</span><span class=\"token punctuation\">}</span></span><span class=\"token punctuation\">></span></span><span class=\"token plain-text\">\r\n      </span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span><span class=\"token class-name\">View</span></span> <span class=\"token attr-name\">style</span><span class=\"token script language-javascript\"><span class=\"token script-punctuation punctuation\">=</span><span class=\"token punctuation\">{</span><span class=\"token punctuation\">{</span> flex<span class=\"token operator\">:</span> <span class=\"token number\">1</span><span class=\"token punctuation\">,</span> minHeight<span class=\"token operator\">:</span> <span class=\"token number\">0</span><span class=\"token punctuation\">,</span> height<span class=\"token operator\">:</span> <span class=\"token string\">'100%'</span><span class=\"token punctuation\">,</span> width<span class=\"token operator\">:</span> <span class=\"token string\">'100%'</span> <span class=\"token punctuation\">}</span><span class=\"token punctuation\">}</span></span> <span class=\"token attr-name\">collapsable</span><span class=\"token script language-javascript\"><span class=\"token script-punctuation punctuation\">=</span><span class=\"token punctuation\">{</span><span class=\"token boolean\">false</span><span class=\"token punctuation\">}</span></span><span class=\"token punctuation\">></span></span><span class=\"token plain-text\">\r\n        </span><span class=\"token punctuation\">{</span>children<span class=\"token punctuation\">}</span><span class=\"token plain-text\">\r\n      </span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;/</span><span class=\"token class-name\">View</span></span><span class=\"token punctuation\">></span></span><span class=\"token plain-text\">\r\n      </span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span><span class=\"token class-name\">View</span></span>\r\n        <span class=\"token attr-name\">id</span><span class=\"token attr-value\"><span class=\"token punctuation attr-equals\">=</span><span class=\"token punctuation\">\"</span>expo-web-modal-shell-host<span class=\"token punctuation\">\"</span></span>\r\n        <span class=\"token attr-name\">ref</span><span class=\"token script language-javascript\"><span class=\"token script-punctuation punctuation\">=</span><span class=\"token punctuation\">{</span>setHostRef<span class=\"token punctuation\">}</span></span>\r\n        <span class=\"token attr-name\">collapsable</span><span class=\"token script language-javascript\"><span class=\"token script-punctuation punctuation\">=</span><span class=\"token punctuation\">{</span><span class=\"token boolean\">false</span><span class=\"token punctuation\">}</span></span>\r\n        <span class=\"token attr-name\">style</span><span class=\"token script language-javascript\"><span class=\"token script-punctuation punctuation\">=</span><span class=\"token punctuation\">{</span><span class=\"token punctuation\">{</span>\r\n          position<span class=\"token operator\">:</span> <span class=\"token string\">'absolute'</span><span class=\"token punctuation\">,</span>\r\n          left<span class=\"token operator\">:</span> <span class=\"token number\">0</span><span class=\"token punctuation\">,</span>\r\n          right<span class=\"token operator\">:</span> <span class=\"token number\">0</span><span class=\"token punctuation\">,</span>\r\n          top<span class=\"token operator\">:</span> <span class=\"token number\">0</span><span class=\"token punctuation\">,</span>\r\n          bottom<span class=\"token operator\">:</span> <span class=\"token number\">0</span><span class=\"token punctuation\">,</span>\r\n          zIndex<span class=\"token operator\">:</span> <span class=\"token number\">1_000_000</span><span class=\"token punctuation\">,</span>\r\n        <span class=\"token punctuation\">}</span><span class=\"token punctuation\">}</span></span>\r\n      <span class=\"token punctuation\">/></span></span><span class=\"token plain-text\">\r\n    </span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;/</span><span class=\"token class-name\">View</span></span><span class=\"token punctuation\">></span></span><span class=\"token plain-text\">\r\n  </span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;/</span><span class=\"token class-name\">WebModalShellContext.Provider</span></span><span class=\"token punctuation\">></span></span>\r\n<span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span></code></pre></div>\n<p>Aí o <code class=\"language-text\">Modal.web.tsx</code> faz aquela coisa óbvia que só parece óbvia depois, alternando entre um portal dentro desse host e o modal normal do RN:</p>\n<div class=\"gatsby-highlight\" data-language=\"tsx\"><pre class=\"language-tsx\"><code class=\"language-tsx\"><span class=\"token keyword\">const</span> useShellPortal <span class=\"token operator\">=</span> Platform<span class=\"token punctuation\">.</span><span class=\"token constant\">OS</span> <span class=\"token operator\">===</span> <span class=\"token string\">'web'</span> <span class=\"token operator\">&amp;&amp;</span> isDesktopFrame <span class=\"token operator\">&amp;&amp;</span> hostElement <span class=\"token operator\">!=</span> <span class=\"token keyword\">null</span><span class=\"token punctuation\">;</span>\r\n\r\n<span class=\"token keyword\">if</span> <span class=\"token punctuation\">(</span>useShellPortal<span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\r\n  <span class=\"token keyword\">return</span> <span class=\"token function\">createPortal</span><span class=\"token punctuation\">(</span>\r\n    <span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span><span class=\"token class-name\">View</span></span>\r\n      <span class=\"token attr-name\">style</span><span class=\"token script language-javascript\"><span class=\"token script-punctuation punctuation\">=</span><span class=\"token punctuation\">{</span><span class=\"token punctuation\">{</span>\r\n        position<span class=\"token operator\">:</span> <span class=\"token string\">'absolute'</span><span class=\"token punctuation\">,</span>\r\n        left<span class=\"token operator\">:</span> <span class=\"token number\">0</span><span class=\"token punctuation\">,</span>\r\n        right<span class=\"token operator\">:</span> <span class=\"token number\">0</span><span class=\"token punctuation\">,</span>\r\n        top<span class=\"token operator\">:</span> <span class=\"token number\">0</span><span class=\"token punctuation\">,</span>\r\n        bottom<span class=\"token operator\">:</span> <span class=\"token number\">0</span><span class=\"token punctuation\">,</span>\r\n        width<span class=\"token operator\">:</span> <span class=\"token string\">'100%'</span><span class=\"token punctuation\">,</span>\r\n        height<span class=\"token operator\">:</span> <span class=\"token string\">'100%'</span><span class=\"token punctuation\">,</span>\r\n      <span class=\"token punctuation\">}</span><span class=\"token punctuation\">}</span></span>\r\n    <span class=\"token punctuation\">></span></span><span class=\"token plain-text\">\r\n      </span><span class=\"token punctuation\">{</span>children<span class=\"token punctuation\">}</span><span class=\"token plain-text\">\r\n    </span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;/</span><span class=\"token class-name\">View</span></span><span class=\"token punctuation\">></span></span><span class=\"token punctuation\">,</span>\r\n    hostElement\r\n  <span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n<span class=\"token punctuation\">}</span>\r\n\r\n<span class=\"token keyword\">return</span> <span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;</span><span class=\"token class-name\">RNModal</span></span> <span class=\"token attr-name\">visible</span><span class=\"token script language-javascript\"><span class=\"token script-punctuation punctuation\">=</span><span class=\"token punctuation\">{</span>visible<span class=\"token punctuation\">}</span></span><span class=\"token punctuation\">></span></span><span class=\"token punctuation\">{</span>children<span class=\"token punctuation\">}</span><span class=\"token tag\"><span class=\"token tag\"><span class=\"token punctuation\">&lt;/</span><span class=\"token class-name\">RNModal</span></span><span class=\"token punctuation\">></span></span><span class=\"token punctuation\">;</span></code></pre></div>\n<p>E o hook compartilhado do overlay sabe se ele deve se comportar como um modal de viewport inteira ou como um modal de viewport de celular fake:</p>\n<div class=\"gatsby-highlight\" data-language=\"tsx\"><pre class=\"language-tsx\"><code class=\"language-tsx\"><span class=\"token keyword\">export</span> <span class=\"token keyword\">function</span> <span class=\"token function\">useWebModalLayerStyle</span><span class=\"token punctuation\">(</span>options <span class=\"token operator\">=</span> <span class=\"token punctuation\">{</span><span class=\"token punctuation\">}</span><span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\r\n  <span class=\"token keyword\">const</span> isDesktopFrame <span class=\"token operator\">=</span> <span class=\"token function\">useWebDesktopPhoneFrame</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span>\r\n\r\n  <span class=\"token keyword\">if</span> <span class=\"token punctuation\">(</span>isDesktopFrame<span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\r\n    <span class=\"token keyword\">return</span> <span class=\"token punctuation\">{</span>\r\n      position<span class=\"token operator\">:</span> <span class=\"token string\">'absolute'</span><span class=\"token punctuation\">,</span>\r\n      top<span class=\"token operator\">:</span> <span class=\"token number\">0</span><span class=\"token punctuation\">,</span>\r\n      left<span class=\"token operator\">:</span> <span class=\"token number\">0</span><span class=\"token punctuation\">,</span>\r\n      right<span class=\"token operator\">:</span> <span class=\"token number\">0</span><span class=\"token punctuation\">,</span>\r\n      bottom<span class=\"token operator\">:</span> <span class=\"token number\">0</span><span class=\"token punctuation\">,</span>\r\n      width<span class=\"token operator\">:</span> <span class=\"token string\">'100%'</span><span class=\"token punctuation\">,</span>\r\n      height<span class=\"token operator\">:</span> <span class=\"token string\">'100%'</span><span class=\"token punctuation\">,</span>\r\n    <span class=\"token punctuation\">}</span><span class=\"token punctuation\">;</span>\r\n  <span class=\"token punctuation\">}</span>\r\n\r\n  <span class=\"token keyword\">return</span> <span class=\"token punctuation\">{</span>\r\n    position<span class=\"token operator\">:</span> <span class=\"token string\">'fixed'</span><span class=\"token punctuation\">,</span>\r\n    top<span class=\"token operator\">:</span> <span class=\"token number\">0</span><span class=\"token punctuation\">,</span>\r\n    left<span class=\"token operator\">:</span> <span class=\"token number\">0</span><span class=\"token punctuation\">,</span>\r\n    right<span class=\"token operator\">:</span> <span class=\"token number\">0</span><span class=\"token punctuation\">,</span>\r\n    bottom<span class=\"token operator\">:</span> <span class=\"token number\">0</span><span class=\"token punctuation\">,</span>\r\n    width<span class=\"token operator\">:</span> <span class=\"token string\">'100vw'</span><span class=\"token punctuation\">,</span>\r\n    height<span class=\"token operator\">:</span> <span class=\"token string\">'100dvh'</span><span class=\"token punctuation\">,</span>\r\n  <span class=\"token punctuation\">}</span><span class=\"token punctuation\">;</span>\r\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>Isso resolveu a parte visual. Aí os pointer events decidiram que também queriam atenção.</p>\n<p>No native, <code class=\"language-text\">pointerEvents=\"box-none\"</code> é normal. No HTML, isso vira <code class=\"language-text\">pointer-events: box-none</code>, que não é CSS de verdade, então o navegador fica livre pra improvisar. Então agora existe uma regra defensiva pro host do overlay:</p>\n<div class=\"gatsby-highlight\" data-language=\"css\"><pre class=\"language-css\"><code class=\"language-css\"><span class=\"token selector\">.expo-web-app-shell #expo-web-modal-shell-host</span> <span class=\"token punctuation\">{</span>\r\n  <span class=\"token property\">pointer-events</span><span class=\"token punctuation\">:</span> none <span class=\"token important\">!important</span><span class=\"token punctuation\">;</span>\r\n<span class=\"token punctuation\">}</span>\r\n\r\n<span class=\"token selector\">.expo-web-app-shell #expo-web-modal-shell-host *</span> <span class=\"token punctuation\">{</span>\r\n  <span class=\"token property\">pointer-events</span><span class=\"token punctuation\">:</span> auto <span class=\"token important\">!important</span><span class=\"token punctuation\">;</span>\r\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>Também existe um <code class=\"language-text\">useLayoutEffect</code> forçando a mesma coisa inline, porque a essa altura eu já não tinha mais interesse em descobrir quão elegante seria a solução correta.</p>\n<h2 id=\"eu-claramente-tenho-um-padrao\" style=\"position:relative;\"><a href=\"#eu-claramente-tenho-um-padrao\" aria-label=\"eu claramente tenho um padrao permalink\" class=\"post-headers-link before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Eu claramente tenho um padrão</h2>\n<p>Se você já lê este blog há algum tempo, nada disso deveria te surpreender.</p>\n<p>Eu já <a href=\"/pt-br/blog/coding/eu-criei-um-jogo-para-acessar-o-conteudo-do-meu-blog-com-phaser-e-react/\">transformei este blog num RPG top-down</a> porque o Konami Code merecia uma recompensa maior do que só um efeitinho de fundo.</p>\n<p>Depois eu <a href=\"/pt-br/blog/coding/meu-blog-tem-stories-agora-mas-nao-me-pergunte-por-que/\">coloquei Stories no blog</a> porque o app da minha operadora tinha Stories, e isso me irritou profundamente num nível suficiente pra eu transformar o problema em problema de todo mundo.</p>\n<p>Então sim, é claro que agora o meu app de fitness serve o site a partir do mesmo repo com Expo Router, e é claro que o app web no desktop roda dentro de uma foto gigante de um celular. Isso não é uma escalada surpreendente. É só continuidade.</p>\n<h2 id=\"por-que-eu-vou-manter-isso\" style=\"position:relative;\"><a href=\"#por-que-eu-vou-manter-isso\" aria-label=\"por que eu vou manter isso permalink\" class=\"post-headers-link before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Por que eu vou manter isso</h2>\n<p>Passando da piada, isso resolveu um imposto de manutenção bem real.</p>\n<p>As traduções são compartilhadas. Os design tokens são compartilhados. Os componentes são compartilhados. As páginas legais ficam do lado do produto que elas estão legalmente defendendo. Quando eu mudo texto, screenshot ou posicionamento de feature, eu não preciso lembrar qual repo guarda a versão pública e comportada do mesmo app.</p>\n<p>O deploy também ficou melhor. Um build. Um export. Um artefato. O site e o app web não têm como sair de sincronia a não ser que eu faça questão de separá-los, o que fica bem mais difícil quando eles literalmente são publicados juntos.</p>\n<p>E quando eu lancei o <a href=\"/pt-br/blog/coding/musclog-redesign-acompanhamento-nutricional-e-por-que-a-assinatura-do-seu-app-de-fitness-e-uma-enganacao/\">redesign do Musclog</a>, o site basicamente veio junto no embalo. Mesmo spacing, mesmas cores, screenshots atualizadas, mesmo PR. Exatamente o tipo de vitória chata de manutenção que faz uma decisão de arquitetura meio estranha parecer inteligente seis meses depois.</p>\n<h2 id=\"conclusao\" style=\"position:relative;\"><a href=\"#conclusao\" aria-label=\"conclusao permalink\" class=\"post-headers-link before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Conclusão</h2>\n<p>Eu poderia ter mantido o site num repo separado em Next.js como uma pessoa emocionalmente mais estável? Poderia.</p>\n<p>Eu ia continuar fazendo isso depois que o Expo Router deixou esse caminho aberto? Não.</p>\n<p>A parte engraçada é que o roteamento foi a parte sem graça. O Expo Router resolveu isso muito bem. O trabalho irritante estava em todas as mentirinhas de navegador ao redor: bloqueio de rota antes da hidratação, i18n em HTML cru, sincronização de assets, modais que sabiam em que shell estavam, esquisitice com pointer-events, e convencer o Chrome do desktop a acreditar temporariamente que era um celular.</p>\n<p>Ainda assim, valeu muito a pena.</p>\n<p>Se quiser fuçar o código, o <a href=\"https://github.com/blopa/musclog-app\" target=\"_blank\" rel=\"noreferrer\">Musclog é open-source no GitHub</a>. Se quiser usar, tá em <a href=\"https://musclog.app\" target=\"_blank\" rel=\"noreferrer\">musclog.app</a>. O site mora dentro do repo do app agora.</p>\n<p>Agora mora todo mundo aqui.</p>","fields":{"postHashId":"Y29kaW5ndHJ1ZW51bGwyMDI2LTA0LTI1VDAwOjAwOjAwLjAwMFo=","slug":"/2026/2026-04-25-site-ou-app-sim-o-guia-gambiarrístico-para-sites-feitos-em-expo-router.pt-br/","path":"/blog/coding/site-ou-app-sim-o-guia-gambiarrístico-para-sites-feitos-em-expo-router/","locale":"pt-br"},"rawMarkdownBody":"\r\nUma das side quests mais chatas de publicar um app na Google Play Store é que o Google às vezes decide que você também virou dono de site.\r\n\r\nLá em 2024, quando eu [escrevi pela primeira vez sobre o Musclog](/pt-br/blog/coding/musclog-aproveitando-minha-experiencia-com-reactjs-para-criar-um-app-em-react-native/), o site existia basicamente porque o Google queria que ele existisse. Eu precisava da política de privacidade, das páginas públicas, do kit básico do desenvolvedor de app respeitável, então subi rapidinho um site pequeno em Next.js, consegui aprovar o app, e segui com a minha vida.\r\n\r\nFuncionou perfeitamente até o dia em que eu precisei mexer no site de novo.\r\n\r\nScreenshots novas? Outro repo. Texto de feature nova? Outro repo. Atualização de página legal? Outro repo. Ajustezinho de tradução? Outro repo.\r\n\r\nNada disso era difícil de verdade, o que quase piorava a situação. Era só chato o suficiente pra continuar me lembrando que eu tinha dividido um produto em duas bases de código por um motivo que eu já não respeitava mais.\r\n\r\nEntão agora eu não faço mais isso.\r\n\r\nO site do Musclog mora dentro do repo do app. Mesmo projeto com Expo Router. Mesmo deploy. [musclog.app](https://musclog.app/) é o site público, [musclog.app/app](https://musclog.app/app) é o app web de verdade, e Android + iOS continuam saindo da mesma base de código.\r\n\r\nBoa prática? Questionável. Conveniente? Demais.\r\n\r\n![Site público do Musclog renderizado pela mesma base de código com Expo Router do app](../../uploads/blog/2026/04/musclog-shared-router-website-home-1.png)\r\n\r\n## Ok mas por quê?\r\n\r\nBasicamente porque o Expo Router arrancou a minha última desculpa.\r\n\r\nO Musclog já usava [Expo Router](https://expo.github.io/router/docs), então o app já estava organizado em rotas baseadas em arquivos dentro da pasta `app/`. Aí eu percebi que podia simplesmente criar um grupo de rotas chamado `(website)`, mover as páginas públicas pra lá, deixar o app de verdade em `app/app/*`, e parar de fingir que eu estava lidando com dois produtos diferentes.\r\n\r\nO repo separado tinha virado uma daquelas decisões de arquitetura que parecem limpas na teoria e depois ficam te cobrando imposto de manutenção pra sempre:\r\n\r\n- dois PRs pra uma mudança num produto\r\n- dois deploys\r\n- dois lugares pras traduções\r\n- dois lugares pras páginas legais\r\n- dois lugares pra esquecer alguma coisa\r\n\r\nSem condições.\r\n\r\n## A divisão\r\n\r\nA parte engraçada é que o roteamento em si acabou sendo a parte menos amaldiçoada dessa história:\r\n\r\n- `app/app/*` é o Musclog de verdade. Treinos, nutrição, coach com IA, tudo.\r\n- `app/(website)/*` é o site público. Landing page, política de privacidade, termos, contato, calculadora.\r\n\r\nA rota raiz só checa a plataforma e te joga pra onde você pertence:\r\n\r\n```tsx\r\n// app/index.tsx\r\nimport { Redirect, useRouter } from 'expo-router';\r\nimport { useEffect } from 'react';\r\nimport { Platform } from 'react-native';\r\n\r\nexport default function Index() {\r\n  const router = useRouter();\r\n\r\n  useEffect(() => {\r\n    if (Platform.OS === 'web') {\r\n      router.replace('/home');\r\n    }\r\n  }, [router]);\r\n\r\n  if (Platform.OS === 'web') {\r\n    return null;\r\n  }\r\n\r\n  return <Redirect href=\"/app\" />;\r\n}\r\n```\r\n\r\nÉ isso. No web, `/` vira o site. No native, `/` vira o app.\r\n\r\nO site também ganha o próprio layout mais leve:\r\n\r\n```tsx\r\n// app/(website)/_layout.web.tsx\r\nimport { Slot } from 'expo-router';\r\n\r\nimport { WebsiteChrome } from '@/components/website/WebsiteChrome';\r\nimport { WebsiteProviders } from '@/components/website/WebsiteProviders';\r\n\r\nexport default function WebsiteLayout() {\r\n  return (\r\n    <WebsiteProviders>\r\n      <WebsiteChrome>\r\n        <Slot />\r\n      </WebsiteChrome>\r\n    </WebsiteProviders>\r\n  );\r\n}\r\n```\r\n\r\nIsso importa porque o layout do app de verdade já tinha crescido e virado um layout de app mesmo. Boot de banco, migrações, React Query, safe area, gestos, modais, snackbars, câmera, contexto do coach, todas aquelas mentiras clássicas de \"é só um projetinho paralelo\". O site não precisa de nada disso só pra explicar o que o app faz.\r\n\r\nE se algum usuário nativo cair numa rota exclusiva do site, a correção é maravilhosamente direta:\r\n\r\n```tsx\r\n// app/(website)/home.tsx\r\nimport { Redirect } from 'expo-router';\r\n\r\nexport default function Home() {\r\n  return <Redirect href=\"/app\" />;\r\n}\r\n```\r\n\r\nPronto. Volta pros macros.\r\n\r\n![Site público do Musclog renderizado pela mesma base de código com Expo Router do app](../../uploads/blog/2026/04/musclog-shared-router-website-home-2.png)\r\n\r\n## A palhaçada do celular no desktop\r\n\r\nFoi aqui que eu parei de me comportar como uma pessoa normal.\r\n\r\nEu queria que [musclog.app/app](https://musclog.app/app/), no desktop, mostrasse o app funcionando dentro de uma moldura de celular. Um pouco porque fica bonito. Um pouco porque funciona como preview do produto. Mas principalmente porque, depois que a ideia entrou na minha cabeça, qualquer opção menos ridícula começou a parecer errada.\r\n\r\nO detalhe chato era que `/app` precisava parecer um celular, mas `/home` definitivamente não. Então só o roteamento não bastava. Eu precisava que o shell bruto de HTML soubesse, antes da hidratação, se o navegador deveria renderizar a gambiarra do celular fake ou não.\r\n\r\nEssa lógica mora em `app/+html.tsx`, dentro de um script no `<head>` do documento:\r\n\r\n```tsx\r\nfunction landingPanelGate(base: string) {\r\n  try {\r\n    function update() {\r\n      const raw = window.location.pathname;\r\n      const path = (base && raw.startsWith(base) ? raw.slice(base.length) : raw) || '/';\r\n\r\n      if (!path.startsWith('/app')) {\r\n        document.documentElement.classList.add('hide-desktop-wrapper');\r\n      } else {\r\n        document.documentElement.classList.remove('hide-desktop-wrapper');\r\n      }\r\n    }\r\n\r\n    update();\r\n    window.addEventListener('popstate', update);\r\n\r\n    const origPush = history.pushState.bind(history);\r\n    history.pushState = function (...args) {\r\n      origPush(...args);\r\n      update();\r\n    };\r\n\r\n    const origReplace = history.replaceState.bind(history);\r\n    history.replaceState = function (...args) {\r\n      origReplace(...args);\r\n      update();\r\n    };\r\n  } catch (_) {}\r\n}\r\n```\r\n\r\nA parte importante é a gambiarra em `pushState` / `replaceState`. A primeira renderização foi tranquila. A navegação client-side é que era a parte chata. Sem isso, dava pra sair de `/app` e continuar com o shell errado ali, como se o Chrome tivesse esquecido em que página estava.\r\n\r\nO shell HTML em si é basicamente o painel da landing, o app roteado, e a moldura do celular:\r\n\r\n```tsx\r\n<body className=\"expo-web-body\">\r\n  <div className=\"expo-web-landing\">...</div>\r\n  <script dangerouslySetInnerHTML={{ __html: LANDING_I18N_SCRIPT }} />\r\n  <div className=\"expo-web-root\">\r\n    <div className=\"expo-web-app-shell\">{children}</div>\r\n    <img\r\n      className=\"expo-web-phone-frame\"\r\n      src={withExpoBaseUrl(PHONE_FRAME_SRC)}\r\n      alt=\"\"\r\n      aria-hidden\r\n    />\r\n  </div>\r\n</body>\r\n```\r\n\r\nAí o CSS comete o crime:\r\n\r\n```css\r\n@media (min-width: 1024px) {\r\n  .expo-web-root {\r\n    --frame-h: min(100dvh, max(min(360px, 100dvh), 85dvh));\r\n    aspect-ratio: 1438 / 2976;\r\n    width: min(100vw, calc(var(--frame-h) * 1438 / 2976));\r\n    max-height: var(--frame-h);\r\n    overflow: hidden;\r\n  }\r\n\r\n  .expo-web-app-shell {\r\n    position: absolute;\r\n    left: 7.4409%;\r\n    top: 2.9906%;\r\n    right: 6.3282%;\r\n    bottom: 3.125%;\r\n    zoom: 0.85;\r\n  }\r\n}\r\n```\r\n\r\nSim, eu medi a área transparente da tela dentro do PNG até o app encaixar direito. Não tem geometria elegante nenhuma por trás dessas porcentagens. Era só eu, uma moldura de celular, e muito empurra-pra-lá até o app parar de parecer torto.\r\n\r\nNas rotas que não são `/app`, o espetáculo inteiro é desligado:\r\n\r\n```css\r\n.hide-desktop-wrapper .expo-web-landing,\r\n.hide-desktop-wrapper .expo-web-phone-frame {\r\n  display: none !important;\r\n}\r\n\r\n.hide-desktop-wrapper .expo-web-app-shell {\r\n  position: static !important;\r\n  width: 100% !important;\r\n  height: auto !important;\r\n  zoom: 1 !important;\r\n  overflow: visible !important;\r\n}\r\n```\r\n\r\nSim, usa `!important`. Isso é CSS de pré-hidratação cujo trabalho inteiro é manter a mentira de pé. Não estamos fazendo arquitetura refinada nessa camada.\r\n\r\n![App web do Musclog no desktop renderizado dentro de uma moldura de celular ao lado da landing](../../uploads/blog/2026/04/musclog-desktop-phone-frame-wrapper.png)\r\n\r\n## Três pequenos crimes\r\n\r\n![Direto pra cadeia. Na hora.](../../uploads/blog/2026/04/straight-to-jail.gif)\r\n\r\nDepois que o shell funcionou, os incômodos menores começaram a aparecer como se tivessem marcado horário.\r\n\r\n### 1. O painel da landing precisava de i18n antes da hidratação\r\n\r\nO texto do lado do celular mora em HTML cru, o que significa que React e i18n ainda estão dormindo quando a página renderiza pela primeira vez. Então, se eu quisesse que a página em português não desse um flash de inglês antes, eu tinha que remendar isso manualmente a partir do `localStorage`:\r\n\r\n```tsx\r\nfunction landingI18nPatcher(translations, storageKey) {\r\n  try {\r\n    let lang = localStorage.getItem(storageKey);\r\n    let s = (lang && translations[lang]) || translations['en-US'];\r\n\r\n    document.querySelectorAll('[data-landing-i18n]').forEach(function (el) {\r\n      let k = el.getAttribute('data-landing-i18n');\r\n      if (k && s[k]) {\r\n        el.textContent = s[k];\r\n      }\r\n    });\r\n  } catch (_) {}\r\n}\r\n```\r\n\r\nNão é elegante. Também evita jogar um flashbang de texto em inglês numa página em português, então eu vou manter.\r\n\r\n### 2. Assets em HTML cru não recebem a ajuda de sempre do Expo\r\n\r\nO PNG da moldura do celular e o QR code também são referenciados a partir de HTML cru, então aquela conveniência normal de assets do Expo não me ajudava muito ali. Resultado: eu precisei de um helper pra base URL:\r\n\r\n```tsx\r\nfunction withExpoBaseUrl(path: string): string {\r\n  const base = process.env.EXPO_BASE_URL;\r\n  if (base == null || base === '') {\r\n    return path;\r\n  }\r\n\r\n  const basePath = String(base).replace(/^\\/+|\\/+$/g, '');\r\n  const normalized = path.startsWith('/') ? path : `/${path}`;\r\n  return `/${basePath}${normalized}`;\r\n}\r\n```\r\n\r\nE como esses arquivos vivem fora do pipeline normal de assets do React Native, eu copio tudo pra `public/` antes de rodar dev e export. Esquece esse passo uma vez e o site imediatamente te lembra quem manda.\r\n\r\n### 3. O `expo export` às vezes precisava de apoio emocional\r\n\r\nEm alguns ambientes, `expo export --platform web` terminava com sucesso e depois só... continuava vivo sem motivo nenhum. Pasta `dist` lá. Arquivos gerados. Processo espiritualmente concluído, tecnicamente ainda pendurado.\r\n\r\nEntão agora existe um script wrapper:\r\n\r\n```js\r\n// scripts/export-web-wrapper.js\r\nif (output.includes('Exported: dist')) {\r\n  console.log('[export-web-wrapper] Detected successful export. Forcing exit in 5s...');\r\n  setTimeout(() => process.exit(0), 5000);\r\n}\r\n```\r\n\r\nNão é glamouroso... mas resolve.\r\n\r\n## Aí os modais ficaram esquisitos\r\n\r\n![Modal do Musclog no desktop renderizado corretamente dentro da moldura do celular](../../uploads/blog/2026/04/musclog-desktop-modal-inside-phone-frame.png)\r\n\r\nClaro que ficaram.\r\n\r\nQuando o app web mora dentro de um celular fake no desktop, o comportamento padrão de `Modal` no React Native Web começa a ficar ridículo bem rápido. Fazer portal direto pro `document.body` funciona bem quando o app é dono da página inteira. Funciona bem menos quando o app está visualmente recortado dentro de uma carcaça de celular e o modal decide do nada que agora pertence à janela inteira do navegador.\r\n\r\nEntão eu adicionei um `WebModalShellProvider` lá em cima em `app/app/_layout.tsx`, com um host de overlay dentro do shell do celular:\r\n\r\n```tsx\r\n// context/WebModalShellContext.web.tsx\r\nreturn (\r\n  <WebModalShellContext.Provider value={{ hostElement }}>\r\n    <View style={{ flex: 1, minHeight: 0, height: '100%', width: '100%', position: 'relative' }}>\r\n      <View style={{ flex: 1, minHeight: 0, height: '100%', width: '100%' }} collapsable={false}>\r\n        {children}\r\n      </View>\r\n      <View\r\n        id=\"expo-web-modal-shell-host\"\r\n        ref={setHostRef}\r\n        collapsable={false}\r\n        style={{\r\n          position: 'absolute',\r\n          left: 0,\r\n          right: 0,\r\n          top: 0,\r\n          bottom: 0,\r\n          zIndex: 1_000_000,\r\n        }}\r\n      />\r\n    </View>\r\n  </WebModalShellContext.Provider>\r\n);\r\n```\r\n\r\nAí o `Modal.web.tsx` faz aquela coisa óbvia que só parece óbvia depois, alternando entre um portal dentro desse host e o modal normal do RN:\r\n\r\n```tsx\r\nconst useShellPortal = Platform.OS === 'web' && isDesktopFrame && hostElement != null;\r\n\r\nif (useShellPortal) {\r\n  return createPortal(\r\n    <View\r\n      style={{\r\n        position: 'absolute',\r\n        left: 0,\r\n        right: 0,\r\n        top: 0,\r\n        bottom: 0,\r\n        width: '100%',\r\n        height: '100%',\r\n      }}\r\n    >\r\n      {children}\r\n    </View>,\r\n    hostElement\r\n  );\r\n}\r\n\r\nreturn <RNModal visible={visible}>{children}</RNModal>;\r\n```\r\n\r\nE o hook compartilhado do overlay sabe se ele deve se comportar como um modal de viewport inteira ou como um modal de viewport de celular fake:\r\n\r\n```tsx\r\nexport function useWebModalLayerStyle(options = {}) {\r\n  const isDesktopFrame = useWebDesktopPhoneFrame();\r\n\r\n  if (isDesktopFrame) {\r\n    return {\r\n      position: 'absolute',\r\n      top: 0,\r\n      left: 0,\r\n      right: 0,\r\n      bottom: 0,\r\n      width: '100%',\r\n      height: '100%',\r\n    };\r\n  }\r\n\r\n  return {\r\n    position: 'fixed',\r\n    top: 0,\r\n    left: 0,\r\n    right: 0,\r\n    bottom: 0,\r\n    width: '100vw',\r\n    height: '100dvh',\r\n  };\r\n}\r\n```\r\n\r\nIsso resolveu a parte visual. Aí os pointer events decidiram que também queriam atenção.\r\n\r\nNo native, `pointerEvents=\"box-none\"` é normal. No HTML, isso vira `pointer-events: box-none`, que não é CSS de verdade, então o navegador fica livre pra improvisar. Então agora existe uma regra defensiva pro host do overlay:\r\n\r\n```css\r\n.expo-web-app-shell #expo-web-modal-shell-host {\r\n  pointer-events: none !important;\r\n}\r\n\r\n.expo-web-app-shell #expo-web-modal-shell-host * {\r\n  pointer-events: auto !important;\r\n}\r\n```\r\n\r\nTambém existe um `useLayoutEffect` forçando a mesma coisa inline, porque a essa altura eu já não tinha mais interesse em descobrir quão elegante seria a solução correta.\r\n\r\n## Eu claramente tenho um padrão\r\n\r\nSe você já lê este blog há algum tempo, nada disso deveria te surpreender.\r\n\r\nEu já [transformei este blog num RPG top-down](/pt-br/blog/coding/eu-criei-um-jogo-para-acessar-o-conteudo-do-meu-blog-com-phaser-e-react/) porque o Konami Code merecia uma recompensa maior do que só um efeitinho de fundo.\r\n\r\nDepois eu [coloquei Stories no blog](/pt-br/blog/coding/meu-blog-tem-stories-agora-mas-nao-me-pergunte-por-que/) porque o app da minha operadora tinha Stories, e isso me irritou profundamente num nível suficiente pra eu transformar o problema em problema de todo mundo.\r\n\r\nEntão sim, é claro que agora o meu app de fitness serve o site a partir do mesmo repo com Expo Router, e é claro que o app web no desktop roda dentro de uma foto gigante de um celular. Isso não é uma escalada surpreendente. É só continuidade.\r\n\r\n## Por que eu vou manter isso\r\n\r\nPassando da piada, isso resolveu um imposto de manutenção bem real.\r\n\r\nAs traduções são compartilhadas. Os design tokens são compartilhados. Os componentes são compartilhados. As páginas legais ficam do lado do produto que elas estão legalmente defendendo. Quando eu mudo texto, screenshot ou posicionamento de feature, eu não preciso lembrar qual repo guarda a versão pública e comportada do mesmo app.\r\n\r\nO deploy também ficou melhor. Um build. Um export. Um artefato. O site e o app web não têm como sair de sincronia a não ser que eu faça questão de separá-los, o que fica bem mais difícil quando eles literalmente são publicados juntos.\r\n\r\nE quando eu lancei o [redesign do Musclog](/pt-br/blog/coding/musclog-redesign-acompanhamento-nutricional-e-por-que-a-assinatura-do-seu-app-de-fitness-e-uma-enganacao/), o site basicamente veio junto no embalo. Mesmo spacing, mesmas cores, screenshots atualizadas, mesmo PR. Exatamente o tipo de vitória chata de manutenção que faz uma decisão de arquitetura meio estranha parecer inteligente seis meses depois.\r\n\r\n## Conclusão\r\n\r\nEu poderia ter mantido o site num repo separado em Next.js como uma pessoa emocionalmente mais estável? Poderia.\r\n\r\nEu ia continuar fazendo isso depois que o Expo Router deixou esse caminho aberto? Não.\r\n\r\nA parte engraçada é que o roteamento foi a parte sem graça. O Expo Router resolveu isso muito bem. O trabalho irritante estava em todas as mentirinhas de navegador ao redor: bloqueio de rota antes da hidratação, i18n em HTML cru, sincronização de assets, modais que sabiam em que shell estavam, esquisitice com pointer-events, e convencer o Chrome do desktop a acreditar temporariamente que era um celular.\r\n\r\nAinda assim, valeu muito a pena.\r\n\r\nSe quiser fuçar o código, o [Musclog é open-source no GitHub](https://github.com/blopa/musclog-app). Se quiser usar, tá em [musclog.app](https://musclog.app). O site mora dentro do repo do app agora.\r\n\r\nAgora mora todo mundo aqui.\r\n","frontmatter":{"tags":["expo","expo router","react native","musclog","website","monorepo","javascript","typescript"],"categories":["coding"],"allowComments":true,"publishOnMedium":false,"cover":null,"date":"2026-04-25T00:00:00.000Z","id":null,"path":"site-ou-app-sim-o-guia-gambiarrístico-para-sites-feitos-em-expo-router","show":true,"title":"Site ou App? Sim. O guia gambiarrístico para sites feitos em Expo Router","hideExcerpt":false,"subtitle":"O roteamento foi fácil. O celular fake no navegador foi a parte amaldiçoada."}},"language":"en","intl":{"language":"en","languages":["en","pt-br"],"messages":{"site_title":"pablo.gg","title":"Title","author":"@thepiratepablo","search_placeholder":"Search...","about":"About","photos":"Photos","archive":"Archive","contact":"Contact","close":"Close","contact_page":"Contact page","see_more":"See more posts","built_with":"Built with ","buy_me_a_soda":"Buy me a soda","blog":"Blog","blog_posts":"Blog posts","go_to_post":"Go to post","search":"Search","loading":"Loading...","search_results":"Search results","search_results_for":"{quantity} search results for: \"{query}\"","search_for_query":"Search for \"{query}\"","no_results":"No results","home":"Home","description":"Yet another developer personal blog","go_back":"Go back to the homepage","thats_me":"That's me!","got_it":"Got it!","check_it_out":"Check it out!","we_are":"We are","e3":"E3","away_from_next_sgf":"away from Summer Game Fest 2026","away_from_next_gamescom":"away from Gamescom 2026","sgf_countdown":"Summer Game Fest Countdown","gamescom_countdown":"Gamescom Countdown","e3_paragraph_1":"This page once featured a countdown to the next E3 event, a moment that countless gamers and industry professionals looked forward to each year. E3 was not just an event; it was a celebration of our shared passion for video games, a place where dreams were realized, and memories were made.","e3_paragraph_2":"From the electrifying announcements to the hands-on demos, E3 was the heartbeat of the gaming world. It brought together people from all corners of the globe, united by their love for games. For many, it was a chance to meet their heroes, discover new titles, and experience the thrill of the latest innovations in gaming technology.","e3_paragraph_3":"However, as the gaming landscape has evolved, so too has the way we connect and celebrate our passion. While E3 has come to an end, the spirit of excitement and community it fostered lives on. We now look forward to new ways of coming together, sharing our love for games, and creating new memories.","e3_paragraph_4":"Though the countdown is gone, the legacy of E3 will forever remain in our hearts, reminding us of the incredible journeys we've taken and the bonds we've formed along the way.","sec":"Sec","secs":"Secs","min":"Min","mins":"Mins","hour":"Hour","hours":"Hours","day":"Day","days":"Days","month":"Month","months":"Months","year":"Year","years":"Years","recent_posts":"Recent posts","email":"Email","twitter":"Twitter","name":"Name","page":"Page","fill_this_want_reply":"Fill this in if you want me to contact you back","sorry_this_post_unavailable_language":"Sorry, this post is not available in the language you picked","language":"Language","comment":"Comment","comments":"Comments","no_comments":"No comments yet.","post_comment":"Post comment","send_message":"Send message","message":"Message","post_a_comment":"Post a comment","your_comment_submitted":"Your comment has been successfully submitted.","your_message_submitted":"Your message has been successfully submitted.","on":"on","ok":"Ok","copy":"Copy","copied":"Copied","photo_num":"Photo {num}","the_matrix_has_you":"The Matrix has you...","about_paragraph_1":"A technology enthusiast from an early age, I have always been interested in computers and video games.","about_paragraph_2":"I graduated in Information Technology at Estácio de Sá University and always try to find out about new technologies and get involved in new development projects, some of which the code can be found on GitHub.","cookie_banner_consent":"By using this website you agree to our use of cookies to deliver a better experience.","written_in":"Written in ","no_post_this_tag":"No post in English include this tag.","tags":"Tags","tag_colon":"Tag: ","tags_colon":"Tags: ","posts_tagged":"Posts tagged with ","categories":"Categories","category":"Category","category_colon":"Category: ","posts_on_category":"Posts on category ","related_posts":"Related posts","read_time":"🕒 {time} min. read","create_post":"Create Post","show":"Show","date":"Date","download":"Download","add_tag":"Add Tag","hide_excerpt":"Hide Excerpt","publish_on_medium":"Publish on Medium","allow_comments":"Allow Comments","subtitle":"Subtitle","you_must_be_truly_desperate":"You must be truly desperate to come to me for help","game.game_title":"pablo.gg - The Game","game.next":"Next","game.ok":"Ok","game.loading_asset_colon":"Loading asset:","game.loading":"Loading...","game.characters.npc_01":"Frost","game.characters.npc_02":"Gavin","game.characters.npc_03":"Giles","game.characters.npc_04":"Godfrey","game.characters.npc_05":"Hugh","game.characters.npc_06":"Ivar","game.characters.npc_07":"Leopold","game.characters.npc_08":"Lucian","game.characters.npc_09":"Gumercindo","game.characters.npc_10":"Mr. Flower","game.characters.npc_11":"Maxim","game.characters.npc_12":"Milo","game.characters.npc_13":"Otto","game.characters.npc_14":"Palmer","game.characters.npc_15":"Quentin","game.characters.npc_16":"Sebastian","game.characters.npc_17":"Neville","game.characters.npc_18":"Cassian","game.characters.npc_19":"Balthasar","game.characters.npc_20":"Jasper","game.characters.sign_01":"Sign","game.characters.book_01":"Book","game.characters.home_page_city_sign":"Sign","game.characters.coding_category_city_sign_01":"Sign","game.characters.coding_category_city_sign_02":"Sign","game.characters.events_category_city_sign":"Sign","game.characters.funny_category_city_sign":"Sign","game.characters.gadgets_category_city_sign_01":"Sign","game.characters.gadgets_category_city_sign_02":"Sign","game.characters.games_category_city_sign":"Sign","game.characters.general_category_city_sign":"Sign","game.characters.tips_category_city_sign":"Sign","game.characters.collectibles_category_city_sign":"Sign","game.characters.sword":"Info","game.characters.push":"Info","game.gamepad.a_button":"A Button","game.gamepad.b_button":"B Button","game.gamepad.d_pad_left":"D-Pad Left","game.gamepad.d_pad_up":"D-Pad Up","game.gamepad.d_pad_right":"D-Pad Right","game.gamepad.d_pad_down":"D-Pad Down","game.gamepad.start_button":"Start Button","game.menu.start":"Start","game.menu.exit":"Exit","game.menu.settings":"Settings","game.game_over.game_over":"Game Over","game.game_over.retry":"Retry","game.game_over.exit":"Exit","game.start_menu.save_game":"Save Game","game.start_menu.exit":"Exit","game.browse_posts.choose_a_post":"Choose a Post to read","game.dialogs.npc_01.01":"Hey, you're finally awake!","game.dialogs.npc_01.02":"What, you don't know where you are?","game.dialogs.npc_01.03":"Don't be silly, you're at the Home Page City, remember?","game.dialogs.npc_01.04":"This city was founded by Pablo Montenegro to be the start of your journey","game.dialogs.npc_01.05":"Go explore the world and find other cities where you can read the accumulated knowledge of our civilization...","game.dialogs.npc_01.06":"... some people call it \"Blog Posts\", I don't know why...","game.dialogs.npc_02.01":"Be careful with the Slimes that live in the wild.","game.dialogs.npc_02.02":"Press SPACE to use your sword","game.dialogs.npc_02.03":"What is SPACE? I have no idea.","game.dialogs.npc_03.01":"Hello, welcome to our library","game.dialogs.npc_03.02":"We only have one book, which contains all of this city category publications.","game.dialogs.npc_03.03":"Go check it out!","game.dialogs.npc_04.01":"I like snails","game.dialogs.npc_05.01":"Incomplete sentences may cause some","game.dialogs.npc_06.01":"Red is greener than purple, for sure.","game.dialogs.npc_07.01":"Having a beard is the new not having a beard","game.dialogs.npc_08.01":"Sup","game.dialogs.npc_09.01":"\" - Cooper, what are you doing?\"\n\" - Docking.\"","game.dialogs.npc_10.01":"I should buy a boat","game.dialogs.npc_11.01":"Have you heard the me neither joke? Me neither.","game.dialogs.npc_12.01":"I clean toilet and rescue princesses, good life, yes?","game.dialogs.npc_13.01":"We want the airwaves back","game.dialogs.npc_14.01":"Save the cheerleader, save the world","game.dialogs.npc_15.01":"Hello, how are you?","game.dialogs.npc_15.02":"Ok bye!","game.dialogs.npc_16.01":"A kangaroo is really just a rabbit on steroids","game.dialogs.npc_17.01":"For the 216th time, he said he would quit drinking soda after this last Coke","game.dialogs.npc_18.01":"Nothing and everything is possimpible","game.dialogs.npc_19.01":"For a city called \"Events\", there isn't much going on...","game.dialogs.npc_20.01":"I have heard that there's a way to push some objects in this game, but I don't know how to do it.","game.dialogs.sign_01.01":"Congrats, you can read this!","game.dialogs.book_01.01":"Hey, thanks for trying out this very weird way to access my website","game.dialogs.book_01.02":"This project wouldn't be possible without the awesome open-source work of many people, like:","game.dialogs.book_01.03":"ArMM1998 - For the characters sprites and tilesets","game.dialogs.book_01.04":"PixElthen - For the slime sprites","game.dialogs.book_01.05":"pixelartm - For the pirate hat sprites","game.dialogs.book_01.06":"jkjkke - For the Game Over screen background","game.dialogs.book_01.07":"KnoblePersona - For the Main Menu screen background","game.dialogs.book_01.08":"Min - For the open book sprite","game.dialogs.book_01.09":"And of course, Richard Davey for making Phaser.io!","game.dialogs.home_page_city_sign":"City of Home Page","game.dialogs.coding_category_city_sign.01":"City of Coding Category","game.dialogs.coding_category_city_sign.02":"City of Coding Category","game.dialogs.events_category_city_sign":"City of Events Category","game.dialogs.funny_category_city_sign":"City of Funny Category","game.dialogs.gadgets_category_city_sign.01":"City of Gadgets Category","game.dialogs.gadgets_category_city_sign.02":"City of Gadgets Category","game.dialogs.games_category_city_sign":"City of Games Category","game.dialogs.general_category_city_sign":"City of General Category","game.dialogs.tips_category_city_sign":"City of Tips Category","game.dialogs.collectibles_category_city_sign":"City of Collectibles Category","game.dialogs.sword_item_description":"You can now attack, press SPACE to use your sword.","game.dialogs.push_item_description":"Now you can push some objects, press SPACE in front of a object to use push it.","zelda_timeline.title":"Zelda Timeline","zelda_timeline.timeline_split":"Timeline Split","zelda_timeline.timeline_unification":"Timeline Unification","zelda_timeline.icons_from":"The icons used on this page are from zeldauniverse.net and game-icons.net","zelda_timeline.creation":"Creation","zelda_timeline.creation_of_land_sky":"The Creation of the Land and Sky","zelda_timeline.goddess_hylia_and_sky_era":"Goddess Hylia and Sky Era","zelda_timeline.skyward_sword":"Skyward Sword","zelda_timeline.the_ancient_battle":"The Ancient Battle and the reincarnation of the Goddess Hylia","zelda_timeline.return_to_surface":"Return to the surface","zelda_timeline.era_of_chaos":"Era of Chaos","zelda_timeline.sacred_realm_sealed":"The Sacred Realm is Sealed","zelda_timeline.era_of_prosperity":"Era of Prosperity","zelda_timeline.establishment_of_hyrule":"The Hyrule Kingdom is established","zelda_timeline.force_era":"Force Era","zelda_timeline.the_minish_cap":"The Minish Cap","zelda_timeline.rise_of_evil_vaati":"The Rise of the Evil Vaati","zelda_timeline.four_swords":"Four Swords","zelda_timeline.resurrection_of_vaati":"The Resurrection of Vaati","zelda_timeline.era_of_the_hero_of_time":"Era of the Hero of Time","zelda_timeline.hyrulean_civil_war":"Hyrulean Civil War","zelda_timeline.ocarina_of_time":"Ocarina of Time","zelda_timeline.sacred_realm_becomes_dark_world":"The Sacred Realm Becomes the Dark World","zelda_timeline.ganondorf_becomes_ganon":"Ganondorf becomes Ganon","zelda_timeline.hero_is_defeated":"Hero is defeated","zelda_timeline.decline_of_last_hero":"The Decline of Hyrule and the Last Hero","zelda_timeline.the_imprisoning_war":"The Imprisoning War","zelda_timeline.era_of_dark_and_light":"Era of Light and Dark","zelda_timeline.a_link_to_the_past":"A Link to the Past","zelda_timeline.resurrection_of_ganon":"The Resurrection of Ganon","zelda_timeline.resurrection_of_ganon_is_prevented":"The Resurrection of Ganon is prevented","zelda_timeline.links_awakening":"Link's Awakening","zelda_timeline.oracle_of_ages_and_seasons":"Oracle of Ages and Oracle of Seasons","zelda_timeline.a_link_between_worlds":"A Link Between Worlds","zelda_timeline.tri_force_heroes":"Tri Force Heroes","zelda_timeline.the_gold_era":"The Gold Era","zelda_timeline.monarchs_of_hyrule_use_triforce":"The Monarchs of Hyrule Use the Triforce","zelda_timeline.era_of_decline":"The Era of Decline","zelda_timeline.tragedy_of_princess_zelda_1":"The Tragedy of Princess Zelda I","zelda_timeline.the_legend_of_zelda":"The Legend of Zelda","zelda_timeline.adventure_of_link":"Adventure of Link","zelda_timeline.hero_defeated":"Hero Defeated","zelda_timeline.child_era":"Child Era","zelda_timeline.adult_era":"Adult Era","zelda_timeline.sacred_realm_protected":"The Sacred Realm Remains Protected","zelda_timeline.twilight_realm_and_legacy_of_hero":"The Twilight Realm and the Legacy of the Hero","zelda_timeline.majoras_mask":"Majora's Mask","zelda_timeline.prince_of_thieves_is_executed":"The Demon Thief, Ganondorf, Is Executed","zelda_timeline.twilight_era":"The Twilight Era","zelda_timeline.twilight_princess":"Twilight Princess","zelda_timeline.shadow_invasion":"The Shadow Invasion","zelda_timeline.shadow_era":"The Shadow Era","zelda_timeline.four_swords_adventures":"Four Swords Adventures","zelda_timeline.reincarnation_of_ganondorf":"The Reincarnation of Ganondorf","zelda_timeline.ganondorf_is_sealed":"Ganondorf is sealed","zelda_timeline.hero_of_wind_and_new_world":"The Hero of Wind and a New World","zelda_timeline.era_without_a_hero":"The Era without a Hero","zelda_timeline.ganondorf_is_resurrected":"Ganondorf is Resurrected","zelda_timeline.hyrule_is_sealed_and_flooded":"Hyrule is sealed and then flooded","zelda_timeline.era_of_the_great_sea":"The Era of the Great Sea","zelda_timeline.the_wind_waker":"The Wind Waker","zelda_timeline.era_of_the_great_voyage":"The Era of the Great Voyage","zelda_timeline.phantom_hourglass":"Phantom Hourglass","zelda_timeline.era_of_hyrule_rebirth":"The Era of Hyrule's Rebirth","zelda_timeline.new_continent_discovered":"New Continent Discovered","zelda_timeline.new_hyrule_is_founded":"A New Hyrule Kingdom is founded","zelda_timeline.spirit_tracks":"Spirit Tracks","zelda_timeline.evil_king_malladus_is_resurrected":"The Evil King Malladus is Resurrected","zelda_timeline.age_of_calamity":"Age of Calamity","zelda_timeline.breath_of_the_wild":"Breath of the Wild","zelda_timeline.era_of_the_wilds":"Era of the Wilds","zelda_timeline.calamity_ganon_is_sealed":"Calamity Ganon is Sealed. Technology is banned, leading some Sheikah to form the Yiga Clan","zelda_timeline.divine_beasts_are_cleansed":"The Divine Beasts are cleansed and Calamity Ganon is Sealed","zelda_timeline.tears_of_the_kingdom":"Tears of the Kingdom","zelda_timeline.hyrule_kingdom_is_teared_apart":"Ganondorf is Resurrected (I think?)","blog_categories.games":"Games","blog_categories.general":"General","blog_categories.tips":"Tips","blog_categories.events":"Events","blog_categories.coding":"Coding","blog_categories.funny":"Funny","blog_categories.collectibles":"Collectibles","blog_categories.gadgets":"Gadgets","forty_two_page.title":"Forty Two","forty_two_page.description":"So long and thanks for all the fish!","projects_page.title":"Projects","projects_page.description":"Here is a list of some of my favorites personal projects.","projects_page.gatsbyMaterialUiBlogDescription":"A simple Gatsby Blog Starter with Material UI.","projects_page.contractBuilderDescription":"Contract Builder is a free open-source project that allows anyone to easily maintain and build any kind of contract (legal documents, lawsuit, rent, agreements, construction and so on) using Google Spreadsheets. This was develop as a personal project to help a friend who was struggling spending up to an hour to make a custom contract, now she is able to do it in less than 5 minutes. Hooray!","projects_page.resumeBuilderDescription":"Resume Builder is a free open-source project that allows anyone to easily maintain and build any kind of resume using Google Spreadsheets. This was develop as a personal project to help a friend who was struggling spending up to an hour to make a custom resumes.","projects_page.magentoChatbotDescription":"With this module you can fully integrate your Magento store with the most popular chat apps in the market. This means that by simply installing this module and a few clicks you can have a new way to show and sell your products to your clients. Very easy to use! Try now, it's FREE.","projects_page.jamStackSortenerDescription":"This is a basic URL shortener POC build with Gatsby.","projects_page.gotinhaDescription":"It was always my dream to make my own game, and after trying Unity a couple years ago, I decided to try it again with something I'm more familiar with: Javascript. As a frontend developer, Javascript is already the language that I write most of my code at work and also in my personal projects, and after a quick search I was able to find the amazing PhaserJS Framework for building 2D web games.","e3_2012_photos.title":"E3 2012","e3_2012_photos.description":"On June 2012 I attended E3 as a media press for a full coverage for Nintendo Blast.","e3_2013_photos.title":"E3 2013","e3_2013_photos.description":"On June 2013 I attended E3 as a media press for a full coverage for Game Blast.","e3_2014_photos.title":"E3 2014","e3_2014_photos.description":"On June 2014 I attended E3 as a media press for a full coverage for Game Blast.","e3_2015_photos.title":"E3 2015","e3_2015_photos.description":"On June 2015 I attended E3 as a media press for a full coverage for Game Blast and Game Over TV.","e3_2017_photos.title":"E3 2017","e3_2017_photos.description":"On June 2012 I attended E3 as a media press for a full coverage for PlayReplay and Game Over TV.","e3_2019_photos.title":"E3 2019","e3_2019_photos.description":"On June 2019 I attended E3 as a media press for a full coverage for PlayReplay.","gamescom_2019_photos.title":"Gamescom 2019","gamescom_2019_photos.description":"On August 2019 I attended Gamescom as a media press for a full coverage for PlayReplay.","san_francisco_2019_photos.title":"San Francisco 2019","san_francisco_2019_photos.description":"On September 2019 I've traveled to San Francisco for the Metallica S&M2 concert.","notfound.title":"404: Not found","notfound.header":"404 NOT FOUND","notfound.description":"Sorry, this page doesn't seem to exist. Perhaps the archives are incomplete?","seo_keywords.developer":"developer","seo_keywords.development":"development","seo_keywords.javascript":"javascript","seo_keywords.es6":"es6","seo_keywords.e3":"e3","seo_keywords.sgf":"sgf","seo_keywords.gamescom":"gamescom","seo_keywords.countdown":"countdown","seo_keywords.archive":"archive","seo_keywords.about_me":"about me","seo_keywords.personal_blog":"personal blog","seo_keywords.personal_projects":"personal projects","seo_keywords.travels":"travels","seo_keywords.tips":"tips","seo_keywords.lifehacks":"lifehacks","seo_keywords.reviews":"reviews","seo_keywords.games":"games","seo_keywords.timeline":"timeline","seo_keywords.photos":"photos","cookie_law.we_use_cookies":"We use cookies to ensure you get the best experience on our website. By using our website you agree to our ","cookie_law.title":"Cookie policy","cookie_law.what_are_cookies":"What are cookies?","cookie_law.what_are_cookies_text":"As is common practice with almost all professional websites this site uses cookies, which are tiny files that are downloaded to your computer, to improve your experience. This page describes what information they gather, how we use it and why we sometimes need to store these cookies. We will also share how you can prevent these cookies from being stored however this may downgrade or 'break' certain elements of the sites functionality. For more general information on cookies, please read ","cookie_law.what_are_cookies_more_info_url":"https://en.wikipedia.org/wiki/HTTP_cookie","cookie_law.how_we_use_cookies":"How we use cookies","cookie_law.how_we_use_cookies_text":"We use cookies for a variety of reasons detailed below. Unfortunately in most cases there are no industry standard options for disabling cookies without completely disabling the functionality and features they add to this site. It is recommended that you leave on all cookies if you are not sure whether you need them or not in case they are used to provide a service that you use.","cookie_law.disabling_cookies":"Disabling cookies","cookie_law.disabling_cookies_text":"You can prevent the setting of cookies by adjusting the settings on your browser (see your browser Help for how to do this). Be aware that disabling cookies will affect the functionality of this and many other websites that you visit. Disabling cookies will usually result in also disabling certain functionality and features of the this site. Therefore it is recommended that you do not disable cookies.","cookie_law.the_cookies_we_set":"The cookies we set","cookie_law.site_preferences_cookie":"Site preferences cookies","cookie_law.site_preferences_cookie_text":"In order to provide you with a great experience on this site we provide the functionality to set your preferences for how this site runs when you use it. In order to remember your preferences we need to set cookies so that this information can be called whenever you interact with a page is affected by your preferences.","cookie_law.third_party_cookies":"Third party cookies","cookie_law.third_party_cookies_text":"In some special cases we also use cookies provided by trusted third parties. The following section details which third party cookies you might encounter through this site.","cookie_law.third_party_cookies_item_1":"This site uses Google Analytics which is one of the most widespread and trusted analytics solution on the web for helping us to understand how you use the site and ways that we can improve your experience. These cookies may track things such as how long you spend on the site and the pages that you visit so we can continue to produce engaging content. For more information on Google Analytics cookies, see the official Google Analytics page.","cookie_law.third_party_cookies_item_2":"From time to time we test new features and make subtle changes to the way that the site is delivered. When we are still testing new features these cookies may be used to ensure that you receive a consistent experience whilst on the site whilst ensuring we understand which optimisations our users appreciate the most.","cookie_law.more_information":"More information","cookie_law.more_information_text":"Hopefully that has clarified things for you and as was previously mentioned if there is something that you aren't sure whether you need or not it's usually safer to leave cookies enabled in case it does interact with one of the features you use on our site. However if you are still looking for more information then you can contact us through our "},"routed":true,"originalPath":"/blog/coding/website-or-app-yes-hacky-expo-router-guide/","redirect":true,"redirectDefaultLanguageToRoot":false,"defaultLanguage":"en","fallbackLanguage":"","ignoredPaths":[]},"blogLocale":"en"}},
    "staticQueryHashes": ["1156153307","1355482417","1591365477","1628619374","2127381735","2288279559","26159077","3566410298","3649515864","3847325417","3982724423","928834867"]}