diff --git a/.env.example b/.env.example
index dd76a4a..0f4d75a 100644
--- a/.env.example
+++ b/.env.example
@@ -12,6 +12,9 @@ REDIS_URL=redis://localhost:6389
# generate with: openssl rand -base64 32
AUTH_SECRET=
AUTH_URL=http://localhost:3000
+# Optional. Leave blank and phone OTP is the only route: the "Continue with
+# Google" button still renders, and tells the user it is not set up.
+# Authorised redirect URI: {NEXT_PUBLIC_APP_URL}/api/auth/callback/google
AUTH_GOOGLE_ID=
AUTH_GOOGLE_SECRET=
@@ -48,3 +51,6 @@ NEXT_PUBLIC_CITY_NAME=Barcelona
NEXT_PUBLIC_CITY_LAT=41.3874
NEXT_PUBLIC_CITY_LNG=2.1686
TWILIO_FROM_NUMBER=
+
+# Dev-only fixed login (+34600000000 / code 000000). MUST stay false/unset in production.
+ALLOW_DEV_LOGIN=false
diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts
index 75e2553..33012f2 100644
--- a/apps/web/next.config.ts
+++ b/apps/web/next.config.ts
@@ -10,6 +10,9 @@ const config: NextConfig = {
transpilePackages: ['@linkder/api', '@linkder/db', '@linkder/shared', '@linkder/storage'],
images: {
remotePatterns: [
+ // Seed data only — real pros upload to R2. The deck renders a plain ,
+ // so these matter only where next/image is used.
+ { protocol: 'https', hostname: 'i.pravatar.cc' },
{ protocol: 'https', hostname: 'picsum.photos' },
{ protocol: 'https', hostname: '**.r2.dev' },
],
diff --git a/apps/web/public/sw.js b/apps/web/public/sw.js
new file mode 100644
index 0000000..6d7ff77
--- /dev/null
+++ b/apps/web/public/sw.js
@@ -0,0 +1,27 @@
+/*
+ * Kill-switch service worker.
+ *
+ * This app does not use a service worker. One was left registered on
+ * localhost:3000 by a DIFFERENT project — service workers are scoped to an
+ * origin, not a project, so any app later served on that port inherits it. It
+ * intercepted requests and served dead chunks, which surfaced as
+ * "Cannot read properties of undefined (reading 'call')" in RootLayout and
+ * survived deleting .next and restarting the dev server.
+ *
+ * The browser re-fetches /sw.js to check for updates; serving this makes the
+ * stale worker replace itself with one that immediately unregisters and drops
+ * every cache it holds.
+ */
+self.addEventListener('install', () => self.skipWaiting());
+
+self.addEventListener('activate', (event) => {
+ event.waitUntil(
+ (async () => {
+ const keys = await caches.keys();
+ await Promise.all(keys.map((k) => caches.delete(k)));
+ await self.registration.unregister();
+ const clientList = await self.clients.matchAll({ type: 'window' });
+ for (const client of clientList) client.navigate(client.url);
+ })(),
+ );
+});
diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx
index c66902d..7e2f389 100644
--- a/apps/web/src/app/layout.tsx
+++ b/apps/web/src/app/layout.tsx
@@ -1,6 +1,8 @@
import type { Metadata, Viewport } from 'next';
import { Wix_Madefor_Display, Wix_Madefor_Text } from 'next/font/google';
import { TRPCProvider } from '@/lib/trpc';
+import { ToastProvider } from '@/components/ui';
+import { PhoneFrame } from '@/components/chrome/phone-frame';
import '@/styles/globals.css';
/**
@@ -52,7 +54,18 @@ export default function RootLayout({ children }: { children: React.ReactNode })
illustration that has to go full-bleed past it. §4
*/}