<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: AI Pars</title>
    <description>The latest articles on DEV Community by AI Pars (aipars).</description>
    <link>https://dev.to/aipars</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Forganization%2Fprofile_image%2F15208%2F7854b8b6-104c-4d55-9e22-2f226879be88.png</url>
      <title>DEV Community: AI Pars</title>
      <link>https://dev.to/aipars</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/aipars"/>
    <language>en</language>
    <item>
      <title>Five traps in a right-to-left Farsi shop, and the tests that guard them</title>
      <dc:creator>AI Pars</dc:creator>
      <pubDate>Fri, 09 Oct 2026 16:29:06 +0000</pubDate>
      <link>https://dev.to/aipars/five-traps-in-a-right-to-left-farsi-shop-and-the-tests-that-guard-them-1ggc</link>
      <guid>https://dev.to/aipars/five-traps-in-a-right-to-left-farsi-shop-and-the-tests-that-guard-them-1ggc</guid>
      <description>&lt;p&gt;We run a laptop and console shop in Tehran. Its storefront, &lt;a href="https://ai-pars.com" rel="noopener noreferrer"&gt;ai-pars.com&lt;/a&gt;, is in Farsi, right to left, and built on Next.js and Payload CMS.&lt;/p&gt;

&lt;p&gt;Most RTL write-ups cover logical properties and mirrored icons. We did all of that, and it isn't where we got hurt. Three of the five problems below reached real visitors. The other two we caught in tests or while measuring. Each one has a test now.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The page scrolled sideways, but only on small phones
&lt;/h2&gt;

&lt;p&gt;Every page on the live site could be dragged a little to the side. Nobody on the team felt it, because our own phones are 390px or wider. We measured production later:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Viewport&lt;/th&gt;
&lt;th&gt;Overflow&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;320px&lt;/td&gt;
&lt;td&gt;67px&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;360px&lt;/td&gt;
&lt;td&gt;27px&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;375px&lt;/td&gt;
&lt;td&gt;12px&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;390px and up&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;An earlier audit had written down «zero horizontal overflow at 390 px». At 390px that was true.&lt;/p&gt;

&lt;p&gt;Two separate things caused it, seven weeks apart. In August, a long product title in a &lt;code&gt;truncate&lt;/code&gt; span inside a one-column grid set the width of the whole checkout page. Flex and grid items default to &lt;code&gt;min-width: auto&lt;/code&gt;, and a grid column with no explicit template is sized to its content. That was 66px of overflow with real titles, and 373px with the longest title our test uses. The fix was &lt;code&gt;min-w-0&lt;/code&gt; on flex and grid children that hold text, and an explicit &lt;code&gt;grid-cols-1&lt;/code&gt; base wherever columns were only declared at a breakpoint.&lt;/p&gt;

&lt;p&gt;In September, a new theme switch pushed the header to 359px of content in a 304px box: a 143px wordmark plus four 44px buttons. No gap tuning got it under, so we moved the account icon into the menu drawer.&lt;/p&gt;

&lt;p&gt;The RTL part is where the extra width goes. It spills off the &lt;strong&gt;left&lt;/strong&gt; edge. The usual hunt, looking for any element whose &lt;code&gt;getBoundingClientRect().right&lt;/code&gt; is past &lt;code&gt;innerWidth&lt;/code&gt;, finds nothing. In RTL, look for a negative &lt;code&gt;left&lt;/code&gt; instead.&lt;/p&gt;

&lt;p&gt;The checkout bug shipped because every end-to-end test ran at Playwright's default 1280px. After it, we added a project that runs at 360x740 and seeds a cart with a title longer than any real one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;expectNoHorizontalOverflow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;overflow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;documentElement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scrollWidth&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;innerWidth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;overflow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; overflows by &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;overflow&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;px at 360px`&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBeLessThanOrEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That project went red on the header bug in September, and we had not acted on it. A guard only works if someone reads it.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Our heading font rewrote the model numbers
&lt;/h2&gt;

&lt;p&gt;Our display font is Estedad, in its FD build («Farsi Digits»). In that build, the ASCII digits 0 to 9 are drawn with Persian shapes. So a blog heading that said &lt;code&gt;RTX 5070&lt;/code&gt; showed &lt;code&gt;RTX ۵۰۷۰&lt;/code&gt;, and a CPU name showed &lt;code&gt;i۷-۱۳۶۵۰HX&lt;/code&gt;. The same string in body text, twenty pixels away, looked right.&lt;/p&gt;

&lt;p&gt;Nothing caught it, because the HTML, the copied text and the JSON-LD all said 5070. Only the pixels were wrong. We measured it in the browser: &lt;code&gt;4&lt;/code&gt; and &lt;code&gt;۴&lt;/code&gt; have the same advance width, and none of &lt;code&gt;lnum&lt;/code&gt;, &lt;code&gt;locl&lt;/code&gt; or &lt;code&gt;ss01&lt;/code&gt; to &lt;code&gt;ss08&lt;/code&gt; changes the rendering. No OpenType feature brings the Latin forms back.&lt;/p&gt;

&lt;p&gt;Model numbers are Latin proper nouns, so we cut strings into Latin runs and render each run in the body font:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// may START with a digit: "4GB" is one token, not two&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;LATIN_RUN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;A-Za-z0-9&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;(?:[&lt;/span&gt;&lt;span class="sr"&gt;-.&lt;/span&gt;&lt;span class="se"&gt;/&lt;/span&gt;&lt;span class="sr"&gt;+_ &lt;/span&gt;&lt;span class="se"&gt;]?[&lt;/span&gt;&lt;span class="sr"&gt;A-Za-z0-9&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;*/g&lt;/span&gt;
&lt;span class="c1"&gt;// a run needs one ASCII letter, so a bare "2026" keeps Persian digits&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;HAS_LETTER&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;A-Za-z&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;LatinRun&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;children&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;children&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ReactNode&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt; &lt;span class="na"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"ltr"&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"font-sans"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first version of that regex required a leading letter. It split &lt;code&gt;4GB&lt;/code&gt; into a &lt;code&gt;4&lt;/code&gt; span and a &lt;code&gt;GB&lt;/code&gt; span, and inside an RTL line the bidi algorithm put them back in the other order. A chip on our category page read &lt;code&gt;GB4&lt;/code&gt; the first time we rendered it. Never split one token across two elements.&lt;/p&gt;

