~$ skillshelf
← all writeups

An Arabic coaching site whose application form writes its own PDF

projectclient-workarabicrtlastrocloudflaretypescript

Hamza Moustafa took first place in classic physique, studies medicine, and coaches online from Latakia. His clients find him on Instagram and in a Telegram channel and arrive on phones, so dcmusclecommunity.com is two pages built for exactly that: a landing page that makes the case, and a subscription form that turns a visitor into an application in his chat. Arabic, right-to-left, live since 2026-09-12 on Cloudflare Workers, built on Astro. The page’s last line is a link back here.

The landing page is the smaller story. The interesting part is what happens to the application.

the application is one file, built on the phone

The form asks one question per screen: name, age, sex, height and weight, supplement history, steroid history, the goal, the plan, then four physique photos, a line about the visitor’s situation, a short bio, and how to be reached. Some of that is not something anyone wants sitting in a stranger’s database. So there is no database. The application exists on the phone that wrote it, and then in Hamza’s Telegram chat, and nowhere in between.

When the visitor taps send, the browser draws the answers onto a canvas — so the browser does the Arabic shaping and the bidi, which a PDF text engine would not — exports that as a JPEG, and wraps it in a PDF it writes by hand: a catalog, a page tree, and per page one object, one content stream and its image XObjects. About 280 lines, no library. Page one is A4 unless the rows need more, in which case it grows; a longer page scrolls on a phone, a cut row is gone. Values with no Arabic in them are drawn left-to-right, so a leading @ or + stays where it was typed.

The four photos become one page each, carried byte for byte as the device produced them. They were made fit to send the moment they were chosen: redrawn on a canvas at most 1600px on the long side, orientation applied, JPEG at 0.8 stepping down until the file is under ~700 KB. A phone photo is 3–8 MB with its EXIF, GPS included, and re-encoding is what drops it — nothing of the original survives but its pixels. A typical application is about a megabyte, the worst case around three.

The photo step of the form: a six-ring progress row with four rings filled amber and the fifth outlined, then four empty 3:4 frames labelled front, back, side and legs, each with a thin drawn plus.
the four slots. a chosen photo is shrunk and stripped on the device before anything else happens to it; the progress row counts phases, not questions.

The file is named after the visitor — the three-part name the form insists on — and posted to one Worker route, about a hundred lines, which forwards it to Telegram’s sendDocument and answers yes or no. No caption: the file is the whole message, so his chat reads as a list of people, one file each. The first version sent the text as a caption with the photos as an album replying to it. One look at the test message and that was noise.

There’s one source of truth for what’s in the file. A single function composes the message; the checkpoint step previews it; the PDF’s rows are parsed from its label: value lines. A field that isn’t in the message isn’t in the PDF — and an answer that got orphaned, like a PCT question answered and then made moot by flipping steroids back to “no”, never reaches him.

when the send fails, the visitor keeps the file

By the time the send runs, everything is valid, so a failure is the site’s fault by definition. The button works again, and a manual path opens under it. “Save your application file” hands over the very PDF that failed to send: the phone’s share sheet where files can be shared — Telegram is one tap away there — and a download everywhere else. “Open Hamza’s chat” is a link carrying a greeting and nothing personal. Nothing is copied to the clipboard, and nothing navigates on its own.

The earlier version copied the message to the clipboard and opened the chat for you. It went on 2026-09-12: the clipboard was the one soft spot in the privacy audit, and being redirected somewhere without asking is a surprise, not a fallback. I know because I hit it myself, on the live site, the day the Worker’s secrets were mis-set and the route answered 500.

The last step of the form after a failed send: the message preview, the send button re-enabled, an amber status line saying the site could not send, and two outlined buttons — save your application file, and open Hamza's chat on Telegram.
the live site with the send aborted at the browser, captured for this post. the status line, the button back, the two doors — and no redirect.

nothing leaves the phone except to him

