Non-Chromium Browser (Safari, Firefox) UI Compatibility Report¶
Scope:
src/services/web/(Angular 21 SPA). The audit covered four dimensions in parallel; full per-dimension findings live inaudits/. This document is the synthesised cross-cutting report.Sub-audits (load-bearing detail):
- 01 — CSS — 18 findings
- 02 — JavaScript / Web APIs — 14 findings
- 03 — Angular / Material / 3rd-party — 18 findings
- 04 — Build / Config — 11 findings
- 05 — Layout (grid/flex sizing) — 2 findings (added in response to user-reported "What's New" overlap bug)
Executive Summary¶
The web app's non-Chromium breakage has one root cause and three independent fault families.
The root cause is the build configuration: there is no .browserslistrc anywhere in the repo, tsconfig.target is ES2022, and polyfills.ts contains a single import 'zone.js' line with a comment that still claims "Safari ≥ 10" support. In combination this means:
- The Angular CLI default browserslist applies (last 2 Safari major / last 1 Firefox / Firefox ESR), so anyone on Safari 16 or earlier is already off the supported matrix without anyone explicitly choosing that.
- TypeScript does not down-emit beyond its
target; ES2022 syntax (#privatefields, top-levelawait,Object.hasOwn,Array.prototype.at,Error.cause) is shipped verbatim and throwsSyntaxError/TypeErroron Safari ≤ 15. - No runtime shims exist for the API surface that the source actually uses (
structuredClone,requestIdleCallback, the legacyIntersectionObserver/ResizeObserverfloor).
Fault family 1 — Safari ITP / Firefox ETP storage and cookie partitioning. Auth0's silent-logout iframe, refresh-token cache in localStorage, third-party-cookie-dependent token rotation, and the 7-day Safari redirect-tracking purge all degrade. Effects: forced re-logins every ~7 days on Safari, sign-out doesn't actually invalidate the IdP session, repeated QuotaExceededError in Safari Private Browsing.
Fault family 2 — touch / iPad parity. The question-management designer reimplements drag-and-drop using native HTML5 drag events. iOS Safari never fires dragstart from touch, so the designer is fully non-functional on iPad without a Bluetooth mouse. Other parts of the codebase already use @angular/cdk/drag-drop (which is pointer-event based and works on touch); standardising on CDK is the only viable fix.
Fault family 3 — CSS prefix and feature-gate gaps. backdrop-filter is unprefixed, so the modal cover doesn't blur on any Safari version. input:has(+ mat-checkbox) { display: none } fails-open on Safari < 15.4 and Firefox < 121, surfacing duplicate native checkboxes. Several 100vh declarations cause action buttons to disappear behind iOS Safari's collapsing toolbar. A handful of CSS Transforms Level-2 properties (rotate:, translate:) are used without the shorthand fallback.
There are also two shipping bugs that aren't strictly Chromium-specific but surface visibly per browser:
study-table.effects.ts:74callsmsSaveOrOpenBlobwithout atypeofguard — aTypeErroron every non-IE browser the moment that path executes.timepoint-array.component.html:1ships a stray<script src="../../../../../../../../../Desktop/main-es5.0d374fcad9b6d03574d7.js">tag — a developer's accidental commit. Surfaces as a noisy CSP/mixed-content warning per browser; harmless but inflammatory.
Fault family 4 — metric-sensitive layout that Chromium happens to render narrowly enough to fit. Added in response to a user-reported "dates overlap and spill into release titles in the What's New section on Safari and Firefox" bug. The home-page release timeline at whats-new.component.scss:222 uses a fixed-rem grid track (auto 7.5rem 1fr auto) with white-space: nowrap + font-variant-numeric: tabular-nums + font-feature-settings: 'ss01' 1. The 'ss01' stylistic-set is a Roboto Flex feature that the regular Roboto loaded by index.html doesn't implement; Chromium silently ignores it, while Safari and Firefox shift glyph metrics by ~1–2 px per character — enough over an 18-character date string to overflow the 7.5 rem (120 px) track. The fix is to move the date column to max-content and the title column to minmax(0, 1fr) and to drop the 'ss01' request that does nothing in Roboto. See LAYOUT.F1. A sweep of the rest of the codebase for the same anti-pattern (fixed-rem grid track + nowrap + tabular-nums) returned zero further occurrences — the bug is isolated to one component.
Total: 63 findings — 8 critical, 28 major, 27 minor. Browser exposure: Safari 55 findings, Firefox 35, both 27.
Findings Master List¶
Findings are tagged <dimension>.F<n> (e.g. CSS.F1) — see the linked sub-audit for full detail.
Critical (8)¶
| ID | Title | File | Browsers | Effort |
|---|---|---|---|---|
BUILD.F1 |
No .browserslistrc — implicit narrow default |
repo-wide | Safari ≤ 15, Firefox ESR tail | trivial |
BUILD.F2 |
tsconfig target: ES2022 ships unparseable syntax |
tsconfig.json:41 | Safari ≤ 15.3 | low |
JS.F1 |
msSaveOrOpenBlob invoked without typeof guard — TypeError |
study-table.effects.ts:74-90 | all non-IE | trivial |
JS.F2 |
dragstart never calls dataTransfer.setData() |
annotation-question-tree-drag-drop.feature.ts:509-527 | Firefox | trivial |
CSS.F1 |
backdrop-filter missing -webkit- prefix on .cover |
app.component.scss:12 | Safari (all), Firefox < 103 | trivial |
NG.F1 |
Auth0 silent-logout iframe broken under ITP / ETP | auth.effects.ts:600-619 | Safari, Firefox | medium |
NG.F11 |
annotation-question-tree-drag-drop.feature.ts:140-310 | Safari (iPad/touch) | n/a | |
NG.F15 |
Stray <script src="../../../Desktop/main-es5..."> tag |
timepoint-array.component.html:1 | all (CSP-noisy on FF/Safari) | trivial |
Major (27 — top contributors)¶
| ID | Title | File | Browsers | Effort |
|---|---|---|---|---|
BUILD.F3 |
polyfills.ts only imports zone.js |
polyfills.ts:1-58 | Safari ≤ 15, Firefox ≤ 95 | medium |
BUILD.F4 |
Production optimization shorthand leaves inlineCritical: true (Beasties drops @supports) |
angular.json:60-62 | Safari | trivial |
BUILD.F5 |
useDefineForClassFields: false is a fig leaf at ES2022 |
tsconfig.json:45 | Safari 14, Firefox ≤ 89 | low |
BUILD.F6 |
user-agent-data-types global types signal Chromium-only API use |
tsconfig.json:28 | Safari, Firefox | medium |
JS.F3 |
navigator.userAgentData fallback parser misorders Edge/Chrome |
client-environment.service.ts:31-46 | Safari, Firefox | low |
JS.F4 / NG.F10 |
PDF.js v4 worker as .mjs (module worker) |
pdf-loader.service.ts:5-13 | Safari < 16.4, Firefox < 114 | medium |
JS.F5 |
crypto.subtle.digest no secure-context guard |
s3-file.service.ts:237-245 | all on non-HTTPS | low |
JS.F6 |
4 navigator.clipboard.writeText call sites without fallback or .catch() |
profile.component.ts:264, project-overview.component.ts:175, study-table.component.ts:732, systematic-searches.component.ts:156 | Safari, Firefox | medium |
NG.F2 |
Auth0 cacheLocation: 'localstorage' — Safari Private Mode quota |
main.ts:404-406 | Safari Private | low |
NG.F3 |
SignalR no explicit transport — silent fallback to LongPolling | signal-r.service.ts:189-199 | Safari, Firefox | low |
NG.F4 |
Hidden <input type="file" fxHide> + programmatic click() |
review-data-upload.component.html:84-93, search-decision-upload.component.html:108-120 | Safari iOS | trivial |
NG.F6 |
MAT_DATE_LOCALE set to navigator.language (Safari often returns territory-less code) |
contact-us.component.ts:286-292, risk-of-bias-job-table.component.ts:47-51 | Safari, Firefox | low |
NG.F7 |
ngx-image-cropper@6.3.3 mishandles iOS EXIF orientation |
image-cropper-dialog.component.html:1-12 | Safari iOS | low |
NG.F8 |
handsontable@^14.4.0 clipboard via execCommand |
timepoint-spreadsheet.component.ts:132-147 | Safari, Firefox | medium |
NG.F17 |
Unconditional localStorage writes for crash-recovery |
app.component.ts:330+, error-recovery-dialog.component.ts:28-32 | Safari Private | low |
NG.F18 |
Auth0 custom-domain CNAME — confirm same eTLD+1 to bypass ITP | main.ts:401 | Safari, Firefox | low (config) |
CSS.F2 |
Bare rotate: / translate: properties (no shorthand fallback) |
question-node.component.scss:30, 3 others | Safari < 14.1 | trivial |
CSS.F3 |
overflow-y: clip not in Safari < 16 |
email-templates.component.scss:16 | Safari < 16 | trivial |
CSS.F4 |
Bare :has() selector hides input that user must use |
styles.scss:362 | Safari < 15.4, Firefox < 121 | low |
CSS.F5 |
100vh should be dvh/svh on iOS |
stage-reconcile.component.scss:7, stage-assign.component.scss:2 | Safari iOS | trivial |
LAYOUT.F1 |
What's New "Earlier releases" date overflows into title (user-reported) | whats-new.component.scss:222,286-293 | Safari, Firefox | trivial |
(11 more major findings in the sub-audits; not all reproduced here.)
Minor (26)¶
Cosmetic, defensive, or already-mitigated-by-modern-browsers items. Examples: dead -moz-/-ms- prefixes in legacy Bootstrap partials, position: -webkit-sticky next to position: sticky in one file but not others, <input type="search"> Safari forced styling. See sub-audits for the full list.
Cross-Cutting Patterns¶
- The build config is the largest single lever.
BUILD.F1-F4collectively dominate the impact: fixing them removes silent breakage on Safari ≤ 15 without touching a single source file. - Auth flows that depend on iframes / third-party cookies are universally fragile in non-Chromium.
NG.F1,NG.F2,NG.F18are three views of the same problem. Thebff-auth.provider.tspathway is the long-term answer — push more environments onto BFF mode. - Two parallel drag-and-drop systems exist. Native HTML5 (broken on touch) in the question-tree designer; CDK (works) elsewhere. Standardise on CDK.
localStorageis used unconditionally throughout. Safari Private Mode quota and Firefox ETP both throw. AsafeStoragewrapper would resolve this once.- Clipboard handling is duplicated 8 ways. Only 2 of 8 sites have a fallback; consolidating into a
ClipboardServiceresolvesJS.F6+JS.F10+NG.F8's clipboard subset together. - No
@supportsblocks anywhere. Adding feature gates around:has()andbackdrop-filterwould harden critical paths without blocking modern browsers. - The codebase mixes pre-modern (
-moz-keyframes,-ms-touch-action) and bleeding-edge (100svh,:has(), individualtranslate:) CSS. A pass that removes dead vendor prefixes (CSS.F16-F18) and adds the prefixes Safari still needs (CSS.F1) would make rendering predictable.
Remediation Roadmap¶
The roadmap is organised by independent change sets that can ship in separate PRs and unlock most of the user-visible improvement quickly.
Wave 1 — Build configuration sprint (~1-2 days, single PR)¶
Highest blast radius for least risk. All in src/services/web/.
- Add
.browserslistrc(suggested content inBUILD.F1). - Lower
tsconfig targetfromES2022toES2020; keepuseDefineForClassFields: false. - Audit source for
structuredClone,Array.prototype.at,Object.hasOwn,requestIdleCallback; add only thecore-jspolyfill imports needed. - Set
optimization.styles.inlineCritical: falsein production (angular.json). - Delete dead
custom-webpack.config.jsand rewrite the stalepolyfills.tsheader comment. - Decide on
user-agent-data-types: either remove globally and import locally, or add an ESLint rule banningnavigator.userAgentDataoutsideclient-environment.service.ts.
Verification: Build under the new config; diff bundle output; smoke-test on Safari 15 (BrowserStack).
Wave 2 — CSS quick wins (~half a day, single PR)¶
All trivial-effort CSS findings, batched. Ship together because they all touch styling.
CSS.F1— Add-webkit-backdrop-filterand a colour fallback to.cover.CSS.F2— Replace barerotate:/translate:withtransformshorthand (4 files).CSS.F3— Replaceoverflow-y: clipwithoverflow-y: hiddenin email templates.CSS.F4— Wrapinput:has(+ mat-checkbox)in@supports selector(:has(*))(and follow-up: refactor to apply a class directly).CSS.F5—100vh→100dvhin stage-reconcile and stage-assign.CSS.F6— Add standardline-clampnext to-webkit-line-clamp(5 files; defensive).CSS.F7— Drop deprecated-webkit-overflow-scrolling: touch.CSS.F11— Add-webkit-clip-pathnext toclip-path: polygon(...)in banner.CSS.F13— Addappearance: textfieldreset for<input type="number">to remove Safari spinner artifacts.LAYOUT.F1— Replacegrid-template-columns: auto 7.5rem 1fr autowithauto max-content minmax(0, 1fr) autoinwhats-new.component.scss:222; drop thefont-feature-settings: 'ss01' 1line at:29(regular Roboto doesn't implement that stylistic set, it only contributes metric drift); add ellipsis safety net to.whats-new__past-date.LAYOUT.F2— Addoverflow-wrap: anywhereto.whats-new__release-labelfor narrow desktop viewports.
Wave 3 — Single-line correctness fixes (~half a day, single PR)¶
JS.F1— ReplacemsSaveOrOpenBlobblock withsaveBlob()import or delete the dead branch.JS.F2— AdddataTransfer.setData('text/plain', draggedQuestionId)at start of question-treedragStart.JS.F8— Drop the boolean from(window.location.reload as any)(true).JS.F11— WrapResizeObserverconstruction intypeof !== 'undefined'guard.NG.F4— ReplacefxHideon hidden file inputs with sr-only positioning (or<label for>pattern).NG.F5— Normaliseacceptstrings to lowercase MIME + dotted extensions.NG.F12— Reset Safari<input type="search">styling (or changetypetotext).NG.F13— NotescrollIntoView({behavior:'smooth'})Safari < 15.4 graceful degradation; no fix needed.NG.F15— Delete the stray<script src="../../Desktop/...">line fromtimepoint-array.component.html:1.NG.F16— Addapple-mobile-web-app-*meta tags,theme-color,apple-touch-icontoindex.html.
Wave 4 — Auth fragility sprint (~1 sprint, multi-PR)¶
These are tied together by Safari ITP / Firefox ETP root cause.
NG.F2— Switch Auth0cacheLocationto'memory'; rely on refresh tokens.NG.F18— Confirmauth0CustomDomainresolves to a same-eTLD+1 host in staging and prod (likelyauth.syrf.org.uk); fix infra if not.NG.F1— Replace silent-logout iframe with a top-level redirect vialogout({ federated: false, openUrl })OR move logout to the same-site sub-domain.NG.F17— Introduce asafeStoragewrapper used everywherelocalStorageis accessed.- Long-term: prioritise BFF auth provider migration for browsers — see
bff-auth.provider.tsalready in the codebase.
Wave 5 — Touch / iPad parity — ~1 sprint~~ DROPPED¶
No longer required: question-management v1 (the only consumer of this code) is behind a feature flag and is being replaced by v2 in PRs #2461, #2572, #2573, #2574, #2575. Touch parity will be a property of the v2 implementation; no migration of v1 needed.NG.F11— Migrate question-tree designer from native HTML5 drag-and-drop to@angular/cdk/drag-drop.
Wave 6 — Library bumps (~1-2 days, separate PRs)¶
JS.F4/NG.F10— Either downgrade pdfjs-dist to 3.x, switch to itslegacy/build/pdf.worker.min.js, or document the Safari 16.4+ / Firefox 114+ floor.NG.F7— Bumpngx-image-cropperto ≥ 7.x for native EXIF handling.NG.F8— Bump Handsontable to ≥ 15 fornavigator.clipboardAPI.
Wave 7 — Hardening / consolidation (ongoing)¶
- Introduce a
ClipboardServiceconsolidatingJS.F6,JS.F10, and Handsontable's clipboard fallback path. - Improve
MAT_DATE_LOCALEresolution (NG.F6). - Add Sentry breadcrumb on SignalR transport fallback (
NG.F3) so we can detect Safari-only LongPolling rates. - Self-host Font Awesome / migrate to Material Symbols (
BUILD.F10). - Plan a separate epic to retire
@ngbracket/ngx-layout(fxFlex,fxLayout) —CSS.F14/NG.F14.
Prevention Recommendations¶
To stop new regressions landing:
- Commit the
.browserslistrc. It is the single source of truth for autoprefixer, lightning-css, esbuild, and core-js. Reviewers can reason about compatibility from one file. - Add a stylelint rule disallowing
:has(),backdrop-filter,overflow: clipoutside an@supportsblock. (Existing sites grandfathered in via// stylelint-disableuntil Wave 2 lands.) - Add an ESLint rule banning
navigator.userAgentData,msSaveOrOpenBlob, andElement.prototype.scrollIntoViewIfNeededoutside the one feature-detection module that's allowed to use them. - Expand the Playwright E2E matrix beyond Chromium. The repo already runs Playwright in CI (
e2e/). Addwebkitandfirefoxprojects toplaywright.config.tsand run them on the smoke label so regressions show up before merge. - Add a "Browser support" section to the web service README stating the explicit minimum versions and what's polyfilled. Future maintainers will know whether to add new polyfills or refactor.
- Add a recurring browser-tail review (every 6 months) — drop versions with < 0.5% real-world traffic from
.browserslistrc, lifttsconfig targetaccordingly, and trim now-unneeded polyfills.
Status¶
- PR opened with this planning doc
- CSS audit complete — see
audits/01-css-audit.md - JS / Web API audit complete — see
audits/02-js-webapi-audit.md - Angular / template audit complete — see
audits/03-angular-template-audit.md - Build / config audit complete — see
audits/04-build-config-audit.md - Layout audit (user-reported What's New bug + sweep) — see
audits/05-layout-audit.md - Findings synthesised into final report (this document)
- Remediation roadmap drafted
- Wave 2 (CSS quick wins) folded into this PR — see commit history
- Wave 3 (single-line correctness fixes) folded into this PR — see commit history
- Roadmap reviewed by team — await sign-off before scheduling Waves 1, 4–7
- Tickets opened for Wave 1, 4-7
Waves shipped in this PR¶
| Wave | Findings shipped | Verification |
|---|---|---|
| Wave 2 | CSS.F1, F2, F3, F4, F5, F6, F7, F11, F13; LAYOUT.F1, F2 | pnpm exec ng build passes; affected component tests pass |
| Wave 3 | JS.F1, F2, F8; NG.F4, F5, F12, F15, F16 | typecheck + build pass; affected component tests pass |
Notes on what was deliberately not folded in¶
- JS.F11 (
ResizeObserverguard) skipped. The audit flagged this asminor — modern Safari/Firefox fine;ResizeObservershipped in Safari 13.1 and Firefox 69, both well below the Safari 14 / iOS 14 baseline that Wave 1 will set. Once Wave 1 lands, this finding is fully resolved by the explicit baseline. - NG.F13 (
scrollIntoView({behavior:'smooth'})) has no concrete fix — graceful degradation on Safari < 15.4 is acceptable. No code change needed.
Findings against question-management v1 — not blocking¶
The current question-management implementation (everything under src/services/web/src/app/project/project-admin/question-management/design/) is behind a feature flag and will never reach production. It is being replaced by question-management v2, in flight across PRs #2461, #2572, #2573, #2574, #2575. Compatibility findings against v1 code therefore do not need to drive any new work; v2 supersedes them.
Findings affected:
- JS.F2 —
dragstartsetData fix inannotation-question-tree-drag-drop.feature.ts. Already shipped in this PR as a defensive one-liner; harmless if v1 is later deleted. - NG.F11 — full HTML5 → CDK DnD migration of the question-tree designer. Drop from the Wave 5 roadmap — v2 will use a different drag system entirely.
- CSS.F2 —
question-node.component.scss:30(one of four files in this finding). Already shipped; the other three CSS.F2 sites (styles.scss,project-overview.component.scss,create-project-wizard.component.scss) are unrelated to v1 and remain valid.
Other findings in this report — including NG.F8 (Handsontable in shared/annotation/annotation-form/annotation-experiment-question/outcome-table-info/ and shared/form-controls/timepoint-spreadsheet/) — touch runtime annotation code shared by both v1 and v2, so they remain valid.