ESP32-S3 Bench Guide
ESP32‑S3 N16R8 벤치 가이드: 실시간 핀아웃, 강의, 시뮬레이터 및 벤치 로그
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
The public landing site for MeduXa.ai — a source-grounded, AI-powered
platform for medical board exam prep. This repo is the marketing site only; the product itself
lives in a separate private repo and is served from app.meduxa.ai.
The repo is still named medboard-landing after the product's former name, MedBoard IL.
The product rebranded to MeduXa in July 2026; the repo name is historical.README 전문 보기 ↗The public landing site for MeduXa.ai — a source-grounded, AI-powered
platform for medical board exam prep. This repo is the marketing site only; the product itself
lives in a separate private repo and is served from app.meduxa.ai.
| Live (HE) | https://meduxa.ai |
| Live (EN) | https://meduxa.ai/en |
| Host | Vercel (static, cleanUrls: true; /he 308s to /?lang=he) |
| Product | Nephrology Stage A board prep — pilot launch September 8, 2026 |
The repo is still named
medboard-landingafter the product's former name, MedBoard IL. The product rebranded to MeduXa in July 2026; the repo name is historical.
index.html Hebrew / RTL landing page — the root (single file: markup + inline CSS + inline JS)
en/index.html English landing page — a full translation, not a wrapper
privacy.html, terms.html, accessibility.html Legal pages, Hebrew (/privacy, /terms, /accessibility)
en/privacy.html, en/terms.html, en/accessibility.html Legal pages, English (under /en)
404.html Not-found page — one bilingual file for every missing path
hero.mp4 / hero.webm Desktop hero video
hero-mobile.mp4 / hero-mobile.webm Phone hero video (<=600px)
og-image.png Open Graph / Twitter card — English (/en)
og-image-he.png Open Graph / Twitter card — Hebrew (root)
specialty.template.html The ONE template behind the four specialty pages below (header + exam countdown, sample
questions, sources, status, method, features, comparison, closing). Hand-authored input to
`scripts/render-specialties.mjs`, which renders it with the page data; not deployed
specialties/ Per-specialty page data (`nephrology|family-medicine|pediatrics|cardiology.json`): copy,
sample questions, sign-up settings. Exam dates and sources are NOT here — see "Exam dates"
nephrology.html /nephrology — the live nephrology sub-specialty (part A) page, with an app CTA
(`התחילו ללמוד — חינם`, `pl=hero|closing`); linked from the homepage's #tracks card
cardiology.html /cardiology — "coming soon" page for the cardiology sub-specialty (part A), with a
consented email sign-up (waitlist source 'landing_cardio', interest 'cardiology');
linked from the homepage's #tracks card. No launch-price copy anywhere
family-medicine.html /family-medicine — "coming soon" page for family medicine Stage A, with a consented
email sign-up (waitlist, source 'landing_fm'); linked from the homepage's #tracks card
pediatrics.html /pediatrics — the same "coming soon" page for pediatrics Stage A (waitlist source
'landing_peds', interest 'pediatrics'); linked from the homepage's #tracks card
(all four pages are generated: `node scripts/render-specialties.mjs`; see "Specialty pages")
og-nephrology.png, og-cardiology.png, og-family-medicine.png, og-pediatrics.png
link-preview cards (what WhatsApp shows for each page's link)
whatsapp-nephrology.png, whatsapp-cardiology.png, whatsapp-family-medicine.png, whatsapp-pediatrics.png
1080x1080 versions of the same ad, to send as an image
tracks.json The study tracks (live / coming soon) behind the homepage's #tracks cards, hero chips,
top banner and closing line, in both languages; see "Study tracks" below
robots.txt, sitemap.xml Indexing — this site is the only indexed MeduXa surface
vercel.json Vercel config (cleanUrls + the language routing rules)
.vercelignore Keeps `specialty.template.html`, `specialties/` and `docs/` out of the deployment, so the raw
template (full of `{{…}}` placeholders), the page data and the internal docs (specs, plans,
unapproved question candidates) are not served. Only those three:
`api/`, `scripts/` and everything else still deploy
api/tutor.js Serverless endpoint behind the hero card's live AI tutor
scripts/prep-hero-video.sh ffmpeg pipeline that produces the four hero video files
scripts/make-og-image.mjs renders an OG card to PNG (`node scripts/make-og-image.mjs he`)
scripts/make-promo-image.mjs renders a specialty promo card: `og|square` × `fm|peds|nephro|cardio` (`fm` is the default;
`nephro` is "open now" with no launch-price line, `cardio` is "coming soon" with none either),
e.g. `node scripts/make-promo-image.mjs og cardio` -> og-cardiology.png. The raw render is large, so shrink
it afterwards: `pngquant --quality 80-95 --ext .png -f <file>` if installed (optional), else
Pillow `quantize(256)` (~100-145 KB either way; the Mac these were made on has no pngquant).
The script loads IBM Plex from Google Fonts itself, so it needs network. Chrome binary: `$CHROME`
if set, else the first of a Playwright headless shell, `/Applications/Google Chrome.app/…`,
`google-chrome`, `chromium` that answers `--version` — so on a Mac the local Chrome is used as is.
After re-rendering, bump the `?v=` on og:image/twitter:image so WhatsApp refetches
scripts/check-crawler-exemption.mjs guards the crawler exemption in the language router
scripts/render-tracks.mjs renders tracks.json into both landing pages (`--check` = drift guard; tests beside it)
scripts/render-specialties.mjs renders specialty.template.html + specialties/*.json + tracks.json into the four
specialty pages (`--check` = drift guard; tests beside it)
scripts/countdown.mjs the Israel-date day count to an exam, shared by the renderer and the page's script (tests beside it)
docs/hero-video-prompt.md The generation prompt behind the hero footage
There is no build step and no dependencies — the pages are hand-authored static HTML with
inline styles and inline scripts, and api/tutor.js is a single fetch against OpenRouter's
chat completions API, so there is no root package.json for Vercel to install from either. Open
index.html in a browser, or serve the directory:
python3 -m http.server 8000
Then visit http://localhost:8000/ (HE) and http://localhost:8000/en/ (EN).
Language routing happens at the Vercel edge, so locally / always renders Hebrew and /en always
renders English — see Language routing below for how to exercise the real rules.
Both language versions share the same section order, anchored for in-page nav:
| Anchor | Section (EN heading) |
|---|---|
| — | Hero — video background + the one-line pitch, and one chip per study track |
#tracks | "Choose your specialty" — one card per study track, rendered from tracks.json |
#science | "Learning backed by how the brain actually works" — the nine evidence-based principles |
#features | "Everything you need to pass — nothing you don't" |
#how | "From first question to exam day" |
#compare | "Not a question bank. Not a general AI tool." — a table against two generic categories (static banks, general AI tools), deliberately naming no specific product |
#pilot | "Ready to study smarter?" — the nephrology call to action, plus a line pointing coming-soon tracks back to #tracks |
#tracks replaced the old #vision section ("One engine, every board exam") on 2026-10-07.
tracks.json is the one list of study tracks. Each entry has an id, a status (live or
soon), an href, and per-language copy (he, en). From it, node scripts/render-tracks.mjs
writes four places in both landing pages, between marker comments:
| Marker | What | Notes |
|---|---|---|
tracks:cards | the #tracks grid | a live card spans two of four columns; soon cards one |
tracks:chips | the hero chips under the main CTA | in-page jumps to #track-<id> |
tracks:announce | the top banner | Hebrew page only (/en has no banner); lists the soon tracks with "launchOffer": true; omitted when there is none |
tracks:closing | the line under the #pilot CTA | lists every soon track |
Adding a specialty is one entry in tracks.json, then node scripts/render-tracks.mjs, then
commit the list and both pages together. Never hand-edit between the markers — the next render
overwrites it, and node scripts/render-tracks.mjs --check (run it beside the crawler check)
exits 1 when a page has drifted from the list. Tests: node --test scripts/render-tracks.test.mjs.
The pages stay plain static HTML, so crawlers, no-JS visitors and the first paint all see the cards.
cta on an entry adds data-cta to its card, which makes the click count as cta_click (see
Analytics). No entry sets it today: the nephrology card goes to /nephrology, not to the app, so it
fires only track_click.exam and sources on an entry feed the specialty pages, and launchOffer puts a soon track in the
top banner (see "Exam dates" and "Specialty pages").pageLang marks a destination page in another language: on /en, every card whose page is Hebrew
(all four today) shows a "Hebrew content" pill, which is also part of the link's accessible
description, and the link carries hreflang="he".{{…}} inside a Hebrew blurb marks a Latin run (1,000+, AI) that is rendered as
<span class="ltr-iso">.status, point href at the app with a pl value
the app already accepts, and re-render — its card widens, and it drops out of the banner and the
closing line by itself./nephrology, /cardiology, /family-medicine and /pediatrics are generated from one template,
specialty.template.html, with one data file per page in specialties/<track-id>.json (copy, status
lines, sample questions, sign-up settings). node scripts/render-specialties.mjs writes each page to
<track href>.html at the repo root; the generated pages are committed, so there is still no build
step at deploy. node scripts/render-specialties.mjs --check exits 1 when a page has drifted (run it
beside the other checks); never hand-edit a generated page. Tests:
node --test scripts/render-specialties.test.mjs scripts/countdown.test.mjs.
Page order: header + exam countdown, sample questions (absent when there are none), sources, status,
method, features, comparison, closing. A mode: "live" page (nephrology) ends in an app button; a
mode: "soon" page ends in the consented sign-up form. Exam name, date and sources come from the
track's entry in tracks.json, never from the page data (see "Exam dates").
The template, the page data and docs/ are not meant to be served: .vercelignore keeps
specialty.template.html, specialties/ and docs/ out of the deployment. Nothing else is excluded —
api/ and scripts/ still deploy.
| Track id | Analytics prefix | Waitlist source / interest | App module |
|---|---|---|---|
nephrology | nephro | — (live: app button) | nephrology |
family-medicine | fm | landing_fm / family_medicine | family |
pediatrics | peds | landing_peds / pediatrics | peds |
cardiology | cardio | landing_cardio / cardiology | cardio |
The dates on the specialty pages and their countdowns live in one place: the exam object of each
entry in tracks.json (date, nameHe, url). No page or script hard-codes a date. The count is
by Israel's calendar day (not UTC), shows הבחינה היום on the day itself, and once the date has
passed shows מועד הבחינה הבא יפורסם בקרוב instead of a negative or stale number. The exam name and
date are also in the served HTML, so crawlers and no-JS visitors see them.
To refresh them:
https://www.ima.org.il/Internship/Events.aspx) and filter by specialty.ועדה מקצועית (a professional committee, not an exam) — and only
part A / Stage A.exam.date for each track in tracks.json.node scripts/render-specialties.mjs and commit tracks.json with the regenerated pages. The
footnote on the pages says the date may change (לפי לוח הבחינות של הר״י · המועד עשוי להשתנות),
so a late move is a data fix, not a redesign.Off that spine sit six standalone legal pages — /privacy, /terms, /accessibility and their
/en counterparts — reachable from every footer.
privacy.html / terms.html and en/privacy.html / en/terms.html are four independent files in
the same hand-authored style as the landing pages, sharing a small .doc stylesheet rather than a
template. They exist because the site collects email addresses from EU visitors by design: the edge
router sends every non-Israeli, non-Hebrew-speaking request to /en, which puts the site squarely
inside the GDPR alongside Israel's Privacy Protection Law (Amendment 13).
What has to stay true as the site changes — each of these is a statement the pages make:
| The pages say | So if you change… |
|---|---|
| Waitlist emails are kept 24 months, or until deletion is requested (30-day turnaround) | …see Handling a deletion request — today that's a manual DELETE against Supabase, not an automated flow, so the 30-day and 24-month promises hold only as long as someone is actually doing that by hand |
Without consent only mx_lang (cookie) and mx_analytics_consent (localStorage) are stored; GA4 and Clarity cookies are set only after opt-in | …any new identifier-based script must load through consent.js and be named in § 3 and § 4 of both pages |
| Processors are Supabase, Vercel, OpenRouter and Anthropic, plus Google (GA4) and Microsoft (Clarity) only with consent, transferring under their DPAs' SCCs | …a new third-party script or backend is a new named processor in § 4 |
[email protected] is answered within 30 days | …the address has to keep reaching a human; it is the only contact point in both documents |
| The controller is "MeduXa" | …on incorporation, name the registered entity here — a trade name alone does not satisfy GDPR Art. 13(1)(a) |
Both documents carry a "last updated" line that should move whenever their substance does. Neither has been reviewed by a lawyer.
/accessibility and /en/accessibility are the accessibility statement, in the same hand-authored
style as the other legal pages. They exist because the site serves the Israeli public, where the
Equal Rights for Persons with Disabilities Regulations (Service Accessibility Adjustments),
5773-2013 expect a business website to meet IS 5568 level AA (which adopts WCAG 2.0) and to
publish a statement saying so. Like the privacy and terms pages, it has not been reviewed by a
lawyer, and no certified accessibility professional (מורשה נגישות) has audited the site — the
statement says both of those in its own text rather than implying otherwise.
The statement is a set of claims about the code, so the code has to keep making them true:
| The statement says | So keep true |
|---|---|
Every interactive element is reachable by Tab and fires on Enter/Space | The demo card's options, chips, send and expand controls are divs with role="button"; their keyboard handling is wired by hand in the page script. A new div control needs the same wiring — see the keydown delegation at the bottom of index.html |
Every page has a skip link, first in the tab order, pointing at <main id="main"> | All eight pages carry <a class="skip" href="#main"> right after <body> and exactly one <main id="main">. A new page needs both |
| Every text colour meets 4.5:1 against its background | Check any new colour before using it. Two were fixed to make this true: the waitlist status line (#94A3B8 → #64748B, 2.56:1 → 4.76:1) and the legal pages' "last updated" line (#64748B → #5B6B80, 4.37:1 → 4.99:1 on #F0F6FA) |
The hero video is silent, decorative and hidden under prefers-reduced-motion | It is muted, aria-hidden="true", and hidden by the reduced-motion media query. Any new motion follows the same rule |
| Section 4 lists the known gaps | It names four, honestly — unmarked lang on Latin terms inside Hebrew (WCAG 3.1.2), the hover-and-click science cards, unreviewed live tutor wording, and third-party DOI links. Fixing one means deleting it from the list; adding a gap means adding it |
Reports go to [email protected], answered within 30 days | Same inbox as the privacy pages. If a dedicated accessibility@ alias is ever created, it changes in both language versions together |
The statement also carries the date it was last checked (currently 2 September 2026) and states that the check was a self-assessment by the development team. That date should move only when someone actually re-checks. Note it deliberately does not name an accessibility coordinator or a phone number: the regulations require appointing one at 25+ employees, so revisit this on hiring.
404.html at the repo root is served by Vercel for every missing path, including paths under
/en, so it is one bilingual file rather than two — the page picks its own language on load, most
explicit signal first:
/en/... was clearly aimed at the English side),mx_lang cookie (a preference the visitor already stated),navigator.language.The edge router's country check cannot be reproduced there. The redirects in vercel.json only
match /, so a 404 never passes through them and x-vercel-ip-country never reaches the page. An
Israeli visitor on an English-language browser therefore lands on the English 404 while the router
would have kept them on Hebrew — the single point where the two disagree, and it is not fixable
client-side.
python3 -m http.server returns its own plain 404 and never reaches this file, so exercise it on a
preview deployment or with a static server that falls back to 404.html.
/ is the Hebrew page and /en is the English one; the choice between them is made by the
Vercel edge router, in the redirects block of vercel.json. Hebrew is the default — a request
for / is sent on to /en only when one of these holds:
| # | Condition | Why |
|---|---|---|
| 1 | Cookie mx_lang=en | The visitor has chosen English before |
| 2 | Accept-Language does not start with he, and x-vercel-ip-country is not IL, and the User-Agent is not a crawler | Neither a Hebrew-language browser nor an Israeli visitor — and a person rather than a bot |
Rule 2 is skipped once the mx_lang cookie exists, so a stated preference always beats a guessed
one. Note it is a single rule with all the conditions at once: an Israeli visitor on an
English-language browser stays on Hebrew, which is the same call the previous English-root version
made in reverse.
The redirects are 307 (temporary) and both pages carry hreflang tags, and that was once
assumed to leave each language independently indexable. It did not. Googlebot crawls from US
addresses and sends no Accept-Language header at all, and it carries no cookie and no query
string — so every one of rule 2's missing conditions held for it and https://meduxa.ai/
answered it with a 307 to /en. Search Console filed the Hebrew homepage under "Page with
redirect", which means never indexed; and because hreflang="he" pointed at a URL that
redirected, the pair could not be read as one cluster either. A 307 tells Google the move is
temporary, but it does not make the redirecting URL indexable in its own right.
The fix is the last missing condition on rule 2: a User-Agent that looks like a crawler. A
bot's UA matches the pattern, so that condition is not "missing", so the rule does not fire and /
serves the Hebrew page with a 200.
This is not cloaking. Googlebot receives at / exactly the bytes an Israeli visitor receives,
/en stays separately crawlable with its own self-referential canonical, and nothing is served to
a bot that a reader cannot reach. The pattern covers search crawlers and also social unfurlers
(facebookexternalhit, WhatsApp, Slack, Telegram, LinkedIn) — without it, a Hebrew link shared
into an Israeli WhatsApp thread was unfurled by following the redirect, so the preview showed the
English title and og-image.png instead of the Hebrew ones.
node scripts/check-crawler-exemption.mjs is the guard: 20 real crawler user-agents must reach /
un-redirected and 7 real browser user-agents must stay routed. Run it after any edit to the
redirects block. It exists because this bug is invisible from Israel — the owner's own
browser satisfies rule 2 and sees a 200 — so nothing short of a request from abroad, or this
check, would notice the condition being dropped again.
The mx_lang cookie (one year, SameSite=Lax) is written client-side by the pages themselves,
since only the router reads it:
/ sets mx_lang=he on load, and strips any lang param back out of the URL (leaving any
utm_* intact) so a copied link doesn't carry the lock to the next person./en sets mx_lang=en on load.Every rule also requires the lang query param to be absent — that is what keeps /?lang=he
from being bounced straight to /en before the cookie can be written. The עברית switcher on /en
links to /?lang=he for exactly that reason.
/he — the address the Hebrew page used to live at — 308s to /?lang=he, not to /. The param
matters: without it, an old Hebrew link followed by someone carrying an mx_lang=en cookie would be
bounced straight to /en, and the explicit link would lose to the stale cookie.
Visitors from before the cookie existed carry mbil_lang in localStorage. /en migrates a stored
he over to the cookie once and sends them back to the root; a stored en needs no migration,
since the router would send them to /en anyway.
Country detection only exists on Vercel. Served locally over
python3 -m http.serverthere is no router, so/always renders Hebrew — test rule 2 on a preview deployment.vercel devdoes not evaluate the geolocationhascondition either.
Everything above is built for exactly two locales — the redirect rules are one binary choice
(mx_lang=en present or not), not a per-language table. Arabic, Russian, and French are on the
roadmap; when one is actually being added, plan for more than a new folder:
vercel.json's redirects need a real per-language rule set instead of the current
he/en binary (cookie value, Accept-Language match, and a fallback per locale).api/tutor.js switches its system prompt on body.locale, currently only
'en' vs. everything-else-is-Hebrew (see SYSTEM_HE / SYSTEM_EN). A new locale needs its own
system prompt, written and reviewed in that language, not a mechanical translation of the Hebrew
or English one — it is instructing a live model on a clinical case..ltr-iso for embedded Latin numerals,
dir handling); Russian and French are LTR like en/index.html.<link rel="alternate" hreflang="..."> block lists all locales, so
each new page means an edit to all the existing pages too, not just a new one.The homepage no longer collects addresses. The signup form in #pilot was removed on 2026-09-07,
when the page collapsed to a single call to action. Someone without an invite code is now offered
the waitlist on the app's own signup screen (app.meduxa.ai), which is also where they would have
been sent to enter a code — one destination instead of two competing ones.
What the form carried is the part that must not be lost in the move. It had a required consent
checkbox and, under it, the notice the Privacy Protection Law (§ 11) wants at the point of
collection: that giving the address is voluntary, what it is used for, who holds it, and that
deletion can be requested. required meant the browser blocked the submit event, so the handler
never saw an address nobody had consented to sending — the check was not in JS and could not be
skipped by a JS error. Consent was then evidenced server-side in waitlist.consent_at (nullable
timestamptz), sent as an ISO timestamp in the insert body, backed by the redundant
if (!val || !consent.checked) return;.
None of that existed on the app side at the time this page stopped collecting, which was tracked
as orilencovsky/Pilot#437 — the app wrote { email, locale } and nothing more, so every new row
carried consent_at = null, i.e. no evidence behind it. That issue was closed on 2026-09-07 by
orilencovsky/Pilot#443, which moved the consent checkbox, the § 11 notice and consent_at onto the
app's signup screen. Rows inserted between the landing form's removal and that merge may still carry
consent_at = null.
Rows already in the table are unaffected, and everything below still applies to them. If a form is
ever added back here, note that adding a new column to the insert body needs the RLS INSERT
policy's with_check to keep allowing it — an unknown column makes PostgREST reject the row with a
400 and the visitor sees the generic failure message.
The "coming soon" pages (/family-medicine, /pediatrics, /cardiology) do collect an address, with
the consent checkbox and the § 11 notice at the point of collection. They call the product's
waitlist_add_interest RPC (p_email, p_interest), and the page's signup.interest is the mark
stored in waitlist.interests (family_medicine, pediatrics, cardiology).
/cardiology must not go live before the product's migration is applied. The interest has to exist
as a row in the product repo's waitlist_interest_keys table (key cardiology, active), added by
orilencovsky/Pilot#1045. Without it a cardiology sign-up is saved with no interest mark, and nobody
sees an error. Gate the deploy on a read-only check:
select key, active from public.waitlist_interest_keys where key = 'cardiology'; must return one
active row. A new "coming soon" specialty needs the same row first.
There is no self-serve deletion endpoint and no DELETE RLS policy on waitlist — the table only
grants INSERT to anon and SELECT (to admins, via is_question_v2_admin()), so the publishable
key used by the form cannot delete a row even if it wanted to. Deletion is a manual, human step:
A request arrives at [email protected] — the only contact point the privacy pages name for
this, in both languages (§ 7).
Whoever holds it opens the Supabase dashboard for the MeduXa project (pappjpdsajkcoqrfqqqx)
→ Table Editor → waitlist, finds the row by the email in the request, and deletes it — or
runs the equivalent delete from waitlist where lower(email) = lower(trim('<address>')); in the
SQL editor. Either path needs a Supabase account with access to this project; there is no
separate admin tool.
The address can also sit in a second table, waitlist_interest_requests (a sign-up on another
bundle's page from an address that was already listed; Pilot docs/claude/supabase-schema.md).
Once the trigger waitlist_delete_interest_requests is live, deleting the waitlist row
deletes those too, in the same transaction. Check with
select 1 from pg_trigger where tgname = 'waitlist_delete_interest_requests'; — if it returns
no row, the trigger has not shipped yet, so also run
delete from waitlist_interest_requests where lower(email) = lower(trim('<address>'));.
Reply to the requester confirming it's done. The privacy pages promise this inside 30 days (§ 5, § 7) — that clock is not enforced by anything technical, so it is on whoever is watching the inbox.
24-month retention (§ 5 of the privacy pages) is likewise not auto-pruned — there is no scheduled
job deleting rows past that age. Until one exists, treat it as a manual housekeeping task: query
select email, created_at from waitlist where created_at < now() - interval '24 months'; from time
to time and delete what comes back the same way as an ad-hoc request.
The question card beside the hero copy is interactive. The visitor picks one of the four answers
and gets the Socratic line written for that option (data-tutor on each .qopt); an expandable
panel lists the full KDIGO staging criteria. From there they may send one message to a live
tutor — by picking one of the suggested chips or by typing — which posts to /api/tutor.
That pick is taken once, the way the exam is sat: it sets the ✓/✕, reveals the right answer
(.qopt.key) if they missed it, spends the one tutor message, and reports demo_answer_selected.
After it the options become a review surface — clicking one only swaps the explanation, and shows
data-review, a neutral line that states the criterion rather than reacting as though they had
just chosen it. So each option needs both attributes, and the verdict, the thread and the
message quota never reset. Re-picking used to reset all three, which let a visitor brute-force
their way to the ✓ and refill the client-side quota at will.
api/tutor.js holds the system prompt server-side, so the endpoint cannot be driven as a
general-purpose chat. It calls OpenRouter (anthropic/claude-haiku-4.5, with
provider.data_collection: 'deny' so prompts only go to providers that don't train on them), not
the Anthropic API directly, since the project's keys are OpenRouter keys. The live chat is
switched off on purpose for now: CHAT_ENABLED = false in both index.html and en/index.html
hides .qchat and makes send() a no-op, so the endpoint is never called (the scripted per-option
explanations still work). To turn it back on, flip that flag in both pages and set a working key as
below. It needs
OPENROUTER_API_KEY set in Vercel → Project → Settings → Environment Variables (Production +
Preview); the old name ANTHROPIC_API_KEY is still read as a fallback and can be deleted once the
new one is set. Without either, the endpoint answers 503 not_configured and the card shows its
"the tutor is taking a short break" message. Spend is bounded on five layers:
| Layer | Limit |
|---|---|
| Client | 1 message per visitor (MAX_MESSAGES), not refilled by re-picking |
| Vercel Firewall | 10 POSTs/10 min per IP on /api/tutor, counted per region; the rule itself lives outside this repo — see docs/firewall-rules.json for a re-appliable record of it |
| Endpoint | Same-origin check (rejects cross-site POSTs); 300 requests/day per-instance kill-switch |
| OpenRouter key | A key used only by this site, with its own credit limit (e.g. $20) set on openrouter.ai → Keys, so the demo can never spend the app's budget |
| Model | anthropic/claude-haiku-4.5, max_tokens: 300 |
At roughly 1–1.5k input + 150 output tokens per reply on Haiku 4.5 ($1/MTok in, $5/MTok out, plus
OpenRouter's fee on credit purchases), each reply costs about $0.002. The hard backstop isn't
the per-instance daily counter — it's the key's credit limit: once it's spent (or the account runs
out of credits) OpenRouter answers 402/403, and the endpoint maps that to 503 paused, which
the card shows as the same "taking a short break" line. Upstream error detail is logged, never sent
to the browser.
The endpoint takes a locale field (he / en) and switches the system prompt on it; each page
sends its own. The pages' copy and the prompt's case notes state the same KDIGO facts, so both
have to be edited together if the demo question ever changes.
Because the card grows as it is answered, the hero videos live inside a .hero-bg wrapper whose
height is pinned once by script — object-fit: cover re-crops on every height change, and without
the pin the footage visibly jumps the moment a visitor answers.
The hero uses two tiers, picked by <source media="(max-width: 600px)">, with WebM before MP4 in
each tier. To regenerate them from a raw clip:
./scripts/prep-hero-video.sh path/to/raw-clip.mp4
The script strips audio, scales to 1600px (desktop) and 800px (phone), and targets roughly
"desktop pair under 5 MB, mobile pair under 1 MB"; the desktop mp4 is capped at 1200 kbps (~1 MB),
since it used to come out at 4.5 MB against a 0.3 MB webm. It needs ffmpeg (brew install ffmpeg).
index.html (Hebrew, the root) and en/index.html are independent
files. A copy or layout change made in one must be mirrored in the other, or the two drift.<=600px breakpoint is tuned against SE-class viewports —
re-check the nav row and the waitlist input at that width after any change.vercel.json's first headers rule applies to every path (/(.*)): X-Content-Type-Options: nosniff,
X-Frame-Options: DENY, Referrer-Policy: strict-origin-when-cross-origin, a Permissions-Policy
turning off camera/microphone/geolocation/payment/usb (none of which this site uses),
Strict-Transport-Security: max-age=63072000 (two years, no includeSubDomains — app.meduxa.ai is
a separate Vercel project, so forcing HTTPS on every subdomain of meduxa.ai from here was judged
out of scope for this repo). A second rule adds an enforced Content-Security-Policy, but only when
the host is meduxa.ai / www.meduxa.ai: preview deployments (*.vercel.app) get no CSP, because
the Vercel Toolbar injects vercel.live scripts and a websocket there that the policy would block.
So a CSP violation only shows up in production — after adding an origin, check the console on
the live site too.
The CSP is built from every external origin the pages actually load or contact, since the site has
no bundler and its inline <script>/<style> blocks mean script-src/style-src need
'unsafe-inline' (no nonces are possible on hand-authored static HTML — a tradeoff, not an
oversight):
https://fonts.googleapis.com (stylesheet) and https://fonts.gstatic.com (the font files it
references) — Google Fonts, every page.https://cdn.jsdelivr.net — the Phosphor Icons stylesheet and the icon font files it references,
the four specialty pages only.https://www.googletagmanager.com and https://www.clarity.ms / https://*.clarity.ms — GA4 and
Microsoft Clarity, loaded by consent.js only after opt-in. Clarity's tag itself loads from a
scripts.clarity.ms subdomain, hence the wildcard rather than just www.https://www.google-analytics.com, https://*.google-analytics.com, https://*.analytics.google.com
— GA4's regional collection endpoints (img-src and connect-src). https://*.googletagmanager.com
is in both too, per Google's gtag.js CSP guide (gtag contacts it once the property has linked
destinations). If Google Signals is ever turned on, add https://stats.g.doubleclick.net.https://*.clarity.ms and https://c.bing.com — Clarity's own data collection (connect-src).https://pappjpdsajkcoqrfqqqx.supabase.co — the specialty-page waitlist inserts (connect-src).Any new third-party origin has to be added to this policy or it is silently blocked — a script,
stylesheet, font, image, or fetch/XHR target that isn't listed simply fails to load, with only a
browser-console CSP violation to explain why. If the new origin is also a tracker, it belongs
alongside consent.js and privacy pages § 4 too, the same rule the Legal pages table above already
states. /_vercel/insights/script.js and /api/tutor are same-origin and covered by 'self'.
Links to app.meduxa.ai are plain navigation (<a href>), which CSP does not restrict, so they need
no entry.
Two layers, with different consent rules.
Vercel Web Analytics — every visitor. Wired inline on both landing pages and on
the four specialty pages (the shim plus /_vercel/insights/script.js). Cookieless, no visitor id, so it
needs no consent. It has to be switched on under Vercel → Project → Analytics; the path is
served by the platform, so it 404s on a local static server. Custom events, all through
va('event', { name, data }):
cta_click — data.placement is nav, hero or closing, read from the button's data-cta
attribute. This is the landing→app click-through measure. A new "start" button needs a data-cta
value in both languages or it is not counted. No #tracks card carries data-cta: from the
specialty-pages change, the nephrology card goes to /nephrology rather than to the app, so a card
click fires track_click only, not cta_click. Between 2026-10-07 and that change the nephrology
card did carry data-cta="hero" (the app's placement vocabulary is closed), so hero counts in
that window include card clicks; from then on hero is the hero button alone. The visit then
reaches the app through /nephrology's own buttons, counted by nephro_app_click below.track_click — data.track is the track id from tracks.json (nephrology,
family-medicine, pediatrics, cardiology), data.via is card (a #tracks card) or chip
(a hero chip). via is deliberately not called placement, so it is never joined with
cta_click's.app.meduxa.ai/?signup=1&src=landing&pl=<placement>. The app reads
src/pl, carries them through signup, and records one row per NEW account in its
signup_sources table (Pilot repo, src/signupSource.js). That is the other half: clicks here,
accounts there. The vocabularies are closed on the app side, so a new placement value needs a
migration there before it is recorded.tutor_reply_shown, demo_answer_selected — the hero demo card.fm_signup — a successful (or already-listed, HTTP 409) waitlist insert on /family-medicine.peds_signup, peds_home_click — the same two events on /pediatrics (data-peds-cta).cardio_signup, cardio_home_click — the same two events on /cardiology (data-cardio-cta).nephro_app_click — is or : which of 's two
"start" buttons sent a visitor to the app (,
the same values the app already accepts). Kept apart from , which is the homepage's
measure, so the two funnels are not mixed.Google Analytics 4 + Microsoft Clarity — only after opt-in. The specialty pages' sample-question
widget sends <prefix>_sample_question (fm_, peds_, nephro_, cardio_) through gtag when a
visitor picks an option, with { result, answer, q }: result is right or wrong, answer the
option key picked, q the question's number (1-based; fm_sample_question gained q with the
specialty-pages change). It fires only when gtag exists, i.e. after consent. Every page loads /consent.js
instead of the tags themselves. It shows a bilingual banner (accept and decline with equal weight),
stores the choice in localStorage.mx_analytics_consent, and injects GA4 (G-XGGFVEFFWZ) and
Clarity (yhjkv4kj7h) only on granted. Undecided or denied loads nothing. The privacy pages
link to window.mxConsent.open() so a visitor can change their mind; withdrawing a grant reloads
the page, since a loaded tracker cannot be unloaded. Why a gate: both trackers set cookies and a
persistent id, and the edge router sends EU traffic to /en (GDPR), on top of Israel's Amendment 13.
GA4/Clarity were first added without a gate on 2026-09-17 (PR #25) while § 3 still said there was no
tracking; this gate and the § 2–§ 4 rewrite closed that. Neither the gate nor the privacy text has
been reviewed by a lawyer.
Private project. All rights reserved.
data.placementheroclosing/nephrologyapp.meduxa.ai/?signup=1&src=landing&pl=hero|closingplcta_clicknephro_home_click — data.placement is logo or nav: which route back to the homepage a
/nephrology visitor used (data-nephro-cta).fm_home_click — data.placement is logo or nav, read from the link's data-fm-cta
attribute: which route back to the homepage a /family-medicine visitor used. Kept separate from
cta_click since these links go to /, not to the app, so they are not part of that
landing→app click-through measure.