Add Kickstarter story image HTML sources and build scripts
This commit is contained in:
@@ -0,0 +1,645 @@
|
||||
"""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>')
|
||||
|
||||
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">
|
||||
<div class="bookwrap"><div class="book">{slot("cover", "Book cover goes here", "Final cover · owner supplies", "cover")}</div></div>
|
||||
<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:34px;align-items:center;padding:6px 6px 26px}
|
||||
.bookwrap{flex:none;padding:6px 14px 14px 4px}
|
||||
.book{width:184px;height:258px;border-radius:3px 8px 8px 3px;position:relative;box-shadow:10px 12px 0 -3px #efe9db,10px 12px 0 -2px #d6cdba,0 26px 40px -18px rgba(18,63,52,.5)}
|
||||
.book .slot{position:absolute;inset:0;border-radius:3px 8px 8px 3px}
|
||||
.book .slot .ph b{font-size:15px}
|
||||
.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}
|
||||
''')
|
||||
CHAPTERS = ['Real Estate', 'Financial Advisors', 'Fractional <span style="white-space:nowrap">C-Suite</span>', 'Higher Education', 'Communities', 'Technology']
|
||||
page('04c-chapter-tiles-DRAFT', '<div class="canvas" id="shot"><p class="spec">Chapters</p><div class="tg">' + ''.join(
|
||||
f'<div class="card t"><span class="n">{i:02d}</span><h3>{c}</h3>{icon("book")}</div>' for i, c in enumerate(CHAPTERS, 1)) + '</div></div>', css='''
|
||||
.tg{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-top:14px}
|
||||
.t{padding:18px 18px 20px;position:relative;min-height:118px;display:flex;flex-direction:column}
|
||||
.t .n{font-family:var(--mono);font-size:13px;color:var(--muted);letter-spacing:.08em}
|
||||
.t h3{font-family:var(--display);font-weight:600;font-size:22px;letter-spacing:-.02em;line-height:1.12;margin-top:auto;padding-top:22px}
|
||||
.t .ico{position:absolute;right:16px;top:16px;width:22px;height:22px;opacity:.8}
|
||||
''')
|
||||
|
||||
# ---------------------------------------------------------------- 05 try one workflow
|
||||
add_header('05')
|
||||
CHECK = ['Use the included sample_listings.csv (no live client file).',
|
||||
'Produce the market-1pager brief.',
|
||||
'Walk the human checklist.',
|
||||
'Mark complete when you have a brief you would send after review.']
|
||||
page('05b-sample-workflow-try-it', f'''<div class="canvas" id="shot">
|
||||
<div class="win">
|
||||
<div class="bar"><i></i><i></i><i></i><span class="url">aibook.fun</span></div>
|
||||
<div class="scr">
|
||||
<article class="recipe">
|
||||
<div class="recipe-top"><span class="recipe-id"><b>RE-01</b> · v1.2 · ~15 min · ≈ $0.03</span><span class="recipe-vert">Real Estate</span></div>
|
||||
<div class="recipe-body">
|
||||
<h3 class="recipe-title">Draft a property-market brief with an AI agent without leaking client data.</h3>
|
||||
<div class="xrow"><h4 class="k">Safeguards</h4><div class="v">
|
||||
<div class="guard">{ICONS["shield"]}<span>Redaction runs on your machine. Raw client data never reaches the model.</span></div>
|
||||
<div class="guard">{ICONS["shield"]}<span>A human checklist is required before the brief is shared.</span></div>
|
||||
</div></div>
|
||||
<div class="xrow try"><h4 class="k">Try-it</h4><div class="v">
|
||||
<p class="mins">About 15 minutes</p>
|
||||
<ul class="cl">{''.join(f'<li><span class="box"></span>{c}</li>' for c in CHECK)}</ul>
|
||||
<div class="acts"><span class="chip">◷ cost to try ≈ $0.03</span><span class="btn btn-ghost">Mark complete</span></div>
|
||||
</div></div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
<p class="foot-note">Sample workflow RE-01, shown in the AiBook.fun reader.</p>
|
||||
</div>''', extra_css='<link rel="stylesheet" href="../reader/globals.css">', css='''
|
||||
body{background:#fff}
|
||||
.win{background:#fff;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)}
|
||||
.scr{padding:20px;background:var(--paper)}
|
||||
.recipe{box-shadow:var(--sh-md);border-radius:16px}
|
||||
.recipe-top{padding:14px 18px}
|
||||
.recipe-id{font-size:13px}
|
||||
.recipe-vert{font-size:12px}
|
||||
.recipe-body{padding:22px 20px 22px}
|
||||
.recipe-title{font-size:23px;margin-bottom:16px}
|
||||
.xrow{display:grid;grid-template-columns:96px 1fr;gap:14px;padding:14px 0;border-top:1px dashed var(--line-2)}
|
||||
.xrow .k{font-family:var(--mono);font-size:12.5px;text-transform:uppercase;letter-spacing:.1em;color:var(--pine);padding-top:3px;font-weight:500}
|
||||
.guard{font-size:15px;padding:5px 0;color:var(--muted);gap:9px;line-height:1.45}
|
||||
.guard svg{width:18px;height:18px;color:var(--pine);flex:none;margin-top:1px}
|
||||
.mins{font-weight:700;font-size:16px;margin-bottom:8px}
|
||||
.cl li{display:flex;gap:11px;align-items:flex-start;font-size:15px;line-height:1.45;padding:5px 0}
|
||||
.cl .box{width:18px;height:18px;border:1.6px solid var(--pine);border-radius:5px;flex:none;margin-top:2px;background:#fff}
|
||||
.acts{display:flex;align-items:center;gap:12px;margin-top:12px;flex-wrap:wrap}
|
||||
.chip{font-size:13px}
|
||||
.btn{font-size:15px;padding:10px 18px}
|
||||
.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">{ICONS["book"]}</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><h3>Do it yourself, online</h3>
|
||||
<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;color:var(--muted);width:22px;height:22px;margin:1px 0 0 4px}
|
||||
.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">Mockup of the AiBook.fun reader, showing the sample Real Estate chapter.</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>'
|
||||
st += f'<div class="stp">{icon(i2, "ico ci")}<b>{t}</b>' + (f'<p>{d}</p>' if d else '') + '</div>'
|
||||
return f'<div class="card lane"><div class="lh">{icon(ic, "ico lhi")}<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'),
|
||||
('book', 'eBook', 'January 2027')]) +
|
||||
lane('Signed paperbacks', 'Ship in February 2027', 'box', [
|
||||
('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}
|
||||
.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)
|
||||
page('14b-authors-and-team', f'''<div class="canvas" id="shot">
|
||||
<p class="spec">The authors</p>
|
||||
<div class="ag">
|
||||
<div class="card au">{slot("author-1", "Author photo goes here", "800 × 800", "ap")}<p class="an">Author name</p><p class="ar">Role in the book</p></div>
|
||||
<div class="card au">{slot("author-2", "Author photo goes here", "800 × 800", "ap")}<p class="an">Author name</p><p class="ar">Role in the book</p></div>
|
||||
</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:1fr 1fr;gap:14px;margin-top:12px}
|
||||
.au{padding:14px 14px 18px}
|
||||
.ap{aspect-ratio:1/1;border-radius:10px}
|
||||
.an{font-family:var(--display);font-weight:600;font-size:22px;letter-spacing:-.02em;margin-top:12px;color:#8d8579}
|
||||
.ar{font-size:15px;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))
|
||||
Reference in New Issue
Block a user