Site selling a done-for-you AI receptionist for dental practices. Two pages, and only two:
| URL | File | Job |
|---|---|---|
/ |
index.html |
Raise a hand. Hero, what missed calls cost (sourced), before/after, the ROI calculator, one CTA. |
/how-it-works/ |
how-it-works/index.html |
Convince a researcher. Live voice demo, recorded call, dashboard tour, full pricing, FAQ, the case study, the founder, final CTA. |
Both pages load the same two shared files, which is what keeps them one site rather than two lookalikes:
dd.css— the entire stylesheet, linked from each page's static<head>(render-blocking there on purpose: the pre-hydration rules at the bottom of the file only work if they are in force during the raw-HTML pass).dd-logic.js— the entire logic class, exposed asDDLogic.create(DCLogic). Each page's<script data-dc-script>is one line. The FAQ text, the pricing numbers, the booking flow and the legal copy exist once, here, so they cannot drift between the two pages. Page-specific behaviour keys offdocument.documentElement.dataset.page(home|deep).
Repo scaffolding for the production build (React + Vite + Tailwind + Vercel functions + Supabase):
api/demo-session.ts— one-demo-per-IP gate (called bystartDemo()before every demo call)api/notify-lead.ts— Supabase insert + owner email via Resend (honeypot + per-IP rate limit)api/event.ts— first-party funnel analytics, one row per event insite_eventsapi/video.ts— stable URLs for the two marketing videos, so no expiring token lives in the pageapi/site-info.ts— public JSON summary for AI agents (no deps, no secrets)supabase/migrations/—001leads + demo sessions,002site events (+ the KPI queries) …010two-page analytics (paste-ready copy:supabase/APPLY_010_ONLY.sql).env.example— every required variable, placeholder values onlyvercel.json— response headers for the SEO/AI files (content types, CORS, caching)
Canonical origin: https://www.digidental.us. The bare apex digidental.us
is an A record to Vercel (216.198.79.1) which 308-redirects to www.
The pages render client-side, so anything inside <x-dc> (including <helmet>)
is invisible to crawlers that don't run JavaScript. All crawler-facing tags
therefore live in the static <head> of each page: title, description,
canonical, robots, hreflang, Open Graph/Twitter, and a JSON-LD @graph.
The entity nodes — Organization, WebSite, Service with offers, Person (founder,
an E-E-A-T signal), ImageObject logo and the Article node for the case study — are
byte-identical on both pages, and a test enforces that: two copies that drift
describe two organisations. What differs is the page-specific part. / adds its
own WebPage and the overview VideoObject; /how-it-works/ adds its WebPage, a
BreadcrumbList, the FAQPage, and the recorded-call VideoObject. FAQPage is
deliberately only on the page that renders the FAQ — marking up an answer the
visitor cannot see is a structured-data violation.
Two pages, two intents. / targets the brand and category term; /how-it-works/
targets the research queries (how it works, what it costs, is it HIPAA-compliant,
does it work with Dentrix). Titles and descriptions are asserted to differ, because
two pages fighting over one query is the classic way a split makes rankings worse
rather than better.
Supporting files served from the root: robots.txt (search engines + ~20 named
AI crawlers, points at the sitemap), sitemap.xml (with image and video
extensions — the <video> elements are client-rendered, so the clips would
otherwise be undiscoverable), llms.txt (plain-text summary for LLMs, including
the full FAQ verbatim), og-image.png (social card), and /api/site-info (JSON).
Googlebot executes JavaScript. GPTBot, ClaudeBot and PerplexityBot largely do not. The dc-runtime keeps its content as real markup, so the copy is in the served HTML — but two things polluted it before the runtime mounts:
sc-ifis a directive, not an HTML conditional. With no JS every branch renders, so the error boundary ("A momentary hiccup. This page had trouble rendering.") was literally the first thing an AI crawler read.- Unresolved
{{ holes }}render as literal text — 17 of them.
Both are now hidden by CSS scoped to x-dc / sc-if / sc-for, tags the runtime
removes from the DOM on hydration. That scoping is the whole safety argument:
the rules can only match before hydration, so they are provably inert for anyone
running JavaScript. Do not widen those selectors.
The FAQ is rendered from faqData by a loop, so it does not exist pre-hydration
at all. A <noscript> mirror on /how-it-works/ carries the identical text,
generated from that page's own FAQPage schema and pinned by tests. Both pages come
out clean: no template syntax, no crash boundary, and every Q&A readable.
True SSR would need a build step or a server framework; this is the progressive- enhancement equivalent for static files.
npm run test:seo guards all of it. 154 static checks, most of them run against
both pages: each canonical pointing at its own URL, self-referencing hreflang,
no dangling JSON-LD @ids, entity nodes identical across pages, the FAQ schema
matching both faqData and the visible FAQ, one <h1> per page with no skipped
levels, every <img> with alt and reserved layout space, no relative image paths
(a subdirectory page turns uploads/x.png into a 404), complete OG/Twitter cards,
the two pages linking to each other in both directions, every nav anchor existing on
the page it points at, and admin/API kept out of robots.txt. SEO decays silently;
nothing here throws on its own.
faqData in dd-logic.js is the source. Two hand-written copies exist because
crawlers need them: the FAQPage schema in how-it-works/index.html's <head>, and
the <noscript> mirror below the FAQ section. npm run test:seo fails if any of the
three drifts, and also if llms.txt stops republishing a question. Change all of
them together.
Note: the HTML is regenerated by Claude Design exports. A fresh export
overwrites the static <head> — re-apply the SEO block after each sync, then
run npm run test:seo to confirm nothing was lost.
The one piece of Digi Dental writing that lives off this domain: The Real Cost of a Missed Call at Your Dental Practice, published on Medium under Benny's byline.
It earns its place in the SEO/AEO surface because it is a second indexable document
making the same claims with the same numbers — which is what an answer engine
corroborates a vendor's own page against. It is wired in six places, all from the single
CASE_STUDY constant in dd-logic.js:
- An
Articlenode in both pages' JSON-LD,@id'd to the Medium URL, authored by the#founderentity andaboutthe#service. citationon bothWebPagenodes;subjectOfonService.https://medium.com/@bennycoin the founder'ssameAs(a profile, so it belongs there — the article itself does not).- A visible cited-source card on
/(in the cost section) and on/how-it-works/(its own#case-studysection), plus a footer link on both pages. - A section in
llms.txtand apublicationsarray in/api/site-info. - Two FAQ entries drawn from it, which is what turns it into answer-engine coverage for "what is a missed call worth" and "do the top practices answer after hours".
It is ours, and every surface says so. Not press, not an independent industry
study — Digi Dental's own analysis under the founder's byline. Tests assert that
wording stays in llms.txt, /api/site-info and the page copy, because the site's
whole credibility argument is built on sourced numbers and an honest demo, and one
overclaimed citation would undo both.
Four screenshots in [data-section="dashboard"], one open at a time.
Click-driven, deliberately. It was onMouseEnter, which meant moving the cursor
toward a panel re-selected every panel it crossed — while each was mid
width-transition, so the element under the pointer kept changing and the selection
thrashed. npm run test:render sweeps the cursor across all four and asserts the
selection does not move.
Two layouts, same 0.55s curve:
- ≥1000px — a row. Collapsed panels are 74px rails with rotated labels; the open
one animates
width. - <1000px — a column. The rail becomes a tappable header and the body animates
grid-template-rows: 0fr → 1fr, which resolves to the content's real height.max-heightwas rejected: a guessed ceiling makes the easing finish early and stutter.
.dd-dash-body is reset to display:block on desktop so the grid transition cannot
fight the width transition.
Canonical, hreflang, Open Graph, JSON-LD @ids, sitemap, robots and llms.txt all
carry an absolute base URL. To move to a different domain, update every occurrence
in one pass from the repo root:
grep -rl 'www.digidental.us' --exclude-dir=.git --exclude-dir=node_modules . \
| xargs sed -i 's|www\.digidental\.us|YOUR-DOMAIN.com|g'
Then update ORIGIN in test/seo.test.mjs and run npm run test:seo — it fails
on any file left behind. Point the domain at the Vercel project (Settings →
Domains) and set it as the primary domain so other hosts redirect to it.
Verify the domain property (digidental.us), not a URL prefix. A domain
property covers the apex, www, http and https and every subdomain in one
record, so nothing needs re-verifying when a hostname changes.
- https://search.google.com/search-console → Add property → Domain →
digidental.us. - Google shows one
TXTrecord. In Namecheap → Domain List → digidental.us → Advanced DNS → Add New Record: typeTXT Record, Host@, Value thegoogle-site-verification=…string, TTL Automatic. Do not delete the existing SPF TXT record — a domain may hold many TXT records but only one startingv=spf1. - Wait for propagation, then press Verify.
- Sitemaps → submit
sitemap.xml. - Settings → Crawl stats is the closest thing to a log-file analyser available on this stack: Vercel's Hobby plan exposes no raw access logs, so bot behaviour is read from Search Console rather than from the origin.
If DNS verification is blocked for any reason, the fallback is a
<meta name="google-site-verification" content="…"> in the static <head> of
index.html, directly under the canonical link (a URL-prefix property covers
/how-it-works/ too, since it sits under the same prefix). That only verifies the exact
URL prefix it is served from, which is why DNS is preferred.
Real values are entered ONLY in your local .env (gitignored) and in the hosting
dashboard (Vercel → Project → Settings → Environment Variables). Never in code.
VITE_*vars are public by design (anon key is protected by RLS; the Voice Infrastructure key is the PUBLIC web key).RESEND_API_KEY,RESEND_FROM,SUPABASE_SERVICE_ROLE_KEY,IP_HASH_SALT,NOTIFY_EMAIL_TO,SITE_TOKEN,VIDEO_VSL_URLandVIDEO_DEMO_URLare server-side only.
The client stops the call at 60s, but the real limit lives in the Voice Infrastructure dashboard:
set maxDurationSeconds: 60 and a spend cap on the demo assistant.
The visitor-level limit is enforced server-side, in two steps: startDemo() asks
/api/demo-session whether this hashed IP may call, and goLive() posts { claim: true }
to spend it once the call is actually connected — so a denied microphone or a dropped
connection costs the visitor nothing. Only an explicit already_demoed blocks a call;
transport errors deliberately fail open, because a hot lead must never be turned away
by our own hiccup. localStorage remains as the instant, no-round-trip layer above it.
Every funnel step fires through track() in dd-logic.js, on both pages. Every event
carries path, so the two pages are separable in the data. Three sinks, one taxonomy:
/api/event→ thesite_eventstable. Always on, no third-party tracker. Applysupabase/APPLY_TO_SUPABASE.sqlfirst (migrations005,006and007in one paste) — untilsite_eventsexists, events are accepted and dropped. If the dashboard reports functions missing from the schema cache, only the tail of that 39 KB file failed to paste: run the matching single-migration file instead —supabase/APPLY_007_ONLY.sqlorsupabase/APPLY_008_ONLY.sql. Each is ~10 KB and prints the functions it created, so a truncated paste is visible rather than silent. (002and003are superseded by005; they are kept only as history and must not be run.) Then applysupabase/APPLY_010_ONLY.sql— the split into two pages made three of006's aggregates wrong (see Two pages, one dataset below). Nothing breaks while you wait: every event already records the page it happened on, so no history is lost, and the Pages view says the split is unavailable rather than showing zeroes.- Plausible — set
PLAUSIBLE_DOMAINindd-logic.jsto the live domain and the script loads itself; every event mirrors automatically. - GA4 — drop a
gtagsnippet in and every event mirrors to it. Nothing else to wire.
Three separate ideas, deliberately not conflated:
| Stored in | Lives for | Used for | |
|---|---|---|---|
visitor_id |
localStorage |
until storage is cleared | counting people, returning visits, journeys |
session_id |
sessionStorage |
one visit, 30-min idle rollover | grouping the steps of a visit |
ip_hash |
server only | per request | abuse control only, never identity |
Both ids are random and carry no personal data. ip_hash used to double as the visitor
count, which was wrong in both directions: IPs rotate on mobile networks and are shared
across an office. The privacy copy in dd-logic.js (LEGAL.privacy) discloses the stored identifier.
Events carry a category so a whole class can be filtered without matching on names. The
allowlist in api/event.ts is derived from the same map, so the two cannot drift.
- BEHAVIOR —
page_view,view_hero,section_view,scroll_depth,section_time,video_play,video_progress,video_watch,calc_interact,session_end,element_click - INTENT —
cta_impression,cta_click,form_open,form_step_1..5,demo_start,exit_intent_shown,exit_intent_demo - CONVERSION —
demo_complete,form_submit,calendly_click,lead_captured,contact_click - TECHNICAL —
demo_error,demo_blocked,form_error,page_not_found
cta_impression is what makes click-through a real rate rather than a reflection of page
position: mark any new button with data-cta="<placement>" and the impression fires when it
scrolls into view. The placement string must match the location the click handler reports,
or the two will not line up. Videos are named with data-clip, never by DOM order.
element_click and contact_click come from a single delegated listener on the document, so
every link and button is counted without anyone having to instrument it — including
controls added later. That matters: before it existed, fewer than half the WhatsApp and email
links on the page had a handler, so "how many people actually emailed me" had no answer.
contact_click carries channel (whatsapp / email / phone), and Calendly is folded in
alongside it by rpc_contact so all four routes to you are compared on one footing.
006 was written when the site was one document, and three of its aggregates quietly assumed
that. Migration 010 fixes all three, entirely in the reporting layer — no table changes, no
backfill, every number recomputed from history already stored.
- Section names are not unique across the two pages.
case_studyandfinalexist on both.rpc_sectionsgrouped on the name alone, so those rows were two different sections added together — reach, median dwell and exits all merged. It is keyed by(page, section)now, and the dashboard labels each row with the page. reach_pctdivided by every visitor to the site. A section on/how-it-works/can only be seen by the people who got there, so every deep-page section read as underperforming by exactly the share of traffic that never left the home page. The denominator is now that page's own visitors.rpc_scrollhad the same problem, plus it averaged two documents of very different heights into one curve; it is per page now.rpc_funnelstepped straight from "landed" to "saw the demo section", and the demo section lives only on the deep page. The largest single drop on the site — people who never make the jump between pages — was invisible, folded into the demo step. There is now a "Reached How it works" step between them.
rpc_pages is new: views, visitors, which page a visit started on, and the cross-over rate
between the two. _dd_page() maps a stored path to home / deep / other and is tolerant
of /how-it-works without the trailing slash and of /index.html.
Anything added to the site later that exists on both pages needs no special handling — the
page comes from the event's own path, not from the section name.
/admin.html is one page with real views rather than a long scroll: the sidebar swaps content
instead of jumping a few hundred pixels. The Overview leads with one large chart that switches
between visitors, clicks, visits, demos, forms, contacts and leads — every series arrives in a
single rpc_series call, so switching is instant and all of them cover the same days.
Pages (rpc_pages) is where the two-page funnel is read: traffic per document, which page
each visit started on, the cross-over rate, and — because neither means anything without
knowing which document it happened on — section reach and scroll depth, split by page. Every
panel in it degrades to the old site-wide numbers if 010 has not been applied, and says so.
Click log (rpc_click_log) is the record behind the click-through rates: one row per click
with the timestamp, what was clicked, which section, the channel, country/region/city, device,
source and the anonymous visitor id. Nothing new is collected; it is the events already stored,
shown individually rather than only in aggregate.
Audit (rpc_monthly, rpc_monthly_breakdown) works in calendar months rather than a
rolling window, so one month can be compared with the last, and splits those conversions by
source, country, device or campaign.
Export covers .md (a full written report), .csv (every dataset), .json (raw), and
.png of the current chart, rendered from its own SVG onto a canvas.
All of it happens in Postgres, in the functions created by 006 and amended by 010.
/api/stats just calls them and returns the result, so the payload stays small no matter how
many events exist. Adding a panel means adding a function, not looping in JavaScript.
Failures are reported per panel, not per request: one missing function greys out one card and names itself in the banner, rather than failing the whole load. That is what makes it safe to ship a dashboard change before its migration has been pasted.
/admin.html is a private traffic dashboard built to answer 24 specific business questions;
each panel is labelled with the ones it covers. Visitors and returning visitors, the funnel
in unique visitors, CTA impressions/clicks/CTR and downstream submits, section reach and exit
rate, video completion, scroll reach, traffic source through to leads, countries, devices,
referrers, errors and dead links, conversion path analysis, a visitor list with a full
journey drill-down, and the lead table with attribution.
- Set
ADMIN_PASSWORDin Vercel → Settings → Environment Variables. Until it is set,/api/statsand/api/admin-loginreturn 503 and the dashboard shows nothing. It never falls open. - Set
ADMIN_SESSION_SECRETto a long random value. It signs the session cookie and is deliberately separate from the password, because a signing key should be random and a password you type is not. Falls back to the password if unset. - Bump
ADMIN_SESSION_VERSIONto sign every session out at once.ADMIN_SESSION_HOURScontrols how long a login lasts (default 168 = 7 days). - Login attempts are counted in Postgres, so the limit survives cold starts. The old in-memory limit reset on every new lambda instance.
/api/statsaggregates server-side with the service-role key, so the browser never holds a database credential and raw event rows never leave the server.- The page is
noindex,no-store, disallowed inrobots.txtand refuses to be framed — but that is tidiness. The password is the security. - Needs migrations
005and006applied, otherwise there is nothing to read.
The in-browser demo call uses the official @vapi-ai/web SDK, pinned to an exact version, loaded
from the <helmet> block of how-it-works/index.html — the only page with a demo on it, and
therefore the only page that should pay for the module. / never loads it, and dd-logic.js
skips the whole Vapi path when PAGE !== 'deep'.
It previously loaded
cdn.jsdelivr.net/gh/VapiAI/html-script-tag@**latest**. An unpinned dependency means every page load takes whatever upstream published last, so the call path can change with no deploy on our side — which is how working calls becameerror-assistant-did-not-receive-customer-audio. Change the pinned version deliberately, and test a real call afterwards. Never go back to a floating tag.
Configuration. VAPI_PUBLIC_KEY and VAPI_ASSISTANT_ID are class properties in dd-logic.js
(mirrored by VITE_VAPI_PUBLIC_KEY / VITE_VAPI_ASSISTANT_ID in .env for a bundled build).
The public web key is the only Vapi key that may appear client-side; a private/server key must
never be in the browser. The assistant id is validated as a UUID before any call: if it is
missing, a placeholder, or malformed, the page shows an error and starts no call at all,
rather than creating one with no assistant attached.
Microphone. Permission is requested explicitly before connecting, and the resulting track is
inspected (readyState, enabled, muted). NotAllowedError, NotFoundError,
NotReadableError and OverconstrainedError each get their own message, and a denial shows
browser-specific instructions with a Retry. Once live, getLocalAudioLevel() is sampled: eight
seconds of silence with nothing ever heard raises a "we can't hear you" hint, which is the
client-side view of the same failure Vapi reports as did not receive customer audio.
Lifecycle. One client per page, listeners attached once, so retries never duplicate handlers
or create a second call. start() returns a Promise and is handled as one — the old code called
it inside a try/catch and returned, so a rejection escaped and the UI hung on "Connecting…".
The call is only shown as connected when the SDK emits call-start; the microphone is released on
call-end, never during setup.
Requirements. Calls start from a click, never on load. Microphone access needs HTTPS (or
localhost). vercel.json sets Permissions-Policy: … microphone=(self). If the page is ever
embedded, the parent iframe needs allow="microphone; autoplay". There is no CSP on the site
today; if one is added it must permit the jsDelivr script and Daily's WebRTC endpoints
(*.daily.co, wss:), or calls will fail to connect.
Diagnostics are development-only — localhost, a Vercel preview, or ?debug=1. They log
configuration state, device label, track state and call lifecycle. No transcripts, no audio, no
keys, no visitor identifiers.
Tests. npm test drives the real page in headless Chromium with the SDK and microphone
stubbed: missing assistant id, permission failures, duplicate clicks, listener reuse and cleanup.
Both clips are served through /api/video?clip=vsl|demo, so the page carries no expiring
token. The signed URLs hardcoded as a fallback in api/video.ts expire 2027-07-24.
Permanent fix: make the digi_dental-VSL bucket public and set VIDEO_VSL_URL /
VIDEO_DEMO_URL to the public URLs. Interim fix: paste fresh signed URLs into those two
env vars — no redeploy of the page needed either way.
The section between the FAQ and the final CTA shows four captures of the Vapi console the practice owner works in. It sits there deliberately: objections have just been answered and the price is known, so the last thing before the ask is evidence that a real system exists.
The interaction is the supplied React accordion — one panel open, the others collapsed to
labelled rails — ported natively. This site has no React build step, no Tailwind and no
components/ui directory; it is a single static HTML file driven by the dc-runtime in
support.js. Dropping in a .tsx component would require adding a bundler and restructuring the
deployment, so the behaviour was reproduced in the existing architecture instead. (To use the
component as written you would need npx shadcn@latest init, Tailwind and TypeScript, which
means converting this page into a React app — a much larger change than the section warrants.)
On aspect ratios. These are full-page captures whose dimensions are not known when the markup
is written, so nothing assumes one. Each frame takes its ratio from the image's own
naturalWidth/naturalHeight on load, and captures taller than roughly 1.2:1 scroll inside their
frame rather than stretching the page. Verified against both a 1:4 and a 2.4:1 image: rendered
height matches the natural ratio exactly, so nothing is cropped or letterboxed.
Images are served through /api/image?name=… for the same reason as the videos: the supplied
Supabase signed URLs expire 2027-08-10. Set IMAGE_*_URL env vars, or make the bucket public,
and the expiry stops mattering. The founder portrait uses the same route.
No PLACEHOLDER strings remain in rendered copy. What is still open, in priority order:
- Legal review. The Privacy and Terms copy in
dd-logic.js(LEGAL), the HIPAA FAQ answer and the contracts/data FAQ answer are final, honest copy that claims no certification — but they have not been read by a lawyer. TheLEGALblock carries aLEGAL REVIEW REQUIREDcomment listing exactly what to check (subprocessors, jurisdiction sections, entity name and governing law, which are deliberately not invented). - Commercial promises. The split start is confirmed: $1,000 up front, the second $1,000
due only if a real patient is booked within 14 days of go-live. It appears in the pricing
section,
LEGAL.termsindd-logic.js,llms.txtandapi/site-info.ts— change all four together. The go-live line is deliberately a speed claim with no refund remedy, because that promise was never confirmed; the comment above the badge on both pages has the exact wording to turn it into a real guarantee if you decide to stand behind one. - Care-plan pricing. $149/mo and $299/mo are carried over unverified from the previous
copy. Confirm they are current before the next push; if they changed, update the pricing
section in
how-it-works/index.html, the JSON-LD offers on both pages,llms.txtandapi/site-info.ts. - Resend sender. Verify a domain in Resend and set
RESEND_FROM; until then leads send fromonboarding@resend.devand land in spam. - Supabase publishable key. The browser-fallback key in
dd-logic.jsis a new-stylesb_publishable_…key, which really is that short. Confirm it with the curl in the comment above it: 401 means replace it, 403 means it is fine and RLS is working. - Founder block. Replace the monogram with a real photo and rewrite the two sentences in your own words. No testimonials, client counts or usage numbers until they are real.
- Statistics were sourced June 2026 — refresh annually. Stat 4 is now labelled as our own arithmetic rather than an uncited external figure.
The four figures in [data-section="cost"] count up over 3.4s via animateCount.
The count grows the string, not just the number. "0" → "8,000" is four
characters wider, and the stat around it reads $<count>–$10,000. Measured, that
span's layout width went 23 → 69 → 101 → 103px during a single run. On a phone
the line fits one row at the start of that swing and needs two by the end, so it
re-wrapped mid-count and the block jumped — worse on Android, where Chrome re-runs
font boosting whenever the layout changes.
The fix holds the box open with a hidden copy of the final string and paints the running value over it, absolutely positioned so it contributes no width. Reserving with the real final glyphs rather than a measured pixel value keeps it exact when Fraunces finishes loading and the metrics change underneath, and it does not rely on the font carrying tabular figures. Width is now a single value for the whole run.
Scoped to max-width:900px. Desktop columns are wide enough that the line never
re-wraps, and there the number growing outward from centre is the nicer read.
When measuring this, use offsetWidth, not getBoundingClientRect() — the stats sit
in a data-reveal block whose entrance transform inflates the visual rect and looks
exactly like layout drift.
Findings closed from the security audit, and what each one actually prevented:
- Client IP. Every IP-keyed control read
x-forwarded-for[0]. A proxy chain prepends the client's own value, so that element is whatever the caller typed — one header bypassed the free-demo gate, the lead rate limit and the login throttle at once.lib/http.tsprefersx-vercel-forwarded-forand otherwise takes the last hop. - Demo gate. Was check-then-insert, so two simultaneous requests both won. Migration
009 adds a unique index on
demo_sessions.ip_hash; the claim is an upsert withignoreDuplicatesand a returned row is the proof. The check now fails closed — an outage costs one retry, not uncapped voice minutes — and the client distinguishesalready_demoedfromunavailableso our outage never spends a visitor's free demo. - Lead email. Visitor fields were interpolated raw into the owner's inbox. All escaped, plain-text part added, control characters stripped, malformed addresses rejected.
- Admin session. 24 hours, and
ADMIN_SESSION_SECRETis now required — no silent fallback to signing with the password. - CSP. Shipped, and
npm run test:renderservesvercel.json's real headers so it is exercised rather than trusted.'unsafe-eval'is load-bearing: the dc-runtime compiles each logic class from a string, and without it the page renders 259 characters and mounts nothing.unpkg(React) andjsdelivr(voice SDK, GSAP) are both required inscript-src.connect-srcis deliberately broad because the voice SDK negotiates WebRTC through hosts chosen at call time.
Still open, and why: the signed storage URLs in api/image.ts and api/video.ts stay
until the buckets are public and IMAGE_*_URL / VIDEO_*_URL are set — emptying them now
breaks every image and both videos. They are download-only and expire 2027-08-10;
npm run test:security pins them to those two files so the count can only go down.
From the marketing audit. Every one is placement or framing — no commercial term moved.
- The split start now leads the hero trust line. It was the strongest argument on the page and lived in pricing, where anyone who bounced early never saw it.
- The live demo is a real secondary button, not an underlined line of text.
- One CTA name everywhere: "Claim your practice audit slot" is gone.
- Year-one cost as a
<details>in the pricing card, because owners annualise whatever they are shown and a guess is always worse than the arithmetic. - The three objections that stop a decision — HIPAA, PMS, go-live — render inline under the
demo and the pricing card, from the same
faqDataas the FAQ section, so an answer cannot say two different things in two places. - The founder card sells accountability, not endorsement. There is a
SOCIAL PROOF SLOTfor real customer quotes: populateSOCIAL_PROOFand the section renders itself, tracked asdata-section="social_proof". Leave it empty until a real practice agrees to be quoted — the page's credibility rests on sourced numbers and a live demo, and one invented quote undoes both.