Repository navigation
Spec: Execile landing page #20
Description
Build-ready spec for the Execile landing page. Synthesised from the wayfinder map and its 18 resolved decision tickets; each decision links to the ticket holding its detail.
Problem Statement
Execile is a new medical billing and RCM service for independent Practices with 1–20 Providers. It has no website, no clients, and no track record. Prospective Practices have no way to learn what Execile does, judge whether it fits them, or start a conversation.
The service's pitch is precision — collecting what a Practice is owed, and quoting its price upfront. A website that is slow, inaccessible, visually inconsistent, or that publishes unsourced statistics would undercut the pitch it is making. And because Execile has no results to show yet, every factual claim on the page must be an industry statistic attributable to a named third party, never an implied claim about Execile's own performance.
Solution
A single, statically prerendered marketing page that explains the service, establishes credibility through sourced industry evidence, and drives one conversion: a request for a free billing Assessment.
The page is built from a fixed Design system of tokens and Base components, so every visual value and every user-facing string has exactly one home. Interactions are native HTML and CSS — the only JavaScript the visitor's browser runs for the page's own behaviour is the Assessment Form submit handler.
The Assessment Form posts to a single endpoint in the same Cloudflare Worker that serves the site, which verifies a Turnstile token and delivers the submission to Execile's Google Workspace Inbox. If delivery fails, the visitor is offered a manual recovery path rather than losing the lead.
Everything runs locally with no accounts — no Cloudflare, no Google Workspace, no PostHog, no Turnstile — so the build never blocks on provisioning.
User Stories
Visitor — understanding the service
- As a Practice manager, I want to see what Execile does within one screen of the page, so that I can decide in seconds whether to keep reading.
- As a Practice manager, I want the page to state which Practice sizes it serves, so that I can tell immediately whether I am the intended customer.
- As a Practice manager, I want to see the problems Execile solves described with sourced industry figures, so that I can trust the framing rather than suspect marketing invention.
- As a sceptical Practice manager, I want every statistic to name its source on the page, so that I can verify it myself.
- As a Practice manager, I want to see the full revenue cycle broken into stages, so that I can locate where my own problems sit.
- As a Practice manager, I want to know what onboarding involves and how long it takes, so that I can judge the disruption.
- As a Practice manager, I want to know whether Execile works inside my existing EHR, so that I know I am not being asked to migrate.
- As a Practice manager, I want to see which Specialties Execile serves, so that I can check mine is covered.
- As a Practice manager, I want a direct comparison against typical billing vendors, so that I can see what is actually different.
- As a Practice manager on a phone, I want that comparison to show both sides at once, so that I am not shown only the competitor's column.
- As a Practice manager, I want an explicit statement of who Execile is not for, so that I can disqualify myself quickly and not waste a call.
- As a Practice manager, I want answers to common questions without contacting anyone, so that I can self-serve my objections.
- As a Practice manager, I want to open one FAQ answer at a time, so that the section stays scannable.
- As a Practice manager, I want to know that a BAA is signed before any data moves, so that I can assess HIPAA risk before engaging.
Visitor — converting
- As an interested Practice manager, I want a clearly visible call to action wherever I am on the page, so that I never have to hunt for how to start.
- As an interested Practice manager, I want to request an Assessment through a short form, so that I can start without a phone call.
- As an interested Practice manager, I want only my name and email to be required, so that I am not blocked by fields I do not have to hand.
- As an interested Practice manager, I want to describe my situation in my own words, so that the first conversation is not generic.
- As an interested Practice manager, I want immediate confirmation that my request was received, so that I do not submit twice.
- As an interested Practice manager, I want to be told what happens next and when, so that I know whether to wait or chase.
- As an interested Practice manager whose submission fails to send, I want to be told plainly that it failed, so that I do not assume it worked.
- As an interested Practice manager whose submission fails, I want a one-click way to email the same information instead, so that my effort is not wasted.
- As an interested Practice manager whose submission fails, I want my form content copied to the clipboard, so that I can paste it into that email.
- As a Practice manager who prefers to talk, I want a phone number in the header and footer, so that I can call instead of filling in a form.
- As a visitor with JavaScript disabled, I want to be told the form needs JavaScript and given the phone number, so that I have a route that works.
Visitor — accessibility
- As a keyboard user, I want a skip link as the first focusable element, so that I can bypass the navigation.
- As a keyboard user, I want focus to actually move to the main content when I activate the skip link, so that the link is not decorative.
- As a keyboard user, I want a visible focus indicator on every interactive element, so that I always know where I am.
- As a keyboard user, I want no focus indicator when I click with a mouse, so that the interface is not noisy.
- As a screen reader user, I want every Section labelled by its heading, so that I can navigate the page by landmark.
- As a screen reader user, I want the mobile menu exposed as a navigation landmark, so that it is reachable the way navigation should be.
- As a screen reader user, I want the Assessment Form's success and failure states announced, so that I learn the outcome without hunting for it.
- As a user with low vision, I want text to meet WCAG AA contrast, so that I can read the page.
- As a user who has asked for reduced motion, I want animation suppressed across the whole page, so that no component can forget my preference.
- As a touch user, I want hover effects not to stick after I tap, so that the interface does not appear stuck.
- As a user on a narrow screen, I want no horizontal page scrolling, so that reading is not a chore.
Visitor — performance
- As a visitor on a slow connection, I want the page's text to render before the brand font arrives, so that I can start reading immediately.
- As a visitor, I want no layout shift when the brand font loads, so that content does not jump as I read.
- As a visitor who never touches the form, I want not to download the bot-check script, so that I am not paying for a feature I do not use.
- As a visitor, I want analytics to load after the page is usable, so that measurement never competes with content.
Client — copy and launch
- As the client, I want every user-facing string in one file, so that I can update copy without touching code.
- As the client, I want unfilled values marked with a consistent convention, so that I can find everything still needing my input.
- As the client, I want a command that lists every remaining placeholder with its location, so that finishing the copy is a checklist rather than a hunt.
- As the client, I want the Results Section to show a "coming soon" state until real client results exist, so that the page never implies outcomes we cannot evidence.
- As the client, I want my registered business address kept off the public page, so that a home address is not published.
- As the client, I want the page to carry the Execile brand while structured data records the registered entity, so that both are accurate.
Developer and agents
- As a developer, I want a single token set covering colour, type, space, radius, shadow and motion, so that no component invents a value.
- As a developer, I want components to take design decisions as variant props rather than raw values, so that the Design system is enforceable at the call site.
- As a developer, I want feature code unable to use raw HTML elements, so that semantics and accessibility live in one place.
- As a developer, I want lint to catch raw elements and literal strings in feature code, so that the two most repetitive rules are automatic.
- As a developer, I want the conventions that cannot be linted written down where agents will read them, so that they are followed without being restated.
- As a developer, I want a one-way import direction, so that the module graph cannot tangle.
- As a developer, I want the whole application to run locally with no third-party accounts, so that I can build before anything is provisioned.
- As a developer, I want to exercise the form's success, validation, challenge-failure and delivery-failure paths locally, so that I can build the UI for all of them.
- As a developer, I want fast pre-commit checks, so that I never want to bypass them.
- As a developer, I want the slow whole-project checks in CI, so that correctness is enforced without slowing every commit.
- As an agent picking up this work, I want each decision to link to the ticket holding its reasoning, so that I can find out why without asking.
Operator
- As the operator, I want an ordered provisioning checklist naming what each step produces, so that accounts can be created in a valid order.
- As the operator, I want to know which steps can only be verified after accounts exist, so that I am not surprised at launch.
- As the operator, I want a production deploy to fail while placeholder copy remains, so that unfinished copy cannot reach the public index.
- As the operator, I want preview deployments per pull request, so that changes can be reviewed before production.
- As the operator, I want form submissions to arrive with the visitor's address as reply-to, so that I can answer directly from the Inbox.
- As the operator, I want to see how many submissions fail to deliver, so that I learn if the email path is unreliable.
- As the operator, I want to know whether the manual email fallback is ever used, so that I learn whether it recovers real leads.
Implementation Decisions
Structure and module boundaries
Five seams, each at the highest available point (#9):
base/— the Base component vocabulary. Sections compose it and never touch raw elements or tokens directly.styles/— the token seam. Every visual value crosses it.messages.json— the copy seam. Every user-facing string crosses it.POST /api/assessment— the only network seam.- The env/config layer — the only seam to external accounts, designed so all four can be absent.
Import direction is one-way: routes → sections → base → styles. Sections compose Base components and never each other. There is no features/ layer — the site is one page with no application state.
Design system (#8)
The Design canvas's ~110 raw values normalise to 62 tokens across 9 groups under a frequency rule: a value used 4+ times earns a step, 1–3 uses round to the nearest neighbour, with documented exceptions where rounding would destroy contrast or has no near neighbour. Recorded as ADR 0001.
Tokens use one defineVars group per category, with Ramp keys and Role keys in the same group and Roles derived by zero-argument functions:
export const color = stylex.defineVars({
teal700: '#0A6B6B', // Ramp
textLink: () => color.teal700, // Role
});A two-file palette→semantic split compiles but breaks under subtree theming — chained var() is substituted where declared, so a theme on the palette group is silently ignored. The single-group form is the only one that themes correctly.
Components import Roles only. Ramp keys exist to derive Roles.
The two Inverse surfaces (footer, comparison card) are a createTheme applied to those subtrees — not a prefers-color-scheme dark mode. The site is light; these are a design inversion.
Contrast forced two changes to the canvas. White-on-teal600 and teal600-on-white both measure 4.18:1 and fail AA for normal text, and the primary CTA is 16px/600 — not "large". So surfaceBrand and textLink are teal700 (6.31:1) with a new teal800 hover step. ink400 is barred from carrying text at 2.50:1.
Base components (#9)
27 components in three tiers. Rule 9 is read as "every tag is owned by exactly one Base component", not "one component per tag" — the latter produces shallow pass-throughs that fail the deletion test.
- Tier 1, primitives (
Box,Text,Heading) — deliberately shallow, with a closedasunion, never unrestricted.Headingtakeslevel, so semantics and size stay independent.Buttonhas noas; a button-styled link isLink variant="button", which the canvas confirms is the common case (6 brand-background anchors vs 2 real buttons). - Tier 2, deep components —
Section,Stack,Grid,Card,Link,Button,IconButton,List,Table,Disclosure,MenuPanel,Field,Input,Select,Textarea,Form,Icon,VisuallyHidden,SkipLink. - Tier 3, role components —
Eyebrow,Badge,Dot,IconTile,Prose.
Table is data-driven (caption, columns, rows), not compositional, so its responsive behaviour lives in one place.
StyleX requires every Base component to accept style?: StyleXStyles and spread stylex.props() itself. The rule: style positions a child within a parent's layout; it never restyles the child's interior.
No forwardRef — React 19 passes ref as an ordinary prop.
Interactions (#10)
Native-first. The form submit handler is the only JavaScript the page's own behaviour needs.
- Mobile menu: Popover API, declarative
popovertarget, zero JS. Light dismiss, Esc, top-layer andaria-expandedcome free. Non-modal by spec, so no focus trap — accepted. Scroll lock viahtml:has([popover]:popover-open)withscrollbar-gutter: stable. - FAQ:
<details name="faq">— exclusivity is native. Panels open independently where unsupported; it degrades, it never breaks. - FAQ items carry no
<h3>: some browsers give<summary>a button role that strips child roles. The Section keeps its<h2>. - Animation:
::details-contentwithcontent-visibility … allow-discrete, opacity and transform only. Firefox opens instantly — accepted. - Hover affordances guarded by
@media (hover: hover)alone, never combined with(pointer: fine). - Focus via
:focus-visible; never a blanketoutline: none. - Reduced motion handled once, globally, so no component author can forget it. The selector list must include
*::details-content—*, *::before, *::afterdoes not cover it. [hidden]needsdisplay: none !importantin global CSS, because any rule setting an explicitdisplaybeats the UA stylesheet.- StyleX cannot express
details[open] .marker— no descendant combinators. Usestylex.when.ancestor('[open]').
Responsive (#17)
Layout is intrinsic — clamp() and auto-fit/minmax(). Two breakpoints only: screen.nav (940px, header) and screen.table (600px, comparison table).
The comparison Table stacks below 600px rather than scrolling horizontally. With column order [label] → [Typical vendor] → [Execile], overflow-x shows a phone the competitor's column and hides Execile's — the Section argues against itself until the visitor interacts. If horizontal scroll is ever reinstated it needs tabindex="0", role="region" and an aria-label, or keyboard users cannot scroll it at all.
Container queries rejected while StyleX's @container ordering bug is open.
SkipLink is its own component, not VisuallyHidden + Link — the latter is unconditionally hidden. <main> needs tabindex="-1" or focus does not move.
Messages (#11)
One English messages.json, nested by Section to mirror sections/. ~200 strings; list content as arrays of objects.
Rich text is one string — the hero heading, also the only <br> — encoded as segments:
type Segment = string | { text: string; tone: 'brand' | 'success' } | { br: true };Access is a static import via resolveJsonModule plus a one-line typed re-export. No codegen, no hook, no key strings.
Placeholders keep the canvas's bracket convention. A placeholders script walks the JSON, prints each with its key path, and exits non-zero while any remain.
Problem Section statistics (#19)
Four industry statistics, each with a visible plain-text source line (not a link — CAQH URLs now redirect and MGMA benchmarks sit behind a paywall):
| Stat | Source |
|---|---|
| 60% — medical groups reporting denials rising | MGMA Stat, March 2024 (n=235) |
| 13.5% — AR past 120 days | MGMA DataDive 2021 · 42 CFR 405.942 |
| 25 min — to check one claim by phone | CAQH Index 2024 |
| 13 wks — before a new hire is billable | BCBS of Texas · Ohio Rev. Code 3963.06 · La. R.S. 22:1009 |
Three copy changes were forced by evidence: 90 days → 120 days (every physician-practice source uses 120); "appeal windows quietly close" removed as an unmeasured trend and replaced by the 120-day Medicare appeal rule; and card 3 carries no percentage because no one has measured "practices lacking revenue visibility". Five widely quoted figures were traced and rejected as unsourced.
Assessment Form pipeline (#12)
POST /api/assessment, a server route in the same Worker, taking FormData. Required fields are name and email only. A honeypot renders via VisuallyHidden with tabIndex={-1}.
Client validation is native only. The server revalidates everything independently and rejects unknown fields.
Turnstile runs invisible, on submit, never gating the button. Tokens are single-use with a 300-second window, so a retry needs a fresh token.
type Result =
| { ok: true }
| { ok: false; error: 'validation'; fields: Record<string, string> }
| { ok: false; error: 'challenge' | 'rate' | 'delivery' };Delivery uses the Cloudflare Email Service send_email binding with replyTo set to the visitor. Sender is on an Email Routing subdomain so the apex MX stays with Google.
On delivery failure, a role="alert" panel offers Open Email (mailto:, subject prefilled, no body — mailto bodies hit length limits) and Copy Message (clipboard, full submission). There is no durable store and no retry, so this converts a lost lead into a manual one.
Rate limiting is WAF configuration, not code, keeping the Worker stateless.
The form is JS-only; a <noscript> line points at the phone number.
Analytics (#13)
Autocapture off — every interactive element is already enumerated, so there is nothing to discover, and autocapture would push marketing copy into PostHog as event data. Surveys and heatmaps off with it; capture_pageleave on for scroll depth.
Ten explicit events, none carrying a field value. faq_opened is keyed on a stable slug, not an index — copy edits reorder FAQs and would silently re-point history.
Two events exist to audit earlier decisions: email_fallback_used{method} tests whether the manual recovery path works, and assessment_failed{reason:"delivery"} is the tripwire for Email Service's Beta status.
Cookieless (cookieless_mode: 'always', person_profiles: 'never'), so no consent banner and no BAA. Never call identify().
SEO (#14)
Head tags from m.meta on the root route. One hand-made 1200×630 OG image. Hand-written robots.txt and sitemap.xml, written at provisioning because they hard-code the domain.
Structured data is Organization, not LocalBusiness — the latter needs a postal address to earn rich results, and only NY/US is published. name is Execile; legalName is EXECIL PVT LTD. telephone and sameAs are omitted rather than placeholdered.
The canvas's landmark structure is already correct and must be preserved: Section takes id and labelledBy; MenuPanel renders nav.
Scaffold and tooling (#15)
vp create @tanstack/start then vp migrate. StyleX unplugin first in the plugin list. Husky is dropped for vp staged — both write core.hooksPath and cannot coexist.
Lint: react/forbid-elements and react/jsx-no-literals scoped to sections/ and routes/; base/ is exempt because Base components exist to render raw tags. forbid-elements takes an explicit list, not a wildcard — a tag not on the list passes silently.
Four rules are structurally unenforceable and documented in CLAUDE.md instead: tokens-not-values, roles-not-ramps, style positions rather than restyles, and user-facing attribute strings. No custom lint plugins.
baseUrl must not be set — tsgolint does not support it and Vite+ silently skips type-checking when present.
Pre-commit is format and lint on staged files only. CI runs full checks; the placeholder check reports on PRs and gates only production deploys, because the copy ships with placeholders deliberately until launch.
Performance (#18)
Measured critical path: ~143 kB brotli — ~8 kB HTML, ~5 kB CSS, 86 kB framework runtime, ~1 kB application code, 43 kB font.
The two largest items have no lever. The framework floor is 86 kB whether or not the page is interactive; deferred hydration was measured and makes the total larger. Satoshi cannot be subsetted — the ITF licence forbids it.
posthog-js-lite would save ~75 kB but lacks cookieless_mode, so the full build is required to preserve the no-banner posture. Loaded on idle after hydration.
Turnstile defers to first form interaction. LCP is the hero h1 — text, no image — protected by the font preload and metric-matched fallback.
This is a documented target, not a CI gate.
Deploy (#16)
One Worker with Static Assets. wrangler versions upload on pull requests, wrangler deploy on main, from GitHub Actions. assets.directory is never hand-written.
An 11-step provisioning checklist, ordered by dependency, each step naming the variable it fills. Email Routing goes on a subdomain, never the apex — enabling it on the apex replaces Google's MX and breaks the client's mail.
Testing Decisions
Rule 12: no automated tests for v1. This is a deliberate constraint on the map, not an omission, so there is no test suite, no test framework configuration, and no prior art to follow.
Verification is therefore manual, and the seams above are what make it tractable:
- The endpoint seam is exercised locally through
wrangler dev, which runs the Worker in workerd via Miniflare with no Cloudflare login. All four failure modes are reachable without an account: Turnstile test keys cover pass (1x…AA), fail (2x…AA) and already-spent (3x…AA); validation and delivery failures are reachable by input and by a failing binding. - The config seam is verified by running the whole application with every external credential absent — an empty PostHog token no-ops, Turnstile test keys pass, and the email binding is simulated.
- The token and message seams are verified by inspection: no literal values in
stylex.create, no Ramp keys in components, no literal strings in JSX. Two of these are lint-enforced; the rest are review. - Cross-browser behaviour must be checked by hand, because the interaction stack degrades differently per engine. The prototype on branch
prototype/interactionscarries a per-browser support readout for exactly this. Known: Firefox opens the accordion instantly and snaps the menu shut; Safari cannot chain pseudo-elements after::details-content. - Contrast is computed, not eyeballed — the ratios are recorded in #8.
If rule 12 is ever lifted, the endpoint is the seam with the highest value per test: it is the only network boundary, it has a discriminated-union contract, and its four failure modes are the page's real risk surface.
Out of Scope
- Legal pages — HIPAA Notice, Privacy Policy, Terms of Service. Footer Legal links stay dead in v1.
- Real copy and placeholder values. The client fills them before launch;
pnpm placeholderslists what remains. - Automated tests (rule 12) and internationalization (rule 13).
messages.jsonis a single English file with no locale layer. - A PostHog reverse proxy. Deferred to after the Cloudflare zone exists.
- Durable storage or retry for form submissions. The manual recovery panel is the mitigation.
- Rate limiting in code. WAF configuration, at provisioning.
- Session replay, surveys, heatmaps, autocapture.
- Italic Satoshi, unless the design later calls for it.
- Delegated credentialing, multi-page routing, a CMS, and a blog.
Further Notes
Live risks, not closed questions
- Cloudflare Email Service is Beta — no SLA, unspecified quotas. The failure panel is the mitigation;
assessment_failed{reason:"delivery"}is the tripwire. - The routing-subdomain sender is unconfirmed. No primary source states that a subdomain enabled for Email Routing is an acceptable
fromdomain for free verified-destination sends. Test at provisioning step 4. If rejected, the fallback is Workers Paid plus Email Sending onboarding — a cost change, not a redesign. - PostHog will undercount. Without a proxy, adblockers block it. This is systematic, not random, and no later proxy backfills the gap. The numbers are directionally useful and absolutely wrong.
- The WAF rate-limiting rule lives only in the dashboard — no code, no review, no history.
[X%]placeholders ship indexable. The site launchesindex, follow, so the pre-deploy placeholder gate is what stands between placeholder copy and Google's cache.
Accepted trade-offs
- FAQ questions do not appear in a screen reader's heading outline — the cost of a working no-JS accordion.
- The mobile menu does not trap focus — popovers are non-modal by spec; making it modal would require
<dialog>.showModal()and JavaScript. - The
messagefield remains free text on a form used by medical Practices, with the existing copy. Recorded as an explicitly accepted risk; the Worker logs field names and outcomes only, never values. - 86 kB of framework delivers ~136 bytes of functionality. Watch PR #6092 — if
hydrate: falselands, this page is the ideal candidate.
Provenance
Two prototype branches carry decisions that prose cannot: prototype/interactions (native interaction stack with a per-browser support readout) and prototype/table-options (comparison table stacked vs scrolled at 375px).
Eight research documents in docs/research/ hold the primary-source evidence. ADR 0001 records the value-normalisation decision and its amendment: the Design canvas is the source of truth for structure only — messages.json owns copy, the token set owns values.
Activity
Abuubkar commentedon Sep 6, 2026
Every implementation ticket from this spec is closed and merged to
main.The landing page is built and prerendered, the assessment endpoint runs end to end in workerd locally, all 13 tickets (#21 to #33) are done, and the copy carries no remaining placeholders. Later work from the competitor review (the control, switching, data-ownership and findings copy) and the legal pages went in on top.
What is left is provisioning and legal review, neither of which is a spec question. Both are tracked on #34.
Note that the wayfinder map is closed