Skip to content

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

EngineMinimumReleasedDetected via
Chromium-based (Chrome, Edge, Opera, Brave, SamsungBrowser, NetCast, Android WebView)111Mar 2023Chrome/(\d+) in UA
Firefox (desktop, Android)113May 2023Firefox/(\d+) in UA
Safari (macOS)16.2Dec 2022Version/(\d+)\.(\d+) … Safari/ in UA
iOS / iPadOS (all browsers — they all use system WebKit)16.2Dec 2022CPU (iPhone|iPad) OS X_Y in UA
Unknown / unrecognised enginen/adefault-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

LayerFeatureFailure 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 — secondarycolor-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 — fineoklch() (Chrome 111+)Already auto-transpiled to #hex fallbacks by Lightning CSS — not a blocker.
JSES2022 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-language cookie 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.