Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
176 commits
Select commit Hold shift + click to select a range
241f31f
Deck narrative design: 21-page core + 10-page appendix beat sheet wit…
hhff Aug 4, 2026
b60d8be
Deck spec: /opportunity route, Sanctuary tone of voice, subscription …
hhff Aug 4, 2026
81123fc
Deck spec: rewrite all page titles as direct title + subtitle pairs
hhff Aug 4, 2026
4c421f0
Deck spec: urgency thread, prototype traction page, take-it-or-leave-…
hhff Aug 4, 2026
c12bada
Deck spec: final pass — fresh 2026 urgency evidence, VC red-team fixes
hhff Aug 5, 2026
de1e65e
Deck spec: format system — sections, chrome, references, layout arche…
hhff Aug 5, 2026
617b024
Deck spec: family-book cover reference, drifting leaves port plan, sh…
hhff Aug 5, 2026
6afede5
Deck spec: styleguide tokens are the only color source; defer PDF export
hhff Aug 5, 2026
d1c2096
Add /opportunity deck implementation plan
hhff Aug 5, 2026
7a7df42
Add family-book cover assets for /opportunity deck
hhff Aug 5, 2026
bf75899
Add opportunity CRM sources to the gate allowlist
hhff Aug 5, 2026
721ec77
Add server-side page-slug to viewed-source resolution
hhff Aug 5, 2026
3e10ed1
gate-status: per-page viewed-source attribution via validated slug
hhff Aug 5, 2026
a2fb790
InlineEmailGate: optional page prop for per-page gate-status attribution
hhff Aug 5, 2026
4861d88
Add /opportunity citation registry with integrity tests
hhff Aug 5, 2026
570dc77
Deck primitives: leaves port, Ref citations, FPO boxes, page chrome
hhff Aug 5, 2026
de42550
Deck archetypes and scroll-snap shell with progress rail
hhff Aug 5, 2026
6bf8e60
Deck content: Act I pages with copy contract test
hhff Aug 5, 2026
ebd4cb8
Spec: correct HIPAA/GDPR page-5 phrasing flagged in content review
hhff Aug 5, 2026
74194f1
Deck content: Act I review fixes (legal accuracy, citations, cover la…
hhff Aug 5, 2026
e02d23e
Deck content: lead page 6 with the Parks device-owner stat
hhff Aug 5, 2026
7e8370c
Deck content: Act II pages
hhff Aug 5, 2026
10fe3e0
Deck content: Act II review fixes
hhff Aug 5, 2026
d3d2e4d
Deck content: Acts III-IV, appendix stubs, sources page
hhff Aug 5, 2026
fb01cee
Spec: name the two deliberate consecutive-archetype exceptions
hhff Aug 5, 2026
aa514a4
Deck review fixes: ref placement, p22 answers, mailto tracking
hhff Aug 5, 2026
c42bcc1
/opportunity route: gated deck shell wired to content
hhff Aug 5, 2026
e5464f1
Deck copy: avoid-ai-writing audit pass
hhff Aug 5, 2026
1ede8f3
Fix deck arrow-key desync, format gate-page test, guard reference keys
hhff Aug 5, 2026
e7e4740
Deck: one-viewport pages, mono chrome, splash + 3-col card archetypes
hhff Aug 5, 2026
f20faa5
Deck: 26-page restructure and a hard copy trim
hhff Aug 5, 2026
40dda78
Deck tests: lock the 26-page contract
hhff Aug 5, 2026
00831d0
Spec: record the post-review 26-page revision
hhff Aug 5, 2026
bdd94ad
Deck: zero out the global section scroll-margin so snap lands on page…
hhff Aug 5, 2026
a05d22e
Deck: tighten title-sub gap, open sub-body gap, homepage-standard bod…
hhff Aug 5, 2026
092c487
Opportunity: second density pass on the deck bodies
hhff Aug 5, 2026
cc7935c
Deck: out-specify the section[id] scroll-margin, homepage-weight cove…
hhff Aug 5, 2026
60baa40
Deck tests: cover wordmark matcher follows the tracking-span markup
hhff Aug 5, 2026
49662bc
Deck: unlayered spacing classes so body gaps survive globals' p reset
hhff Aug 5, 2026
28cacb3
Deck: quarter the sub-to-body gap (56px to 14px)
hhff Aug 5, 2026
153598b
Deck: 28px sub-to-body gap, 500px body measure
hhff Aug 5, 2026
63d3022
Deck: fixed chrome layer, ambient backdrop, unclipped cover tree
hhff Aug 5, 2026
706babb
Deck: ?debug=true live copy editor with clipboard diff and layout picker
hhff Aug 5, 2026
0f20374
Debug editor: layout picks in state, satisfy react-hooks/refs
hhff Aug 5, 2026
7f55aa1
Debug editor: delete-slide control with live hide and report line
hhff Aug 5, 2026
99f7263
Debug editor: select reflects the in-view slide's archetype
hhff Aug 5, 2026
b02aa78
Deck chrome: hidden on the cover with fade-in, counter rolls only the…
hhff Aug 5, 2026
ba94575
Deck: tree fades out on leaving the cover
hhff Aug 5, 2026
1f18128
Deck: delete the Apple II pricing slide, drop the 7-in-10 BigStat tre…
hhff Aug 5, 2026
d1d389b
Deck: halve leaf drift speed (periods, phases, wobble doubled)
hhff Aug 5, 2026
330a7fc
Deck: delete the prototype slide, renumber to 24 pages
hhff Aug 5, 2026
06a5efa
Deck p4: stat leads the title, brand-gap line becomes the subtitle
hhff Aug 5, 2026
32b34dd
Deck: new architecture-difference slide (Mozilla vs friend.com), 25 p…
hhff Aug 5, 2026
4736997
Deck: rename Act II to Our First Device
hhff Aug 5, 2026
abded43
Deck chrome: drop the top-left wordmark
hhff Aug 5, 2026
51072df
Deck copy: apply Hugh's debug-editor round (p3, p5, p9, p10, p11, p16…
hhff Aug 5, 2026
2b1fb79
Deck: lighter card fill on the darker act backgrounds
hhff Aug 5, 2026
310778f
Deck p5: closer reads 'posture is what wins customers over'
hhff Aug 5, 2026
3483338
Deck p14: context-window page becomes a 50/50 split, text right
hhff Aug 5, 2026
5a79cb7
Deck copy: apply Hugh's second debug-editor round (p5, p16-17, p19, p…
hhff Aug 5, 2026
cc48a92
Deck: appendix splash is just the title
hhff Aug 5, 2026
a8f3974
Deck copy: apply Hugh's third debug-editor round (p10-14, p16-17, p19…
hhff Aug 5, 2026
4ab9000
Deck copy: fourth debug round, title punctuation and casing
hhff Aug 5, 2026
16bcfc1
Deck: Ask-act splash page, 26 pages, dedupe the either-way line
hhff Aug 5, 2026
2473984
Deck p7 splash: local inference, not local compute
hhff Aug 5, 2026
402739f
Deck copy: fifth debug round (p5, p9, p13, p21 titles)
hhff Aug 6, 2026
fbebd55
Deck p14: real context-window house illustration replaces the FPO
hhff Aug 7, 2026
23ce813
Deck: dark-mode treatment for the Under the Hood act with mono H1s
hhff Aug 7, 2026
c1fd0e3
Deck dark act: plain sans H1s instead of the mono axis
hhff Aug 7, 2026
50aba69
Deck: serif splash titles in the dark act, leaves only on cover/splas…
hhff Aug 7, 2026
2d2598f
Deck: stack page as 2x3 component cards with floating art slots; dark…
hhff Aug 7, 2026
02ba06b
Deck stack card: Family trust anchor replaces hardware root of trust
hhff Aug 7, 2026
4a28bf0
Deck: 8-up sector grid follows the stack page, with directional TAMs
hhff Aug 7, 2026
b8c6cf5
Deck: source-order the 8-up beside the stack page
hhff Aug 7, 2026
54a8ae1
Deck: sector slide becomes a rate-card ledger, differentiated from th…
hhff Aug 7, 2026
ee71cfc
Deck sector ledger: Families & The Home leads at ~$130B
hhff Aug 7, 2026
5cc7ba7
Deck: wide ledger flexes to always fit the viewport height
hhff Aug 7, 2026
e177aa6
Deck ledger: TAM figures baseline to the row's bottom rule
hhff Aug 7, 2026
5b30518
Deck ledger: rows never shrink below their content
hhff Aug 7, 2026
145d2a1
Deck copy: sixth debug round, Android of Local AI framing
hhff Aug 7, 2026
2180dba
Deck p17: SDK line leads as the subtitle, suite line closes
hhff Aug 7, 2026
eff1508
Deck p11: graveyard framing per debug round seven
hhff Aug 7, 2026
9af6abb
Deck copy: eighth debug round, stack cards renamed to platform compon…
hhff Aug 8, 2026
5d4533f
Deck p5: real Instagram sentiment screenshots, brand-radius corners
hhff Aug 8, 2026
2cb4703
Deck p3: capability-gap thread screenshot replaces the FPO
hhff Aug 8, 2026
921d06c
Deck CSS: document the screenshot-vs-diecut image convention
hhff Aug 8, 2026
27fed75
Deck screenshots: element hugs the image so the radius shows
hhff Aug 8, 2026
a1f2cea
Deck p3: mono caption linking the Jie Tang prediction tweet
hhff Aug 8, 2026
6bb7688
Deck captions: caption hugs image width, Link with NE arrow carries t…
hhff Aug 8, 2026
682ee60
Deck p2: compute-eras illustration replaces the timeline FPO
hhff Aug 8, 2026
8dad67e
Deck p2: split layout, eras art left and text right
hhff Aug 8, 2026
187fab1
Deck copy: ninth debug round (p4, p5, Ask splash)
hhff Aug 8, 2026
2bac811
Deck: team slide follows the careers splash
hhff Aug 8, 2026
efe48d1
Deck team: 3-up founder cards with photo slots, raise-hires card cut
hhff Aug 8, 2026
a52c38c
Deck: LandingLine style (mono + hand-drawn underline), first use on p5
hhff Aug 8, 2026
7b685cb
Revert "Deck: LandingLine style (mono + hand-drawn underline), first …
hhff Aug 8, 2026
be16dd5
Deck copy: tenth debug round (p14, stack cards, Android OS title, bios)
hhff Aug 8, 2026
0c4c309
Deck copy: complete round ten (stack cards final names, Android OS ti…
hhff Aug 8, 2026
883f746
Deck copy: round ten bios and test contract
hhff Aug 8, 2026
09507b1
Deck tests: p14 subtitle contract follows round ten
hhff Aug 8, 2026
7ee3531
Deck p25: interactive Gantt timeline in the brand palette
hhff Aug 8, 2026
654dcf8
Deck tests: Timeline title matcher dodges the component import
hhff Aug 8, 2026
51d7cec
Deck timeline: Notion plan rows and dates, body copy removed
hhff Aug 8, 2026
08aefe6
Deck stack: real TEE vault illustration on the first card
hhff Aug 8, 2026
3845196
Deck stack: cert-tree and cloud-vault art on their cards
hhff Aug 8, 2026
a3d8630
Deck: 22-page core — local-AI leads, two slides cut, two moved to app…
hhff Aug 8, 2026
3ceec1c
Deck p2: prototype board photo replaces the Z.ai tweet
hhff Aug 8, 2026
df01991
Deck p2: prototype caption in Hugh's words
hhff Aug 8, 2026
358fba0
Deck copy: eleventh debug round (p4, p7, p15)
hhff Aug 8, 2026
e0c2f2c
Deck p15: four business-case cards (regulation, IP, connectivity, eco…
hhff Aug 8, 2026
425205a
Deck copy: twelfth debug round on the business-case cards
hhff Aug 8, 2026
42ea24f
Deck p15: title tense and IP-card closer from round twelve
hhff Aug 8, 2026
e8ff47c
Deck copy: thirteenth debug round (splash, ask sub, ask body break)
hhff Aug 8, 2026
24fc7d6
Deck dark act: glass cards instead of olive fill
hhff Aug 8, 2026
1d3f91e
Deck stack slide: drop the closing suite line
hhff Aug 8, 2026
235b9bc
Deck stack cards: icons sit inside the card above the heading
hhff Aug 8, 2026
8085128
Deck dark ledger: first divider matches the muted green
hhff Aug 8, 2026
9a7ee0b
Deck ledger footnote: right-aligned to the TAM column
hhff Aug 8, 2026
0a421fb
Deck: drop the regulation slide, 21-page core
hhff Aug 8, 2026
af59a27
Deck: business cases lead the dark act, ahead of the stack
hhff Aug 8, 2026
8209e0c
Deck copy: fourteenth debug round (p8-p10)
hhff Aug 8, 2026
17d20d7
Deck cover: two-line byline from the fundraising hero
hhff Aug 8, 2026
dcb1820
Deck cover: hand-drawn underline beneath the families line
hhff Aug 8, 2026
29373a5
Deck copy: sixteenth debug round (p2, p4, p5)
hhff Aug 8, 2026
ae97c61
Deck: subscription slide follows the family vault
hhff Aug 8, 2026
46bd22d
Deck: the Problem slide joins Act I, 22-page core
hhff Aug 8, 2026
573d3d2
Deck: pricing-tier and takeaway-band layouts from the pptx; device fa…
hhff Aug 8, 2026
26017ae
Deck: unit economics closes Act II with model v3.1 numbers; stat-tile…
hhff Aug 8, 2026
564d999
Deck: act1 counter joins the 23-page total
hhff Aug 8, 2026
9289eff
Deck: base-case-funds-itself slide and round construction on the ask
hhff Aug 8, 2026
3eadb6b
Deck grafts: ghost-ship staffing band on team, licensing anchors on t…
hhff Aug 8, 2026
51b14d8
Appendix: revenue model, scenarios, and model methodology replace the…
hhff Aug 8, 2026
160d84d
Appendix: alternating light-mode callouts replace the dark bands
hhff Aug 8, 2026
049745c
Deck: retire dark green highlights on light-mode slides
hhff Aug 8, 2026
89ca853
Deck: Band accent-box component with heart icon, theme-driven background
hhff Aug 8, 2026
cce5693
Debug bar: live accent-box icon picker over the family-book icon set
hhff Aug 8, 2026
3dbbf8f
Copy round 17: problem-slide sharpening and 7-in-10 backlash beat
hhff Aug 8, 2026
16d954d
7-in-10 slide: market line moves into a body-width accent box
hhff Aug 8, 2026
b24914d
GPU slide: compute-eras art framed in a white brand-radius card
hhff Aug 8, 2026
f788bbf
Media box: off-white #F7F9F6, more padding, placeholder soft shadow
hhff Aug 8, 2026
3e63cec
Sentiment slide: architecture line moves into an accent box via Split…
hhff Aug 8, 2026
dbfb1e7
Tier cards adopt the homepage QuoteBox style; Bezos line into an acce…
hhff Aug 8, 2026
a2c1c45
Stat tiles adopt the QuoteBox card style
hhff Aug 8, 2026
4884bd9
Act III: trajectory and platform-revenue slides replace the licensing…
hhff Aug 8, 2026
cc7b843
Media box: Airgapt primary-container shadow and border
hhff Aug 8, 2026
a408393
Accent boxes go off-white on the green-200 act backgrounds
hhff Aug 8, 2026
a2dfb24
Accent boxes on green-200 acts use fi-green-100, not off-white
hhff Aug 8, 2026
72012df
Act I: 7-in-10 moves above the problem slide
hhff Aug 8, 2026
da1ce90
Split band matches the body-copy width
hhff Aug 8, 2026
a9eaafb
Copy round 18: device-family and home-hubs edits
hhff Aug 8, 2026
5879870
Act III: remove the trajectory slide, core back to 24 pages
hhff Aug 9, 2026
3bb17ad
Copy round 19: platform slide retitle and licensing close
hhff Aug 9, 2026
c0446f6
Device family: drop the family-memory accent closer
hhff Aug 9, 2026
1d6616c
Data tables: sans labels left, mono figures right, unified
hhff Aug 9, 2026
b72857d
Tables: mono figures sized to match sans labels everywhere
hhff Aug 9, 2026
4dc4274
Copy round 20: businesses-slide sharpening, hub sub line break
hhff Aug 9, 2026
07dcf0b
Home hubs: FPO slot moves left of the copy in a split layout
hhff Aug 9, 2026
4c32b59
Copy round 21: sentiment and unit-econ retitles
hhff Aug 9, 2026
0232019
Debug editor: datapoint tiles, table cells, and ledger spans become e…
hhff Aug 9, 2026
ca868ec
Unit economics: stat tiles stack left, ledger copy right
hhff Aug 9, 2026
8557bcb
The base case funds itself moves to the appendix
hhff Aug 9, 2026
a9dbe50
Copy round 22: research line trim, licensing want-to
hhff Aug 9, 2026
70312dc
Copy round 22b: they will want to license it
hhff Aug 9, 2026
e9e14aa
Appendix cleanup: context window rejoins Act II, unit-econ page retired
hhff Aug 9, 2026
6c6a06c
Appendix: dry A-numbered titles across every page
hhff Aug 9, 2026
c666349
A2 Base case: tighten the follow-on tile label
hhff Aug 9, 2026
d0b6066
A3 Scenarios: drop the band, caption aligns to the table width
hhff Aug 9, 2026
dbd5f34
A3 Scenarios: left-align the table caption
hhff Aug 9, 2026
eef58cf
Appendix: retire the three stub slides, sources becomes A5
hhff Aug 9, 2026
f10f01d
Ask page: market-window line moves into an accent box
hhff Aug 9, 2026
9ff4b82
Platform slide: licensing close moves into an accent box
hhff Aug 9, 2026
7bb752b
Problem slide: drop the accent-box closer
hhff Aug 9, 2026
cd30484
Unit economics: lighten to 1.7x BigStat with two tiles
hhff Aug 9, 2026
77e640b
act2: drop unused Ledger import
hhff Aug 9, 2026
d6e2716
Adversarial review round 1: appendix id renumbering, citation, copy f…
hhff Aug 9, 2026
e03b6dc
Adversarial review round 2: cover dead class, unused import, dead CSS
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
1,180 changes: 1,180 additions & 0 deletions docs/superpowers/plans/2026-08-04-opportunity-deck.md

Large diffs are not rendered by default.

377 changes: 377 additions & 0 deletions docs/superpowers/specs/2026-08-04-investor-deck-narrative-design.md

Large diffs are not rendered by default.

8 changes: 6 additions & 2 deletions www/app/api/gate-status/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { NextRequest, NextResponse, after } from 'next/server';
import { consume } from '@/lib/rate-limit';
import { readVerifiedCookie } from '@/lib/verified-session';
import { createCrmContact, VIEWED_SOURCE } from '@/lib/crm';
import { createCrmContact } from '@/lib/crm';
import { resolveViewedSource } from '@/lib/gate-page';
import { clientIp } from '@/lib/client-ip';

const PING_LIMIT = 20;
Expand All @@ -22,11 +23,14 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
return NextResponse.json({ verified: false });
}

const viewedSource = resolveViewedSource(
request.nextUrl.searchParams.get('page')
);
// Each verified page load logs a view in the CRM (source_events counts
// repeats server-side). Over the ping limit we stop counting but never
// block the unlock.
if (consume(`gate-status:${clientIp(request)}`, PING_LIMIT, PING_WINDOW_MS)) {
after(() => createCrmContact(session.email, VIEWED_SOURCE));
after(() => createCrmContact(session.email, viewedSource));
}
return NextResponse.json({ verified: true });
}
60 changes: 60 additions & 0 deletions www/app/opportunity/OpportunityClient.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
'use client';
import { useEffect, useState } 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 { 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);

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'
);
}, []);

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

