~$ skillshelf
← all writeups

An Arabic landing page that loads nothing from anyone else

projectclient-workarabicrtltypescriptfrontend

A clinic in Damascus hired me to build their landing page. One page, in Arabic, with exactly one job: turn a tap on an Instagram bio link into a WhatsApp conversation. Nearly every visitor arrives on a phone, inside Instagram’s in-app browser — so that’s what it was built and measured against, not a desktop window that got narrower.

The client and the address stay out of this post. Everything else is the build.

The page's opening screen: a large right-aligned Arabic headline above a single green WhatsApp button, on a near-black green ground.
the only filled button on the page is the WhatsApp one. the brand green is used as ink — text, hairlines, icon strokes — and never as a fill, so "green pill means tap to talk" stays unambiguous.

one content file, rendered before the browser sees it

Every Arabic string lives in one typed file. Not the renderers, not the HTML shell, not the stylesheet — change a line there and you’ve changed the page.

The obvious way to get that is to render in the browser: import the strings, build the DOM at runtime. But the page also has to work with JavaScript switched off, and client-side rendering kills that.

So the render happens at build time. A small Vite plugin calls the renderers and bakes the finished HTML into the output file. Nothing renders content in the browser; the JavaScript that ships is only for behaviour. One reviewable file of Arabic copy, and a page that’s finished before any script runs — otherwise mutually exclusive. That’s the entire reason this project has a build step.

arabic isn’t english with the text moved to the other side

The bug that took longest to find never appeared on the page at all.

The booking form doesn’t submit anywhere. It composes a WhatsApp message and opens it, with the visitor’s answers — phone number included — dropped into an Arabic sentence. Arabic runs right to left, and a paragraph’s direction reorders any weak run inside it. A string of digits is a weak run. Measured, 0912 345 678 reached the other end as 678 345 0912; +963 912 345 678 arrived with its groups reversed and the plus stranded on the wrong end. One unbroken run of digits survives. A single space doesn’t. A leading + doesn’t.

The fix is the plain-text version of <bdi>: wrap every interpolated value in U+2068 and U+2069, the directional isolate characters.

It hid because the phone field is dir="ltr" — the number looks correct the whole time it’s being typed, and flips inside WhatsApp, on the recipient’s screen, where nobody on this end ever looks. A form that works perfectly, sending a number that doesn’t. Interpolate user input into right-to-left text and that bug is already waiting for you.

Four smaller ones from the same family:

  • Never put letter-spacing on Arabic. The letters join; spacing them apart breaks the joins and renders a word as loose disconnected glyphs. There’s none anywhere in the stylesheet, so any that turns up is a bug by definition.
  • Two adjacent isolates render backwards. <bdi>2026</bdi> <bdi>Clinic</bdi> displays as “Clinic 2026” — isolates lay out right-to-left against each other like everything else. Consecutive Latin words go inside one isolate, not two.
  • Isolation belongs to markup, not to strings. Copy gets its Latin runs wrapped on the way into HTML. A <title> or an attribute value holds text, not markup — use the markup-producing helper there and the browser tab shows literal <bdi> tags. Two escapes, two jobs, and the compiler can’t tell them apart for you.
  • Position logically, draw physically. inset-inline-end to place a thing so it flips with the page; border-right and border-bottom to draw it. Drawing the select chevron with border-inline-end flipped it to point sideways in RTL. A down arrow points down in both directions.
An Arabic FAQ accordion: questions right-aligned, small green chevrons down the left edge.
placed with a logical property so it sits on the correct edge, drawn with a physical one so it still points down.

no backend, and nothing from anyone else’s server

One FAQ answer says the site stores nothing and has no database. That answer is the best thing on the page, because it’s literally true rather than a policy someone wrote.

There’s no server. The form URL-encodes its four fields into a wa.me link and opens WhatsApp with the message pre-written, so the visitor reads it before sending.

