Browser Support
The patient portal targets modern browsers. Older browsers (notably old smart-TV kiosk browsers) are explicitly blocked at the proxy and shown a static "unsupported browser" page instead of a broken portal.
Minimum versions
| Engine | Minimum | Released | Detected via |
|---|---|---|---|
| Chromium-based (Chrome, Edge, Opera, Brave, SamsungBrowser, NetCast, Android WebView) | 111 | Mar 2023 | Chrome/(\d+) in UA |
| Firefox (desktop, Android) | 113 | May 2023 | Firefox/(\d+) in UA |
| Safari (macOS) | 16.2 | Dec 2022 | Version/(\d+)\.(\d+) … Safari/ in UA |
| iOS / iPadOS (all browsers — they all use system WebKit) | 16.2 | Dec 2022 | CPU (iPhone|iPad) OS X_Y in UA |
| Unknown / unrecognised engine | n/a | — | default-deny |
The threshold is set by the strictest CSS feature in the compiled Tailwind v4 bundle: color-mix(in srgb, …). Lightning CSS emits 198 color-mix() calls in our production CSS (every /opacity utility — bg-foreground/95, border-border/50, etc.). Engines that ship color-mix also ship @layer (older) and oklch() (concurrent). Pinning to color-mix covers the whole Tailwind v4 + Next 16 / React 19 stack.
Default-deny on unknown engines is intentional. Pre-Chromium WebKit smart TVs, classic Edge (EdgeHTML), IE/Trident, and homebrew kiosk firmware all fall into "no recognised modern-engine signature." A clear unsupported page is a better failure mode than a half-broken portal.
What was breaking
| Layer | Feature | Failure on old engine |
|---|---|---|
| CSS — primary | @layer (Chrome 99+, Firefox 97+, Safari 15.4+) | Wraps every Tailwind rule. Old engines drop the entire block contents → no utilities apply → page renders as unstyled HTML. |
| CSS — secondary | color-mix() (Chrome 111+, Firefox 113+, Safari 16.2+) | 198 occurrences from Tailwind 4 opacity utilities. Even with @layer fixed, transparency variants would still drop. |
| CSS — tertiary | @property (Chrome 85+) | 88 typed-custom-property declarations. CSS variables still resolve as strings; transitions on Tailwind utilities misbehave. Not load-bearing — gracefully degrades. |
| CSS — fine | oklch() (Chrome 111+) | Already auto-transpiled to #hex fallbacks by Lightning CSS — not a blocker. |
| JS | ES2022 bundle output (private fields, top-level await, Array.prototype.at) | Chrome ≤56 / pre-Chromium WebKit parse-fail the bundle → no React hydration → buttons dead. Clerk client bootstrap also fails → blank sign-in. |
How it works
Detection (proxy)
apps/portal/lib/browser-support.ts — single source of truth for the gate. classifyBrowser(ua) returns { unsupported, detected, reason }. Used by:
apps/portal/proxy.ts— the actual gate.apps/portal/app/dev/tv/route.ts— the diagnostic, which shows what the gate would do for the current UA.
The check sits after org resolution in the proxy. That order matters: an invalid hostname returns a clean 404 (regardless of browser); only requests with a valid org get the unsupported rewrite. Org cookies (org-name, language, ...) are still set on the rewrite response so /unsupported can show clinic context and the right locale.
Block page (/unsupported)
apps/portal/app/unsupported/route.ts — pure HTML route handler (no Next layout chain, no Tailwind, no Clerk, no React hydration). Inline styles only. Renders:
- Title + body in the user's language (Romanian default, English if Accept-Language signals it, with the clinic's
org-languagecookie taking precedence). - A QR code of the portal URL (via external
api.qrserver.com). - The URL in large monospace as a load-bearing fallback if the QR API is unreachable.
- A
<details>"Why am I seeing this?" with the version threshold.
Bypassed in proxy.ts so it never recurses into itself.
Diagnostic (/dev/tv)
apps/portal/app/dev/tv/route.ts — also a pure HTML route handler. Shows:
- The UA, the proxy's classification (
BLOCKED/ALLOWED), and the threshold reason. - Four CSS visual-feature checks (text turns colour iff the feature parses).
- Fourteen JS feature-detection rows, each in its own
new Function()try/catch so a single SyntaxError doesn't kill the rest. - A 1-Hz ticking clock as proof JS is alive.
- Inline-vs-bare comparison blocks.
/dev/* is exempted from the proxy's unsupported-browser rewrite, so /dev/tv is reachable even on UAs that would otherwise be blocked.
Updating the threshold
When raising or lowering the bar, change CHROMIUM_MIN / FIREFOX_MIN / SAFARI_MIN_MAJOR / SAFARI_MIN_MINOR / IOS_MIN_MAJOR / IOS_MIN_MINOR in apps/portal/lib/browser-support.ts. The diagnostic at /dev/tv reads from the same module, so it stays in sync.
If new Tailwind / React / Next features push the floor higher, update this table along with the constants.
What this is NOT
- It is not a polyfill or compatibility shim. We're not transpiling. Modern browsers are unaffected; old ones get a separate static page.
- It is not a tier-gating mechanism. There's no "lite mode" for old browsers — the portal is too feature-dense for that to be meaningful.
- It is not a final answer for the smart-TV kiosk story. Once the phone-tv-companion design ships, an old TV will become a presentation-only iframe driven by a phone, and the TV's browser-support bar drops to "can render an iframe" — much lower than the portal's bar. The kiosk surface will live under a separate route (e.g.
/k/...) outside this gate.