const gate = unlocked ? null : (
<InlineEmailGate
onSuccess={handleUnlock}
source={OPPORTUNITY_GATE_SOURCE}
page="opportunity"
prompt="Enter your email to view the deck"
/>
);

const pages = unlocked
? [coverPage(null), ...ALL_PAGES.slice(1), ...APPENDIX_PAGES]
: [coverPage(gate)];

return (
<>
<DeckShell
pages={pages}
railActs={unlocked ? ACT_STARTS : []}
pageMeta={PAGE_META}
/>
{debug && (
<DebugCopyEditor refreshKey={unlocked ? 'unlocked' : 'locked'} />
)}
</>
);
}
302 changes: 302 additions & 0 deletions www/app/opportunity/components/DebugCopyEditor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,302 @@
'use client';

import { useEffect, useRef, useState } from 'react';

type Entry = { el: HTMLElement; page: string; label: string; original: string };

// Module-level so the React Compiler treats the DOM writes as external to
// the component (mutating ref contents inside a handler trips its lint).
function restoreOriginals(list: Entry[]) {
for (const e of list) {
e.el.textContent = e.original;
}
}

function setSlideHidden(page: string, hidden: boolean) {
const section = document.getElementById(`page-${page}`);
if (section) section.style.display = hidden ? 'none' : '';
}