And the built page makes zero requests off its own origin — no analytics tag, no embedded posts, no font CDN. The six woff2 files are self-hosted for that reason; every icon and the logo are inline SVG; the favicon is a data URI. One third-party script would turn that FAQ answer into a lie, so it’s an absolute rule and not a preference. The page has no runtime dependencies at all.

A booking form with four Arabic-labelled fields — name, phone, reason, preferred time — above a green WhatsApp submit button.
four fields, then WhatsApp. the phone input is dir="ltr" inside an RTL page — which is precisely why the reversed-number bug stayed invisible from this end.

nothing unfinished reaches a visitor

A landing page gets built while the client is still finding the photographs.

The rule: a field with no value renders no element at all. No placeholder strings, no empty shells, no “coming soon”. Leave the address unset and the address block is absent from the DOM — the section stands on the phone number and reads finished rather than half-filled. Policy lives in the types the same way: a testimonial needs a consent flag in its own data or the renderer drops it, so one can’t go live ahead of its paperwork.

Around that sits a guard script and two builds that differ in one thing. Both fail on a marker word or an href="#", across the sources and the built output. The preview build prints every slot that’s deliberately standing in — that’s how a client is shown a white rectangle at the exact size of the photo they still owe. The launch build treats those same stand-ins as fatal. A placeholder can sit on a preview all week and still can’t reach a live domain by being forgotten.

It exists because it once failed: five white rectangles rendered to visitors while the guard passed clean, because it was looking for marker words and a generated data URI contains none in any language. Same lesson as a rule — refuse loudly at build time, never disappear quietly at render time. A renderer that quietly drops a malformed card leaves whoever pasted the link with nothing to explain why.

what it took

Two things ate most of the time, and neither was visible from my desk.

The reel rail. A horizontal row of cards you flick through, looping — which means quietly jumping the scroll position back a whole row at the end. The first version gated that jump on the rail being still for 80ms, on the theory that a write during a fling gets discarded. It stopped the jump from ever firing while someone was flicking, which is the only moment it matters: the next flick lands while the last one’s momentum is still firing scroll events, so the rail is never still.

Measured at 390px with flicks 200ms apart, the fourth flick left the rail on the first card of the strip, where a swipe does nothing at all — overscroll-behavior-x: contain, so not even a rubber-band says why. Twice the runway didn’t help: the same gesture walked fourteen cards over eight flicks and the jump never fired once. Runway is finite; flicking isn’t.

It jumps on every touchstart now, by a whole row — not by centring a card, because off a snap point the rail is never exactly on one, so centring moves it a row plus the remainder, and the remainder is what a visitor sees. Snap has to be off for the frame the jump happens in, or mandatory snapping clamps a four-card jump to one.

That section also broke the whole page, in a way that looked unrelated. Every card carries a screen-reader note positioned absolutely, and overflow only clips a descendant whose containing block is inside the scroller. The rail was position: static, so those notes resolved against the document: cards waiting off-screen put boxes at x ≈ -786, and the page scrolled sideways and shrank itself to a third of the screen on every phone, JavaScript on or off. A scroll container has to be position: relative.

The failure mode worse than no JavaScript. Elements fade in as you reach them. An inline script sets the hiding flag before first paint, the stylesheet hides 39 elements behind it, and only the main module un-hides them. So the page had a state worse than JavaScript being off: JavaScript on, module not arriving. Measured with that request blocked, 39 of 39 stayed invisible for good and the booking form sat there visible and silently dead — <noscript> doesn’t apply when scripting is enabled. The module now sets a ready flag, and the inline script drops the hiding flag after three seconds if it never appears.

the takeaway

On a page like this, correctness is mostly about what the person who built it never sees. The number that arrives reversed in someone else’s WhatsApp. The section that renders as an empty shell because a field is missing. The 39 elements that stay hidden when one request doesn’t land. None of those show up in a browser on my desk on a good connection — they show up on the other end, on a phone, once, to someone who won’t report it. Every mechanism in here exists to drag one of those failures back to build time, where it can be loud.

About 3,500 lines of TypeScript and CSS. No framework and no runtime dependencies — the build’s only job is to make the page finished before it’s served.