Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
2287253
7-in-10: real Signal/Mozilla/1Password brand row; context slide 17-de…
hhff Aug 9, 2026
61debde
Prototype slide: photo gallery with thumbnail toggles and swapping ca…
hhff Aug 9, 2026
0095b8f
Gallery: uniform 4:3 frame, images crop to fill
hhff Aug 9, 2026
8911518
Gallery: reserve caption height so toggles don't shift the slide
hhff Aug 9, 2026
3b61197
Gallery slot 2: Snapdragon X Elite photo
hhff Aug 9, 2026
8a160f1
7-in-10: delete the brand logo strip
hhff Aug 9, 2026
a72be13
Copy round 23: problem-slide rewording, splits, tree accent icon
hhff Aug 9, 2026
ab1fc67
GPU slide: Airgapt ragged-edge container via border-image
hhff Aug 9, 2026
cb48079
First-device slide: portrait play-test video replaces the FPO
hhff Aug 9, 2026
5c4f26e
Fix unclosed div on the first-device video
hhff Aug 9, 2026
2ec8b2a
Jumpnav replaces the page rail: act numerals, hover titles, homepage …
hhff Aug 9, 2026
06b136a
Remove the jump nav entirely
hhff Aug 9, 2026
8bcbb59
Fix jump-nav removal: DeckShell restored to a clean shell
hhff Aug 9, 2026
6e868d6
Home hubs: incumbent product photos with cloud tags replace the FPO
hhff Aug 9, 2026
2444d61
Families already pay: drop the redundant comp strip FPO
hhff Aug 9, 2026
cd16380
Plaud moves from family comps to the unit-economics slide
hhff Aug 9, 2026
e351052
Team slide: remove the ghost-ship staffing accent box
hhff Aug 9, 2026
807eefe
Unit economics: drop the two stat tiles, the 1.7x carries alone
hhff Aug 9, 2026
6d7cb41
Businesses slide: notebook, stacks, battery, lightning icons above ca…
hhff Aug 9, 2026
b0fb249
Businesses slide: IP card icon stacks to settings
hhff Aug 9, 2026
652c2a9
Businesses slide: IP card icon settings to tree
hhff Aug 9, 2026
19dbc2b
Sentiment slide: sub becomes the shared caption under the 2-up
hhff Aug 9, 2026
2386187
Sentiment slide: architecture line becomes the sub, accent box removed
hhff Aug 9, 2026
813135f
Home hubs: text-only slide, incumbent photos removed
hhff Aug 9, 2026
dd4e5cf
Copy round 24: platform sub rewording
hhff Aug 9, 2026
e3d40d2
Copy round 25: hub sub back to a single line
hhff Aug 9, 2026
00d03b8
First device slide: video left, text right in a split
hhff Aug 9, 2026
2d0bfc9
Revert "First device slide: video left, text right in a split"
hhff Aug 9, 2026
ac4191d
Play-test video: visibility-gated playback, portrait-sized re-encode
hhff Aug 9, 2026
ffeb734
First device: CAD render joins the video, third portrait slot open
hhff Aug 9, 2026
4d57f1e
First device: prototype photo fills slot 3, CAD render goes landscape
hhff Aug 9, 2026
5fb507b
Accent boxes adopt the ragged-edge treatment, theme-colored variants
hhff Aug 9, 2026
4ea5983
Solid-fill cards adopt the ragged-edge treatment across all themes
hhff Aug 9, 2026
a07b6f5
Problem slide: heart and close icons above the card headlines
hhff Aug 9, 2026
0aacd52
Problem slide icons at half size
hhff Aug 9, 2026
f8a8ece
Home hubs: Nest Hub photo left of the copy
hhff Aug 9, 2026
5c6499e
Unit econ caption left-aligned
hhff Aug 9, 2026
c261398
Copy round 26: unit-econ tightening, hard questions retitle
hhff Aug 9, 2026
31fc534
Cover: email gate pinned bottom-right
hhff Aug 9, 2026
a5fdf65
Cover gate moves to bottom-left
hhff Aug 9, 2026
9112f3c
Team slide: fundraising Who We Are media strip replaces the logo FPO
hhff Aug 9, 2026
6289695
Team strip: drop the caption
hhff Aug 9, 2026
833f78a
Team strip: proof portal video joins the row
hhff Aug 9, 2026
f0aa15e
Team strip: fifth asset from the Sanctuary CDN
hhff Aug 9, 2026
37adf62
Team strip: USB Club Transport lit shot
hhff Aug 9, 2026
33c8d6b
Team strip: talk-intro stage shot
hhff Aug 9, 2026
1ccce33
A4 Methodology: drop the accent-box closer
hhff Aug 9, 2026
8120061
Video perf: leaves skip compositing when hidden, playback waits for s…
hhff Aug 9, 2026
05545d1
Play-test video: wrapper-layer clipping, 24fps baseline fastdecode en…
hhff Aug 9, 2026
69772e7
OpportunityClient: useSyncExternalStore replaces setState-in-effect
hhff Aug 9, 2026
ac80042
Review fixes: right-sized team assets, observer freshness, honest ARIA
hhff Aug 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 32 additions & 21 deletions www/app/opportunity/OpportunityClient.tsx
Original file line number Diff line number Diff line change
@@ -1,35 +1,50 @@
'use client';
import { useEffect, useState } from 'react';
import { useState, useSyncExternalStore } from 'react';
import InlineEmailGate from '@/components/InlineEmailGate';
import { OPPORTUNITY_GATE_SOURCE } from '@/lib/crm';
import DeckShell from './components/DeckShell';
import DebugCopyEditor from './components/DebugCopyEditor';
import { ALL_PAGES, APPENDIX_PAGES, ACT_STARTS, PAGE_META } from './content';
import { ALL_PAGES, APPENDIX_PAGES, PAGE_META } from './content';
import { coverPage } from './content/act1';
import './opportunity.css';

