629 lines
43 KiB
Python
629 lines
43 KiB
Python
"""Build and render the We Own AI Kickstarter story images.
|
||
|
||
Usage: python build.py (render everything into ../)
|
||
Owner photos: drop files into src/assets/ using the names in SLOT_FILES (jpg/png/webp) and re-run;
|
||
images whose photo slots are still empty get a -PLACEHOLDER suffix.
|
||
Sources of truth: ks-research/kickstarter-campaign-guide.md (v2), Story section and Part 3.1."""
|
||
import asyncio, json, math, pathlib, re, sys
|
||
from icons import ICONS, ILLUS, MARK, icon
|
||
HERE = pathlib.Path(__file__).parent
|
||
OUT = HERE.parent
|
||
PAGES = HERE / 'pages'
|
||
PAGES.mkdir(exist_ok=True)
|
||
MAP = json.loads((HERE / 'assets-gen' / 'us-map.json').read_text())
|
||
MAP_SVG = (HERE / 'assets-gen' / 'us-map.svg').read_text()
|
||
SCREENS = json.loads((HERE / 'assets-gen' / 'screens.json').read_text())
|
||
|
||
TITLE = 'We Own AI — The New American Dream'
|
||
|
||
HEAD = """<!doctype html><html lang="en"><head><meta charset="utf-8">
|
||
{extra_css}<link rel="stylesheet" href="../styles.css"><style>{css}</style></head><body>{body}
|
||
<script>
|
||
window.__ready = (async () => {{
|
||
let empty = 0;
|
||
for (const s of document.querySelectorAll('.slot[data-src]')) {{
|
||
let ok = false;
|
||
for (const ext of ['jpg', 'jpeg', 'png', 'webp']) {{
|
||
const url = s.dataset.src + '.' + ext;
|
||
ok = await new Promise(r => {{ const i = new Image(); i.onload = () => r(true); i.onerror = () => r(false); i.src = url; }});
|
||
if (ok) {{ s.style.backgroundImage = 'url("' + url + '")'; s.classList.add('filled'); break; }}
|
||
}}
|
||
if (!ok) empty++;
|
||
}}
|
||
await document.fonts.ready;
|
||
for (const el of document.querySelectorAll('[data-fit]')) {{
|
||
let fs = parseFloat(getComputedStyle(el).fontSize);
|
||
const max = el.dataset.fit ? parseFloat(el.dataset.fit) : el.parentElement.clientWidth;
|
||
while (el.scrollWidth > max && fs > 10) {{ fs -= 0.5; el.style.fontSize = fs + 'px'; }}
|
||
}}
|
||
if (window.afterLayout) await window.afterLayout();
|
||
return empty;
|
||
}})();
|
||
</script></body></html>"""
|
||
|
||
def slot(src, label, size, cls='', style=''):
|
||
return (f'<div class="slot {cls}" data-src="../assets/{src}" style="{style}"><div class="ph">{ICONS["camera"]}'
|
||
f'<b>{label}</b><small>{size}</small></div></div>')
|
||
|
||
COVER_3D = '../assets-gen/cover-3d.png' # rendered from the landing page (render_cover.py)
|
||
COVER_FLAT = '../assets-gen/cover-flat.png'
|
||
pages = [] # dicts: name, body, css, sel, w, h(optional), dpr
|
||
def page(name, body, css='', w=680, h=None, extra_css='', px_h=None):
|
||
pages.append(dict(name=name, body=body, css=css, w=w, h=h, extra_css=extra_css, px_h=px_h))
|
||
|
||
# ---------------------------------------------------------------- headers (1360 x 300)
|
||
HEADERS = [
|
||
('01', 'welcome', '👋', 'Welcome'), ('02', 'why-this-book', '❤️', 'Why this book'),
|
||
('03', 'who-its-for', '🎯', 'Who it’s for'), ('04', 'whats-inside', '📖', 'What’s inside'),
|
||
('05', 'try-one-workflow', '🧪', 'Try one workflow'), ('06', 'three-ways-to-back', '🧭', 'Three ways to back'),
|
||
('07', 'the-retreat', '🏕️', 'The Retreat'), ('08', 'the-academy', '🎓', 'The Academy'),
|
||
('09', 'cloud-and-aibook-fun', '☁️', 'Cloud and AiBook.fun'), ('10', 'stretch-goals', '🎁', 'Stretch goals'),
|
||
('11', 'timeline', '⏳', 'Timeline'), ('12', 'shipping-and-delivery', '📦', 'Shipping and delivery'),
|
||
('13', 'early-readers', '👀', 'Early readers'), ('14', 'authors-and-team', '✍️', 'The authors and team'),
|
||
('15', 'special-thanks', '🙏', 'Special thanks'), ('16', 'how-to-help', '📣', 'How to help'),
|
||
]
|
||
def header(num, slug, emoji, title):
|
||
body = (f'<div class="hdr" id="shot"><div class="tabs"><span></span><span></span><span></span><span></span><span></span></div>'
|
||
f'<div class="mark">{MARK}</div><div class="badge"><span class="emoji">{emoji}</span></div>'
|
||
f'<div style="min-width:0;flex:1"><div class="kicker">{TITLE}</div><h1 data-fit>{title}</h1></div><div class="rule"></div></div>')
|
||
return body
|
||
HDR = {n: (f'{n}-header-{s}', header(n, s, e, t)) for n, s, e, t in HEADERS}
|
||
def add_header(n):
|
||
name, body = HDR[n]; page(name, body, w=680, h=150)
|
||
|
||
# ---------------------------------------------------------------- 01 welcome
|
||
add_header('01')
|
||
page('01b-welcome-cover-icons', f'''
|
||
<div class="canvas" id="shot">
|
||
<div class="wel">
|
||
<figure class="bookwrap"><img src="{COVER_3D}" alt=""><figcaption>Cover mockup</figcaption></figure>
|
||
<div class="wtxt">
|
||
<h2>Welcome</h2>
|
||
<p class="t">{TITLE}</p>
|
||
<p class="s">A field book for advisors who want to use AI in their practice in ways they control.</p>
|
||
<p class="pub">BurnedOut Media / WeOwnNet</p>
|
||
</div>
|
||
</div>
|
||
<div class="trio">
|
||
<div>{icon("authors", "ico big")}<p>Multi-author field book</p></div>
|
||
<div>{icon("tested", "ico big")}<p>Tested AI workflows</p></div>
|
||
<div>{icon("updated", "ico big")}<p>Library updated often</p></div>
|
||
</div>
|
||
</div>''', css='''
|
||
.wel{display:flex;gap:30px;align-items:center;padding:0 6px 22px}
|
||
.bookwrap{flex:none;margin:0;width:196px;text-align:center}
|
||
.bookwrap img{width:196px;display:block}
|
||
.bookwrap figcaption{font-family:var(--mono);font-size:12px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);margin-top:-14px}
|
||
.wtxt h2{font-family:var(--display);font-weight:900;font-size:50px;letter-spacing:-.03em;line-height:1;color:var(--pine-deep)}
|
||
.wtxt .t{font-family:var(--display);font-weight:600;font-size:22px;letter-spacing:-.02em;line-height:1.2;margin:14px 0 12px}
|
||
.wtxt .s{font-size:16px;line-height:1.5;color:var(--muted);max-width:25rem}
|
||
.wtxt .pub{font-family:var(--mono);font-size:13px;letter-spacing:.06em;color:var(--pine);margin-top:16px}
|
||
.trio{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}
|
||
.trio>div{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:20px 14px 18px;text-align:center;box-shadow:var(--sh-sm)}
|
||
.ico.big{width:58px;height:58px;padding:13px;border-radius:999px;background:var(--pine-wash);border:1px solid var(--pine-tint);display:block;margin:0 auto 12px}
|
||
.trio p{font-weight:700;font-size:16px;line-height:1.3}
|
||
''')
|
||
|
||
# ---------------------------------------------------------------- 02 why this book
|
||
add_header('02')
|
||
WHY = [('newsletters', 'Too much noise', 'Each week brings a new tool and a new prediction, and very little of it fits a real client file.'),
|
||
('lockedwindow', 'Processes you don’t control', 'When your process lives inside a vendor’s product, a price change or a feature change can undo it.'),
|
||
('clock11', 'No time to learn', 'The job already takes up the evenings, and learning AI on top of it feels like a second job.'),
|
||
('chair', 'Clients are changing', 'Firms and clients are changing how they work faster than most playbooks are updated.')]
|
||
page('02b-why-this-book-cards', '<div class="canvas" id="shot"><div class="g">' + ''.join(
|
||
f'<div class="card c"><div class="il">{ILLUS[k]}</div><h3>{t}</h3><p>{d}</p></div>' for k, t, d in WHY) + '</div></div>', css='''
|
||
.g{display:grid;grid-template-columns:1fr 1fr;gap:14px}
|
||
.c{padding:20px 22px 22px}
|
||
.il{width:120px;height:84px;margin:0 0 12px -6px}
|
||
.c h3{font-family:var(--display);font-weight:600;font-size:21px;letter-spacing:-.02em;line-height:1.15;margin-bottom:8px}
|
||
.c p{font-size:15px;line-height:1.5;color:var(--muted)}
|
||
''')
|
||
|
||
# ---------------------------------------------------------------- 03 who it's for
|
||
add_header('03')
|
||
page('03b-audience-cards', f'''<div class="canvas" id="shot">
|
||
<p class="spec">Mainly for</p>
|
||
<div class="two">
|
||
<div class="card a">{icon("accounting", "ico big")}<h3>Accounting advisors</h3><p class="k">Exit to Client</p></div>
|
||
<div class="card a">{icon("chart", "ico big")}<h3>Financial advisors</h3><p class="k">CKA® and CEPA® focus</p></div>
|
||
</div>
|
||
<p class="spec" style="margin-top:20px">Also for</p>
|
||
<div class="card b">{icon("briefcase", "ico mid")}<h3>Other professionals who advise clients</h3></div>
|
||
</div>''', css='''
|
||
.two{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-top:12px}
|
||
.a{padding:24px 24px 26px;border-top:4px solid var(--pine)}
|
||
.ico.big{width:60px;height:60px;padding:14px;border-radius:14px;background:var(--pine-wash);border:1px solid var(--pine-tint);display:block;margin-bottom:18px}
|
||
.a h3{font-family:var(--display);font-weight:900;font-size:28px;letter-spacing:-.025em;line-height:1.08}
|
||
.a .k{font-family:var(--mono);font-size:15px;font-weight:600;letter-spacing:.02em;color:var(--pine);margin-top:10px;background:var(--pine-wash);border:1px solid var(--pine-tint);display:inline-block;padding:5px 12px;border-radius:999px}
|
||
.b{margin-top:12px;padding:16px 22px;display:flex;align-items:center;gap:16px}
|
||
.ico.mid{width:44px;height:44px;padding:10px;border-radius:12px;background:var(--paper-2);border:1px solid var(--line);color:var(--muted)}
|
||
.b h3{font-family:var(--display);font-weight:600;font-size:20px;letter-spacing:-.02em}
|
||
''')
|
||
|
||
# ---------------------------------------------------------------- 04 what's inside
|
||
add_header('04')
|
||
RECIPE = [('Problem', 'The problem', 'The real task it solves.'),
|
||
('Who runs it', 'Who runs it', 'A person, a role, or an AI agent working under a person’s supervision.'),
|
||
('Setup', 'Setup', 'The tools, sample files, and expected cost.'),
|
||
('Safeguards', 'Safeguards', 'How to handle client data, where a person must review the work, and what can go wrong. These come before the steps.'),
|
||
('Steps', 'The steps', 'With the actual output the tools produced.'),
|
||
('Evidence', 'Evidence', 'What the result looked like when we ran it.'),
|
||
('Try it', 'Try it', 'A short exercise of about 15 minutes, using sample data.'),
|
||
('Record', 'Costs, author, and sources', '')]
|
||
rows = ''
|
||
for i, (k, t, d) in enumerate(RECIPE, 1):
|
||
first = ' first' if i == 1 else ''; last = ' last' if i == len(RECIPE) else ''
|
||
bars = '<i style="width:88%"></i><i style="width:64%"></i>'
|
||
if k == 'Steps': bars = '<i style="width:80%"></i><span class="term"></span>'
|
||
if k == 'Try it': bars = '<i style="width:72%"></i><span class="pill"></span>'
|
||
if k == 'Safeguards': bars = '<span class="gd"></span><span class="gd"></span>'
|
||
rows += (f'<div class="pc{first}{last}"><span class="k">{k}</span><span class="bars">{bars}</span><b class="n">{i}</b></div>'
|
||
f'<div class="ln"></div><div class="co"><h4>{t}</h4>' + (f'<p>{d}</p>' if d else '') + '</div>')
|
||
page('04b-workflow-recipe-diagram', f'''<div class="canvas" id="shot">
|
||
<p class="spec">Every workflow follows the same recipe</p>
|
||
<div class="rg">
|
||
<div class="ptop"><span>Workflow page</span><em>~15 min</em></div><div></div><div></div>
|
||
{rows}
|
||
</div></div>''', css='''
|
||
.rg{display:grid;grid-template-columns:250px 34px 1fr;margin-top:16px;align-items:stretch}
|
||
.ptop{background:linear-gradient(#fff,var(--paper));border:1px solid var(--line);border-bottom:1px solid var(--line);border-radius:16px 16px 0 0;padding:12px 16px;display:flex;justify-content:space-between;align-items:center}
|
||
.ptop span{font-family:var(--mono);font-size:12px;color:var(--muted)}
|
||
.ptop em{font-style:normal;font-family:var(--mono);font-size:11px;letter-spacing:.1em;text-transform:uppercase;color:var(--pine);background:var(--pine-wash);border:1px solid var(--pine-tint);padding:3px 9px;border-radius:999px}
|
||
.pc{background:#fff;border-left:1px solid var(--line);border-right:1px solid var(--line);padding:11px 30px 11px 16px;position:relative;display:flex;flex-direction:column;justify-content:center;gap:7px;border-top:1px dashed var(--line-2)}
|
||
.pc.first{border-top:none;padding-top:16px}
|
||
.pc.last{border-bottom:1px solid var(--line);border-radius:0 0 16px 16px;padding-bottom:16px}
|
||
.pc .k{font-family:var(--mono);font-size:12px;letter-spacing:.1em;text-transform:uppercase;color:var(--pine)}
|
||
.bars{display:flex;flex-direction:column;gap:5px}
|
||
.bars i{display:block;height:6px;border-radius:3px;background:#ece6db}
|
||
.bars .term{display:block;height:20px;border-radius:5px;background:#14110e}
|
||
.bars .pill{display:block;width:70px;height:14px;border-radius:999px;background:rgba(180,121,31,.14);border:1px solid rgba(180,121,31,.3)}
|
||
.bars .gd{display:block;height:6px;border-radius:3px;background:linear-gradient(90deg,var(--pine) 0 8px,transparent 8px 14px,#ece6db 14px);width:86%}
|
||
.pc .n{position:absolute;right:-13px;top:50%;transform:translateY(-50%);width:26px;height:26px;border-radius:999px;background:var(--pine);color:#fff;font-family:var(--mono);font-size:13px;font-weight:600;display:grid;place-items:center;box-shadow:0 0 0 3px var(--paper-2);z-index:2}
|
||
.ln{position:relative}.ln::before{content:"";position:absolute;left:13px;right:4px;top:50%;border-top:1.5px solid var(--pine);opacity:.5}
|
||
.co{padding:8px 0 8px 4px;display:flex;flex-direction:column;justify-content:center}
|
||
.co h4{font-weight:700;font-size:16px;line-height:1.25}
|
||
.co p{font-size:14px;line-height:1.42;color:var(--muted);margin-top:2px}
|
||
''')
|
||
# Published on the landing page: marketing-site/components/Contents.jsx ("The founding library")
|
||
CHAPTERS = [('Real Estate', 'Market briefs & listings, without leaking client data'),
|
||
('Financial Advisors', 'Portfolio summaries that stay compliant & reviewed'),
|
||
('Fractional <span style="white-space:nowrap">C-Suite</span>', 'Board updates & plans with real escalation paths'),
|
||
('Higher Education', 'Course & research workflows with academic integrity'),
|
||
('Communities', 'Moderation & programming that respects members'),
|
||
('Technology', 'Agent patterns, evals & the responsible defaults')]
|
||
page('04c-chapter-tiles', '<div class="canvas" id="shot"><p class="spec">The founding library</p><div class="tg">' + ''.join(
|
||
f'<div class="card t"><span class="n">{i:02d}</span><h3>{c}</h3><p>{d}</p></div>' for i, (c, d) in enumerate(CHAPTERS, 1)) + '</div></div>', css='''
|
||
.tg{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-top:14px}
|
||
.t{padding:16px 16px 18px;display:flex;flex-direction:column}
|
||
.t .n{font-family:var(--mono);font-size:13px;color:var(--pine);letter-spacing:.08em}
|
||
.t h3{font-family:var(--display);font-weight:600;font-size:21px;letter-spacing:-.02em;line-height:1.12;margin:10px 0 6px}
|
||
.t p{font-size:14px;line-height:1.4;color:var(--muted)}
|
||
''')
|
||
|
||
# ---------------------------------------------------------------- 05 try one workflow
|
||
add_header('05')
|
||
page('05b-sample-workflow-try-it', '''<div class="canvas" id="shot">
|
||
<div class="win">
|
||
<div class="bar"><i></i><i></i><i></i><span class="url">aibook.fun/read/recipe/RE-01</span></div>
|
||
<img src="../assets-gen/re01-top.png" alt="">
|
||
<div class="gap"><span>Problem · Actors · Setup · Safeguards · Method · Evidence</span></div>
|
||
<img src="../assets-gen/re01-tryit.png" alt="">
|
||
</div>
|
||
<p class="foot-note">Screenshots of sample workflow RE-01 at aibook.fun/read/recipe/RE-01</p>
|
||
</div>''', css='''
|
||
.win{background:var(--paper);border:1px solid var(--line-2);border-radius:14px;overflow:hidden;box-shadow:var(--sh-lg)}
|
||
.bar{display:flex;align-items:center;gap:7px;padding:10px 14px;background:#f1eee7;border-bottom:1px solid var(--line)}
|
||
.bar i{width:11px;height:11px;border-radius:999px;background:#d8d1c4;display:block}
|
||
.bar .url{margin-left:14px;flex:1;background:#fff;border:1px solid var(--line);border-radius:999px;padding:4px 14px;font-family:var(--mono);font-size:13px;color:var(--muted)}
|
||
.win img{display:block;width:614px}
|
||
.gap{padding:10px 0;text-align:center;background:linear-gradient(transparent calc(50% - 1px),var(--line-2) calc(50% - 1px) calc(50% + 1px),transparent calc(50% + 1px))}
|
||
.gap span{font-family:var(--mono);font-size:12.5px;color:var(--muted);background:var(--paper);padding:2px 10px;letter-spacing:.02em}
|
||
.foot-note{text-align:center}
|
||
''')
|
||
page('05c-button-try-the-workflow', f'''<div class="canvas" id="shot">
|
||
<div class="cta"><span class="b">Try the sample workflow {ICONS["arrow"]}</span>
|
||
<p>Free · about 15 minutes · sample data · aibook.fun</p></div></div>''', css='''
|
||
.canvas{padding:26px 32px 24px}
|
||
.cta{text-align:center}
|
||
.b{display:inline-flex;align-items:center;gap:12px;background:var(--pine);color:#f7fbf9;font-weight:700;font-size:24px;letter-spacing:-.01em;border-radius:999px;padding:18px 34px;box-shadow:0 10px 24px -10px rgba(28,93,76,.6)}
|
||
.b svg{width:24px;height:24px}
|
||
.cta p{font-family:var(--mono);font-size:14px;color:var(--muted);letter-spacing:.03em;margin-top:14px}
|
||
''')
|
||
|
||
# ---------------------------------------------------------------- 06 three ways to back
|
||
add_header('06')
|
||
def three_ways(paperback=True):
|
||
cloud_items = [('$19', 'Early Bird eBook', 'First 200 backers', False),
|
||
('$25', 'eBook and backer-only updates', '', False),
|
||
('$47', 'eBook and 12 months of the AiBook.fun library', '', True)]
|
||
rows = ''.join(f'<li class="{"feat" if f else ""}"><b>{p}</b><span>{t}' + (f'<small>{s}</small>' if s else '') + '</span></li>'
|
||
for p, t, s, f in cloud_items)
|
||
if paperback:
|
||
rows += f'<li><b class="np"><img src="{COVER_3D}" alt=""></b><span>Signed paperback and eBook<small>Price on the reward</small></span></li>'
|
||
cloud_deliv = ('<li>Library access: within about 14 days of a successful campaign</li><li>eBook: January 2027</li>'
|
||
+ ('<li>Signed paperback: February 2027</li>' if paperback else ''))
|
||
inc = f'<ul class="inc"><li>{icon("seat")}Your seat</li><li>{icon("book")}The eBook</li><li>{icon("library")}12 months of AiBook.fun</li></ul>'
|
||
return f'''<div class="canvas" id="shot"><div class="cols">
|
||
<div class="col card">
|
||
<p class="nm">Retreat</p><h3>Burned Out Advisor Retreat</h3>
|
||
<div class="sec"><p class="lb">What it is</p><p>Three days in person with the authors and other advisors.</p>
|
||
<p class="fact">{icon("people")}369 seats</p></div>
|
||
<div class="sec"><p class="lb">Includes</p>{inc}</div>
|
||
<div class="sec"><p class="lb">When</p><p class="fact">{icon("calendar")}4–6 November 2026</p></div>
|
||
<div class="pr"><p class="price">Price on the reward</p></div>
|
||
</div>
|
||
<div class="col card">
|
||
<p class="nm">Academy</p><h3>AiCoach + AOP.biz BootCamp</h3>
|
||
<div class="sec"><p class="lb">What it is</p><p>A seat in the next cohort at one of three sites:</p>
|
||
<p class="fact">{icon("pin")}Orlando, Florida</p><p class="fact">{icon("pin")}North Denver, Colorado</p><p class="fact">{icon("pin")}Villa Park, Chicagoland, Illinois</p></div>
|
||
<div class="sec"><p class="lb">Includes</p>{inc}</div>
|
||
<div class="sec"><p class="lb">When</p><p class="fact">{icon("calendar")}The next cohort at your chosen site</p></div>
|
||
<div class="pr"><p class="price">Price on the reward</p></div>
|
||
</div>
|
||
<div class="col card cloud">
|
||
<p class="nm">Cloud</p><div class="ch"><h3>Do it yourself, online</h3><img src="{COVER_FLAT}" alt=""></div>
|
||
<div class="sec span2"><p class="lb">Choose one</p><ul class="prices">{rows}</ul></div>
|
||
<div class="sec"><p class="lb">When</p><ul class="dl">{cloud_deliv}</ul></div>
|
||
<div class="pr"></div>
|
||
</div>
|
||
</div></div>'''
|
||
TW_CSS = '''
|
||
.cols{display:grid;grid-template-columns:1fr 1fr 1.3fr;grid-template-rows:repeat(6,auto);column-gap:10px}
|
||
.col{padding:18px 15px 16px;display:grid;grid-template-rows:subgrid;grid-row:span 6}
|
||
.nm{font-family:var(--mono);font-size:13px;letter-spacing:.12em;text-transform:uppercase;color:var(--pine);font-weight:600}
|
||
.col h3{font-family:var(--display);font-weight:600;font-size:19px;letter-spacing:-.02em;line-height:1.15;margin:6px 0 10px}
|
||
.sec{border-top:1px dashed var(--line-2);padding:10px 0 12px}
|
||
.span2{grid-row:span 2}
|
||
.lb{font-family:var(--mono);font-size:12px;letter-spacing:.1em;text-transform:uppercase;color:var(--muted);margin-bottom:6px}
|
||
.sec p{font-size:14px;line-height:1.4}
|
||
.fact{display:flex;gap:7px;align-items:flex-start;margin-top:6px;font-weight:600}
|
||
.lb+.fact{margin-top:0}
|
||
.fact .ico,.inc .ico{width:17px;height:17px;margin-top:1px}
|
||
.inc li{display:flex;gap:7px;align-items:flex-start;font-size:14px;line-height:1.35;padding:2px 0}
|
||
.pr{align-self:end}
|
||
.price{font-family:var(--mono);font-size:12.5px;font-weight:600;color:var(--pine);background:var(--pine-wash);border:1px solid var(--pine-tint);border-radius:999px;padding:7px 6px;text-align:center;white-space:nowrap}
|
||
.cloud{border-color:rgba(28,93,76,.28)}
|
||
.prices li{display:grid;grid-template-columns:58px 1fr;gap:8px;align-items:baseline;padding:7px 8px;border-radius:10px;font-size:14px;line-height:1.35}
|
||
.prices li+li{margin-top:4px}
|
||
.prices b{font-family:var(--display);font-weight:900;font-size:24px;letter-spacing:-.03em;color:var(--ink)}
|
||
.prices b.np{align-self:start;width:30px;margin:0 0 0 6px}.prices b.np img{width:30px;display:block}
|
||
.ch{display:flex;align-items:flex-start;justify-content:space-between;gap:10px}
|
||
.ch img{width:46px;border-radius:2px 4px 4px 2px;box-shadow:0 6px 12px -6px rgba(18,63,52,.5);margin:4px 0 8px}
|
||
.prices small{display:block;font-size:13px;color:var(--muted)}
|
||
.prices li.feat{background:var(--pine);color:#eaf1ec}
|
||
.prices li.feat b{color:#fff}
|
||
.dl li{font-size:14px;line-height:1.4;padding:2px 0 2px 14px;position:relative}
|
||
.dl li::before{content:"";position:absolute;left:0;top:8px;width:6px;height:6px;border-radius:999px;background:var(--pine)}
|
||
'''
|
||
page('06b-three-ways-to-back', three_ways(True), css=TW_CSS)
|
||
page('06c-three-ways-to-back-no-paperback', three_ways(False), css=TW_CSS)
|
||
|
||
# ---------------------------------------------------------------- 07 retreat
|
||
add_header('07')
|
||
page('07b-retreat-venue', f'''<div class="canvas" id="shot">
|
||
{slot("retreat-venue", "Venue photo goes here", "Landscape · at least 1360 × 765 px", "venue")}
|
||
<div class="ov"><p class="spec">{TITLE.split(" — ")[0]}</p><h3>Burned Out Advisor Retreat</h3><p class="d">{icon("calendar")}4 to 6 November 2026</p></div>
|
||
<div class="seats"><b>369</b><span>seats</span></div>
|
||
</div>''', h=383, px_h=765, css='''
|
||
.canvas{padding:0;height:383px}
|
||
.venue{position:absolute;inset:0}
|
||
.venue .ph{border-radius:0;border-width:0;justify-content:flex-start;padding-top:70px}
|
||
.ov{position:absolute;left:28px;bottom:26px;background:rgba(252,251,248,.96);border-radius:16px;padding:16px 22px 18px;box-shadow:var(--sh-lg);max-width:420px}
|
||
.ov h3{font-family:var(--display);font-weight:900;font-size:30px;letter-spacing:-.025em;line-height:1.05;margin:6px 0 10px}
|
||
.ov .d{display:flex;align-items:center;gap:9px;font-weight:700;font-size:18px}
|
||
.ov .d .ico{width:22px;height:22px}
|
||
.seats{position:absolute;right:28px;top:26px;width:108px;height:108px;border-radius:999px;background:var(--pine);color:#fff;display:flex;flex-direction:column;align-items:center;justify-content:center;box-shadow:0 0 0 5px rgba(252,251,248,.9),var(--sh-lg)}
|
||
.seats b{font-family:var(--display);font-weight:900;font-size:38px;line-height:1;letter-spacing:-.03em}
|
||
.seats span{font-family:var(--mono);font-size:14px;letter-spacing:.12em;text-transform:uppercase;color:#cfe0d6;margin-top:3px}
|
||
''')
|
||
|
||
# ---------------------------------------------------------------- 08 academy
|
||
add_header('08')
|
||
W, H = MAP['w'], MAP['h']
|
||
pins = MAP['pins']
|
||
def pct(k): return pins[k][0] / W * 100, pins[k][1] / H * 100
|
||
SITES = [('villapark', 'Chicagoland, Illinois', 'Villa Park', 'lab-r'),
|
||
('denver', 'North Denver, Colorado', '#NoDe', 'lab-l'),
|
||
('orlando', 'Orlando, Florida', 'Azur Resorts', 'lab-l')]
|
||
pin_html = ''
|
||
for k, city, sub, cls in SITES:
|
||
x, y = pct(k)
|
||
pin_html += (f'<div class="pin" style="left:{x:.2f}%;top:{y:.2f}%">{ICONS["pin"]}'
|
||
f'<div class="lab {cls}"><b>{sub}</b><span>{city}</span></div></div>')
|
||
page('08b-academy-map', f'''<div class="canvas" id="shot">
|
||
<p class="spec" style="text-transform:none;letter-spacing:.04em;font-size:14px">AiCoach + AOP.biz BootCamp · three sites</p>
|
||
<div class="map">{MAP_SVG}{pin_html}</div>
|
||
</div>''', css=f'''
|
||
.map{{position:relative;margin-top:14px;aspect-ratio:{W}/{H}}}
|
||
.map>svg{{position:absolute;inset:0;width:100%;height:100%}}
|
||
.pin{{position:absolute;width:0;height:0}}
|
||
.pin>svg{{position:absolute;width:40px;height:40px;left:-20px;top:-36px;color:#fff;fill:var(--pine);stroke:#fff;stroke-width:1.6;filter:drop-shadow(0 3px 4px rgba(0,0,0,.25))}}
|
||
.lab{{position:absolute;background:#fff;border:1px solid var(--line);border-radius:12px;padding:8px 13px 9px;box-shadow:var(--sh-md);white-space:nowrap}}
|
||
.lab b{{display:block;font-family:var(--display);font-weight:600;font-size:18px;letter-spacing:-.02em;line-height:1.1}}
|
||
.lab span{{display:block;font-size:14px;color:var(--muted);margin-top:2px}}
|
||
.lab-r{{left:26px;top:-46px}}
|
||
.lab-l{{right:26px;top:-46px}}
|
||
''')
|
||
SITE_CARDS = [('site-orlando', 'Azur Resorts', 'Orlando, Florida'), ('site-north-denver', '#NoDe', 'North Denver, Colorado'),
|
||
('site-villa-park', 'Villa Park', 'Chicagoland, Illinois')]
|
||
page('08c-academy-sites', '<div class="canvas" id="shot"><div class="sg">' + ''.join(
|
||
f'<div class="card s">{slot(src, "Site photo goes here", "At least 800 × 600 px", "sp")}<div class="b"><p class="n">Site {i}</p><h3>{name}</h3><p class="pl">{icon("pin")}{place}</p></div></div>'
|
||
for i, (src, name, place) in enumerate(SITE_CARDS, 1)) + '</div></div>', css='''
|
||
.sg{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}
|
||
.s{overflow:hidden}
|
||
.sp{aspect-ratio:4/3}
|
||
.sp .ph{border-radius:0;border-width:0 0 2px 0}
|
||
.sp .ph b{font-size:14px}.sp .ph small{font-size:11.5px}
|
||
.b{padding:14px 15px 16px}
|
||
.n{font-family:var(--mono);font-size:12px;letter-spacing:.1em;text-transform:uppercase;color:var(--muted)}
|
||
.b h3{font-family:var(--display);font-weight:600;font-size:21px;letter-spacing:-.02em;margin:4px 0 6px}
|
||
.pl{display:flex;gap:6px;align-items:flex-start;font-size:14px;line-height:1.35;color:var(--ink)}
|
||
.pl .ico{width:17px;height:17px;margin-top:1px}
|
||
''')
|
||
|
||
# ---------------------------------------------------------------- 09 cloud and aibook.fun (device mockup)
|
||
add_header('09')
|
||
L, Ph = SCREENS['laptop'], SCREENS['phone']
|
||
def tgt(box, vw, vh, name, side='left', gap=16):
|
||
"""Callout target just outside the text box (left or right side), vertically centred."""
|
||
x, y, w, h = box
|
||
cx = x - gap if side == 'left' else x + w + gap
|
||
return f'<i class="tg" data-t="{name}" style="left:{cx / vw * 100:.2f}%;top:{(y + h / 2) / vh * 100:.2f}%"></i>'
|
||
page('09b-aibook-fun-device-mockup', f'''<div class="canvas" id="shot">
|
||
<div class="stage">
|
||
<div class="laptop"><div class="lid"><div class="scr"><img src="../assets-gen/screen-laptop.png">
|
||
{tgt(L["essay"], L["vw"], L["vh"], "essay", gap=22)}{tgt(L["workflow"], L["vw"], L["vh"], "workflow", gap=22)}</div></div><div class="base"></div></div>
|
||
<div class="phone"><div class="scr"><img src="../assets-gen/screen-phone.png">
|
||
{tgt(Ph["tryit"], Ph["vw"], Ph["vh"], "tryit", "right", 22)}{tgt(Ph["updated"], Ph["vw"], Ph["vh"], "updated", "right", 22)}</div></div>
|
||
<span class="co" data-c="essay" style="left:0;top:0">Chapter essay</span>
|
||
<span class="co" data-c="workflow" style="left:0;bottom:0">Workflow</span>
|
||
<span class="co" data-c="tryit" style="right:0;top:0">Try-it (about 15 minutes)</span>
|
||
<span class="co" data-c="updated" style="right:0;bottom:0">Last updated</span>
|
||
<svg class="lines"></svg>
|
||
</div>
|
||
<p class="foot-note">Screenshots of the AiBook.fun reader (sample Real Estate chapter) in device frames.</p>
|
||
</div>
|
||
<script>
|
||
window.afterLayout = async () => {{
|
||
await Promise.all([...document.images].map(i => i.complete ? 1 : new Promise(r => i.onload = r)));
|
||
const st = document.querySelector('.stage').getBoundingClientRect();
|
||
const svg = document.querySelector('.lines');
|
||
let s = '';
|
||
for (const c of document.querySelectorAll('.co')) {{
|
||
const t = document.querySelector('.tg[data-t="' + c.dataset.c + '"]').getBoundingClientRect();
|
||
const r = c.getBoundingClientRect();
|
||
const tx = t.left - st.left, ty = t.top - st.top;
|
||
const top = r.top - st.top < st.height / 2;
|
||
const cx = Math.min(Math.max(tx, r.left - st.left + 16), r.right - st.left - 16);
|
||
const cy = top ? r.bottom - st.top : r.top - st.top;
|
||
s += `<path d="M${{cx}} ${{cy}} L${{cx}} ${{ty}} L${{tx}} ${{ty}}" />`;
|
||
s += `<circle cx="${{tx}}" cy="${{ty}}" r="4.5" class="dot"/><circle cx="${{tx}}" cy="${{ty}}" r="8" class="halo"/>`;
|
||
}}
|
||
svg.innerHTML = s;
|
||
}};
|
||
</script>''', css='''
|
||
.canvas{padding:24px 26px 22px}
|
||
.stage{position:relative;height:500px}
|
||
.laptop{position:absolute;left:18px;top:56px;width:470px}
|
||
.lid{background:#1f1b17;border-radius:14px 14px 4px 4px;padding:11px 11px 13px;box-shadow:var(--sh-lg)}
|
||
.laptop .scr{position:relative;aspect-ratio:1120/700;overflow:hidden;border-radius:3px;background:#fff}
|
||
.base{height:14px;margin:0 -30px;background:linear-gradient(#d9d4cb,#bdb6aa);border-radius:0 0 14px 14px;position:relative;box-shadow:0 14px 22px -12px rgba(0,0,0,.35)}
|
||
.base::before{content:"";position:absolute;left:50%;top:0;width:80px;height:6px;transform:translateX(-50%);background:#a9a295;border-radius:0 0 8px 8px}
|
||
.phone{position:absolute;right:18px;top:62px;width:176px;background:#1f1b17;border-radius:30px;padding:8px;box-shadow:var(--sh-lg),0 0 0 1px #3a342b inset}
|
||
.phone .scr{position:relative;aspect-ratio:390/844;overflow:hidden;border-radius:23px;background:#fff}
|
||
.scr img{position:absolute;inset:0;width:100%;height:100%;display:block}
|
||
.tg{position:absolute;width:0;height:0}
|
||
.co{position:absolute;z-index:3;background:#fff;border:1.5px solid var(--pine);color:var(--pine-deep);font-weight:700;font-size:16px;padding:8px 16px;border-radius:999px;box-shadow:var(--sh-md);white-space:nowrap}
|
||
.lines{position:absolute;inset:0;width:100%;height:100%;z-index:2;overflow:visible}
|
||
.lines path{fill:none;stroke:var(--pine);stroke-width:1.8}
|
||
.lines .dot{fill:var(--pine);stroke:#fff;stroke-width:2}
|
||
.lines .halo{fill:none;stroke:var(--pine);stroke-width:1.5;opacity:.35}
|
||
.foot-note{text-align:center;margin-top:14px}
|
||
''')
|
||
|
||
# ---------------------------------------------------------------- 10 stretch goals (1360 x 500)
|
||
add_header('10')
|
||
GOALS = [(15000, '$15,000', 'Bonus chapter', 'PDF for every backer whose reward includes the eBook'),
|
||
(35000, '$35,000', 'Audiobook sample', 'One chapter recorded as audio, for every backer'),
|
||
(54000, '$54,000', 'Live Q&A replay pack', 'Recording of a live Q&A with the authors, for every backer')]
|
||
GX = [16, 50, 84] # schematic milestone positions (%), evenly spaced so labels never collide
|
||
def stretch(unlocked):
|
||
fill = 0 if unlocked == 0 else GX[unlocked - 1]
|
||
marks = ''
|
||
for i, ((v, amt, name, desc), x) in enumerate(zip(GOALS, GX), 1):
|
||
on = i <= unlocked
|
||
marks += (f'<div class="m {"on" if on else ""}" style="left:{x}%">'
|
||
f'<span class="lk">{ICONS["unlock" if on else "lock"]}</span>'
|
||
f'<div class="lbl"><b>{amt}' + ('<span class="ul">Unlocked</span>' if on else '') + '</b>'
|
||
f'<span class="nm">{name}</span><span class="ds">{desc}</span></div></div>')
|
||
status = 'Not yet unlocked' if unlocked == 0 else ('Unlocked: ' + ', '.join(g[1] for g in GOALS[:unlocked]))
|
||
return f'''<div class="canvas" id="shot">
|
||
<div class="top"><p class="spec">Stretch goals</p><p class="st">{status}</p></div>
|
||
<div class="track"><div class="fill" style="width:{fill}%"></div>
|
||
<div class="goal"><span class="gf">{ICONS["flag"]}</span><div class="gl"><b>Goal $5,000</b></div></div>
|
||
{marks}
|
||
</div></div>'''
|
||
ST_CSS = '''
|
||
.canvas{height:250px;padding:24px 32px 18px}
|
||
.top{display:flex;justify-content:space-between;align-items:baseline}
|
||
.st{font-family:var(--mono);font-size:13px;color:var(--muted);letter-spacing:.02em}
|
||
.track{position:relative;height:14px;border-radius:999px;background:#e6e0d4;margin:58px 0 0;box-shadow:inset 0 1px 2px rgba(0,0,0,.08)}
|
||
.fill{position:absolute;left:0;top:0;bottom:0;border-radius:999px;background:linear-gradient(90deg,#23745f,var(--pine))}
|
||
.m{position:absolute;top:50%;width:0;height:0}
|
||
.lk{position:absolute;left:-20px;top:-20px;width:40px;height:40px;border-radius:999px;background:#fff;border:2px solid var(--line-2);color:var(--muted);display:grid;place-items:center;box-shadow:var(--sh-sm)}
|
||
.lk svg{width:21px;height:21px}
|
||
.m.on .lk{background:var(--pine);border-color:#fff;color:#fff;box-shadow:0 0 0 2px var(--pine),var(--sh-md)}
|
||
.m.on .lbl b{color:var(--pine-deep)}
|
||
.ul{display:inline-block;vertical-align:4px;margin-left:8px;font-family:var(--mono);font-size:11.5px;font-weight:600;letter-spacing:.1em;text-transform:uppercase;color:#fff;background:var(--pine);padding:3px 10px;border-radius:999px;white-space:nowrap}
|
||
.lbl{position:absolute;top:30px;width:196px;left:-98px;text-align:center}
|
||
.lbl b{display:block;font-family:var(--display);font-weight:900;font-size:23px;letter-spacing:-.03em;line-height:1.1}
|
||
.lbl .nm{display:block;font-weight:700;font-size:15.5px;margin-top:2px}
|
||
.lbl .ds{display:block;font-size:13.5px;line-height:1.35;color:var(--muted);margin-top:3px}
|
||
.goal{position:absolute;left:0;top:50%;width:0;height:0}
|
||
.goal::before{content:"";position:absolute;left:0;top:-7px;width:14px;height:14px;border-radius:999px;background:#fff;border:3px solid var(--amber)}
|
||
.gf{position:absolute;left:3px;top:-44px;width:24px;height:24px;color:var(--amber)}
|
||
.gf svg{width:24px;height:24px}
|
||
.gl{position:absolute;left:28px;top:-42px;white-space:nowrap}
|
||
.gl b{font-family:var(--mono);font-size:13px;font-weight:600;letter-spacing:.04em;color:var(--amber)}
|
||
'''
|
||
for n, nm in [(0, '10b-stretch-goals-locked'), (1, '10c-stretch-goals-15k-unlocked'), (2, '10d-stretch-goals-35k-unlocked'), (3, '10e-stretch-goals-54k-unlocked')]:
|
||
page(nm, stretch(n), css=ST_CSS, h=250)
|
||
|
||
# ---------------------------------------------------------------- 11 timeline
|
||
add_header('11')
|
||
STOPS = [('Fri 2 Oct 2026', 'The campaign opens.'),
|
||
('Wed 4 Nov 2026', 'The campaign ends. The Burned Out Advisor Retreat runs 4 to 6 November.'),
|
||
('By mid-November 2026', 'AiBook.fun access for backers whose reward includes it, and the backer survey.'),
|
||
('Dates to be announced', 'Academy BootCamp cohorts in Orlando, North Denver, and Villa Park.'),
|
||
('January 2027', 'Final layout and eBook files. The eBook is delivered and the paperbacks are signed.'),
|
||
('February 2027', 'Signed paperbacks are delivered.')]
|
||
XS = [96, 290, 484]
|
||
Y1, Y2 = 30, 250
|
||
pos = [(XS[0], Y1), (XS[1], Y1), (XS[2], Y1), (XS[2], Y2), (XS[1], Y2), (XS[0], Y2)]
|
||
stops = ''.join(f'<div class="stop{" tba" if i == 4 else ""}" style="left:{x}px;top:{y}px"><span class="num">{i}</span><div class="tx"><b>{d}</b><p>{t}</p></div></div>'
|
||
for i, ((x, y), (d, t)) in enumerate(zip(pos, STOPS), 1))
|
||
page('11b-timeline', f'''<div class="canvas" id="shot"><div class="tl">
|
||
<svg class="path" viewBox="0 0 616 420"><path d="M{XS[0]} {Y1} H{XS[2]} C{XS[2]+160} {Y1} {XS[2]+160} {Y2} {XS[2]} {Y2} H{XS[0]}" /></svg>
|
||
{stops}</div></div>''', css=f'''
|
||
.tl{{position:relative;height:396px}}
|
||
.path{{position:absolute;inset:0;width:616px;height:420px}}
|
||
.path path{{fill:none;stroke:var(--pine);stroke-width:3;stroke-dasharray:2 9;stroke-linecap:round;opacity:.7}}
|
||
.stop{{position:absolute;width:0;height:0}}
|
||
.num{{position:absolute;left:-23px;top:-23px;width:46px;height:46px;border-radius:999px;background:var(--pine);color:#fff;font-family:var(--display);font-weight:900;font-size:22px;display:grid;place-items:center;box-shadow:0 0 0 5px var(--paper-2),var(--sh-md)}}
|
||
.stop.tba .num{{background:#fff;color:var(--pine);border:2.5px solid var(--pine)}}
|
||
.tx{{position:absolute;top:34px;left:-92px;width:184px;text-align:center}}
|
||
.tx b{{display:block;font-family:var(--display);font-weight:600;font-size:18px;letter-spacing:-.02em;line-height:1.15;color:var(--ink)}}
|
||
.tx p{{font-size:14px;line-height:1.4;color:var(--muted);margin-top:5px}}
|
||
.stop.tba .tx b{{color:var(--pine)}}
|
||
''')
|
||
|
||
# ---------------------------------------------------------------- 12 shipping and delivery
|
||
add_header('12')
|
||
def lane(title, sub, ic, steps):
|
||
st = ''
|
||
for j, (i2, t, d) in enumerate(steps):
|
||
if j: st += f'<span class="ar">{ICONS["arrow"]}</span>'
|
||
vis = f'<img class="cv" src="{i2[4:]}" alt="">' if i2.startswith('img:') else icon(i2, "ico ci")
|
||
st += f'<div class="stp">{vis}<b>{t}</b>' + (f'<p>{d}</p>' if d else '') + '</div>'
|
||
head = f'<img class="lhc" src="{ic[4:]}" alt="">' if ic.startswith('img:') else icon(ic, "ico lhi")
|
||
return f'<div class="card lane"><div class="lh">{head}<div><h3>{title}</h3><p>{sub}</p></div></div><div class="steps n{len(steps)}">{st}</div></div>'
|
||
page('12b-shipping-and-delivery-lanes', '<div class="canvas" id="shot">' +
|
||
lane('Digital rewards', 'Arrive by email', 'mail', [
|
||
('mail', 'Email', 'To the address on your Kickstarter account'),
|
||
('library', 'Library access', 'Within about 14 days of a successful campaign'),
|
||
('img:' + COVER_FLAT, 'eBook', 'January 2027')]) +
|
||
lane('Signed paperbacks', 'Ship in February 2027', 'img:' + COVER_3D, [
|
||
('survey', 'Survey', 'Kickstarter’s Pledge Manager asks for your address and charges shipping and any taxes'),
|
||
('printer', 'Printing', ''),
|
||
('pen', 'Signing', 'January 2027'),
|
||
('truck', 'Shipping', 'February 2027')]) +
|
||
'</div>', css='''
|
||
.lane{padding:16px 18px 18px}
|
||
.cv{display:block;height:46px;margin:0 auto 8px;border-radius:1px 3px 3px 1px;box-shadow:0 4px 8px -4px rgba(18,63,52,.5)}
|
||
.lhc{height:52px;display:block;margin:-6px 2px -4px 0}
|
||
.lane+.lane{margin-top:14px}
|
||
.lh{display:flex;align-items:center;gap:12px;padding-bottom:12px;border-bottom:1px dashed var(--line-2);margin-bottom:14px}
|
||
.ico.lhi{width:40px;height:40px;padding:9px;border-radius:12px;background:var(--pine);color:#fff}
|
||
.lh h3{font-family:var(--display);font-weight:600;font-size:21px;letter-spacing:-.02em;line-height:1.1}
|
||
.lh p{font-family:var(--mono);font-size:13px;color:var(--muted);margin-top:3px}
|
||
.steps{display:flex;align-items:flex-start}
|
||
.stp{flex:1;min-width:0;text-align:center;padding:0 4px}
|
||
.ar{flex:none;width:18px;height:18px;color:var(--line-2);margin-top:14px}
|
||
.ico.ci{width:46px;height:46px;padding:11px;border-radius:999px;background:var(--pine-wash);border:1px solid var(--pine-tint);display:block;margin:0 auto 8px}
|
||
.stp b{display:block;font-size:15.5px;font-weight:700}
|
||
.stp p{font-size:13.5px;line-height:1.38;color:var(--muted);margin-top:3px}
|
||
''')
|
||
|
||
# ---------------------------------------------------------------- 13 early readers (placeholder cards)
|
||
add_header('13')
|
||
# Fill in once early readers approve their quotes in writing (photo files: assets/reader-1.jpg etc.).
|
||
QUOTES = [ # (quote, name, role and firm)
|
||
('Early reader quote 1 goes here.', 'Name goes here', 'Role and firm'),
|
||
('Early reader quote 2 goes here.', 'Name goes here', 'Role and firm'),
|
||
('Early reader quote 3 goes here.', 'Name goes here', 'Role and firm'),
|
||
]
|
||
page('13b-early-reader-quotes', '<div class="canvas" id="shot">' + ''.join(
|
||
f'<div class="card q">{slot(f"reader-{i}", "Photo", "400 × 400", "qp")}<div class="qt"><p class="qq">“{q}”</p>'
|
||
f'<p class="who"><b>{n}</b> · {r}</p></div></div>' for i, (q, n, r) in enumerate(QUOTES, 1)) + '</div>', css='''
|
||
.q{display:flex;gap:18px;align-items:center;padding:18px 20px}
|
||
.q+.q{margin-top:12px}
|
||
.qp{width:84px;height:84px;border-radius:999px;flex:none}
|
||
.qp .ph{border-width:2px;gap:2px}.qp .ph svg{width:20px;height:20px}.qp .ph b{font-size:12px}.qp .ph small{font-size:10px}
|
||
.qq{font-family:var(--display);font-style:italic;font-size:21px;letter-spacing:-.01em;color:#8d8579;line-height:1.3}
|
||
.who{font-size:14.5px;color:var(--muted);margin-top:6px}
|
||
.who b{color:var(--ink)}
|
||
''')
|
||
|
||
# ---------------------------------------------------------------- 14 authors and team (placeholders)
|
||
add_header('14')
|
||
TEAM = [('@THY', 'Name goes here', 'Author and Retreat host'),
|
||
('@LFG', 'Name goes here', 'Creative lead for design, website, campaign page, and video'),
|
||
('@CEO', 'Name goes here', 'Business strategy and partnerships'),
|
||
('@GTM', 'Name goes here', 'Payments, email, and technical setup'),
|
||
('', 'Osinachi Patrick', 'Product lead, AiBook.fun'),
|
||
('', 'Name goes here', 'Academy BootCamp lead')]
|
||
team_html = ''.join(
|
||
f'<div class="card tm">{slot("team-" + (h[1:].lower() if h else ("osinachi-patrick" if n.startswith("Osinachi") else "academy-lead")), "Photo", "800 × 800", "tp")}'
|
||
f'<div><p class="tn">{n}</p>' + (f'<p class="th">{h}</p>' if h else '') + f'<p class="tr">{r}</p></div></div>' for h, n, r in TEAM)
|
||
# Landing page (Authors.jsx) lists three co-authors as confirmed, by chapter; names are not published yet.
|
||
AUTHORS = [('Name goes here', 'Co-author, Real Estate & Finance'), ('Name goes here', 'Co-author, <span style="white-space:nowrap">Fractional C-Suite</span>'),
|
||
('Name goes here', 'Co-author, Technology & RAAI')]
|
||
AUTHOR_HTML = ''.join(f'<div class="card au">{slot(f"author-{i}", "Author photo goes here", "800 × 800", "ap")}<p class="an">{n}</p><p class="ar">{r}</p></div>'
|
||
for i, (n, r) in enumerate(AUTHORS, 1))
|
||
page('14b-authors-and-team', f'''<div class="canvas" id="shot">
|
||
<p class="spec">The authors</p>
|
||
<div class="ag">{AUTHOR_HTML}</div>
|
||
<p class="spec" style="margin-top:22px">The team</p>
|
||
<div class="tg">{team_html}</div>
|
||
</div>''', css='''
|
||
.ag{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-top:12px}
|
||
.au{padding:14px 14px 18px}
|
||
.ap{aspect-ratio:1/1;border-radius:10px}
|
||
.ap .ph b{font-size:13px}
|
||
.an{font-family:var(--display);font-weight:600;font-size:19px;letter-spacing:-.02em;margin-top:12px;color:#8d8579}
|
||
.ar{font-size:14px;line-height:1.35;color:var(--muted);margin-top:2px}
|
||
.tg{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-top:12px}
|
||
.tm{padding:12px;display:flex;gap:11px;align-items:flex-start}
|
||
.tp{width:60px;height:60px;border-radius:999px;flex:none}
|
||
.tp .ph{gap:1px}.tp .ph svg{width:16px;height:16px}.tp .ph b{font-size:11px}.tp .ph small{display:none}
|
||
.tn{font-weight:700;font-size:14.5px;line-height:1.25}
|
||
.th{font-family:var(--mono);font-size:12px;color:var(--pine);margin-top:2px}
|
||
.tr{font-size:13px;line-height:1.35;color:var(--muted);margin-top:3px}
|
||
''')
|
||
page('14c-author-headshot-800x800', f'''<div class="canvas" id="shot">{slot("headshot-template", "Author photo goes here", "800 × 800 px · square", "hs")}</div>''',
|
||
w=400, h=400, css='''.canvas{width:400px;height:400px;padding:0}.hs{position:absolute;inset:0}.hs .ph{border-radius:0;border-width:3px}.hs .ph b{font-size:20px}.hs .ph small{font-size:14px}.hs .ph svg{width:44px;height:44px}''')
|
||
|
||
# ---------------------------------------------------------------- 15, 16
|
||
add_header('15')
|
||
add_header('16')
|
||
|
||
# ---------------------------------------------------------------- render
|
||
async def render(only=None):
|
||
from playwright.async_api import async_playwright
|
||
results = []
|
||
async with async_playwright() as p:
|
||
b = await p.chromium.launch(executable_path='/usr/bin/google-chrome')
|
||
for pg in pages:
|
||
if only and not re.search(only, pg['name']): continue
|
||
html = HEAD.format(body=pg['body'], css=pg['css'], extra_css=pg['extra_css'])
|
||
f = PAGES / f"{pg['name']}.html"; f.write_text(html)
|
||
page_ = await b.new_page(viewport={'width': pg['w'], 'height': math.ceil(pg['h'] or 800)}, device_scale_factor=2)
|
||
await page_.goto(f.as_uri())
|
||
empty = await page_.evaluate('window.__ready')
|
||
name = pg['name'] + ('-PLACEHOLDER' if empty else '')
|
||
for old in OUT.glob(pg['name'] + '*.png'): old.unlink()
|
||
el = await page_.query_selector('#shot')
|
||
if pg['h']: # fixed-size artwork: clip exactly (element screenshots round fractional heights up)
|
||
await page_.screenshot(path=str(OUT / f'{name}.png'), clip={'x': 0, 'y': 0, 'width': pg['w'], 'height': pg['h']})
|
||
else:
|
||
await el.screenshot(path=str(OUT / f'{name}.png'))
|
||
if pg.get('px_h'): # exact pixel height (e.g. 16:9 = 1360 x 765)
|
||
from PIL import Image
|
||
im = Image.open(OUT / f'{name}.png'); im.crop((0, 0, im.width, pg['px_h'])).save(OUT / f'{name}.png')
|
||
results.append((name, empty))
|
||
await page_.close()
|
||
await b.close()
|
||
for r in results: print(*r)
|
||
if __name__ == '__main__':
|
||
asyncio.run(render(sys.argv[1] if len(sys.argv) > 1 else None))
|