elements so both route tables can splice
+ * them in without duplicating the list. /about and /faq also have 301s in
+ * public/_redirects that take precedence at the edge.
+ */
+export function documentRoutes() {
+ return (
+ <>
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ >
+ );
+}
diff --git a/src/styles/brand-utilities.css b/src/styles/brand-utilities.css
index 71314ce6b..d70ab5612 100644
--- a/src/styles/brand-utilities.css
+++ b/src/styles/brand-utilities.css
@@ -56,6 +56,16 @@
.brand-link-card-violet:hover { box-shadow: 8px 8px 0 0 hsl(var(--brand-violet)); }
.brand-link-card-yellow:hover { box-shadow: 8px 8px 0 0 hsl(var(--brand-yellow)); }
+ /* Hide the scrollbar while keeping the element scrollable — used by the
+ phone-frame reel so the swipe surface reads as a clean app screen. */
+ .hide-scrollbar {
+ scrollbar-width: none; /* Firefox */
+ -ms-overflow-style: none; /* legacy Edge */
+ }
+ .hide-scrollbar::-webkit-scrollbar {
+ display: none; /* WebKit */
+ }
+
/* Respect reduced-motion */
@media (prefers-reduced-motion: reduce) {
.brand-sticker { transition: none; }
diff --git a/tests/visual/a11y.spec.ts b/tests/visual/a11y.spec.ts
index 74689a953..824b993f6 100644
--- a/tests/visual/a11y.spec.ts
+++ b/tests/visual/a11y.spec.ts
@@ -1,7 +1,9 @@
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
-const ROUTES = ['/', '/discovery', '/search', '/merch', '/family', '/age-review', '/kids', '/__brand-preview'];
+// Showcase mode is the default build, so /discovery and /search 404 here and
+// are covered by the full-mode suite instead.
+const ROUTES = ['/', '/merch', '/family', '/safety', '/age-review', '/kids', '/__brand-preview'];
for (const route of ROUTES) {
test(`a11y: ${route} has no WCAG 2 A/AA violations`, async ({ page }) => {
diff --git a/tests/visual/responsive.spec.ts b/tests/visual/responsive.spec.ts
new file mode 100644
index 000000000..e75a0419d
--- /dev/null
+++ b/tests/visual/responsive.spec.ts
@@ -0,0 +1,91 @@
+import { test, expect, devices } from '@playwright/test';
+
+// Widths that actually matter: iPhone SE is the narrowest phone worth
+// supporting, iPhone 14 is the common case, and 768 is the tablet breakpoint
+// where the desktop nav is still hidden.
+const VIEWPORTS = [
+ { name: 'iphone-se', width: 375, height: 667 },
+ { name: 'iphone-14', width: 390, height: 844 },
+ { name: 'tablet', width: 768, height: 1024 },
+];
+
+const ROUTES = ['/', '/family', '/safety', '/kids', '/merch', '/terms'];
+
+for (const vp of VIEWPORTS) {
+ for (const route of ROUTES) {
+ test(`responsive: ${route} does not scroll horizontally at ${vp.name}`, async ({ page }) => {
+ await page.setViewportSize({ width: vp.width, height: vp.height });
+ await page.goto(route, { waitUntil: 'domcontentloaded' });
+ await expect(page.locator('body')).toBeVisible();
+
+ // A page that scrolls sideways on a phone is the single most visible
+ // symptom of a broken responsive layout, and it is cheap to assert.
+ const { scrollWidth, clientWidth } = await page.evaluate(() => ({
+ scrollWidth: document.documentElement.scrollWidth,
+ clientWidth: document.documentElement.clientWidth,
+ }));
+
+ expect(
+ scrollWidth,
+ `${route} overflows by ${scrollWidth - clientWidth}px at ${vp.width}px wide`,
+ ).toBeLessThanOrEqual(clientWidth + 1); // 1px tolerance for subpixel rounding
+ });
+ }
+}
+
+test.describe('marketing header', () => {
+ test('collapses into a menu on mobile and keeps the CTA reachable', async ({ page }) => {
+ await page.setViewportSize({ width: 390, height: 844 });
+ await page.goto('/', { waitUntil: 'domcontentloaded' });
+
+ // The download CTA stays visible — it is the point of the page. It's a
+ // dropdown button on Android/desktop and a direct link on iOS, so match
+ // either role. (Playwright's default UA is desktop → the dropdown button.)
+ const cta = page
+ .getByRole('button', { name: /get the app/i })
+ .or(page.getByRole('link', { name: /get the app/i }));
+ await expect(cta.first()).toBeVisible();
+
+ // The full nav list is behind the menu, not crammed into the bar.
+ await expect(page.getByRole('link', { name: 'In the News' })).toBeHidden();
+
+ await page.getByRole('button', { name: /open menu/i }).click();
+ await expect(page.getByRole('link', { name: 'In the News' })).toBeVisible();
+ });
+
+ test('shows the full nav on desktop with no menu button', async ({ page }) => {
+ await page.setViewportSize({ width: 1280, height: 800 });
+ await page.goto('/', { waitUntil: 'domcontentloaded' });
+
+ await expect(page.getByRole('link', { name: 'In the News' })).toBeVisible();
+ await expect(page.getByRole('button', { name: /open menu/i })).toBeHidden();
+ });
+});
+
+test.describe('hero get-the-app CTA', () => {
+ test('is visible and tappable on a phone', async ({ page }) => {
+ await page.setViewportSize({ width: 375, height: 667 });
+ await page.goto('/', { waitUntil: 'domcontentloaded' });
+
+ // Playwright's default UA is desktop, so the hero CTA is the dropdown button.
+ const cta = page.locator('main').getByRole('button', { name: /get the app/i });
+ await expect(cta).toBeVisible();
+
+ // Apple's own guidance is a 44px minimum touch target.
+ const box = await cta.boundingBox();
+ expect(box, 'CTA has no box').not.toBeNull();
+ expect(box!.height, `CTA is only ${box!.height}px tall`).toBeGreaterThanOrEqual(40);
+
+ // Opens the store picker.
+ await cta.click();
+ await expect(page.getByRole('menuitem')).toHaveCount(3);
+ });
+});
+
+test('showcase page renders its real mobile layout', async ({ page }) => {
+ await page.setViewportSize({ ...devices['iPhone 13'].viewport });
+ await page.goto('/', { waitUntil: 'domcontentloaded' });
+
+ await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
+ await expect(page.getByTestId('curated-showcase')).toBeVisible();
+});
From 5c2dd4ee13d6fd923cdcf66626b1f3b7113aaed5 Mon Sep 17 00:00:00 2001
From: Liz Sweigart <127434495+NotThatKindOfDrLiz@users.noreply.github.com>
Date: Fri, 24 Jul 2026 16:23:43 -0500
Subject: [PATCH 02/19] fix: harden showcase video safety
---
package.json | 7 ++--
playwright.config.ts | 31 ++++++++++++----
scripts/verify-live-bundle.mjs | 46 +++++++++++++++---------
scripts/verify-live-bundle.test.ts | 23 ++++++++++++
scripts/verify-web-mode-sync.mjs | 23 ++++++++++++
src/components/family/StoreBadgesCta.tsx | 2 --
src/hooks/useCuratedShowcase.ts | 11 +++---
src/hooks/useVideoLists.test.ts | 2 ++
src/lib/ageRestrictedVideos.test.ts | 29 +++++++++++++++
src/lib/fetchListVideos.test.ts | 33 +++++++++++++++--
src/lib/fetchListVideos.ts | 17 +++++++--
src/pages/AppCallbackPage.tsx | 23 ++++--------
src/pages/ListDetailPage.tsx | 4 +--
src/pages/ShowcaseVideoPage.tsx | 20 ++++++++++-
tests/visual/a11y.spec.ts | 11 +++---
15 files changed, 219 insertions(+), 63 deletions(-)
create mode 100644 scripts/verify-web-mode-sync.mjs
diff --git a/package.json b/package.json
index 441c60ddb..1050e71a5 100644
--- a/package.json
+++ b/package.json
@@ -13,11 +13,12 @@
"deploy": "npm run build && npx -y nostr-deploy-cli deploy --skip-setup",
"deploy:cloudflare": "npm run build && wrangler pages deploy dist",
"deploy:cloudflare:preview": "npm run build && wrangler pages deploy dist --branch preview",
- "fastly:local": "npm run build && npm i && npm run -w divine-web-edge dev:publish && npm run -w divine-web-edge dev:start",
- "fastly:deploy": "npm run build && npm i && npm run -w divine-web-edge fastly:deploy",
- "fastly:publish": "npm run build && npm i && npm run -w divine-web-edge fastly:publish",
+ "fastly:local": "npm run verify:web-mode-sync && npm run build && npm i && npm run -w divine-web-edge dev:publish && npm run -w divine-web-edge dev:start",
+ "fastly:deploy": "npm run verify:web-mode-sync && npm run build && npm i && npm run -w divine-web-edge fastly:deploy",
+ "fastly:publish": "npm run verify:web-mode-sync && npm run build && npm i && npm run -w divine-web-edge fastly:publish",
"fastly:release": "npm run fastly:deploy && npm run fastly:publish",
"verify:well-known": "node scripts/verify-well-known.mjs",
+ "verify:web-mode-sync": "node scripts/verify-web-mode-sync.mjs",
"precalculate-thumbnails": "tsx scripts/precalculate-hashtag-thumbnails.ts",
"generate-icons": "node scripts/generate-icons.js",
"test:visual": "playwright test",
diff --git a/playwright.config.ts b/playwright.config.ts
index 4857c2e51..e40fcadf7 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -3,6 +3,8 @@ import { defineConfig, devices } from '@playwright/test';
// Use a dedicated port so parallel worktrees on 8080 don't collide.
const PORT = Number(process.env.PLAYWRIGHT_PORT ?? 8088);
const BASE_URL = `http://localhost:${PORT}`;
+const FULL_MODE_PORT = Number(process.env.PLAYWRIGHT_FULL_MODE_PORT ?? 8089);
+const FULL_MODE_BASE_URL = `http://localhost:${FULL_MODE_PORT}`;
export default defineConfig({
testDir: './tests/visual',
@@ -14,11 +16,26 @@ export default defineConfig({
baseURL: BASE_URL,
trace: 'on-first-retry',
},
- projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
- webServer: {
- command: `npx vite --port ${PORT} --strictPort`,
- url: BASE_URL,
- reuseExistingServer: !process.env.CI,
- timeout: 120_000,
- },
+ projects: [
+ { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
+ {
+ name: 'full-mode-a11y',
+ testMatch: /a11y\.spec\.ts/,
+ use: { ...devices['Desktop Chrome'], baseURL: FULL_MODE_BASE_URL },
+ },
+ ],
+ webServer: [
+ {
+ command: `npx vite --port ${PORT} --strictPort`,
+ url: BASE_URL,
+ reuseExistingServer: !process.env.CI,
+ timeout: 120_000,
+ },
+ {
+ command: `VITE_WEB_MODE=full npx vite --port ${FULL_MODE_PORT} --strictPort`,
+ url: FULL_MODE_BASE_URL,
+ reuseExistingServer: !process.env.CI,
+ timeout: 120_000,
+ },
+ ],
});
diff --git a/scripts/verify-live-bundle.mjs b/scripts/verify-live-bundle.mjs
index d0046c1c6..3b637d4e9 100644
--- a/scripts/verify-live-bundle.mjs
+++ b/scripts/verify-live-bundle.mjs
@@ -26,6 +26,7 @@ const CURL_STATUS_MARKER = '\n__HTTP_STATUS__:';
// so a compressed-only 500 (#489) sails through a green deploy. verifyInjectedRoutesOk uses
// this to assert the injected routes actually serve browsers.
const BROWSER_ACCEPT_ENCODING = 'gzip, deflate, br';
+const FULL_MODE_INJECTED_URLS = ['https://divine.video/', 'https://divine.video/discovery/classics'];
function headerEntries(headers = {}) {
if (typeof Headers !== 'undefined' && headers instanceof Headers) {
@@ -92,6 +93,17 @@ export function extractEntryScript(html) {
return match ? match[1] : null;
}
+export function resolveInjectedUrls({ env = process.env } = {}) {
+ if (env.VERIFY_INJECTED_URLS !== undefined) {
+ return env.VERIFY_INJECTED_URLS
+ .split(',')
+ .map((value) => value.trim())
+ .filter(Boolean);
+ }
+
+ return env.VITE_WEB_MODE === 'full' ? FULL_MODE_INJECTED_URLS : [];
+}
+
/**
* Poll each live origin until it serves the expected entry bundle, or fail.
*
@@ -277,15 +289,11 @@ if (invokedDirectly) {
const attempts = numberFromEnv('VERIFY_BUNDLE_ATTEMPTS', 18);
const delayMs = numberFromEnv('VERIFY_BUNDLE_DELAY_MS', 20000);
- // Edge-injected routes (apex landing + a /discovery tab) are the only ones that read and
- // rewrite the HTML at the edge, so they are the only ones that can 500 on compressed input
- // (#489). A deterministic 500 here won't self-heal, so use a short retry budget that only
- // absorbs a cold-start blip rather than the KV-propagation window the bundle check needs.
- const injectedUrls = (process.env.VERIFY_INJECTED_URLS
- ?? 'https://divine.video/,https://divine.video/discovery/classics')
- .split(',')
- .map((value) => value.trim())
- .filter(Boolean);
+ // In full mode, edge-injected routes (apex landing + a /discovery tab) are the only
+ // ones that read and rewrite HTML at the edge, so they are the only ones that can
+ // 500 on compressed input (#489). Showcase mode deliberately disables that feed
+ // injection; VERIFY_INJECTED_URLS can still force a custom check when needed.
+ const injectedUrls = resolveInjectedUrls();
const injectedAttempts = numberFromEnv('VERIFY_INJECTED_ATTEMPTS', 3);
const injectedDelayMs = numberFromEnv('VERIFY_INJECTED_DELAY_MS', 10000);
@@ -300,14 +308,18 @@ if (invokedDirectly) {
});
console.log(`✓ Live origins serve the freshly built bundle ${expected}`);
- await verifyInjectedRoutesOk({
- urls: injectedUrls,
- attempts: injectedAttempts,
- delayMs: injectedDelayMs,
- fetchImpl: fetchWithCurl,
- log: (message) => console.log(message),
- });
- console.log('✓ Injected routes return 2xx to a browser (Accept-Encoding: br)');
+ if (injectedUrls.length > 0) {
+ await verifyInjectedRoutesOk({
+ urls: injectedUrls,
+ attempts: injectedAttempts,
+ delayMs: injectedDelayMs,
+ fetchImpl: fetchWithCurl,
+ log: (message) => console.log(message),
+ });
+ console.log('✓ Injected routes return 2xx to a browser (Accept-Encoding: br)');
+ } else {
+ console.log('- Skipping injected-route check; showcase mode has no feed-injected routes');
+ }
} catch (err) {
console.error(`✗ ${err.message}`);
process.exit(1);
diff --git a/scripts/verify-live-bundle.test.ts b/scripts/verify-live-bundle.test.ts
index 25b6d18e7..31215c110 100644
--- a/scripts/verify-live-bundle.test.ts
+++ b/scripts/verify-live-bundle.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest';
import {
buildCurlArgs,
extractEntryScript,
+ resolveInjectedUrls,
verifyLiveBundle,
verifyInjectedRoutesOk,
} from './verify-live-bundle.mjs';
@@ -336,6 +337,28 @@ describe('verifyInjectedRoutesOk', () => {
});
});
+describe('resolveInjectedUrls', () => {
+ it('defaults to no injected routes in showcase mode', () => {
+ expect(resolveInjectedUrls({ env: { VITE_WEB_MODE: 'showcase' } })).toEqual([]);
+ });
+
+ it('defaults to the feed-injected routes in full mode', () => {
+ expect(resolveInjectedUrls({ env: { VITE_WEB_MODE: 'full' } })).toEqual([
+ 'https://divine.video/',
+ 'https://divine.video/discovery/classics',
+ ]);
+ });
+
+ it('lets VERIFY_INJECTED_URLS override the mode default', () => {
+ expect(resolveInjectedUrls({
+ env: {
+ VITE_WEB_MODE: 'showcase',
+ VERIFY_INJECTED_URLS: 'https://example.test/a, https://example.test/b',
+ },
+ })).toEqual(['https://example.test/a', 'https://example.test/b']);
+ });
+});
+
describe('buildCurlArgs', () => {
it('emits request headers as -H flags so they actually reach curl', () => {
const args = buildCurlArgs('https://divine.video/', {
diff --git a/scripts/verify-web-mode-sync.mjs b/scripts/verify-web-mode-sync.mjs
new file mode 100644
index 000000000..67352a526
--- /dev/null
+++ b/scripts/verify-web-mode-sync.mjs
@@ -0,0 +1,23 @@
+import { readFile } from 'node:fs/promises';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const edgeModeFile = path.join(projectRoot, 'compute-js', 'src', 'webMode.js');
+
+const effectiveViteMode = process.env.VITE_WEB_MODE === 'full' ? 'full' : 'showcase';
+const edgeSource = await readFile(edgeModeFile, 'utf8');
+const edgeMode = edgeSource.match(/export const WEB_MODE = ['"]([^'"]+)['"];/)?.[1];
+
+if (edgeMode !== 'showcase' && edgeMode !== 'full') {
+ console.error(`✗ Could not read a valid WEB_MODE from ${edgeModeFile}`);
+ process.exit(1);
+}
+
+if (edgeMode !== effectiveViteMode) {
+ console.error(`✗ VITE_WEB_MODE resolves to "${effectiveViteMode}" but compute-js/src/webMode.js exports "${edgeMode}"`);
+ console.error(' Keep the frontend and Fastly worker modes in sync before deploying.');
+ process.exit(1);
+}
+
+console.log(`✓ Frontend and Fastly worker web mode are both "${edgeMode}"`);
diff --git a/src/components/family/StoreBadgesCta.tsx b/src/components/family/StoreBadgesCta.tsx
index f789d9728..97a669ad1 100644
--- a/src/components/family/StoreBadgesCta.tsx
+++ b/src/components/family/StoreBadgesCta.tsx
@@ -5,8 +5,6 @@ import { trackEvent } from "@/lib/analytics";
import { HubSpotSignup } from "@/components/HubSpotSignup";
import { buildStoreLinks } from "@/lib/mobileStoreLinks";
-export { buildStoreLinks };
-
interface StoreBadgesCtaProps {
/** utm_campaign value; use the route slug */
campaign: string;
diff --git a/src/hooks/useCuratedShowcase.ts b/src/hooks/useCuratedShowcase.ts
index 9d0edddf5..31798d4ef 100644
--- a/src/hooks/useCuratedShowcase.ts
+++ b/src/hooks/useCuratedShowcase.ts
@@ -18,7 +18,7 @@ import { fetchListVideos } from '@/lib/fetchListVideos';
import { enrichAgeRestrictedVideos } from '@/lib/ageRestrictedVideos';
import { filterShowcaseSafeVideos } from '@/lib/showcaseSafety';
import { shuffle } from '@/lib/shuffle';
-import type { NostrEvent } from '@nostrify/nostrify';
+import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
import type { ParsedVideoData } from '@/types/video';
const CURATION_LIST_KIND = 30005;
@@ -79,8 +79,6 @@ export function mergeCuratedRefs(events: NostrEvent[], options: MergeOptions = {
export interface CuratedShowcaseResult {
videos: ParsedVideoData[];
- /** True when no curators and no seed lists are configured — the reel cannot load. */
- isUnconfigured: boolean;
}
/**
@@ -113,7 +111,7 @@ export function useCuratedShowcase() {
const signal = AbortSignal.any([context.signal, AbortSignal.timeout(8000)]);
const relays = getEventLookupRelayUrls({ configuredRelayUrls: relayUrls });
- const listFilters = [];
+ const listFilters: NostrFilter[] = [];
// All 30005 lists from allowlisted curators — title-filtered client side,
// since Nostr can't filter on an arbitrary tag value.
if (CURATION_ADMIN_PUBKEYS.length > 0) {
@@ -130,12 +128,12 @@ export function useCuratedShowcase() {
listFilters.push({ kinds: [CURATION_LIST_KIND], authors: [pubkey], '#d': dTags });
}
- if (listFilters.length === 0) return { videos: [], isUnconfigured: false };
+ if (listFilters.length === 0) return { videos: [] };
const listEvents = await nostr.query(listFilters, { signal, relays });
const refs = mergeCuratedRefs(listEvents);
- if (refs.length === 0) return { videos: [], isUnconfigured: false };
+ if (refs.length === 0) return { videos: [] };
const videos = await fetchListVideos(nostr, refs, signal);
@@ -146,7 +144,6 @@ export function useCuratedShowcase() {
return {
videos: filterShowcaseSafeVideos(enriched),
- isUnconfigured: false,
};
},
});
diff --git a/src/hooks/useVideoLists.test.ts b/src/hooks/useVideoLists.test.ts
index 0f56d9ccc..b4fe1c705 100644
--- a/src/hooks/useVideoLists.test.ts
+++ b/src/hooks/useVideoLists.test.ts
@@ -495,6 +495,7 @@ describe('useVideoLists hooks', () => {
pubkey: TEST_PUBKEY,
createdAt: 1,
videoCoordinates: [],
+ videoEventIds: [],
public: true,
},
{
@@ -503,6 +504,7 @@ describe('useVideoLists hooks', () => {
pubkey: TEST_PUBKEY,
createdAt: 2,
videoCoordinates: [],
+ videoEventIds: [],
public: true,
},
]);
diff --git a/src/lib/ageRestrictedVideos.test.ts b/src/lib/ageRestrictedVideos.test.ts
index 6fa1e80df..7b599cfe6 100644
--- a/src/lib/ageRestrictedVideos.test.ts
+++ b/src/lib/ageRestrictedVideos.test.ts
@@ -1,5 +1,7 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { enrichAgeRestrictedVideos } from './ageRestrictedVideos';
+import { mapVideoEvent } from './fetchListVideos';
+import type { NostrEvent } from '@nostrify/nostrify';
import type { ParsedVideoData } from '@/types/video';
const mockFetchVideoModerationStatus = vi.fn();
@@ -59,4 +61,31 @@ describe('enrichAgeRestrictedVideos', () => {
undefined,
);
});
+
+ it('can enrich videos produced by the shared Nostr event mapper', async () => {
+ mockFetchVideoModerationStatus.mockResolvedValue({
+ ageRestricted: true,
+ });
+ const event: NostrEvent = {
+ id: 'a'.repeat(64),
+ pubkey: 'b'.repeat(64),
+ kind: 34236,
+ created_at: 1700000000,
+ tags: [
+ ['d', 'mapped-video'],
+ ['imeta', 'url https://media.divine.video/mapped.mp4', 'm video/mp4', 'x abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789'],
+ ],
+ content: '',
+ sig: 'c'.repeat(128),
+ };
+
+ const mapped = mapVideoEvent(event);
+ const result = await enrichAgeRestrictedVideos(mapped ? [mapped] : []);
+
+ expect(result[0].ageRestricted).toBe(true);
+ expect(mockFetchVideoModerationStatus).toHaveBeenCalledWith(
+ 'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789',
+ undefined,
+ );
+ });
});
diff --git a/src/lib/fetchListVideos.test.ts b/src/lib/fetchListVideos.test.ts
index 498e02bfc..d1dfda344 100644
--- a/src/lib/fetchListVideos.test.ts
+++ b/src/lib/fetchListVideos.test.ts
@@ -19,7 +19,14 @@ function videoEvent(eventId: string, dTag: string, pubkey = AUTHOR_A): NostrEven
tags: [
['d', dTag],
['title', `video ${dTag}`],
- ['imeta', `url https://cdn.divine.video/${dTag}.mp4`, 'm video/mp4'],
+ [
+ 'imeta',
+ `url https://cdn.divine.video/${dTag}.mp4`,
+ 'm video/mp4',
+ 'x 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
+ 'dim 480x480',
+ 'blurhash LEHV6nWB2yk8pyo0adR*.7kCMdnj',
+ ],
],
content: '',
sig: 'f'.repeat(128),
@@ -48,8 +55,9 @@ describe('fetchListVideos', () => {
const videos = await fetchListVideos(nostr, [id(1), id(2)], signal);
expect(videos.map(v => v.id)).toEqual([id(1), id(2)]);
+ expect(videos[0].sha256).toBe('0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef');
// e-ids are batched into a single ids filter.
- expect(nostr.calls[0].some(f => Array.isArray(f.ids))).toBe(true);
+ expect(nostr.calls[0].some(f => Array.isArray(f.ids) && Array.isArray(f.kinds))).toBe(true);
});
it('resolves a-tag coordinates', async () => {
@@ -108,6 +116,27 @@ describe('fetchListVideos', () => {
expect(await fetchListVideos(nostr, [id(1)], signal)).toEqual([]);
});
+ it('drops non-video events returned by an event-id lookup', async () => {
+ const wrongKind = { ...videoEvent(id(1), 'one'), kind: 1 };
+ const nostr = querierReturning([wrongKind]);
+
+ expect(await fetchListVideos(nostr, [id(1)], signal)).toEqual([]);
+ });
+
+ it('drops videos that exceed the short-form duration limit', async () => {
+ const longVideo: NostrEvent = {
+ ...videoEvent(id(1), 'one'),
+ tags: [
+ ['d', 'one'],
+ ['title', 'too long'],
+ ['imeta', 'url https://cdn.divine.video/one.mp4', 'm video/mp4', 'duration 7'],
+ ],
+ };
+ const nostr = querierReturning([longVideo]);
+
+ expect(await fetchListVideos(nostr, [id(1)], signal)).toEqual([]);
+ });
+
it('returns nothing for an empty ref list without querying', async () => {
const nostr = querierReturning([]);
expect(await fetchListVideos(nostr, [], signal)).toEqual([]);
diff --git a/src/lib/fetchListVideos.ts b/src/lib/fetchListVideos.ts
index 69f4a017e..627224c94 100644
--- a/src/lib/fetchListVideos.ts
+++ b/src/lib/fetchListVideos.ts
@@ -5,6 +5,7 @@ import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
import { SHORT_VIDEO_KIND, VIDEO_KINDS, type ParsedVideoData } from '@/types/video';
import {
parseVideoEvent,
+ validateVideoEvent,
getVineId,
getThumbnailUrl,
getOriginalVineTimestamp,
@@ -15,6 +16,7 @@ import {
getOriginalCommentCount,
getOriginPlatform,
isVineMigrated,
+ getTextTrackRef,
} from '@/lib/videoParser';
type NostrQuerier = {
@@ -34,13 +36,20 @@ function isEventIdRef(ref: string): boolean {
* by the single-video showcase share page.
*/
export function mapVideoEvent(event: NostrEvent): ParsedVideoData | null {
+ if (!validateVideoEvent(event)) return null;
+
const videoEvent = parseVideoEvent(event);
if (!videoEvent?.videoMetadata?.url) return null;
+ const duration = videoEvent.videoMetadata?.duration;
+ if (duration !== undefined && duration >= 7) return null;
+
+ const textTrack = getTextTrackRef(event);
+
return {
id: event.id,
pubkey: event.pubkey,
- kind: SHORT_VIDEO_KIND,
+ kind: event.kind as typeof SHORT_VIDEO_KIND,
createdAt: event.created_at,
originalVineTimestamp: getOriginalVineTimestamp(event),
content: event.content,
@@ -48,9 +57,11 @@ export function mapVideoEvent(event: NostrEvent): ParsedVideoData | null {
fallbackVideoUrls: videoEvent.videoMetadata?.fallbackUrls,
hlsUrl: videoEvent.videoMetadata?.hlsUrl,
thumbnailUrl: getThumbnailUrl(videoEvent),
+ blurhash: videoEvent.videoMetadata?.blurhash,
title: videoEvent.title,
duration: videoEvent.videoMetadata?.duration,
dimensions: videoEvent.videoMetadata?.dimensions,
+ sha256: videoEvent.videoMetadata?.hash,
hashtags: videoEvent.hashtags || [],
vineId: getVineId(event),
loopCount: getLoopCount(event),
@@ -60,6 +71,8 @@ export function mapVideoEvent(event: NostrEvent): ParsedVideoData | null {
proofMode: getProofModeData(event),
origin: getOriginPlatform(event),
isVineMigrated: isVineMigrated(event),
+ textTrackRef: textTrack?.ref,
+ textTrackLanguage: textTrack?.language,
reposts: [], // List videos don't include repost data
originalEvent: event, // Retained so callers can inspect moderation tags
};
@@ -111,7 +124,7 @@ export async function fetchListVideos(
// One batched filter for the `e` event ids.
if (eventIds.size > 0) {
- filters.push({ ids: [...eventIds], limit: eventIds.size });
+ filters.push({ kinds: VIDEO_KINDS, ids: [...eventIds], limit: eventIds.size });
}
if (filters.length === 0) return [];
diff --git a/src/pages/AppCallbackPage.tsx b/src/pages/AppCallbackPage.tsx
index bac4be3c7..1f14941bb 100644
--- a/src/pages/AppCallbackPage.tsx
+++ b/src/pages/AppCallbackPage.tsx
@@ -4,19 +4,10 @@
import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
-import { DIVINE_IOS_APP_ID } from '@/lib/mobileStoreLinks';
+import { detectPlatform, type Platform } from '@/lib/detectPlatform';
+import { buildStoreLinks } from '@/lib/mobileStoreLinks';
-type Platform = 'android' | 'ios' | 'desktop';
-
-function detectPlatform(): Platform {
- const ua = navigator.userAgent;
- if (/Android/i.test(ua)) return 'android';
- if (/iPhone|iPad|iPod/i.test(ua)) return 'ios';
- return 'desktop';
-}
-
-const APP_STORE_URL = `https://apps.apple.com/us/app/divine-video/id${DIVINE_IOS_APP_ID}`;
-const PLAY_STORE_URL = 'https://play.google.com/store/apps/details?id=co.openvine.app';
+const STORE_LINKS = buildStoreLinks('app_callback', 'app_callback');
export function AppCallbackPage() {
const [searchParams] = useSearchParams();
@@ -32,7 +23,7 @@ export function AppCallbackPage() {
// Use S.browser_fallback_url to prevent redirect loops
// If app not installed, Android will go to Play Store instead of looping
- const fallbackUrl = encodeURIComponent(PLAY_STORE_URL);
+ const fallbackUrl = encodeURIComponent(STORE_LINKS.playStore);
const intentUrl = `intent://divine.video/app/callback?code=${encodeURIComponent(code)}#Intent;scheme=https;package=co.openvine.app;S.browser_fallback_url=${fallbackUrl};end`;
window.location.href = intentUrl;
@@ -53,7 +44,7 @@ export function AppCallbackPage() {
{
if (!list) return [];
@@ -176,7 +176,7 @@ export default function ListDetailPage() {
AbortSignal.timeout(10000)
]);
- return fetchListVideos(nostr, list.videoCoordinates, signal);
+ return fetchListVideos(nostr, [...list.videoCoordinates, ...list.videoEventIds], signal);
},
enabled: !!list
});
diff --git a/src/pages/ShowcaseVideoPage.tsx b/src/pages/ShowcaseVideoPage.tsx
index fc42ba905..94af1f1ae 100644
--- a/src/pages/ShowcaseVideoPage.tsx
+++ b/src/pages/ShowcaseVideoPage.tsx
@@ -1,6 +1,7 @@
// ABOUTME: Public single-video landing in showcase mode — the target of shared links
// ABOUTME: One video in a phone frame, download CTAs, safety-gated; no app shell, no login
+import { useEffect, useRef, useState } from 'react';
import { useParams, Link } from 'react-router-dom';
import { useSeoMeta } from '@unhead/react';
import { ShareNetwork } from '@phosphor-icons/react';
@@ -15,12 +16,15 @@ import { useShowcaseShare } from '@/hooks/useShowcaseShare';
import { getVideoShareData } from '@/lib/shareUtils';
import { resolveDisplayName } from '@/lib/showcaseDisplayName';
import { getSafeProfileImage } from '@/lib/imageUtils';
+import { parseAspectRatio, pickObjectFit } from '@/lib/showcaseVideoFit';
export default function ShowcaseVideoPage() {
const { id } = useParams<{ id: string }>();
const { data: video, isLoading } = useShowcaseVideo(id);
const author = useAuthor(video?.pubkey);
const share = useShowcaseShare();
+ const videoRef = useRef(null);
+ const [objectFit, setObjectFit] = useState<'cover' | 'contain'>('cover');
const metadata = author.data?.metadata;
const displayName = video
@@ -28,6 +32,17 @@ export default function ShowcaseVideoPage() {
: '';
const avatar = getSafeProfileImage(metadata?.picture ?? video?.authorAvatar);
+ useEffect(() => {
+ setObjectFit(pickObjectFit(parseAspectRatio(video?.dimensions)));
+ }, [video?.dimensions]);
+
+ const handleLoadedMetadata = () => {
+ const el = videoRef.current;
+ if (el?.videoWidth && el.videoHeight) {
+ setObjectFit(pickObjectFit(el.videoWidth / el.videoHeight));
+ }
+ };
+
useSeoMeta({
title: video?.title ? `${video.title} · Divine` : 'Divine',
description: video?.title
@@ -45,13 +60,16 @@ export default function ShowcaseVideoPage() {
{isLoading && }
{!isLoading && video && (
)}
{!isLoading && !video && (
diff --git a/tests/visual/a11y.spec.ts b/tests/visual/a11y.spec.ts
index 824b993f6..43d1cf122 100644
--- a/tests/visual/a11y.spec.ts
+++ b/tests/visual/a11y.spec.ts
@@ -1,12 +1,15 @@
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
-// Showcase mode is the default build, so /discovery and /search 404 here and
-// are covered by the full-mode suite instead.
-const ROUTES = ['/', '/merch', '/family', '/safety', '/age-review', '/kids', '/__brand-preview'];
+const SHOWCASE_ROUTES = ['/', '/merch', '/family', '/safety', '/age-review', '/kids', '/__brand-preview'];
+const FULL_MODE_ROUTES = ['/', '/discovery', '/search', '/merch', '/family', '/safety', '/age-review', '/kids', '/__brand-preview'];
+const ROUTES = Array.from(new Set([...SHOWCASE_ROUTES, ...FULL_MODE_ROUTES]));
for (const route of ROUTES) {
- test(`a11y: ${route} has no WCAG 2 A/AA violations`, async ({ page }) => {
+ test(`a11y: ${route} has no WCAG 2 A/AA violations`, async ({ page }, testInfo) => {
+ const routes = testInfo.project.name === 'full-mode-a11y' ? FULL_MODE_ROUTES : SHOWCASE_ROUTES;
+ test.skip(!routes.includes(route), `${route} is not served in ${testInfo.project.name}`);
+
test.setTimeout(60_000); // discovery + search do a fair bit of fetching
await page.goto(route, { waitUntil: 'domcontentloaded' });
await expect(page.locator('body')).toBeVisible();
From 63afef8be45fad15edccc4ea68f79c797b958d91 Mon Sep 17 00:00:00 2001
From: Liz Sweigart <127434495+NotThatKindOfDrLiz@users.noreply.github.com>
Date: Fri, 24 Jul 2026 17:34:17 -0500
Subject: [PATCH 03/19] feat: invite families in on the showcase homepage
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds a section below the joyscrolling block that says out loud what
Divine wants social media to be for a household: a thing you enjoy
together rather than a thing you fight about.
Two doors out of it — the /family hub for parents and kids creating
together, and the Divine Greenlight section of /kids for teens 13-15
who get a parent-supported start instead of going it alone.
Greenlight copy stays hedged ("where local rules allow it") so the
homepage never promises more than the policy page does.
---
.../showcase/FamilyWelcomeSection.test.tsx | 31 ++++++++
.../showcase/FamilyWelcomeSection.tsx | 78 +++++++++++++++++++
src/pages/ShowcasePage.tsx | 5 ++
3 files changed, 114 insertions(+)
create mode 100644 src/components/showcase/FamilyWelcomeSection.test.tsx
create mode 100644 src/components/showcase/FamilyWelcomeSection.tsx
diff --git a/src/components/showcase/FamilyWelcomeSection.test.tsx b/src/components/showcase/FamilyWelcomeSection.test.tsx
new file mode 100644
index 000000000..17a6a1110
--- /dev/null
+++ b/src/components/showcase/FamilyWelcomeSection.test.tsx
@@ -0,0 +1,31 @@
+// ABOUTME: Tests for the showcase homepage's family / Divine Greenlight section
+// ABOUTME: Locks the two outbound destinations and the heading contract
+
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { FamilyWelcomeSection } from "./FamilyWelcomeSection";
+
+describe("FamilyWelcomeSection", () => {
+ it("points families at the family hub", () => {
+ render();
+
+ const link = screen.getByRole("link", { name: /families on divine/i });
+ expect(link).toHaveAttribute("href", "/family");
+ });
+
+ it("points teens at the Divine Greenlight section of the kids policy", () => {
+ render();
+
+ const link = screen.getByRole("link", { name: /divine greenlight/i });
+ // The 13-15 anchor is the Greenlight section on /kids; a bare /kids link
+ // would drop the reader at the top of a long policy page.
+ expect(link).toHaveAttribute("href", "/kids#13-15");
+ });
+
+ it("renders as an h2 section so it nests under the page h1", () => {
+ render();
+
+ expect(screen.getByRole("heading", { level: 2 })).toBeInTheDocument();
+ });
+});
diff --git a/src/components/showcase/FamilyWelcomeSection.tsx b/src/components/showcase/FamilyWelcomeSection.tsx
new file mode 100644
index 000000000..4e0000e63
--- /dev/null
+++ b/src/components/showcase/FamilyWelcomeSection.tsx
@@ -0,0 +1,78 @@
+// ABOUTME: Homepage section inviting families in and pointing teens at Divine Greenlight
+// ABOUTME: Links out to the /family hub and the 13-15 section of the kids policy
+
+import { ArrowSquareOut, HouseLine, VideoCamera } from "@phosphor-icons/react";
+
+import { SectionHeader } from "@/components/brand/SectionHeader";
+import { staticPageLinkCardClass, type LinkCardAccent } from "@/components/static-pages";
+
+interface FamilyLink {
+ to: string;
+ accent: LinkCardAccent;
+ icon: React.ReactNode;
+ title: string;
+ body: React.ReactNode;
+}
+
+const LINKS: FamilyLink[] = [
+ {
+ to: "/family",
+ accent: "green",
+ icon: ,
+ title: "Families on Divine",
+ body: "Parents and kids make loops together here, and it's genuinely some of our favorite stuff on the app. The family guides cover content settings, starting a conversation that isn't an interrogation, and what to do when something goes wrong.",
+ },
+ {
+ to: "/kids#13-15",
+ accent: "violet",
+ icon: ,
+ title: "Divine Greenlight, for teens 13-15",
+ // Deliberately hedged: Greenlight depends on local law and on a parent or
+ // guardian video. Don't let homepage copy promise more than /kids does.
+ body: "Where local rules allow it, a teen can hold their own account with a parent or guardian in it from day one. A short video together, then good habits built alongside someone—instead of figured out alone.",
+ },
+];
+
+export function FamilyWelcomeSection() {
+ return (
+
+
+ Bring the whole house
+
+
+ In most homes, social media is a thing to argue about—screen time, what they
+ saw, who they're talking to. We're building for the opposite: a place the
+ whole family can actually enjoy together.
+
+
+
+
+
+ No app fixes this on its own. It also doesn't have to make it worse.
+
+
+ );
+}
diff --git a/src/pages/ShowcasePage.tsx b/src/pages/ShowcasePage.tsx
index 5ed74450e..cb59f633e 100644
--- a/src/pages/ShowcasePage.tsx
+++ b/src/pages/ShowcasePage.tsx
@@ -7,6 +7,7 @@ import { MarketingLayout } from "@/components/MarketingLayout";
import { SectionHeader } from "@/components/brand/SectionHeader";
import { GetAppButton } from "@/components/GetAppButton";
import { ShowcasePhone } from "@/components/showcase/ShowcasePhone";
+import { FamilyWelcomeSection } from "@/components/showcase/FamilyWelcomeSection";
import { useCuratedShowcase } from "@/hooks/useCuratedShowcase";
export default function ShowcasePage() {
@@ -77,6 +78,10 @@ export default function ShowcasePage() {
+
+ {/* Full width below the hero grid: who else this is for, and the two
+ doors into the family material. */}
+
);
From 756a6597716fd295b477f0adc23294d15c9a3e25 Mon Sep 17 00:00:00 2001
From: Liz Sweigart <127434495+NotThatKindOfDrLiz@users.noreply.github.com>
Date: Fri, 24 Jul 2026 18:57:07 -0500
Subject: [PATCH 04/19] refactor: fold the family block into the hero column
Moves the family / Divine Greenlight copy out of a full-width band
below the fold and into the left column directly under the
joyscrolling block, so it reads as the next beat of the same
column rather than a separate marketing section.
Drops the link cards for inline links in body copy, matching the
heading size, muted paragraph, and spacing of the block above it.
---
.../showcase/FamilyWelcomeSection.test.tsx | 2 +-
.../showcase/FamilyWelcomeSection.tsx | 82 ++++---------------
src/pages/ShowcasePage.tsx | 6 +-
3 files changed, 21 insertions(+), 69 deletions(-)
diff --git a/src/components/showcase/FamilyWelcomeSection.test.tsx b/src/components/showcase/FamilyWelcomeSection.test.tsx
index 17a6a1110..7d1b9228c 100644
--- a/src/components/showcase/FamilyWelcomeSection.test.tsx
+++ b/src/components/showcase/FamilyWelcomeSection.test.tsx
@@ -10,7 +10,7 @@ describe("FamilyWelcomeSection", () => {
it("points families at the family hub", () => {
render();
- const link = screen.getByRole("link", { name: /families on divine/i });
+ const link = screen.getByRole("link", { name: /family guides/i });
expect(link).toHaveAttribute("href", "/family");
});
diff --git a/src/components/showcase/FamilyWelcomeSection.tsx b/src/components/showcase/FamilyWelcomeSection.tsx
index 4e0000e63..4b45774e4 100644
--- a/src/components/showcase/FamilyWelcomeSection.tsx
+++ b/src/components/showcase/FamilyWelcomeSection.tsx
@@ -1,77 +1,31 @@
-// ABOUTME: Homepage section inviting families in and pointing teens at Divine Greenlight
-// ABOUTME: Links out to the /family hub and the 13-15 section of the kids policy
-
-import { ArrowSquareOut, HouseLine, VideoCamera } from "@phosphor-icons/react";
+// ABOUTME: Homepage copy block inviting families in and pointing teens at Divine Greenlight
+// ABOUTME: Styled to match the joyscrolling block above it — heading plus one muted paragraph
import { SectionHeader } from "@/components/brand/SectionHeader";
-import { staticPageLinkCardClass, type LinkCardAccent } from "@/components/static-pages";
-
-interface FamilyLink {
- to: string;
- accent: LinkCardAccent;
- icon: React.ReactNode;
- title: string;
- body: React.ReactNode;
-}
-const LINKS: FamilyLink[] = [
- {
- to: "/family",
- accent: "green",
- icon: ,
- title: "Families on Divine",
- body: "Parents and kids make loops together here, and it's genuinely some of our favorite stuff on the app. The family guides cover content settings, starting a conversation that isn't an interrogation, and what to do when something goes wrong.",
- },
- {
- to: "/kids#13-15",
- accent: "violet",
- icon: ,
- title: "Divine Greenlight, for teens 13-15",
- // Deliberately hedged: Greenlight depends on local law and on a parent or
- // guardian video. Don't let homepage copy promise more than /kids does.
- body: "Where local rules allow it, a teen can hold their own account with a parent or guardian in it from day one. A short video together, then good habits built alongside someone—instead of figured out alone.",
- },
-];
+// Inline link treatment for body copy on a light surface, same as the static pages.
+const LINK_CLASS =
+ "text-brand-dark-green dark:text-brand-green underline underline-offset-2 hover:opacity-80";
export function FamilyWelcomeSection() {
return (
-
+
Bring the whole house
-
+
In most homes, social media is a thing to argue about—screen time, what they
- saw, who they're talking to. We're building for the opposite: a place the
- whole family can actually enjoy together.
-
-
-
-
-
- No app fixes this on its own. It also doesn't have to make it worse.
+ saw, who they're talking to. We're building for the opposite. Parents and kids
+ make loops together here, and our{" "}
+
+ family guides
+ {" "}
+ cover the rest. Where local rules allow it, teens 13-15 can start with a parent
+ or guardian alongside them through{" "}
+
+ Divine Greenlight
+
+ .
);
diff --git a/src/pages/ShowcasePage.tsx b/src/pages/ShowcasePage.tsx
index cb59f633e..406469a0b 100644
--- a/src/pages/ShowcasePage.tsx
+++ b/src/pages/ShowcasePage.tsx
@@ -66,6 +66,8 @@ export default function ShowcasePage() {
)}
+
+
{/* Right column (desktop) / below the copy (mobile): the phone,
@@ -78,10 +80,6 @@ export default function ShowcasePage() {
-
- {/* Full width below the hero grid: who else this is for, and the two
- doors into the family material. */}
-
);
From 2b990703af120f2d2c3ed82d191b307d18fb1eb6 Mon Sep 17 00:00:00 2001
From: Liz Sweigart <127434495+NotThatKindOfDrLiz@users.noreply.github.com>
Date: Fri, 24 Jul 2026 19:11:18 -0500
Subject: [PATCH 05/19] copy: retitle and tighten the family block
Heading becomes "Building a healthier social media experience" and
the copy leads with the friction before the pivot.
Keeps "family guides" lowercase rather than coining a "Divine Family"
product name the /family hub doesn't use yet. Trimmed back to the
length of the block it replaced so the hero column stays level with
the phone frame.
---
.../showcase/FamilyWelcomeSection.tsx | 17 ++++++++---------
1 file changed, 8 insertions(+), 9 deletions(-)
diff --git a/src/components/showcase/FamilyWelcomeSection.tsx b/src/components/showcase/FamilyWelcomeSection.tsx
index 4b45774e4..77bb673a8 100644
--- a/src/components/showcase/FamilyWelcomeSection.tsx
+++ b/src/components/showcase/FamilyWelcomeSection.tsx
@@ -1,4 +1,4 @@
-// ABOUTME: Homepage copy block inviting families in and pointing teens at Divine Greenlight
+// ABOUTME: Homepage copy block on healthier social media, linking the family guides and Divine Greenlight
// ABOUTME: Styled to match the joyscrolling block above it — heading plus one muted paragraph
import { SectionHeader } from "@/components/brand/SectionHeader";
@@ -11,21 +11,20 @@ export function FamilyWelcomeSection() {
return (
- Bring the whole house
+ Building a healthier social media experience
- In most homes, social media is a thing to argue about—screen time, what they
- saw, who they're talking to. We're building for the opposite. Parents and kids
- make loops together here, and our{" "}
+ Screen time is a fight in a lot of homes. It doesn't have to be. Parents and
+ kids make loops together here, and some of our favorite videos are theirs. Our{" "}
family guides
{" "}
- cover the rest. Where local rules allow it, teens 13-15 can start with a parent
- or guardian alongside them through{" "}
+ cover the conversations that help, and{" "}
Divine Greenlight
-
- .
+ {" "}
+ gives teens 13-15 a guided start with a parent or guardian alongside them, where
+ local rules allow it.
);
From 915c1dd4bf074215e4a6814d791925cd6900a681 Mon Sep 17 00:00:00 2001
From: Liz Sweigart <127434495+NotThatKindOfDrLiz@users.noreply.github.com>
Date: Fri, 24 Jul 2026 19:12:59 -0500
Subject: [PATCH 06/19] copy: drop "make loops together here" from the family
block
Leads with the compliment instead of the mechanic, and loses both
the "loops" jargon and the vague "here".
---
src/components/showcase/FamilyWelcomeSection.tsx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/components/showcase/FamilyWelcomeSection.tsx b/src/components/showcase/FamilyWelcomeSection.tsx
index 77bb673a8..0f9545852 100644
--- a/src/components/showcase/FamilyWelcomeSection.tsx
+++ b/src/components/showcase/FamilyWelcomeSection.tsx
@@ -14,8 +14,8 @@ export function FamilyWelcomeSection() {
Building a healthier social media experience
- Screen time is a fight in a lot of homes. It doesn't have to be. Parents and
- kids make loops together here, and some of our favorite videos are theirs. Our{" "}
+ Screen time is a fight in a lot of homes. It doesn't have to be. Some of our
+ favorite videos come from parents and kids creating together. Our{" "}
family guides
{" "}
From b1f88592fa5649d6e29f370da24b219085f29f28 Mon Sep 17 00:00:00 2001
From: Liz Sweigart <127434495+NotThatKindOfDrLiz@users.noreply.github.com>
Date: Fri, 24 Jul 2026 19:16:19 -0500
Subject: [PATCH 07/19] copy: say Divine is 16+ and built for the real world
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Splits the family block in two: the invitation stays first, and a
second paragraph states the age posture plainly — 16 and up, no solo
accounts under 13, parent-held family accounts instead, Greenlight
for 13-15 where local law allows.
Claims track /kids and the Terms; a comment marks them as needing to
stay in sync with those pages.
---
.../showcase/FamilyWelcomeSection.tsx | 17 +++++++++++++----
1 file changed, 13 insertions(+), 4 deletions(-)
diff --git a/src/components/showcase/FamilyWelcomeSection.tsx b/src/components/showcase/FamilyWelcomeSection.tsx
index 0f9545852..66e102adf 100644
--- a/src/components/showcase/FamilyWelcomeSection.tsx
+++ b/src/components/showcase/FamilyWelcomeSection.tsx
@@ -15,16 +15,25 @@ export function FamilyWelcomeSection() {
Screen time is a fight in a lot of homes. It doesn't have to be. Some of our
- favorite videos come from parents and kids creating together. Our{" "}
+ favorite videos come from parents and kids creating together, and our{" "}
family guides
{" "}
- cover the conversations that help, and{" "}
+ cover the conversations that help.
+
+ {/*
+ Second paragraph tracks /kids and the Terms: 16+ without parental
+ involvement, parent-held accounts under 13, Greenlight for 13-15 where
+ local law allows. Keep these three claims in sync with those pages.
+ */}
+
+ Divine is built for 16 and up, but we live in the real world. We don't host solo
+ accounts for under-13s—a parent or guardian holds the account, and kids can be
+ in the videos. Where local rules allow it,{" "}
Divine Greenlight
{" "}
- gives teens 13-15 a guided start with a parent or guardian alongside them, where
- local rules allow it.
+ gives teens 13-15 a guided start with a parent or guardian alongside them.
);
From 95510753aaaacb63c016c4b87c2363b1d47e0260 Mon Sep 17 00:00:00 2001
From: Liz Sweigart <127434495+NotThatKindOfDrLiz@users.noreply.github.com>
Date: Fri, 24 Jul 2026 19:20:46 -0500
Subject: [PATCH 08/19] copy: adopt Liz's wording for the family block
Author's revision, applied verbatim. States the under-13 rule as a
prohibition on solo accounts rather than a hosting choice, and moves
the age posture to "designed for ages 16 and up."
---
src/components/showcase/FamilyWelcomeSection.tsx | 15 ++++++++-------
1 file changed, 8 insertions(+), 7 deletions(-)
diff --git a/src/components/showcase/FamilyWelcomeSection.tsx b/src/components/showcase/FamilyWelcomeSection.tsx
index 66e102adf..682d0a945 100644
--- a/src/components/showcase/FamilyWelcomeSection.tsx
+++ b/src/components/showcase/FamilyWelcomeSection.tsx
@@ -14,12 +14,12 @@ export function FamilyWelcomeSection() {
Building a healthier social media experience
- Screen time is a fight in a lot of homes. It doesn't have to be. Some of our
- favorite videos come from parents and kids creating together, and our{" "}
+ Screen time is a battle in many homes. It does not have to be. Some of our
+ favorite videos are made by parents and kids together, and our{" "}
family guides
{" "}
- cover the conversations that help.
+ help start the conversations that matter.
{/*
Second paragraph tracks /kids and the Terms: 16+ without parental
@@ -27,13 +27,14 @@ export function FamilyWelcomeSection() {
local law allows. Keep these three claims in sync with those pages.
*/}
- Divine is built for 16 and up, but we live in the real world. We don't host solo
- accounts for under-13s—a parent or guardian holds the account, and kids can be
- in the videos. Where local rules allow it,{" "}
+ Divine is designed for ages 16 and up, but we know younger people may still want
+ to participate. Children under 13 cannot hold their own accounts; a parent or
+ guardian must manage the account, though kids can appear in videos. Where local
+ rules permit,{" "}
Divine Greenlight
{" "}
- gives teens 13-15 a guided start with a parent or guardian alongside them.
+ gives teens ages 13–15 a guided start alongside a parent or guardian.
);
From 20c2e9a05257ac1755b97f9507a681ac42f7df79 Mon Sep 17 00:00:00 2001
From: Liz Sweigart <127434495+NotThatKindOfDrLiz@users.noreply.github.com>
Date: Fri, 24 Jul 2026 19:28:50 -0500
Subject: [PATCH 09/19] copy: lead the family block with humility, not policy
Replaces both paragraphs with a single mission-voice paragraph and
consolidates the two links into the closing sentence.
Drops the explicit age posture (16+, under-13 accounts, the 13-15
range) from the homepage; that detail still lives on /kids, which
both links reach.
---
.../showcase/FamilyWelcomeSection.tsx | 21 ++++++-------------
1 file changed, 6 insertions(+), 15 deletions(-)
diff --git a/src/components/showcase/FamilyWelcomeSection.tsx b/src/components/showcase/FamilyWelcomeSection.tsx
index 682d0a945..aec7ac4e3 100644
--- a/src/components/showcase/FamilyWelcomeSection.tsx
+++ b/src/components/showcase/FamilyWelcomeSection.tsx
@@ -14,27 +14,18 @@ export function FamilyWelcomeSection() {
Building a healthier social media experience
- Screen time is a battle in many homes. It does not have to be. Some of our
- favorite videos are made by parents and kids together, and our{" "}
+ We're trying to rethink social media. No app has all the answers, and we do not
+ pretend to. We are parents and social media users, too, working to make our
+ little corner of the internet a bit healthier by helping families navigate tough
+ conversations about screens and build positive online habits together. Our{" "}
family guides
{" "}
- help start the conversations that matter.
-
- {/*
- Second paragraph tracks /kids and the Terms: 16+ without parental
- involvement, parent-held accounts under 13, Greenlight for 13-15 where
- local law allows. Keep these three claims in sync with those pages.
- */}
-
- Divine is designed for ages 16 and up, but we know younger people may still want
- to participate. Children under 13 cannot hold their own accounts; a parent or
- guardian must manage the account, though kids can appear in videos. Where local
- rules permit,{" "}
+ and{" "}
Divine Greenlight
{" "}
- gives teens ages 13–15 a guided start alongside a parent or guardian.
+ are here to help.
);
From be91feca355764651131acbf6898d4cc67869aed Mon Sep 17 00:00:00 2001
From: Liz Sweigart <127434495+NotThatKindOfDrLiz@users.noreply.github.com>
Date: Fri, 24 Jul 2026 19:35:53 -0500
Subject: [PATCH 10/19] copy: tighten the family block to its final wording
Author's revision. Opens on the humility line, names what the guides
actually do for a family, and restores the research-backed claim that
/family's citations support.
---
src/components/showcase/FamilyWelcomeSection.tsx | 9 +++++----
1 file changed, 5 insertions(+), 4 deletions(-)
diff --git a/src/components/showcase/FamilyWelcomeSection.tsx b/src/components/showcase/FamilyWelcomeSection.tsx
index aec7ac4e3..6fe047fc9 100644
--- a/src/components/showcase/FamilyWelcomeSection.tsx
+++ b/src/components/showcase/FamilyWelcomeSection.tsx
@@ -14,10 +14,11 @@ export function FamilyWelcomeSection() {
Building a healthier social media experience
- We're trying to rethink social media. No app has all the answers, and we do not
- pretend to. We are parents and social media users, too, working to make our
- little corner of the internet a bit healthier by helping families navigate tough
- conversations about screens and build positive online habits together. Our{" "}
+ No app has all the answers, and we don’t pretend to. We’re parents and social
+ media users, too, trying to make the internet more thoughtful, creative, and
+ human. That means helping families have productive conversations about screens,
+ decide what works for them, and build healthier online habits together. Our
+ research-backed{" "}
family guides
{" "}
From ea9e2bbac21a346b8f56af28efd630326e8da06f Mon Sep 17 00:00:00 2001
From: Liz Sweigart <127434495+NotThatKindOfDrLiz@users.noreply.github.com>
Date: Fri, 24 Jul 2026 19:41:26 -0500
Subject: [PATCH 11/19] copy: call it AI slop in the homepage hero
"AI-generated content" is the neutral description; "AI slop" is the
brand's own word for it, and the tone guide uses it directly.
---
src/pages/ShowcasePage.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/pages/ShowcasePage.tsx b/src/pages/ShowcasePage.tsx
index 406469a0b..1bd92a273 100644
--- a/src/pages/ShowcasePage.tsx
+++ b/src/pages/ShowcasePage.tsx
@@ -41,7 +41,7 @@ export default function ShowcasePage() {
Authentic moments. Human creativity.
- In a world of AI-generated content, Divine is putting creativity back in
+ In a world of AI slop, Divine is putting creativity back in
human hands. Create, share, and discover old gems and new favorites. This is
social media for humans, by humans. 6 seconds at a time.
From f075085e5b04ee6557b809bd7297ebffc7b0ce2b Mon Sep 17 00:00:00 2001
From: Liz Sweigart <127434495+NotThatKindOfDrLiz@users.noreply.github.com>
Date: Fri, 24 Jul 2026 19:43:58 -0500
Subject: [PATCH 12/19] copy: link the kids policy alongside the guides and
Greenlight
Narrows the middle of the paragraph to kids specifically and adds a
third destination, so the homepage now reaches the policy page, the
family hub, and the Greenlight section.
Covers the new /kids link in the section test.
---
.../showcase/FamilyWelcomeSection.test.tsx | 7 +++++++
.../showcase/FamilyWelcomeSection.tsx | 20 +++++++++++--------
2 files changed, 19 insertions(+), 8 deletions(-)
diff --git a/src/components/showcase/FamilyWelcomeSection.test.tsx b/src/components/showcase/FamilyWelcomeSection.test.tsx
index 7d1b9228c..ef4c9de80 100644
--- a/src/components/showcase/FamilyWelcomeSection.test.tsx
+++ b/src/components/showcase/FamilyWelcomeSection.test.tsx
@@ -14,6 +14,13 @@ describe("FamilyWelcomeSection", () => {
expect(link).toHaveAttribute("href", "/family");
});
+ it("points readers at the kids policy", () => {
+ render();
+
+ const link = screen.getByRole("link", { name: /kids policy/i });
+ expect(link).toHaveAttribute("href", "/kids");
+ });
+
it("points teens at the Divine Greenlight section of the kids policy", () => {
render();
diff --git a/src/components/showcase/FamilyWelcomeSection.tsx b/src/components/showcase/FamilyWelcomeSection.tsx
index 6fe047fc9..ec2451739 100644
--- a/src/components/showcase/FamilyWelcomeSection.tsx
+++ b/src/components/showcase/FamilyWelcomeSection.tsx
@@ -14,19 +14,23 @@ export function FamilyWelcomeSection() {
Building a healthier social media experience
- No app has all the answers, and we don’t pretend to. We’re parents and social
- media users, too, trying to make the internet more thoughtful, creative, and
- human. That means helping families have productive conversations about screens,
- decide what works for them, and build healthier online habits together. Our
- research-backed{" "}
+ No app has all the answers, and we don’t pretend to. We’re simply trying to make
+ the internet more thoughtful, creative, and human. When it comes to kids and
+ Divine, that means giving families tools to have productive conversations about
+ social media, decide what works for them, and build healthier online habits
+ together. Our research-backed{" "}
+
+ kids policy
+
+ ,{" "}
family guides
- {" "}
- and{" "}
+
+ , and{" "}
Divine Greenlight
{" "}
- are here to help.
+ are here to support them.
);
From edf58460ff9d1f0276d8df77327e5103b3aa9d30 Mon Sep 17 00:00:00 2001
From: Liz Sweigart <127434495+NotThatKindOfDrLiz@users.noreply.github.com>
Date: Fri, 24 Jul 2026 20:38:07 -0500
Subject: [PATCH 13/19] copy: lead the family block with the age rules
Replaces the mission framing with a direct statement of the age
posture: not built for under-13s, rules vary by age and location,
Greenlight for 13-15 where permitted, families welcome together.
Links move onto "Divine Greenlight", "tools", and "resources".
---
.../showcase/FamilyWelcomeSection.test.tsx | 4 +--
.../showcase/FamilyWelcomeSection.tsx | 30 +++++++++----------
2 files changed, 17 insertions(+), 17 deletions(-)
diff --git a/src/components/showcase/FamilyWelcomeSection.test.tsx b/src/components/showcase/FamilyWelcomeSection.test.tsx
index ef4c9de80..d9be87938 100644
--- a/src/components/showcase/FamilyWelcomeSection.test.tsx
+++ b/src/components/showcase/FamilyWelcomeSection.test.tsx
@@ -10,14 +10,14 @@ describe("FamilyWelcomeSection", () => {
it("points families at the family hub", () => {
render();
- const link = screen.getByRole("link", { name: /family guides/i });
+ const link = screen.getByRole("link", { name: /resources/i });
expect(link).toHaveAttribute("href", "/family");
});
it("points readers at the kids policy", () => {
render();
- const link = screen.getByRole("link", { name: /kids policy/i });
+ const link = screen.getByRole("link", { name: /tools/i });
expect(link).toHaveAttribute("href", "/kids");
});
diff --git a/src/components/showcase/FamilyWelcomeSection.tsx b/src/components/showcase/FamilyWelcomeSection.tsx
index ec2451739..c9d9f6806 100644
--- a/src/components/showcase/FamilyWelcomeSection.tsx
+++ b/src/components/showcase/FamilyWelcomeSection.tsx
@@ -1,4 +1,4 @@
-// ABOUTME: Homepage copy block on healthier social media, linking the family guides and Divine Greenlight
+// ABOUTME: Homepage copy block on healthier social media, linking the kids policy, family hub, and Greenlight
// ABOUTME: Styled to match the joyscrolling block above it — heading plus one muted paragraph
import { SectionHeader } from "@/components/brand/SectionHeader";
@@ -14,23 +14,23 @@ export function FamilyWelcomeSection() {
Building a healthier social media experience
- No app has all the answers, and we don’t pretend to. We’re simply trying to make
- the internet more thoughtful, creative, and human. When it comes to kids and
- Divine, that means giving families tools to have productive conversations about
- social media, decide what works for them, and build healthier online habits
- together. Our research-backed{" "}
-
- kids policy
-
- ,{" "}
-
- family guides
-
- , and{" "}
+ Social media is facing understandable scrutiny around the world. Divine is not
+ built for children under 13, and account rules vary by age and location. Where
+ permitted, teens ages 13–15 can join through{" "}
Divine Greenlight
{" "}
- are here to support them.
+ with an involved parent or guardian. Families are welcome to enjoy Divine
+ together, and our{" "}
+
+ tools
+ {" "}
+ and{" "}
+
+ resources
+ {" "}
+ are designed to help them make informed choices as we work toward a better
+ internet for everyone.
);
From 96eaa1776d3e9afe19cad4c14473abca24d9f858 Mon Sep 17 00:00:00 2001
From: Liz Sweigart <127434495+NotThatKindOfDrLiz@users.noreply.github.com>
Date: Fri, 24 Jul 2026 20:50:05 -0500
Subject: [PATCH 14/19] copy: argue the position, point at one destination
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Replaces the age-rule recitation with Divine's stance — better answers
than blanket bans or mass surveillance — and narrows the block to a
single call to action on the family resources page.
Test now pins the link count so a stray second CTA can't creep back in.
---
.../showcase/FamilyWelcomeSection.test.tsx | 18 +++++----------
.../showcase/FamilyWelcomeSection.tsx | 22 ++++++-------------
2 files changed, 12 insertions(+), 28 deletions(-)
diff --git a/src/components/showcase/FamilyWelcomeSection.test.tsx b/src/components/showcase/FamilyWelcomeSection.test.tsx
index d9be87938..defe50a45 100644
--- a/src/components/showcase/FamilyWelcomeSection.test.tsx
+++ b/src/components/showcase/FamilyWelcomeSection.test.tsx
@@ -10,24 +10,16 @@ describe("FamilyWelcomeSection", () => {
it("points families at the family hub", () => {
render();
- const link = screen.getByRole("link", { name: /resources/i });
+ const link = screen.getByRole("link", { name: /family resources page/i });
expect(link).toHaveAttribute("href", "/family");
});
- it("points readers at the kids policy", () => {
+ it("sends readers to exactly one destination", () => {
render();
- const link = screen.getByRole("link", { name: /tools/i });
- expect(link).toHaveAttribute("href", "/kids");
- });
-
- it("points teens at the Divine Greenlight section of the kids policy", () => {
- render();
-
- const link = screen.getByRole("link", { name: /divine greenlight/i });
- // The 13-15 anchor is the Greenlight section on /kids; a bare /kids link
- // would drop the reader at the top of a long policy page.
- expect(link).toHaveAttribute("href", "/kids#13-15");
+ // The block deliberately carries a single call to action. /kids stays
+ // reachable through the footer, and Greenlight is a section within it.
+ expect(screen.getAllByRole("link")).toHaveLength(1);
});
it("renders as an h2 section so it nests under the page h1", () => {
diff --git a/src/components/showcase/FamilyWelcomeSection.tsx b/src/components/showcase/FamilyWelcomeSection.tsx
index c9d9f6806..5a3d319b0 100644
--- a/src/components/showcase/FamilyWelcomeSection.tsx
+++ b/src/components/showcase/FamilyWelcomeSection.tsx
@@ -14,23 +14,15 @@ export function FamilyWelcomeSection() {
Building a healthier social media experience
- Social media is facing understandable scrutiny around the world. Divine is not
- built for children under 13, and account rules vary by age and location. Where
- permitted, teens ages 13–15 can join through{" "}
-
- Divine Greenlight
- {" "}
- with an involved parent or guardian. Families are welcome to enjoy Divine
- together, and our{" "}
-
- tools
- {" "}
- and{" "}
+ Social media is facing understandable scrutiny around the world. We believe the
+ challenges of an increasingly digital world call for better solutions than
+ blanket bans or mass surveillance. As part of our commitment to a more
+ human-centered internet, we’ve created research-backed tools to help families
+ build healthy online habits together. Visit our{" "}
- resources
+ family resources page
{" "}
- are designed to help them make informed choices as we work toward a better
- internet for everyone.
+ to learn more.
);
From cf9d359cbdb61ed9cb572002db623e4a0fdf5df9 Mon Sep 17 00:00:00 2001
From: Liz Sweigart <127434495+NotThatKindOfDrLiz@users.noreply.github.com>
Date: Fri, 24 Jul 2026 20:51:35 -0500
Subject: [PATCH 15/19] copy: swap hand-crafted for handpicked in the reel
intro
Also makes the CTA an instruction ("Download the app and join the
fun") instead of a question-and-answer, and switches the apostrophe
to a curly one so both paragraphs in the column now match.
---
src/pages/ShowcasePage.tsx | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/src/pages/ShowcasePage.tsx b/src/pages/ShowcasePage.tsx
index 1bd92a273..29f47cea9 100644
--- a/src/pages/ShowcasePage.tsx
+++ b/src/pages/ShowcasePage.tsx
@@ -54,9 +54,9 @@ export default function ShowcasePage() {
Your joyscrolling era starts now
- Enjoy this hand-crafted set of what's happening on Divine right now, from
- nostalgic classics to fresh, new takes. Want more? Grab the app to join in
- the fun.
+ Explore a handpicked mix of what’s happening on Divine right now, from
+ nostalgic classics to fresh new takes. Ready for more? Download the app
+ and join the fun.
{isEmpty && (
From e9a52bef79eaed0757e7b9f355acfe63c665b2d9 Mon Sep 17 00:00:00 2001
From: Liz Sweigart <127434495+NotThatKindOfDrLiz@users.noreply.github.com>
Date: Fri, 24 Jul 2026 20:56:04 -0500
Subject: [PATCH 16/19] fix: put the reel above the family block on mobile
The family copy was nested inside the hero column, so on a phone it
rendered before the reel and pushed it down the page. Lifts it out as
a sibling and lets the phone span both grid rows, so mobile reads
copy, reel, family while desktop keeps the family block beside the
phone rather than below it.
Adds Playwright coverage for the ordering at both breakpoints.
---
.../showcase/FamilyWelcomeSection.tsx | 9 +++--
src/pages/ShowcasePage.tsx | 31 +++++++++-------
tests/visual/responsive.spec.ts | 36 +++++++++++++++++++
3 files changed, 61 insertions(+), 15 deletions(-)
diff --git a/src/components/showcase/FamilyWelcomeSection.tsx b/src/components/showcase/FamilyWelcomeSection.tsx
index 5a3d319b0..1bbdbc91c 100644
--- a/src/components/showcase/FamilyWelcomeSection.tsx
+++ b/src/components/showcase/FamilyWelcomeSection.tsx
@@ -2,14 +2,19 @@
// ABOUTME: Styled to match the joyscrolling block above it — heading plus one muted paragraph
import { SectionHeader } from "@/components/brand/SectionHeader";
+import { cn } from "@/lib/utils";
// Inline link treatment for body copy on a light surface, same as the static pages.
const LINK_CLASS =
"text-brand-dark-green dark:text-brand-green underline underline-offset-2 hover:opacity-80";
-export function FamilyWelcomeSection() {
+export function FamilyWelcomeSection({ className }: { className?: string }) {
return (
-
+
Building a healthier social media experience
diff --git a/src/pages/ShowcasePage.tsx b/src/pages/ShowcasePage.tsx
index 29f47cea9..ab6b21b01 100644
--- a/src/pages/ShowcasePage.tsx
+++ b/src/pages/ShowcasePage.tsx
@@ -28,15 +28,18 @@ export default function ShowcasePage() {
{/*
- Mobile: a single stacked column — hero copy, the taste intro, then the
- phone below (unchanged from before).
- Desktop (lg): two columns — all the copy on the left, the phone pinned
- to the right so it sits above the fold. Top-aligned so the headline and
- the phone both start high, with no dead space above.
+ Mobile: one stacked column in DOM order — hero copy and the taste
+ intro, then the phone, then the family block. The reel has to come
+ before the family copy so the thing being described is on screen
+ first.
+ Desktop (lg): two columns. The copy stacks in column one across two
+ rows and the phone spans both rows in column two, which keeps the
+ family block tucked under the copy instead of dropping below the
+ phone. Row gap is zero because each block carries its own mt-8.
*/}
-
- {/* Left column: all the copy, left-aligned in its half. */}
-
+
+ {/* Column one, row one: hero copy and the reel intro. */}
+
Authentic moments. Human creativity.
@@ -67,18 +70,20 @@ export default function ShowcasePage() {
)}
-
- {/* Right column (desktop) / below the copy (mobile): the phone,
- centered in its half so it sits in the middle of the space it has
- and shifts with the window width. */}
+ {/* Column two on desktop, second on mobile: the phone, centered in
+ its half so it shifts with the window width. Spans both rows so
+ the family block below can sit beside it, not under it. */}
+
+ {/* Column one, row two: reads after the reel on both layouts. */}
+
diff --git a/tests/visual/responsive.spec.ts b/tests/visual/responsive.spec.ts
index e75a0419d..b58d4035c 100644
--- a/tests/visual/responsive.spec.ts
+++ b/tests/visual/responsive.spec.ts
@@ -89,3 +89,39 @@ test('showcase page renders its real mobile layout', async ({ page }) => {
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
await expect(page.getByTestId('curated-showcase')).toBeVisible();
});
+
+test.describe('showcase hero ordering', () => {
+ // The phone has to come before the family copy on a phone-sized screen: the
+ // reel is the thing the page is showing off, and burying it under every
+ // paragraph pushes it off the first screenful entirely.
+ test('puts the reel above the family block on mobile', async ({ page }) => {
+ await page.setViewportSize({ width: 390, height: 844 });
+ await page.goto('/', { waitUntil: 'domcontentloaded' });
+
+ const phone = await page.getByTestId('curated-showcase').boundingBox();
+ const family = await page.getByTestId('family-welcome').boundingBox();
+
+ expect(phone, 'reel has no box').not.toBeNull();
+ expect(family, 'family block has no box').not.toBeNull();
+ expect(
+ phone!.y,
+ `reel starts at ${phone!.y}, family block at ${family!.y}`,
+ ).toBeLessThan(family!.y);
+ });
+
+ // On desktop the two share a row instead of stacking, so the family block
+ // stays in the left column beside the phone rather than dropping below it.
+ test('keeps the family block beside the reel on desktop', async ({ page }) => {
+ await page.setViewportSize({ width: 1280, height: 900 });
+ await page.goto('/', { waitUntil: 'domcontentloaded' });
+
+ const phone = await page.getByTestId('curated-showcase').boundingBox();
+ const family = await page.getByTestId('family-welcome').boundingBox();
+
+ expect(family!.x, 'family block should sit left of the reel').toBeLessThan(phone!.x);
+ expect(
+ family!.y,
+ 'family block should start before the reel ends',
+ ).toBeLessThan(phone!.y + phone!.height);
+ });
+});
From 946f5b5facb46975a91394724f9ed4f3508339ba Mon Sep 17 00:00:00 2001
From: Liz Sweigart <127434495+NotThatKindOfDrLiz@users.noreply.github.com>
Date: Fri, 24 Jul 2026 21:05:29 -0500
Subject: [PATCH 17/19] feat: loop the showcase reel in both directions
Navigation clamped at both ends, so the last clip was a dead stop and
the first could not go back. Both edges now wrap, and a wrap jumps
instantly rather than smooth-scrolling back through every clip in
between, which read as a glitch rather than a loop.
Also stops navigation reading the current slide from activeIndex.
That state is set by an IntersectionObserver callback and trails the
real scroll position, so a tap arriving before the observer caught up
computed its target from a stale index and landed on the wrong clip.
Scroll position is authoritative, so navigation reads it directly.
Adds unit tests for the wrap arithmetic and Playwright coverage that
drives the reel with real key presses through both edges.
---
src/components/showcase/ShowcaseReel.tsx | 55 +++++++++++++------
src/lib/wrapIndex.test.ts | 49 +++++++++++++++++
src/lib/wrapIndex.ts | 19 +++++++
tests/visual/showcase-reel.spec.ts | 68 ++++++++++++++++++++++++
4 files changed, 175 insertions(+), 16 deletions(-)
create mode 100644 src/lib/wrapIndex.test.ts
create mode 100644 src/lib/wrapIndex.ts
create mode 100644 tests/visual/showcase-reel.spec.ts
diff --git a/src/components/showcase/ShowcaseReel.tsx b/src/components/showcase/ShowcaseReel.tsx
index bb840c275..efb137d15 100644
--- a/src/components/showcase/ShowcaseReel.tsx
+++ b/src/components/showcase/ShowcaseReel.tsx
@@ -1,10 +1,11 @@
// ABOUTME: Vertical scroll-snap reel of curated videos, framed like a phone
-// ABOUTME: Swipe/scroll, or tap top/bottom of the screen; read-only, share only
+// ABOUTME: Tap top/bottom or arrow-key through it in a loop; read-only, share only
import { useCallback, useEffect, useRef, useState } from 'react';
import { useShowcaseShare } from '@/hooks/useShowcaseShare';
import { getVideoShareData } from '@/lib/shareUtils';
import { ShowcaseSlide } from '@/components/showcase/ShowcaseSlide';
+import { isWrap, wrapIndex } from '@/lib/wrapIndex';
import type { ParsedVideoData } from '@/types/video';
interface ShowcaseReelProps {
@@ -50,16 +51,38 @@ export function ShowcaseReel({ videos }: ShowcaseReelProps) {
return () => observer.disconnect();
}, [videos.length]);
- const scrollToSlide = useCallback((index: number) => {
- const scroller = scrollerRef.current;
- if (!scroller) return;
- const clamped = Math.max(0, Math.min(index, slideRefs.current.length - 1));
- // Scroll the reel container itself rather than scrollIntoView(), which would
- // also scroll every ancestor — including the window — and shove the whole
- // page up so the phone's top clips under the header. Each slide is exactly
- // the scroller's height, so slide N sits at N × clientHeight.
- scroller.scrollTo({ top: clamped * scroller.clientHeight, behavior: 'smooth' });
- }, []);
+ // Step the reel by a number of slides, looping past either end.
+ //
+ // The current slide comes from the scroller's own scrollTop rather than from
+ // `activeIndex`: that state is set by an IntersectionObserver callback, so it
+ // trails the actual scroll position by a frame or more. Reading it here meant
+ // a second tap landing before the observer caught up computed its target from
+ // a stale index and jumped to the wrong clip. Scroll position is authoritative
+ // and always current.
+ const stepSlides = useCallback(
+ (delta: number) => {
+ const scroller = scrollerRef.current;
+ if (!scroller) return;
+ const count = videos.length;
+ if (count === 0) return;
+
+ const current = Math.round(scroller.scrollTop / scroller.clientHeight);
+ const requested = current + delta;
+ // Scroll the reel container itself rather than scrollIntoView(), which would
+ // also scroll every ancestor — including the window — and shove the whole
+ // page up so the phone's top clips under the header. Each slide is exactly
+ // the scroller's height, so slide N sits at N × clientHeight.
+ //
+ // A wrap jumps instantly instead of animating: smooth-scrolling from the
+ // last slide back to the first would rewind through every clip in between,
+ // which reads as a glitch rather than a loop.
+ scroller.scrollTo({
+ top: wrapIndex(requested, count) * scroller.clientHeight,
+ behavior: isWrap(requested, count) ? 'auto' : 'smooth',
+ });
+ },
+ [videos.length],
+ );
// Arrow keys page the reel when it (or a child) holds focus — the keyboard
// equivalent of the tap zones.
@@ -67,13 +90,13 @@ export function ShowcaseReel({ videos }: ShowcaseReelProps) {
(e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
- scrollToSlide(activeIndex + 1);
+ stepSlides(1);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
- scrollToSlide(activeIndex - 1);
+ stepSlides(-1);
}
},
- [activeIndex, scrollToSlide],
+ [stepSlides],
);
return (
@@ -99,8 +122,8 @@ export function ShowcaseReel({ videos }: ShowcaseReelProps) {
muted={muted}
onToggleMute={onToggleMute}
onShare={onShare}
- onTapPrev={() => scrollToSlide(activeIndex - 1)}
- onTapNext={() => scrollToSlide(activeIndex + 1)}
+ onTapPrev={() => stepSlides(-1)}
+ onTapNext={() => stepSlides(1)}
/>
))}
diff --git a/src/lib/wrapIndex.test.ts b/src/lib/wrapIndex.test.ts
new file mode 100644
index 000000000..5368742cd
--- /dev/null
+++ b/src/lib/wrapIndex.test.ts
@@ -0,0 +1,49 @@
+// ABOUTME: Tests for circular slide-index navigation
+// ABOUTME: Covers both ends, negative wrapping, and degenerate list sizes
+
+import { describe, expect, it } from 'vitest';
+
+import { isWrap, wrapIndex } from './wrapIndex';
+
+describe('wrapIndex', () => {
+ it('leaves in-range indexes alone', () => {
+ expect(wrapIndex(0, 5)).toBe(0);
+ expect(wrapIndex(3, 5)).toBe(3);
+ expect(wrapIndex(4, 5)).toBe(4);
+ });
+
+ it('wraps forward past the last slide to the first', () => {
+ expect(wrapIndex(5, 5)).toBe(0);
+ expect(wrapIndex(6, 5)).toBe(1);
+ });
+
+ it('wraps backward before the first slide to the last', () => {
+ // JS `%` keeps the dividend's sign, so this is the case a naive
+ // `index % count` gets wrong.
+ expect(wrapIndex(-1, 5)).toBe(4);
+ expect(wrapIndex(-2, 5)).toBe(3);
+ });
+
+ it('handles a single slide by staying put', () => {
+ expect(wrapIndex(1, 1)).toBe(0);
+ expect(wrapIndex(-1, 1)).toBe(0);
+ });
+
+ it('returns 0 for an empty list rather than NaN', () => {
+ expect(wrapIndex(2, 0)).toBe(0);
+ expect(wrapIndex(-2, 0)).toBe(0);
+ });
+});
+
+describe('isWrap', () => {
+ it('flags only out-of-range indexes', () => {
+ expect(isWrap(0, 5)).toBe(false);
+ expect(isWrap(4, 5)).toBe(false);
+ expect(isWrap(5, 5)).toBe(true);
+ expect(isWrap(-1, 5)).toBe(true);
+ });
+
+ it('never flags a wrap on an empty list', () => {
+ expect(isWrap(-1, 0)).toBe(false);
+ });
+});
diff --git a/src/lib/wrapIndex.ts b/src/lib/wrapIndex.ts
new file mode 100644
index 000000000..be70e33d5
--- /dev/null
+++ b/src/lib/wrapIndex.ts
@@ -0,0 +1,19 @@
+// ABOUTME: Wraps a slide index around the ends of a list so navigation is circular
+// ABOUTME: Past the last item lands on the first; before the first lands on the last
+
+/**
+ * Normalize an out-of-range index into `[0, count)`.
+ *
+ * Plain `index % count` is not enough: JavaScript's `%` keeps the sign of the
+ * dividend, so `-1 % 5` is `-1` rather than `4`. Adding `count` before the
+ * second modulo pulls negatives back into range.
+ */
+export function wrapIndex(index: number, count: number): number {
+ if (count <= 0) return 0;
+ return ((index % count) + count) % count;
+}
+
+/** True when the index falls outside the list and therefore wrapped. */
+export function isWrap(index: number, count: number): boolean {
+ return count > 0 && (index < 0 || index >= count);
+}
diff --git a/tests/visual/showcase-reel.spec.ts b/tests/visual/showcase-reel.spec.ts
new file mode 100644
index 000000000..306e5ed3f
--- /dev/null
+++ b/tests/visual/showcase-reel.spec.ts
@@ -0,0 +1,68 @@
+import { test, expect } from '@playwright/test';
+
+// The reel is populated from a live curated list, so these assertions are
+// written against whatever it actually loaded rather than a fixed count, and
+// skip themselves if the reel came back empty or with a single clip (nothing
+// to wrap between).
+async function reelState(page: import('@playwright/test').Page) {
+ return page.evaluate(() => {
+ const reel = document.querySelector(
+ '[role="group"][aria-label="Curated video reel"]',
+ );
+ if (!reel) return null;
+ const slideHeight = reel.clientHeight;
+ return {
+ count: Math.round(reel.scrollHeight / slideHeight),
+ index: Math.round(reel.scrollTop / slideHeight),
+ };
+ });
+}
+
+test.describe('showcase reel loops', () => {
+ test('wraps in both directions instead of dead-ending', async ({ page }) => {
+ await page.goto('/', { waitUntil: 'domcontentloaded' });
+
+ const reel = page.getByRole('group', { name: 'Curated video reel' });
+ await expect(reel).toBeVisible({ timeout: 15_000 });
+
+ const initial = await reelState(page);
+ test.skip(!initial || initial.count < 2, 'reel needs at least two clips to wrap');
+ const { count } = initial!;
+
+ await reel.focus();
+ expect((await reelState(page))!.index).toBe(0);
+
+ // Backwards off the front edge lands on the last clip.
+ await page.keyboard.press('ArrowUp');
+ await expect
+ .poll(async () => (await reelState(page))!.index, { timeout: 5000 })
+ .toBe(count - 1);
+
+ // Forwards off the back edge comes back around to the first.
+ await page.keyboard.press('ArrowDown');
+ await expect
+ .poll(async () => (await reelState(page))!.index, { timeout: 5000 })
+ .toBe(0);
+ });
+
+ test('steps one clip at a time within the list', async ({ page }) => {
+ await page.goto('/', { waitUntil: 'domcontentloaded' });
+
+ const reel = page.getByRole('group', { name: 'Curated video reel' });
+ await expect(reel).toBeVisible({ timeout: 15_000 });
+
+ const initial = await reelState(page);
+ test.skip(!initial || initial.count < 3, 'reel needs three clips to step twice');
+
+ await reel.focus();
+ await page.keyboard.press('ArrowDown');
+ await expect
+ .poll(async () => (await reelState(page))!.index, { timeout: 5000 })
+ .toBe(1);
+
+ await page.keyboard.press('ArrowUp');
+ await expect
+ .poll(async () => (await reelState(page))!.index, { timeout: 5000 })
+ .toBe(0);
+ });
+});
From 75ca253eafe67ed54a10c2f41dec09e1461f9464 Mon Sep 17 00:00:00 2001
From: Liz Sweigart <127434495+NotThatKindOfDrLiz@users.noreply.github.com>
Date: Fri, 24 Jul 2026 21:05:29 -0500
Subject: [PATCH 18/19] copy: drop the scrutiny opener from the family block
Paragraph now leads with Divine's position instead of the context for
it.
---
src/components/showcase/FamilyWelcomeSection.tsx | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/src/components/showcase/FamilyWelcomeSection.tsx b/src/components/showcase/FamilyWelcomeSection.tsx
index 1bbdbc91c..936910df2 100644
--- a/src/components/showcase/FamilyWelcomeSection.tsx
+++ b/src/components/showcase/FamilyWelcomeSection.tsx
@@ -19,9 +19,9 @@ export function FamilyWelcomeSection({ className }: { className?: string }) {
Building a healthier social media experience
- Social media is facing understandable scrutiny around the world. We believe the
- challenges of an increasingly digital world call for better solutions than
- blanket bans or mass surveillance. As part of our commitment to a more
+ We believe the challenges of an increasingly digital world call for better
+ solutions than blanket bans or mass surveillance. As part of our commitment to a
+ more
human-centered internet, we’ve created research-backed tools to help families
build healthy online habits together. Visit our{" "}
From a219ea9ea6ca6b348bf6b7fb8fa6a99387642083 Mon Sep 17 00:00:00 2001
From: Liz Sweigart <127434495+NotThatKindOfDrLiz@users.noreply.github.com>
Date: Fri, 24 Jul 2026 22:40:49 -0500
Subject: [PATCH 19/19] fix(seo): give the shell showcase-mode social metadata
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
/ is not prerendered, and showcase mode switches off the worker's OG
injection for the apex, so social crawlers fall through to index.html.
They don't run JS, so ShowcasePage's useSeoMeta never reached them and
every share of divine.video previewed as "Watch and share 6-second
looping videos" — a description of the feed showcase mode removes.
Points the shell defaults at the same copy ShowcasePage sets. Prerendered
legal and family routes build their own head, so they are unaffected.
Also teaches verify-og-tags.sh the new fallback string. That check fails
a route whose og:title is the shell default, meaning no per-route handler
fired; without this it would have stopped catching that. The old string
stays matched so a stale cached shell is still caught.
---
index.html | 19 ++++++++++++-------
scripts/verify-og-tags.sh | 6 +++++-
2 files changed, 17 insertions(+), 8 deletions(-)
diff --git a/index.html b/index.html
index 948872c0f..af108e5c1 100644
--- a/index.html
+++ b/index.html
@@ -15,8 +15,13 @@
with self-referencing canonicals at build time. -->
- Divine Web - Short-form Looping Videos on Nostr
-
+
+ Divine — short video on an open protocol
+
@@ -30,18 +35,18 @@
-
-
+
+
-
+
-
-
+
+
diff --git a/scripts/verify-og-tags.sh b/scripts/verify-og-tags.sh
index 264c05d92..38735372e 100755
--- a/scripts/verify-og-tags.sh
+++ b/scripts/verify-og-tags.sh
@@ -175,7 +175,11 @@ run_check() {
og_title_not_brand)
local og_title
og_title=$(extract_meta "$body" "og:title")
- if [[ "$og_title" == "Divine Web - Short-form Looping Videos on Nostr" ]]; then
+ # Both the current shell default and the one it replaced count as "no
+ # handler fired". Keeping the old string means this still catches a
+ # stale cached shell being served from the edge.
+ if [[ "$og_title" == "Divine — short video on an open protocol" ]] \
+ || [[ "$og_title" == "Divine Web - Short-form Looping Videos on Nostr" ]]; then
all_passed=false
echo " FAIL: og:title is generic brand fallback (no per-route handler fired)"
echo " got: ${og_title}"