diff --git a/packages/playground/personal-wp/src/components/ios-pwa-notice/index.tsx b/packages/playground/personal-wp/src/components/ios-pwa-notice/index.tsx
new file mode 100644
index 00000000000..013a98c96e6
--- /dev/null
+++ b/packages/playground/personal-wp/src/components/ios-pwa-notice/index.tsx
@@ -0,0 +1,107 @@
+import { useState } from 'react';
+import { Notice, Button } from '@wordpress/components';
+import { isIOSSafari, isRunningAsPWA } from '../../lib/is-ios-safari';
+import css from './style.module.css';
+
+const DISMISS_KEY = 'playground-ios-pwa-notice-dismissed';
+
+function isDismissedInStorage(): boolean {
+ try {
+ return localStorage.getItem(DISMISS_KEY) === 'true';
+ } catch {
+ return false;
+ }
+}
+
+function persistDismissal(): void {
+ try {
+ localStorage.setItem(DISMISS_KEY, 'true');
+ } catch {
+ // Storage unavailable — the notice will reappear on
+ // next visit, which is acceptable.
+ }
+}
+
+/**
+ * A dismissible notice shown to iOS/iPadOS Safari users who have
+ * not installed the app as a PWA. It explains the risk of data
+ * loss due to Safari's Intelligent Tracking Prevention (ITP)
+ * which can wipe all script-writable storage after 7 days of
+ * inactivity, and encourages the user to add the app to their
+ * Home Screen.
+ */
+export function IosPwaNotice() {
+ const [dismissed, setDismissed] = useState(isDismissedInStorage);
+
+ if (dismissed || !isIOSSafari() || isRunningAsPWA()) {
+ return null;
+ }
+
+ const handleDismiss = () => {
+ persistDismissal();
+ setDismissed(true);
+ };
+
+ return (
+
+
+
+
+ Your data may be erased by Safari after 7 days
+
+
+
+ Safari automatically clears website data after 7 days of
+ inactivity. To keep your WordPress data safe, install this
+ app to your Home Screen.
+
+
+
+ Tap the{' '}
+
+ Share button{' '}
+
+ {/* Safari share icon (box with arrow) */}
+
+
+ {' '}
+ then choose{' '}
+ "Add to Home Screen".
+
+
+
+
+
+
+
+ );
+}
diff --git a/packages/playground/personal-wp/src/components/ios-pwa-notice/style.module.css b/packages/playground/personal-wp/src/components/ios-pwa-notice/style.module.css
new file mode 100644
index 00000000000..12e5bebe8ef
--- /dev/null
+++ b/packages/playground/personal-wp/src/components/ios-pwa-notice/style.module.css
@@ -0,0 +1,61 @@
+.iosPwaNotice {
+ margin: 0;
+ color: #1e1e1e;
+ font-size: inherit;
+ border-left-color: #dba617;
+
+ .components-notice__content {
+ margin-right: 0;
+ }
+}
+
+.content {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.headline {
+ margin: 0;
+ font-size: 13px;
+ line-height: 1.4;
+}
+
+.body {
+ margin: 0;
+ font-size: 13px;
+ line-height: 1.5;
+}
+
+.instructions {
+ margin: 4px 0 0;
+}
+
+.step {
+ margin: 0;
+ font-size: 13px;
+ line-height: 1.5;
+}
+
+.shareIcon {
+ display: inline-flex;
+ align-items: center;
+ vertical-align: middle;
+}
+
+.shareIcon svg {
+ width: 14px;
+ height: 14px;
+ vertical-align: middle;
+ margin: 0 1px;
+}
+
+.actions {
+ display: flex;
+ justify-content: flex-end;
+ margin-top: 4px;
+}
+
+.dismissButton {
+ font-size: 12px;
+}
diff --git a/packages/playground/personal-wp/src/components/layout/index.tsx b/packages/playground/personal-wp/src/components/layout/index.tsx
index 2cc78491e1d..f00ca9fc7f9 100644
--- a/packages/playground/personal-wp/src/components/layout/index.tsx
+++ b/packages/playground/personal-wp/src/components/layout/index.tsx
@@ -15,6 +15,7 @@ import { MissingSiteModal } from '../missing-site-modal';
import { modalSlugs } from '../../lib/state/redux/slice-ui';
import { SiteManager } from '../site-manager';
import { useAutoBackup } from '../../lib/hooks/use-auto-backup';
+import { IosPwaNotice } from '../ios-pwa-notice';
const displayMode = getDisplayModeFromQuery();
function getDisplayModeFromQuery(): DisplayMode {
@@ -55,6 +56,7 @@ export function Layout() {
+
diff --git a/packages/playground/personal-wp/src/components/layout/style.module.css b/packages/playground/personal-wp/src/components/layout/style.module.css
index 7fa6dfdb0ac..9f43799d7ed 100644
--- a/packages/playground/personal-wp/src/components/layout/style.module.css
+++ b/packages/playground/personal-wp/src/components/layout/style.module.css
@@ -37,6 +37,8 @@ body {
.site-view {
height: 100%;
+ display: flex;
+ flex-direction: column;
}
.site-manager-wrapper {
@@ -127,6 +129,8 @@ body {
overflow: hidden;
transition: border-radius 300ms;
height: 100%;
+ flex: 1 1 auto;
+ min-height: 0;
}
/*
diff --git a/packages/playground/personal-wp/src/lib/is-ios-safari.spec.ts b/packages/playground/personal-wp/src/lib/is-ios-safari.spec.ts
new file mode 100644
index 00000000000..c5a1a7b6221
--- /dev/null
+++ b/packages/playground/personal-wp/src/lib/is-ios-safari.spec.ts
@@ -0,0 +1,110 @@
+import { describe, it, expect } from 'vitest';
+import { isIOS, isIOSSafari } from './is-ios-safari';
+
+describe('isIOS', () => {
+ it('returns true for iPhone user agent', () => {
+ const ua =
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) ' +
+ 'AppleWebKit/605.1.15 (KHTML, like Gecko) ' +
+ 'Version/17.0 Mobile/15E148 Safari/604.1';
+ expect(isIOS(ua, 'iPhone', 5)).toBe(true);
+ });
+
+ it('returns true for iPad user agent', () => {
+ const ua =
+ 'Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) ' +
+ 'AppleWebKit/605.1.15 (KHTML, like Gecko) ' +
+ 'Version/17.0 Mobile/15E148 Safari/604.1';
+ expect(isIOS(ua, 'iPad', 5)).toBe(true);
+ });
+
+ it('returns true for iPadOS 13+ (MacIntel with touch)', () => {
+ const ua =
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ' +
+ 'AppleWebKit/605.1.15 (KHTML, like Gecko) ' +
+ 'Version/17.0 Safari/605.1.15';
+ expect(isIOS(ua, 'MacIntel', 5)).toBe(true);
+ });
+
+ it('returns false for macOS desktop (no touch)', () => {
+ const ua =
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ' +
+ 'AppleWebKit/605.1.15 (KHTML, like Gecko) ' +
+ 'Version/17.0 Safari/605.1.15';
+ expect(isIOS(ua, 'MacIntel', 0)).toBe(false);
+ });
+
+ it('returns false for Android', () => {
+ const ua =
+ 'Mozilla/5.0 (Linux; Android 14; Pixel 8) ' +
+ 'AppleWebKit/537.36 (KHTML, like Gecko) ' +
+ 'Chrome/120.0.0.0 Mobile Safari/537.36';
+ expect(isIOS(ua, 'Linux armv8l', 5)).toBe(false);
+ });
+
+ it('returns false for Windows', () => {
+ const ua =
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ' +
+ 'AppleWebKit/537.36 (KHTML, like Gecko) ' +
+ 'Chrome/120.0.0.0 Safari/537.36';
+ expect(isIOS(ua, 'Win32', 0)).toBe(false);
+ });
+});
+
+describe('isIOSSafari', () => {
+ it('returns true for Safari on iPhone', () => {
+ const ua =
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) ' +
+ 'AppleWebKit/605.1.15 (KHTML, like Gecko) ' +
+ 'Version/17.0 Mobile/15E148 Safari/604.1';
+ expect(isIOSSafari(ua, 'iPhone', 5)).toBe(true);
+ });
+
+ it('returns true for Safari on iPadOS 13+', () => {
+ const ua =
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ' +
+ 'AppleWebKit/605.1.15 (KHTML, like Gecko) ' +
+ 'Version/17.0 Safari/605.1.15';
+ expect(isIOSSafari(ua, 'MacIntel', 5)).toBe(true);
+ });
+
+ it('returns false for Chrome on iOS (CriOS)', () => {
+ const ua =
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) ' +
+ 'AppleWebKit/605.1.15 (KHTML, like Gecko) ' +
+ 'CriOS/120.0.6099.119 Mobile/15E148 Safari/604.1';
+ expect(isIOSSafari(ua, 'iPhone', 5)).toBe(false);
+ });
+
+ it('returns false for Firefox on iOS (FxiOS)', () => {
+ const ua =
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) ' +
+ 'AppleWebKit/605.1.15 (KHTML, like Gecko) ' +
+ 'FxiOS/120.0 Mobile/15E148 Safari/604.1';
+ expect(isIOSSafari(ua, 'iPhone', 5)).toBe(false);
+ });
+
+ it('returns false for WKWebView (no Version/)', () => {
+ const ua =
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) ' +
+ 'AppleWebKit/605.1.15 (KHTML, like Gecko) ' +
+ 'Mobile/15E148';
+ expect(isIOSSafari(ua, 'iPhone', 5)).toBe(false);
+ });
+
+ it('returns false for macOS Safari (no touch)', () => {
+ const ua =
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ' +
+ 'AppleWebKit/605.1.15 (KHTML, like Gecko) ' +
+ 'Version/17.0 Safari/605.1.15';
+ expect(isIOSSafari(ua, 'MacIntel', 0)).toBe(false);
+ });
+
+ it('returns false for Android Chrome', () => {
+ const ua =
+ 'Mozilla/5.0 (Linux; Android 14; Pixel 8) ' +
+ 'AppleWebKit/537.36 (KHTML, like Gecko) ' +
+ 'Chrome/120.0.0.0 Mobile Safari/537.36';
+ expect(isIOSSafari(ua, 'Linux armv8l', 5)).toBe(false);
+ });
+});
diff --git a/packages/playground/personal-wp/src/lib/is-ios-safari.ts b/packages/playground/personal-wp/src/lib/is-ios-safari.ts
new file mode 100644
index 00000000000..d41c2dd0524
--- /dev/null
+++ b/packages/playground/personal-wp/src/lib/is-ios-safari.ts
@@ -0,0 +1,71 @@
+/**
+ * Detects whether the current environment is iOS/iPadOS Safari
+ * (not a WKWebView, not Chrome, not Firefox) and whether the app
+ * is running as an installed PWA (standalone display mode).
+ */
+
+/**
+ * Returns true when the device is running iOS or iPadOS.
+ *
+ * iPads with iPadOS 13+ report as "MacIntel" in the platform
+ * string but expose multi-touch support, so we check both the
+ * legacy UA tokens and the modern platform + touchPoints
+ * combination.
+ */
+export function isIOS(
+ ua: string = navigator.userAgent,
+ platform: string = navigator.platform,
+ maxTouchPoints: number = navigator.maxTouchPoints
+): boolean {
+ return (
+ /iPad|iPhone|iPod/.test(ua) ||
+ (platform === 'MacIntel' && maxTouchPoints > 1)
+ );
+}
+
+/**
+ * Returns true when the browser is Safari on iOS/iPadOS.
+ *
+ * Safari includes "Version/" in its user-agent string whereas
+ * WKWebViews (in-app browsers) do not. Chrome on iOS identifies
+ * itself with "CriOS" and Firefox with "FxiOS".
+ */
+export function isIOSSafari(
+ ua: string = navigator.userAgent,
+ platform: string = navigator.platform,
+ maxTouchPoints: number = navigator.maxTouchPoints
+): boolean {
+ if (!isIOS(ua, platform, maxTouchPoints)) {
+ return false;
+ }
+ // Safari proper includes "Version/"
+ if (!/Version\//.test(ua)) {
+ return false;
+ }
+ // Exclude Chrome and Firefox on iOS
+ if (/CriOS\//.test(ua) || /FxiOS\//.test(ua)) {
+ return false;
+ }
+ return true;
+}
+
+/**
+ * Returns true when the app is running as an installed PWA
+ * (standalone display mode).
+ */
+export function isRunningAsPWA(): boolean {
+ return window.matchMedia('(display-mode: standalone)').matches;
+}
+
+/**
+ * Returns true when the user is on iOS Safari *and* the app is
+ * not installed as a PWA — i.e. the user is at risk of losing
+ * data due to Safari's ITP 7-day storage expiration policy.
+ */
+export function isIOSSafariWithoutPWA(
+ ua?: string,
+ platform?: string,
+ maxTouchPoints?: number
+): boolean {
+ return isIOSSafari(ua, platform, maxTouchPoints) && !isRunningAsPWA();
+}