Digital Presence for a Medical Clinic: Multi-Page Site and Appointment Intake
Roughly forty pages of static architecture with generative media produced over the clinic's real interiors — at an operating cost approaching zero.
Context
Private dentistry in regional markets combines fierce competition with systematically weak digital presence: site builders, stock photos, admin panels owners are afraid to touch. Yet for a medical organization the site is also a public document: the license number, legal entity details, and the equipment list fall under accuracy requirements, so a wrong fact is a legal risk, not a cosmetic flaw.
The engineering constraints compounded: the clinic has no IT staff, so the system must live for years without a developer; real content arrived in waves throughout the project; the audience uses low-end devices and browsers with non-standard rendering; and the visual bar was set at the level of big-city clinic chains.
Approach
The architectural decision was deliberate static: roughly forty HTML files at the root, a single style.css and one main.js for the entire site, no bundler and no CMS. Static pages give instant delivery, predictable behavior on any device, and zero operating cost — the site cannot be taken down by a dependency update or a plugin conflict.
The price of static — duplicated shared blocks — was paid off with tooling. The nav block is byte-identical across all files and is edited by a single Python script using regular expressions. Every bulk content operation ran as one controlled pass: the contacts-and-content wave touched 52 files, the legal-details wave 39, with no risk of a forgotten instance.
Front-end engineering
Navigation serves two scenarios: on desktop, a two-column services mega-menu with fully clickable overlay cards; on mobile, a slide-out drawer with the booking CTA, phone, and messengers in the thumb-reach zone. The most telling defect: backdrop-filter turned the header into a containing block for position:fixed descendants and broke the drawer — fixed by disabling backdrop-filter on mobile. CSS specificity conflicts got dedicated .nav__foot and .nav__sub selectors, and the preloader was rewritten from a 2.4-second window.load spinner to a requestAnimationFrame hide at DOM readiness, capped at 0.6 seconds.
Publishing is separated from development: a push to main triggers auto-deployment on Railway, and a stuck-edge-cache defect was closed with Cache-Control no-store in serve.json, so updates reach users immediately. The semantic layer is Schema.org markup synchronized with the organization's real data.
Generative media pipeline
The visual thesis: motion where the niche defaults to a static picture, authenticity where it defaults to stock. The hero is a still frame brought to life as a 5-second cinemagraph via image-to-video; the living gallery is three clinic interiors animated the same way; with prefers-reduced-motion, videos pause and CSS animations switch off. For seven services, scenes with people were generated image-to-image over seven different real clinic interiors — while the facade, interiors, and doctors' portraits remained genuine photographs.
The media economics were closed by an ffmpeg pipeline — scale to 1600 px, crf 25, faststart, no audio: gallery clips weigh ~140–200 KB each and the hero came down to 388 KB. Video-production-grade impact on the traffic budget of an ordinary photograph, critical for weak devices and mobile networks.
Outcome & what shipped
The site is in production: an animated video hero and living gallery, seven service pages with unique scenes on real interiors, a corrected price table, and verified legal details fixed across 39 files at once. Equipment the clinic does not own was removed from every page, and contact data was replaced uniformly across all HTML files, main.js, the Schema.org markup, and the map link.
The operating model is reduced to one CSS file, one JS file, and a bulk-edit script; any change wave runs across dozens of pages in a single controlled pass. Static with no bundler here is not economizing — it is control: a predictable system that survives the years.
What we built
Static multi-page site
About 40 HTML files at the root in plain HTML/CSS/JS, one style.css and one main.js for the entire site, no bundler and no CMS.
Mobile drawer menu
A slide-out .nav inside the .header with a conversion footer: close button, booking CTA, phone, and messengers — all in the thumb-reach zone.
Desktop services mega-menu
Two columns with icons; service cards fully clickable via an overlay link.
Animated video hero and gallery
A 5-second cinemagraph hero (388 KB) and three gallery clips (~140–200 KB), all respecting prefers-reduced-motion.
Service scenes on real interiors
7 service cards: scenes with people generated image-to-image over 7 different real clinic interiors.
Bulk-edit pipeline
Python + regex for uniform edits to nav, contacts, legal details, and markup: 52 files per content pass, 39 per legal-details pass.
Medical data verification
Removal of nonexistent equipment; reconciliation of the license, tax and registration numbers, and legal entity type against official documents across all pages.
Controlled deployment
A separated preview folder, push to main → auto-deployment on Railway, serve.json with Cache-Control no-store against cache sticking.
Engineering challenges
Containing block broke the drawer
backdrop-filter on the .header made it a containing block for position:fixed descendants and broke the mobile menu. backdrop-filter was disabled on mobile and the .header raised to z-index:50 — the drawer opens correctly.
CSS specificity conflicts
The mobile .nav a rule (0,1,1) overrode single-class rules. The .nav__foot and .nav__sub selectors were introduced for the drawer footer and submenus; the form error color (p.form-status--error) and the submenu transform reset were fixed the same way.
Preloader slowed transitions
The previous implementation waited for window.load and spun for up to 2.4 s, noticeably lagging in Yandex Browser. Rewritten to hide via requestAnimationFrame on DOM readiness with an 0.6 s auto-hide.
Stuck deployments and generative API load
Without serve.json the static server served a cached index.html — Cache-Control no-store was added. During parallel generation (7 jobs), status polling broke on timeout/SSL even though jobs reached SUCCESS: the taskId was persisted, results were retrieved by re-querying record-info, and generation ran 2–3 jobs at a time.