+ {/* Pre-cached with this page by the service worker, so it renders with no network. */}
+
You are offline
Prospect data and the approval queue need a connection — approvals are never served from a
diff --git a/apps/web/components/brand.tsx b/apps/web/components/brand.tsx
new file mode 100644
index 0000000..d124f0f
--- /dev/null
+++ b/apps/web/components/brand.tsx
@@ -0,0 +1,56 @@
+/**
+ * The brand lockup, in the two forms the assets actually support.
+ *
+ * `logo.png` sets "Outreach" in white and "Graph" in blue, so the full wordmark
+ * reads on the dark theme and half of it vanishes on the light one. That is why
+ * `BrandLockup` swaps: dark surfaces get the wordmark, light surfaces get the
+ * mark from `favicon.png` beside live text — which is also what shows if the
+ * image never arrives.
+ *
+ * Plain `
`, not `next/image`: the runtime optimiser needs `sharp`, which
+ * this workspace does not install, and the standalone build in docker/Dockerfile
+ * would 500 on the first request for an optimised asset.
+ *
+ * The intrinsic sizes below are the real pixel dimensions of the two files. They
+ * are here so the browser reserves the right box before the image loads.
+ */
+
+const WORDMARK = { src: '/logo.png', width: 1007, height: 256 } as const;
+const MARK = { src: '/favicon.png', width: 512, height: 512 } as const;
+
+/** The graph mark alone — the one asset that reads on any background. */
+export function BrandMark({ className = 'h-7 w-7' }: { className?: string }) {
+ return
;
+}
+
+/** The full wordmark. Dark surfaces only, for the reason above. */
+export function BrandWordmark({ className = 'h-7 w-auto' }: { className?: string }) {
+ return
;
+}
+
+/**
+ * Safe anywhere the surface follows the colour scheme.
+ *
+ * Only one branch is ever rendered, so assistive technology sees a single
+ * "OutreachGraph" rather than two.
+ */
+export function BrandLockup({
+ className = '',
+ height = 'h-7',
+ text = 'text-[17px]',
+}: {
+ className?: string;
+ height?: string;
+ text?: string;
+}) {
+ return (
+
+
+
+
+
+ OutreachGraph
+
+
+ );
+}
diff --git a/apps/web/public/favicon.png b/apps/web/public/favicon.png
index 4bf75f4..ad8e217 100644
Binary files a/apps/web/public/favicon.png and b/apps/web/public/favicon.png differ
diff --git a/apps/web/public/logo.png b/apps/web/public/logo.png
index 5aa1594..52b7394 100644
Binary files a/apps/web/public/logo.png and b/apps/web/public/logo.png differ
diff --git a/apps/web/public/sw.js b/apps/web/public/sw.js
index 77a048b..c1d1f21 100644
--- a/apps/web/public/sw.js
+++ b/apps/web/public/sw.js
@@ -11,13 +11,15 @@
* error page.
*/
-const VERSION = 'v1';
+const VERSION = 'v2';
const SHELL_CACHE = `og-shell-${VERSION}`;
const ASSET_CACHE = `og-assets-${VERSION}`;
-// The public landing page and the offline fallback. `/today` is deliberately
-// absent: it is per-user and must never be served from a shared cache.
-const SHELL = ['/', '/offline'];
+// The public landing page and the offline fallback, plus the two brand assets
+// those pages render — an offline screen that renders a broken image is worse
+// than the browser error it replaces. `/today` is deliberately absent: it is
+// per-user and must never be served from a shared cache.
+const SHELL = ['/', '/offline', '/favicon.png', '/logo.png'];
self.addEventListener('install', (event) => {
event.waitUntil(
diff --git a/packages/email/src/mailer.test.ts b/packages/email/src/mailer.test.ts
index 822069c..dd0c06a 100644
--- a/packages/email/src/mailer.test.ts
+++ b/packages/email/src/mailer.test.ts
@@ -100,4 +100,17 @@ describe('verificationEmail', () => {
expect(message.html).not.toContain('