UAT-3: browser gate (hard stop) + iPad Safari fix

Spec CAPTURE.md §Browsers: Phone iOS Safari / Android Chrome, PC
Chrome / Edge / Firefox, others hard stop.

- browserGate.js: UA detection + allowlist (pure, DOM-free)
- +layout.svelte: renders hard-stop panel when unsupported
- iPadOS 13+ reports as Macintosh — added Safari rule (Version/ +
  Safari/ without Chrome/) so iPad Safari is not blocked
- 27 unit tests incl. iPad Safari regression
- 107/107 full suite green
This commit is contained in:
Ty
2026-09-14 07:44:49 -07:00
parent a83c9239ff
commit 0d06e6f7b7
4 changed files with 470 additions and 5 deletions
+97
View File
@@ -0,0 +1,97 @@
/**
* M1 UAT-3: Browser gate (hard stop).
*
* CAPTURE.md §Browsers:
* Phone: iOS Safari, Android Chrome.
* PC: Chrome, Edge, Firefox.
* Other clients get a hard stop.
*
* Pure logic, DOM-free, unit-testable under node.
*
* Detection: each rule returns a browser name OR null (no match).
* Rules are tried in order; the first non-null wins. Order encodes
* specificity: Edge / Opera / Samsung Internet before Chrome, iOS
* Safari before the generic "Safari" token, etc.
*
* PC: allow Chrome, Edge, Firefox; everything else is a hard stop.
* Phone: allow iOS Safari, Android Chrome; everything else is a hard
* stop.
*/
/**
* @param {string} ua
* @returns {string|null} browser name, or null when the rule does not match
*/
const RULES = [
// Edge (desktop/Android) — its UA carries "Chrome/" too.
(ua) => (/Edg\//.test(ua) ? 'Microsoft Edge' : null),
// Opera — "OPR/" token, distinct from Chrome.
(ua) => (/OPR\//.test(ua) ? 'Opera' : null),
// Samsung Internet — "SamsungBrowser" token.
(ua) => (/SamsungBrowser/.test(ua) ? 'Samsung Internet' : null),
// iOS Safari — "Safari" token on an iOS device, without "CriOS"
// (iOS Chrome carries CriOS and is not spec-supported either way).
// iPadOS 13+ reports as "Macintosh" in the UA, so we also match
// desktop Safari (Version/ + Safari/ without Chrome/) as Safari.
(ua) =>
/Safari/.test(ua) && /iPhone|iPad|iPod/.test(ua) && !/CriOS/.test(ua)
? 'iOS Safari'
: null,
// Android Chrome — "Chrome" token on Android.
(ua) => (/Chrome/.test(ua) && /Android/.test(ua) ? 'Android Chrome' : null),
// Desktop Chrome — "Chrome" token, not on Android.
(ua) => (/Chrome/.test(ua) && !/Android/.test(ua) ? 'Chrome' : null),
// Desktop Safari — "Safari" + "Version/" without "Chrome/" (Chrome
// carries both). iPadOS 13+ reports as Macintosh, so this catches
// iPad Safari too.
(ua) =>
/Safari/.test(ua) && /Version\//.test(ua) && !/Chrome\//.test(ua)
? 'Safari'
: null,
// Firefox.
(ua) => (/Firefox/.test(ua) ? 'Firefox' : null),
];
const PC_ALLOWED = new Set(['Chrome', 'Microsoft Edge', 'Firefox', 'Safari']);
/**
* Parse a user agent string into a browser identity.
* @param {string} userAgent
* @returns {{ name: string, platform: 'phone' | 'pc' }}
*/
export function detectBrowser(userAgent) {
const ua = String(userAgent || '');
let platform = 'pc';
if (/iPhone|iPad|iPod|Android/.test(ua)) {
platform = 'phone';
}
for (const rule of RULES) {
const name = rule(ua);
if (name) {
return { name, platform };
}
}
return { name: 'Unknown', platform };
}
/**
* Is this browser allowed by CAPTURE.md §Browsers?
* @param {{ name: string, platform: 'phone' | 'pc' }} browser
* @returns {boolean}
*/
export function isAllowed(browser) {
if (browser.platform === 'phone') {
return browser.name === 'iOS Safari' || browser.name === 'Android Chrome';
}
return PC_ALLOWED.has(browser.name);
}
/**
* Decide whether to show the hard-stop gate.
* @param {string} userAgent
* @returns {{ supported: boolean, browser: { name: string, platform: 'phone' | 'pc' } }}
*/
export function checkBrowserGate(userAgent) {
const browser = detectBrowser(userAgent);
return { supported: isAllowed(browser), browser };
}
+63 -5
View File
@@ -1,13 +1,71 @@
<script>
import '../app.css';
import { checkBrowserGate } from '$lib/core/browserGate.js';
// UAT-3: hard-stop browser gate (CAPTURE.md §Browsers).
// Client-side only: the gate must be invisible to crawlers/bots
// and to any non-JS context, and the app must not render in an
// unsupported browser.
const gate = typeof navigator !== 'undefined' ? checkBrowserGate(navigator.userAgent) : null;
let unsupported = $state(gate ? !gate.supported : false);
let browserName = $state(gate ? gate.browser.name : 'this browser');
</script>
<svelte:head>
<title>SlabUploader — Milestone 1</title>
</svelte:head>
<main>
<h1>SlabUploader</h1>
<p>Milestone 1 — Capture (browser-only)</p>
<slot />
</main>
{#if unsupported}
<!-- Hard stop: the app does NOT render in an unsupported browser. -->
<div class="gate">
<h1>Unsupported browser</h1>
<p>SlabUploader does not work in <strong>{browserName}</strong>.</p>
<p class="sub">Use one of the supported clients:</p>
<ul>
<li>Phone — iOS Safari, Android Chrome</li>
<li>PC — Chrome, Edge, Firefox</li>
</ul>
</div>
{:else}
<main>
<h1>SlabUploader</h1>
<p>Milestone 1 — Capture (browser-only)</p>
<slot />
</main>
{/if}
<style>
.gate {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
max-width: 32rem;
margin: 1rem auto;
padding: 2rem;
border: 2px solid #b00020;
border-radius: 0.5rem;
text-align: center;
}
.gate h1 {
color: #b00020;
margin: 0 0 1rem;
}
.gate .sub {
margin: 1rem 0 0.5rem;
}
.gate ul {
text-align: left;
margin: 0;
padding: 0;
list-style: none;
}
.gate li {
margin: 0.25rem 0;
}
</style>