function slideArchetype(page: string): string {
const section = document.getElementById(`page-${page}`);
return (
section
?.querySelector('[data-archetype]')
?.getAttribute('data-archetype') ?? ''
);
}

function slideTitle(page: string): string {
const section = document.getElementById(`page-${page}`);
const heading = section?.querySelector('h1, h2');
return heading?.textContent?.trim() ?? '(untitled)';
}

// The family-book UI kit icons in public/opportunity/icons/.
const BAND_ICONS = [
'heart',
'tree',
'home',
'notebook',
'lightning',
'microphone',
'stacks',
'settings',
'battery',
'image',
'play',
'close',
] as const;
const DEFAULT_BAND_ICON = 'heart';

function setBandIcon(name: string) {
document.documentElement.style.setProperty(
'--deck-band-icon',
`url('/opportunity/icons/${name}.png')`
);
}

const LAYOUT_VARIANTS = [
'Statement',
'Statement (splash)',
'BigStat',
'Split',
'Split (flipped)',
'EvidenceGrid',
'DiagramPage',
'Ledger',
'Cards',
'Cards (3-col)',
'PricingTiers',
] as const;

/**
* Live copy-editing tool, mounted only when the URL carries ?debug=true.
* Every text block inside the deck pages becomes contentEditable; the Copy
* button writes an OLD/NEW diff of everything touched (plus any requested
* layout-variant changes) to the clipboard so the edits can be pasted back
* and applied to the source files. Layout picks are recorded, not
* live-previewed: slides are pre-composed components.
*/
export default function DebugCopyEditor({
refreshKey,
}: {
refreshKey: string;
}) {
const entries = useRef<Entry[]>([]);
const [layoutPicks, setLayoutPicks] = useState<Map<string, string>>(
() => new Map()
);
const [count, setCount] = useState(0);
const [copied, setCopied] = useState<string | null>(null);
const [currentPage, setCurrentPage] = useState('1');
const [currentArchetype, setCurrentArchetype] = useState('');
const [deleted, setDeleted] = useState<Map<string, string>>(() => new Map());
const [bandIcon, setBandIconState] = useState(DEFAULT_BAND_ICON);

const pickBandIcon = (name: string) => {
setBandIcon(name);
setBandIconState(name);
};

useEffect(() => {
// Wait a frame so the (un)locked page list has rendered before scanning.
const raf = requestAnimationFrame(() => {
for (const e of entries.current) {
const el = e.el;
el.removeAttribute('contenteditable');
el.classList.remove('debug-editable');
}
entries.current = [];
const sections = document.querySelectorAll<HTMLElement>(
'section[id^="page-"]'
);
sections.forEach(section => {
const page = section.id.replace('page-', '');
const targets = section.querySelectorAll<HTMLElement>(
'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'
);
let i = 0;
targets.forEach(target => {
const el = target;
const text = el.textContent ?? '';
if (!text.trim()) return;
i += 1;
try {
// Chrome/Safari support plaintext-only, which keeps pasted markup out.
el.contentEditable = 'plaintext-only';
} catch {
el.contentEditable = 'true';
}
el.classList.add('debug-editable');
el.spellcheck = false;
entries.current.push({
el,
page,
label: `${el.tagName.toLowerCase()} #${i}`,
original: text,
});
});
});
setCount(entries.current.length);
});
return () => cancelAnimationFrame(raf);
}, [refreshKey]);

// Track which slide is in view so the layout picker targets it.
useEffect(() => {
const deck = document.querySelector('.deck');
if (!deck) return;
let raf = 0;
const onScroll = () => {
cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => {
let best: { page: string; dist: number } | null = null;
document
.querySelectorAll<HTMLElement>('section[id^="page-"]')
.forEach(section => {
const dist = Math.abs(section.getBoundingClientRect().top);
if (!best || dist < best.dist) {
best = { page: section.id.replace('page-', ''), dist };
}
});
if (best) {
const page = (best as { page: string }).page;
setCurrentPage(page);
setCurrentArchetype(slideArchetype(page));
}
});
};
onScroll();
deck.addEventListener('scroll', onScroll, { passive: true });
return () => {
deck.removeEventListener('scroll', onScroll);
cancelAnimationFrame(raf);
};
}, [refreshKey]);

