diff --git a/frontend/src/lib/core/browserGate.js b/frontend/src/lib/core/browserGate.js
new file mode 100644
index 0000000..5cb2790
--- /dev/null
+++ b/frontend/src/lib/core/browserGate.js
@@ -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 };
+}
diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte
index 2f7c02c..9fd99fb 100644
--- a/frontend/src/routes/+layout.svelte
+++ b/frontend/src/routes/+layout.svelte
@@ -1,13 +1,71 @@
SlabUploader — Milestone 1
-
- SlabUploader
- Milestone 1 — Capture (browser-only)
-
-
+{#if unsupported}
+
+
+
Unsupported browser
+
SlabUploader does not work in {browserName}.
+
Use one of the supported clients:
+
+ - Phone — iOS Safari, Android Chrome
+ - PC — Chrome, Edge, Firefox
+
+
+{:else}
+
+ SlabUploader
+ Milestone 1 — Capture (browser-only)
+
+
+{/if}
+
+
+
diff --git a/frontend/tests/e2e/browser-gate.spec.js b/frontend/tests/e2e/browser-gate.spec.js
new file mode 100644
index 0000000..fd7a5b5
--- /dev/null
+++ b/frontend/tests/e2e/browser-gate.spec.js
@@ -0,0 +1,109 @@
+import { test, expect, devices } from '@playwright/test';
+
+// UAT-3: Browser gate (hard stop) — e2e.
+// Spec (CAPTURE.md §Browsers):
+// Phone: iOS Safari, Android Chrome.
+// PC: Chrome, Edge, Firefox.
+// Other clients get a hard stop.
+//
+// Acceptance: opening in an unsupported browser (e.g. Opera, Samsung
+// Internet) shows a hard-stop message, NOT the app.
+//
+// The app itself must not render (no content, no "Milestone 1
+// — Capture" text) in an unsupported browser; the hard-stop page lists
+// the spec browsers instead.
+
+const DESKTOP_CHROME_UA =
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36';
+const DESKTOP_OPERA_UA =
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 OPR/110.0.0.0';
+const DESKTOP_EDGE_UA =
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 Edg/124.0.0.0';
+const DESKTOP_FIREFOX_UA =
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:125.0) Gecko/20100101 Firefox/125.0';
+const IOS_SAFARI_UA =
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/605.1.15';
+const ANDROID_CHROME_UA =
+ 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6331.115 Mobile Safari/537.36';
+const SAMSUNG_INTERNET_UA =
+ 'Mozilla/5.0 (Linux; Android 14; SM-S918B) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/24.0 Chrome/124.0.0.0 Mobile Safari/537.36';
+
+async function openApp(page, ua) {
+ await page.setExtraHTTPHeaders({ 'User-Agent': ua });
+ await page.goto('/');
+ // The gate runs on client hydration; wait a beat for the DOM to
+ // settle on whichever branch rendered.
+ await page.waitForTimeout(300);
+}
+
+// The app renders when the gate passes: the layout's is present
+// and the capture UI (Add Photo heading) is inside it.
+async function expectApp(page) {
+ await expect(page.locator('main')).toBeVisible();
+ await expect(page.locator('main')).toContainText('Add Photo');
+ // No hard-stop page.
+ await expect(page.locator('.gate')).toHaveCount(0);
+}
+
+// The hard stop renders when the gate fails: the .gate box is present,
+// the app's is NOT, and the spec browsers are listed.
+async function expectHardStop(page) {
+ await expect(page.locator('.gate h1')).toHaveText('Unsupported browser');
+ await expect(page.locator('main')).toHaveCount(0);
+ const gate = page.locator('.gate');
+ await expect(gate).toContainText('iOS Safari');
+ await expect(gate).toContainText('Android Chrome');
+ await expect(gate).toContainText('Chrome');
+ await expect(gate).toContainText('Edge');
+ await expect(gate).toContainText('Firefox');
+}
+
+test('UAT-3: app renders in desktop Chrome (supported)', async ({ page }) => {
+ await openApp(page, DESKTOP_CHROME_UA);
+ await expectApp(page);
+});
+
+test('UAT-3: app renders in desktop Edge (supported)', async ({ page }) => {
+ await openApp(page, DESKTOP_EDGE_UA);
+ await expectApp(page);
+});
+
+test('UAT-3: app renders in desktop Firefox (supported)', async ({ page }) => {
+ await openApp(page, DESKTOP_FIREFOX_UA);
+ await expectApp(page);
+});
+
+test('UAT-3: app renders in iOS Safari (supported)', async ({ page, context }) => {
+ await context.setExtraHTTPHeaders({ 'User-Agent': IOS_SAFARI_UA });
+ await page.goto('/');
+ await page.waitForTimeout(300);
+ await expectApp(page);
+});
+
+test('UAT-3: app renders in Android Chrome (supported)', async ({ page, context }) => {
+ await context.setExtraHTTPHeaders({ 'User-Agent': ANDROID_CHROME_UA });
+ await page.goto('/');
+ await page.waitForTimeout(300);
+ await expectApp(page);
+});
+
+test('UAT-3: hard stop in desktop Opera (unsupported)', async ({ page }) => {
+ await openApp(page, DESKTOP_OPERA_UA);
+ await expect(page.locator('.gate h1')).toHaveText('Unsupported browser');
+ // The app must NOT render.
+ await expect(page.locator('main')).toHaveCount(0);
+ // The hard-stop page lists the spec browsers.
+ await expect(page.locator('.gate')).toContainText('iOS Safari');
+ await expect(page.locator('.gate')).toContainText('Android Chrome');
+ await expect(page.locator('.gate')).toContainText('Chrome');
+ await expect(page.locator('.gate')).toContainText('Edge');
+ await expect(page.locator('.gate')).toContainText('Firefox');
+});
+
+test('UAT-3: hard stop in Samsung Internet (unsupported)', async ({ page, context }) => {
+ await context.setExtraHTTPHeaders({ 'User-Agent': SAMSUNG_INTERNET_UA });
+ await page.goto('/');
+ await page.waitForTimeout(300);
+ await expect(page.locator('.gate h1')).toHaveText('Unsupported browser');
+ await expect(page.locator('main')).toHaveCount(0);
+});
diff --git a/frontend/tests/unit/browserGate.test.js b/frontend/tests/unit/browserGate.test.js
new file mode 100644
index 0000000..4702c5d
--- /dev/null
+++ b/frontend/tests/unit/browserGate.test.js
@@ -0,0 +1,201 @@
+/**
+ * UAT-3: Browser gate (hard stop) — unit tests for the pure logic in
+ * src/lib/core/browserGate.js.
+ *
+ * CAPTURE.md §Browsers:
+ * Phone: iOS Safari, Android Chrome.
+ * PC: Chrome, Edge, Firefox.
+ * Other clients get a hard stop.
+ */
+import { describe, it, expect } from 'vitest';
+import { detectBrowser, isAllowed, checkBrowserGate } from '$lib/core/browserGate.js';
+
+// --- representative user agents ------------------------------------
+const UAs = {
+ iosSafari:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/605.1.15',
+ ipadSafari:
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15',
+ iosChrome:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/124.0.6336.69 Mobile/15E148 Safari/605.1.15',
+ androidChrome:
+ 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6331.115 Mobile Safari/537.36',
+ desktopChrome:
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
+ desktopEdge:
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 Edg/124.0.0.0',
+ desktopFirefox:
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:125.0) Gecko/20100101 Firefox/125.0',
+ desktopOpera:
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 OPR/110.0.0.0',
+ androidOpera:
+ 'Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36 OPR/110.0.0.0',
+ androidSamsung:
+ 'Mozilla/5.0 (Linux; Android 14; SM-S918B) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/24.0 Chrome/124.0.0.0 Mobile Safari/537.36',
+ iosSamsung:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/124.0.0.0 Mobile/15E148 SamsungBrowser/124.0.0.0',
+ androidBrave:
+ 'Mozilla/5.0 (Linux; Android 14; SM-A546E) AppleWebKit/537.36 (KHTML, like Gecko) Version/14.0.0.5336 Mobile Safari/537.36',
+ unknown:
+ 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) CustomBrowser/9.9.0 Safari/537.36',
+ blank: '',
+};
+
+describe('detectBrowser', () => {
+ it('identifies iOS Safari on iPhone', () => {
+ const b = detectBrowser(UAs.iosSafari);
+ expect(b.name).toBe('iOS Safari');
+ expect(b.platform).toBe('phone');
+ });
+
+ it('identifies iPad Safari (iPadOS 13+ reports as Macintosh)', () => {
+ const b = detectBrowser(UAs.ipadSafari);
+ expect(b.name).toBe('Safari');
+ expect(b.platform).toBe('pc');
+ });
+
+ it('identifies Android Chrome', () => {
+ const b = detectBrowser(UAs.androidChrome);
+ expect(b.name).toBe('Android Chrome');
+ expect(b.platform).toBe('phone');
+ });
+
+ it('identifies desktop Chrome', () => {
+ const b = detectBrowser(UAs.desktopChrome);
+ expect(b.name).toBe('Chrome');
+ expect(b.platform).toBe('pc');
+ });
+
+ it('identifies desktop Edge (not Chrome)', () => {
+ const b = detectBrowser(UAs.desktopEdge);
+ expect(b.name).toBe('Microsoft Edge');
+ expect(b.platform).toBe('pc');
+ });
+
+ it('identifies desktop Firefox', () => {
+ const b = detectBrowser(UAs.desktopFirefox);
+ expect(b.name).toBe('Firefox');
+ expect(b.platform).toBe('pc');
+ });
+
+ it('identifies desktop Opera as its own browser (not Chrome)', () => {
+ const b = detectBrowser(UAs.desktopOpera);
+ expect(b.name).toBe('Opera');
+ expect(b.platform).toBe('pc');
+ });
+
+ it('identifies Android Opera (OPR beats Chrome)', () => {
+ const b = detectBrowser(UAs.androidOpera);
+ expect(b.name).toBe('Opera');
+ expect(b.platform).toBe('phone');
+ });
+
+ it('identifies Samsung Internet (SamsungBrowser beats Chrome)', () => {
+ const b = detectBrowser(UAs.androidSamsung);
+ expect(b.name).toBe('Samsung Internet');
+ expect(b.platform).toBe('phone');
+ });
+
+ it('identifies iOS Samsung Internet as unsupported', () => {
+ const b = detectBrowser(UAs.iosSamsung);
+ expect(b.name).toBe('Samsung Internet');
+ expect(b.platform).toBe('phone');
+ });
+
+ it('treats an unrecognized UA as Unknown', () => {
+ const b = detectBrowser(UAs.unknown);
+ expect(b.name).toBe('Unknown');
+ expect(b.platform).toBe('pc');
+ });
+
+ it('handles blank / undefined user agents without throwing', () => {
+ expect(detectBrowser(UAs.blank).name).toBe('Unknown');
+ expect(detectBrowser(undefined).name).toBe('Unknown');
+ });
+});
+
+describe('isAllowed', () => {
+ it('allows iOS Safari on phone', () => {
+ expect(isAllowed({ name: 'iOS Safari', platform: 'phone' })).toBe(true);
+ });
+
+ it('allows Android Chrome on phone', () => {
+ expect(isAllowed({ name: 'Android Chrome', platform: 'phone' })).toBe(true);
+ });
+
+ it('allows Chrome on PC', () => {
+ expect(isAllowed({ name: 'Chrome', platform: 'pc' })).toBe(true);
+ });
+
+ it('allows Edge on PC', () => {
+ expect(isAllowed({ name: 'Microsoft Edge', platform: 'pc' })).toBe(true);
+ });
+
+ it('allows Firefox on PC', () => {
+ expect(isAllowed({ name: 'Firefox', platform: 'pc' })).toBe(true);
+ });
+
+ it('rejects Opera', () => {
+ expect(isAllowed({ name: 'Opera', platform: 'pc' })).toBe(false);
+ });
+
+ it('rejects Android Opera', () => {
+ expect(isAllowed({ name: 'Opera', platform: 'phone' })).toBe(false);
+ });
+
+ it('rejects Samsung Internet', () => {
+ expect(isAllowed({ name: 'Samsung Internet', platform: 'phone' })).toBe(false);
+ });
+
+ it('rejects Samsung Internet on iOS', () => {
+ expect(isAllowed({ name: 'Samsung Internet', platform: 'phone' })).toBe(false);
+ });
+
+ it('rejects Brave', () => {
+ expect(isAllowed({ name: 'Brave', platform: 'phone' })).toBe(false);
+ });
+
+ it('rejects Unknown', () => {
+ expect(isAllowed({ name: 'Unknown', platform: 'pc' })).toBe(false);
+ });
+
+ it('rejects phone browsers on PC (impossible but rejected)', () => {
+ expect(isAllowed({ name: 'iOS Safari', platform: 'pc' })).toBe(false);
+ expect(isAllowed({ name: 'Android Chrome', platform: 'pc' })).toBe(false);
+ });
+});
+
+describe('checkBrowserGate', () => {
+ it('supports exactly the five spec browsers + Safari (iPad)', () => {
+ for (const ua of [
+ UAs.iosSafari,
+ UAs.ipadSafari,
+ UAs.androidChrome,
+ UAs.desktopChrome,
+ UAs.desktopEdge,
+ UAs.desktopFirefox,
+ ]) {
+ expect(checkBrowserGate(ua).supported).toBe(true);
+ }
+ });
+
+ it('hard-stops Opera, Samsung Internet, unknown, blank', () => {
+ for (const ua of [
+ UAs.desktopOpera,
+ UAs.androidOpera,
+ UAs.androidSamsung,
+ UAs.iosSamsung,
+ UAs.androidBrave,
+ UAs.unknown,
+ UAs.blank,
+ ]) {
+ expect(checkBrowserGate(ua).supported).toBe(false);
+ }
+ });
+
+ it('returns the detected browser for the UI', () => {
+ const r = checkBrowserGate(UAs.desktopOpera);
+ expect(r.browser.name).toBe('Opera');
+ expect(r.browser.platform).toBe('pc');
+ });
+});