The requirement I set was that nothing about a visitor leaks anywhere but to Hamza — down to the input fields. What the site does about it:

  • Zero third-party requests. Fonts self-hosted, no analytics, no CDN, Cloudflare’s own injected scripts switched off. I measured both pages again for this post: each contacts exactly one host, its own.
  • A Content-Security-Policy that starts from default-src 'none' and opens 'self' per type. The one inline script — a line that adds a js class before first paint — is allowed by its SHA-256 hash, and Astro is told never to inline a script so the policy can stay hash-strict. Then HSTS, Referrer-Policy: no-referrer, frame-ancestors 'none', and a Permissions-Policy that switches the sensors off.
  • The form is autocomplete="off" — the steroid and bio answers stay out of the browser’s autofill history; name and contact opt back in per field — and every field is spellcheck="false", because Chrome’s enhanced spell check would otherwise send what’s typed to Google.
  • The route checks same-origin, size and type, and a honeypot field or a fill time no person could manage get a quiet “ok” and no send, so a script learns nothing. It logs nothing and returns nothing of Telegram’s reply. The bot token lives in the Worker’s secret store and in a gitignored local file, and nowhere a browser can fetch.

the anatomy plate

The hero is his photo, annotated like a plate from an anatomy atlas: four muscles named in Arabic and Latin, each on a hairline leader from the edge of the frame to a dot on the muscle. He studies medicine. A mislabelled muscle would be the first thing he noticed.

The hero at phone width: the name in large white Arabic type, then an arms-crossed portrait on a black ground with four thin leader lines running from the edges to dots on the shoulder, trapezius, biceps and forearm, each labelled in Arabic with the Latin name in italic beneath.
the plate. the photo's own black dissolves into the page; the labels are set in amiri, which is what makes it read as an atlas and not a fitness ad.

Five things in there are coupled, and the coupling is the part worth writing down:

  • The coordinates are physical percentages of the photo, so that one CSS block uses physical left and right while the rest of the site uses logical properties. Convert it to match and every leader line flips to the wrong muscle under dir="rtl".
  • The overlay is a viewBox="0 0 100 100" stretched with preserveAspectRatio="none", so each line needs vector-effect="non-scaling-stroke" to stay a one-pixel hairline. That moves dash units into device pixels, so pathLength does nothing — the draw-in length comes from the line’s length as a percentage times 1cqw, which resolves only because the box is a size container.
  • Entrance animations use animation-fill-mode: backwards. The resting state is the visible one, so an interrupted or restarted animation degrades to content, never to a blank plate.
  • The photo’s black is a few points lighter than the page’s — L 24–35 against 21 — and the rectangle it makes is invisible on a desktop and obvious on a phone. The image is feathered into the ground with a mask-image; nothing about the layout changes, so the coordinates still land.
  • The palette comes from the photographs. The ground is the black of his own photos, measured at the hero’s corners, which is why a dark portrait sits in the page instead of in a box. The accent is a gold on the far side of the skin tones from red, an echo of the stage tan. A red accent was built and rejected because it lands inside the skin’s own hue family; the first palette, cream sections and a dusty maroon, was rejected by the client in one word — باهتة, faded. The name was a masthead first, two stacked lines on a phone, and the first time I opened it the eye went to the white type and not the photo. It’s a cover line now, and the photo rises into it.

the build refuses what isn’t real

Every visitor-facing string on the site lives in one file, in Arabic; the components carry no literals, not a label, not a placeholder. A content guard runs in the layout on every request and every build. In dev, problems render as a red bar across the top of the page. In production it throws, and the build fails. A required field empty, a TODO anywhere in the content, a results photo named but not on disk, a FAQ link whose text doesn’t occur in its answer — none of those can ship.

Then there’s the softer category, pending. Two FAQ answers only he can give: when the plan arrives after you subscribe, and how the follow-up runs. Until he answers, those rows are not on the page — an unanswered row never is — the dev bar keeps asking, and the build passes. The site went live on 2026-09-12 without them rather than wait, and no invented text stands in.