const changed = () =>
entries.current.filter(
e => (e.el.textContent ?? '').trim() !== e.original.trim()
);

const copy = async () => {
const edits = changed();
const picks = [...layoutPicks.entries()];
const removals = [...deleted.entries()];
const iconChanged = bandIcon !== DEFAULT_BAND_ICON;
if (
edits.length === 0 &&
picks.length === 0 &&
removals.length === 0 &&
!iconChanged
) {
setCopied('No edits yet');
setTimeout(() => setCopied(null), 1500);
return;
}
const lines = ['COPY EDITS from /opportunity?debug=true', ''];
for (const e of edits) {
lines.push(`[page ${e.page} · ${e.label}]`);
lines.push(`OLD: ${e.original.trim()}`);
lines.push(`NEW: ${(e.el.textContent ?? '').trim()}`);
lines.push('');
}
for (const [page, variant] of picks) {
lines.push(`[page ${page} · layout]`);
const current = slideArchetype(page);
if (current) lines.push(`OLD LAYOUT: ${current}`);
lines.push(`NEW LAYOUT: ${variant}`);
lines.push('');
}
for (const [page, title] of removals) {
lines.push(`[page ${page} · slide]`);
lines.push(`DELETE SLIDE: ${title}`);
lines.push('');
}
if (iconChanged) {
lines.push('[accent box · icon]');
lines.push(`OLD ICON: ${DEFAULT_BAND_ICON}`);
lines.push(`NEW ICON: ${bandIcon}`);
lines.push('');
}
await navigator.clipboard.writeText(lines.join('\n'));
const n =
edits.length + picks.length + removals.length + (iconChanged ? 1 : 0);
setCopied(`Copied ${n} edit${n === 1 ? '' : 's'}`);
setTimeout(() => setCopied(null), 2000);
};

