An Arabic coaching site whose application form writes its own PDF
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 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.
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 ajsclass 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 isspellcheck="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.
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
leftandrightwhile the rest of the site uses logical properties. Convert it to match and every leader line flips to the wrong muscle underdir="rtl". - The overlay is a
viewBox="0 0 100 100"stretched withpreserveAspectRatio="none", so each line needsvector-effect="non-scaling-stroke"to stay a one-pixel hairline. That moves dash units into device pixels, sopathLengthdoes nothing — the draw-in length comes from the line’s length as a percentage times1cqw, 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 leading00becomes+. 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 apointerupwithin 500 ms before thechange. - Under RTL,
left: -9999pxis 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-flexbeats thehiddenattribute. 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.devURL by default. The dashboard switch alone didn’t hold;workers_dev: falselives 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.