The results section shipped the same way: three before/after pairs, all empty, on purpose. Each frame reserves its 4:5 box with the قبل / بعد chip on it, and an empty one is a plain aria-hidden <div> — no <img>, no alt, no stock, no stand-in. When he has a pair with written permission it drops into the same box; that was proved with a throwaway image before the section went up.

And the copy has rules the guard can’t check, so they’re written down instead. No numbers as claims — no prices, no client counts, no years, no testimonials — unless they exist with a real value. His achievement in his own words and nowhere else. He studies medicine and is not a doctor: the word طبيب appears on the landing page exactly twice, both inside the last FAQ row, and any other hit is a regression.

what it took

  • The contact field. The site can’t verify a phone number — an OTP needs stored state, and the site stores nothing — so it does the three things that catch real typos. The keypad follows the platform: inputmode="tel" for WhatsApp, because the numeric keypad is the single biggest typo killer on a phone. A shape check: 8–15 digits, or a Telegram handle. And a readback, the value as it will reach him with the digits in groups of three, which appears only once the value passes. Arabic keyboards produce ٠١٢٣; those become Western digits so the number is tappable in Telegram and dialable, and a leading 00 becomes +. No type-it-twice — it catches only transposed digits and wasn’t worth the friction.
  • The stepper is one <form> with one <fieldset> per question. With JavaScript off, every step is visible and the submit still works. A step’s required fields are derived from its own markup and from the answers so far — a follow-up only after its “yes” — so moving a question between steps changes nothing in the script. A tapped answer moves on by itself after 260 ms, so the choice is seen; a keyboard pick stays put, so the options can be read through first. The heuristic is a pointerup within 500 ms before the change.
  • Under RTL, left: -9999px is not off-screen. The origin is on the right, so it’s reachable overflow — it widened the document to ten thousand pixels and the page scrolled sideways. Clip instead: one pixel, overflow: hidden, clip-path: inset(50%).
  • display: inline-flex beats the hidden attribute. The back button showed on step one until [hidden] { display: none !important } went into the base stylesheet.
  • The link-preview card is part of the description, because his clients get the link in Telegram and WhatsApp before they see the page. Open Graph on every page, absolute URLs only — both apps ignore relative ones — and the page URL taken from the route pattern, because with file-format output the build’s own URL for the form is /subscribe.html. The picture is his face, 1200×630, about 32 KB, with no text baked in: image tools break Arabic letter-joining. Over ~300 KB and WhatsApp drops the picture.
  • The form is built as subscribe.html, so the asset server answers it directly. Before that, every call-to-action click bounced through a 307 to /subscribe/.
  • One bdi { font-family } rule had silently put the Latin anatomy labels in the wrong face, so the Amiri italic was imported and never fetched. Font imports are subset- and weight-specific for the same reason: the browser fetches a subset only when a glyph in its range actually renders, so an unused face costs nothing — but importing a whole family adds ~100 KB that nothing displays.
  • A Cloudflare deploy re-enables the workers.dev URL by default. The dashboard switch alone didn’t hold; workers_dev: false lives in the config now, next to the reason.

Measured at phone width for this post, on 2026-09-13: about 420 KB for the landing page, more than half of it the two Arabic faces, and 318 KB for the form. Lighthouse mobile: performance 92, accessibility 98 — the missing two points are a <main> landmark the landing page doesn’t have yet.

the takeaway

The application exists in exactly two places: the phone that wrote it, and the chat it was written for. Everything else in this build follows from taking that literally. No database means no breach, no backups, no retention question — and it also means the site can’t verify a number, can’t resume a half-finished form, and can’t tell me how many people stopped at the photos. Those are real costs, paid on purpose: a form that asks about someone’s steroid use has no business keeping a copy. The failure path is the same idea from the other side. When the site can’t deliver the file, it hands it back to the one person who should have it.

About 4,400 lines of TypeScript, Astro and CSS, plus a 580-line content file that holds every word on the site. First commit on 2026-09-11, live on the 12th. Built with Claude Code doing the typing; the design, the decisions, and the palette he sent back were mine.