const UNLOCK_KEY = 'fi_opportunity_unlocked_v1';

export default function OpportunityClient() {
const [unlocked, setUnlocked] = useState(false);
const [debug, setDebug] = useState(false);
// Client-only reads, expressed as external stores so hydration stays clean
// (server snapshot false) without a setState-in-effect cascade. Reading
// window.location instead of useSearchParams keeps the page statically
// prerenderable (no Suspense boundary required).
const emptySubscribe = () => () => {};
const readStoredUnlock = () => {
try {
return localStorage.getItem(UNLOCK_KEY) === '1';
} catch {
return false;
}
};
const readDebugFlag = () =>
new URLSearchParams(window.location.search).get('debug') === 'true';
const serverSnapshot = () => false;

useEffect(() => {
try {
if (localStorage.getItem(UNLOCK_KEY) === '1') setUnlocked(true);
} catch {}
// window.location instead of useSearchParams keeps the page statically
// prerenderable (no Suspense boundary required).
setDebug(
new URLSearchParams(window.location.search).get('debug') === 'true'
);
}, []);
export default function OpportunityClient() {
const storedUnlock = useSyncExternalStore(
emptySubscribe,
readStoredUnlock,
serverSnapshot
);
const debug = useSyncExternalStore(
emptySubscribe,
readDebugFlag,
serverSnapshot
);
const [unlockedNow, setUnlockedNow] = useState(false);
const unlocked = unlockedNow || storedUnlock;

const handleUnlock = () => {
try {
localStorage.setItem(UNLOCK_KEY, '1');
} catch {}
setUnlocked(true);
setUnlockedNow(true);
};

const gate = unlocked ? null : (
Expand All @@ -47,11 +62,7 @@ export default function OpportunityClient() {

return (
<>
<DeckShell
pages={pages}
railActs={unlocked ? ACT_STARTS : []}
pageMeta={PAGE_META}
/>
<DeckShell pages={pages} pageMeta={PAGE_META} />
{debug && (
<DebugCopyEditor refreshKey={unlocked ? 'unlocked' : 'locked'} />
)}
Expand Down
4 changes: 2 additions & 2 deletions www/app/opportunity/components/DebugCopyEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ const BAND_ICONS = [
'play',
'close',
] as const;
const DEFAULT_BAND_ICON = 'heart';
const DEFAULT_BAND_ICON = 'tree';

function setBandIcon(name: string) {
document.documentElement.style.setProperty(
Expand Down Expand Up @@ -117,7 +117,7 @@ export default function DebugCopyEditor({
'h1, h2, h3, h4, p, figcaption, li, td, th, ' +
'.deck-stat-tile-value, .deck-stat-tile-label, ' +
'.deck-tier-price, .deck-tier-meta, ' +
'.deck-ledger-label, .deck-ledger-value'
'.deck-ledger-label, .deck-ledger-value, .deck-media-caption'
);
let i = 0;
targets.forEach(target => {
Expand Down
17 changes: 0 additions & 17 deletions www/app/opportunity/components/DeckShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,9 @@ import DriftingLeaves from './DriftingLeaves';

export default function DeckShell({
pages,
railActs,
pageMeta,
}: {
pages: ReactNode[];
railActs: Array<{ page: number }>;
pageMeta: Array<{
act: string;
counter: string;
Expand Down Expand Up @@ -73,7 +71,6 @@ export default function DeckShell({
return () => observer.disconnect();
}, [pages.length]);

const actStarts = new Set(railActs.map(a => a.page));
return (
<div className="deck">
<div className="deck-ambient" aria-hidden="true">
Expand All @@ -95,20 +92,6 @@ export default function DeckShell({
dark={pageMeta[current - 1]?.dark === true}
/>
{pages}
<nav
className={`deck-rail${pageMeta[current - 1]?.dark ? ' deck-rail-dark' : ''}`}
aria-label="Deck pages"
>
{pages.map((_, i) => (
<a
key={i}
href={`#page-${i + 1}`}
aria-label={`Page ${i + 1}`}
className={`${i + 1 === current ? 'active ' : ''}${actStarts.has(i + 1) ? 'act-start' : ''}`}
onClick={() => setCurrent(i + 1)}
/>
))}
</nav>
</div>
);
}
59 changes: 59 additions & 0 deletions www/app/opportunity/components/DeckVideo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
'use client';

import { useEffect, useRef } from 'react';

/** Ambient looping video that only plays while its slide is on screen, so
* off-screen slides cost no decode work. */
export default function DeckVideo({
src,
className,
label,
poster,
}: {
src: string;
className?: string;
label: string;
poster?: string;
}) {
const ref = useRef<HTMLVideoElement>(null);

useEffect(() => {
const el = ref.current;
if (!el) return;
let timer: ReturnType<typeof setTimeout> | undefined;
const observer = new IntersectionObserver(
entries => {
const entry = entries[entries.length - 1];
clearTimeout(timer);
if (entry.isIntersecting) {
// Let the scroll snap settle before decode starts.
timer = setTimeout(() => {
el.play().catch(() => {});
}, 250);
} else {
el.pause();
}
},
{ threshold: 0.6 }
);
observer.observe(el);
return () => {
clearTimeout(timer);
observer.disconnect();
};
}, []);

return (
<video
ref={ref}
src={src}
poster={poster}
className={className}
muted
loop
playsInline
preload="metadata"
aria-label={label}
/>
);
}
51 changes: 51 additions & 0 deletions www/app/opportunity/components/MediaGallery.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
'use client';

import { useState, type ReactNode } from 'react';

type Slide = {
/** Null renders an FPO placeholder until the real photo lands. */
src: string | null;
alt: string;
caption: ReactNode;
};

/** Framed photo with a thumbnail rail: clicking a thumb swaps the photo and
* its caption together. */
export default function MediaGallery({ slides }: { slides: Slide[] }) {
const [active, setActive] = useState(0);
const current = slides[active];
return (
<div className="deck-gallery">
<div className="deck-media-figure deck-gallery-main">
<span className="deck-gallery-frame">
{current.src ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={current.src} alt={current.alt} />
) : (
<span className="deck-gallery-fpo">{current.alt}</span>
)}
</span>
<span className="deck-media-caption">{current.caption}</span>
</div>
<div className="deck-gallery-thumbs" aria-label="Photos">
{slides.map((slide, i) => (
<button
key={i}
type="button"
aria-pressed={i === active}
aria-label={slide.alt}
className={i === active ? 'active' : undefined}
onClick={() => setActive(i)}
>
{slide.src ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={slide.src} alt="" />
) : (
<span>{i + 1}</span>
)}
</button>
))}
</div>
</div>
);
}
2 changes: 1 addition & 1 deletion www/app/opportunity/components/archetypes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,7 @@ export function PricingTiers({
}

/** Accent-box callout. The act theme sets the background via .deck-band;
* the icon comes from the --deck-band-icon CSS variable (heart by default,
* the icon comes from the --deck-band-icon CSS variable (tree by default,
* swappable live from the ?debug=true bar). */
export function Band({
children,
Expand Down
Loading
Loading