const reset = () => {
restoreOriginals(entries.current);
for (const page of deleted.keys()) setSlideHidden(page, false);
setDeleted(new Map());
setLayoutPicks(new Map());
pickBandIcon(DEFAULT_BAND_ICON);
setCopied('Reset');
setTimeout(() => setCopied(null), 1200);
};

const deleteSlide = () => {
const title = slideTitle(currentPage);
setSlideHidden(currentPage, true);
setDeleted(prev => new Map(prev).set(currentPage, title));
};

const pickLayout = (variant: string) => {
setLayoutPicks(prev => {
const next = new Map(prev);
// Re-selecting the slide's real archetype clears the override.
if (variant === '' || variant === currentArchetype) {
next.delete(currentPage);
} else {
next.set(currentPage, variant);
}
return next;
});
};

return (
<div className="debug-bar" role="toolbar" aria-label="Copy editor">
<span className="debug-bar-hint">
debug · p{currentPage} · click text to edit ({count})
</span>
<label className="debug-bar-hint">
layout{' '}
<select
value={layoutPicks.get(currentPage) ?? currentArchetype}
onChange={e => pickLayout(e.target.value)}
>
<option value="">(bespoke)</option>
{LAYOUT_VARIANTS.map(v => (
<option key={v} value={v}>
{v}
</option>
))}
</select>
</label>
<label className="debug-bar-hint">
icon{' '}
<select value={bandIcon} onChange={e => pickBandIcon(e.target.value)}>
{BAND_ICONS.map(name => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
</label>
<button type="button" onClick={deleteSlide}>
Delete slide{deleted.size > 0 ? ` (${deleted.size})` : ''}
</button>
<button type="button" onClick={copy}>
{copied ?? 'Copy edits'}
</button>
<button type="button" onClick={reset}>
Reset
</button>
</div>
);
}
Loading
Loading