&lt;p&gt;The font had also been hiding a bug of ours. Quantities built with template strings, like &lt;code&gt;`${ram} گیگ`&lt;/code&gt;, produced ASCII digits that only looked Persian. On a slow connection the fallback font renders first, and they would have flashed Latin. Those now go through an explicit &lt;code&gt;toFaDigits()&lt;/code&gt;. The rule runs both ways: anything a machine reads, like schema.org prices or the code in a verification email that people retype, stays in Latin digits.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. One word, three spellings
&lt;/h2&gt;

&lt;p&gt;Many Persian words contain a zero-width non-joiner (U+200C), the «half space». PlayStation is «پلی‌استیشن» with one. Many shoppers type it with a full space, «پلی استیشن», and some run it together, «پلیاستیشن».&lt;/p&gt;

&lt;p&gt;We ran our real search functions against the catalogue to see which normalization matches what:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Delete the ZWNJ on both sides: the ZWNJ and fused spellings match, the spaced one misses.&lt;/li&gt;
&lt;li&gt;Turn the ZWNJ into a space and match tokens: the ZWNJ and spaced spellings match, the fused one misses.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No fold that keeps word boundaries covers all three, so token search can't have it. Matching a whole phrase can: delete the ZWNJ and every space, and all three become «پلیاستیشن», at the cost of looser matches. So our token search turns the ZWNJ into a space, and our phrase router squashes everything. Both sides of a comparison always go through the same function.&lt;/p&gt;

&lt;p&gt;Two more things. &lt;code&gt;\s&lt;/code&gt; in a JavaScript regex does not match U+200C, so word counts differ by spelling. And some platforms strip U+200C from text you paste into them.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Toman on the page, Rial in the markup
&lt;/h2&gt;

&lt;p&gt;Iranians quote prices in Toman. Schema.org needs an ISO 4217 code, and the code for Iran's currency is IRR, the Rial. One Toman is ten Rial. So every price has two spellings a factor of ten apart, and Google expects the structured price to match the visible one.&lt;/p&gt;

&lt;p&gt;We store and show Toman everywhere, and the ×10 happens in exactly one function:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;offer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Offer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;priceCurrency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;IRR&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="c1"&gt;// stored in Toman; schema.org wants Rial&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Multiplying by ten is easy. The risk is a second place that prints a price, like a share card or a mobile buy bar, showing one number while the markup says another. So on the product page the buy box, cart line, JSON-LD and share card all come from one resolver over one query result. An end-to-end test parses the JSON-LD and checks the buy box shows the same number divided by ten.&lt;/p&gt;

&lt;p&gt;Two related bugs from the same code reached the live site. A component added «تومان» after a formatter that already adds it, and the price history read «تومان تومان». And &lt;code&gt;priceValidUntil&lt;/code&gt; was set to today's date, so every offer would have expired the day Google crawled it. A crawl audit caught that on the day we turned indexing on. It is now a date 30 days ahead, and the test asserts it is in the future, which is the property the bug broke.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Tehran's midnight is 20:30 UTC
&lt;/h2&gt;

&lt;p&gt;Iran stopped using daylight saving after the summer of 2022, so Tehran is now a fixed UTC+3:30. Our servers run in UTC. Between 20:30 and 24:00 UTC it is already tomorrow in Tehran, and anything keyed on a UTC date is wrong for three and a half hours every night.&lt;/p&gt;

&lt;p&gt;A flaky test bit us on it. A helper pinned timestamps to 12:00 UTC, and the code under test grouped price changes by Tehran day. It passed at 19:00 UTC when it was written and failed in CI at 20:37, with nothing changed but the clock. The natural reaction, re-running it, would have made it pass.&lt;/p&gt;

&lt;p&gt;We name the zone (&lt;code&gt;Asia/Tehran&lt;/code&gt;) wherever we can. For hot paths we do the arithmetic, but only because a test compares it with &lt;code&gt;Intl&lt;/code&gt; for every hour of a year:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;TEHRAN_OFFSET_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="nx"&gt;_600_000&lt;/span&gt; &lt;span class="c1"&gt;// +03:30, no DST after 2022&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;DAY_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;86&lt;/span&gt;&lt;span class="nx"&gt;_400_000&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;startOfTehranDayMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;TEHRAN_OFFSET_MS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;DAY_MS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;DAY_MS&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;TEHRAN_OFFSET_MS&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same boundary came back when we added a data cache. Three queries had «now minus N days» in their &lt;code&gt;where&lt;/code&gt; clause. A query that contains the current time is a new cache key every millisecond. Freeze it instead, and a strike-through price that should expire in two minutes stays up until the next write. So a cached loader never reads the clock. It queries from the start of today minus N days, which only changes at Tehran midnight and always includes every row the exact query would return, then makes the exact cut in JavaScript on each request.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This post was drafted with help from an AI assistant working from our commit history, tests and code. The incidents, measurements and code are from our own repository and were checked against that history before publishing.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>i18n</category>
      <category>nextjs</category>
      <category>css</category>
      <category>rtl</category>
    </item>
  </channel>
</rss>
