"""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 = """ {extra_css}{body} """ def slot(src, label, size, cls='', style=''): return (f'
{ICONS["camera"]}' f'{label}{size}
') 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'
' f'
{MARK}
{emoji}
' f'
{TITLE}

{title}

') 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'''
Cover mockup

Welcome

{TITLE}

A field book for advisors who want to use AI in their practice in ways they control.

BurnedOut Media / WeOwnNet

{icon("authors", "ico big")}

Multi-author field book

{icon("tested", "ico big")}

Tested AI workflows

{icon("updated", "ico big")}

Library updated often

''', 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', '
' + ''.join( f'
{ILLUS[k]}

{t}

{d}

' for k, t, d in WHY) + '
', 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'''

Mainly for

{icon("accounting", "ico big")}

Accounting advisors

Exit to Client

{icon("chart", "ico big")}

Financial advisors

CKA® and CEPA® focus

Also for

{icon("briefcase", "ico mid")}

Other professionals who advise clients

''', 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 = '' if k == 'Steps': bars = '' if k == 'Try it': bars = '' if k == 'Safeguards': bars = '' rows += (f'
{k}{bars}{i}
' f'

{t}

' + (f'

{d}

' if d else '') + '
') page('04b-workflow-recipe-diagram', f'''

Every workflow follows the same recipe

Workflow page~15 min
{rows}
''', 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 C-Suite', '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', '

The founding library

' + ''.join( f'
{i:02d}

{c}

{d}

' for i, (c, d) in enumerate(CHAPTERS, 1)) + '
', 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', '''
aibook.fun/read/recipe/RE-01
Problem · Actors · Setup · Safeguards · Method · Evidence

Screenshots of sample workflow RE-01 at aibook.fun/read/recipe/RE-01

''', 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'''
Try the sample workflow {ICONS["arrow"]}

Free · about 15 minutes · sample data · aibook.fun

''', 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'
  • {p}{t}' + (f'{s}' if s else '') + '
  • ' for p, t, s, f in cloud_items) if paperback: rows += f'
  • Signed paperback and eBookPrice on the reward
  • ' cloud_deliv = ('
  • Library access: within about 14 days of a successful campaign
  • eBook: January 2027
  • ' + ('
  • Signed paperback: February 2027
  • ' if paperback else '')) inc = f'' return f'''

    Retreat

    Burned Out Advisor Retreat

    What it is

    Three days in person with the authors and other advisors.

    {icon("people")}369 seats

    Includes

    {inc}

    When

    {icon("calendar")}4–6 November 2026

    Price on the reward

    Academy

    AiCoach + AOP.biz BootCamp

    What it is

    A seat in the next cohort at one of three sites:

    {icon("pin")}Orlando, Florida

    {icon("pin")}North Denver, Colorado

    {icon("pin")}Villa Park, Chicagoland, Illinois

    Includes

    {inc}

    When

    {icon("calendar")}The next cohort at your chosen site

    Price on the reward

    Cloud

    Do it yourself, online

    Choose one

      {rows}

    When

      {cloud_deliv}
    ''' 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'''
    {slot("retreat-venue", "Venue photo goes here", "Landscape · at least 1360 × 765 px", "venue")}

    {TITLE.split(" — ")[0]}

    Burned Out Advisor Retreat

    {icon("calendar")}4 to 6 November 2026

    369seats
    ''', 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'
    {ICONS["pin"]}' f'
    {sub}{city}
    ') page('08b-academy-map', f'''

    AiCoach + AOP.biz BootCamp · three sites

    {MAP_SVG}{pin_html}
    ''', 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', '
    ' + ''.join( f'
    {slot(src, "Site photo goes here", "At least 800 × 600 px", "sp")}

    Site {i}

    {name}

    {icon("pin")}{place}

    ' for i, (src, name, place) in enumerate(SITE_CARDS, 1)) + '
    ', 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'' page('09b-aibook-fun-device-mockup', f'''
    {tgt(L["essay"], L["vw"], L["vh"], "essay", gap=22)}{tgt(L["workflow"], L["vw"], L["vh"], "workflow", gap=22)}
    {tgt(Ph["tryit"], Ph["vw"], Ph["vh"], "tryit", "right", 22)}{tgt(Ph["updated"], Ph["vw"], Ph["vh"], "updated", "right", 22)}
    Chapter essay Workflow Try-it (about 15 minutes) Last updated

    Screenshots of the AiBook.fun reader (sample Real Estate chapter) in device frames.

    ''', 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'
    ' f'{ICONS["unlock" if on else "lock"]}' f'
    {amt}' + ('Unlocked' if on else '') + '' f'{name}{desc}
    ') status = 'Not yet unlocked' if unlocked == 0 else ('Unlocked: ' + ', '.join(g[1] for g in GOALS[:unlocked])) return f'''

    Stretch goals

    {status}

    {ICONS["flag"]}
    Goal $5,000
    {marks}
    ''' 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'
    {i}
    {d}

    {t}

    ' for i, ((x, y), (d, t)) in enumerate(zip(pos, STOPS), 1)) page('11b-timeline', f'''
    {stops}
    ''', 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'{ICONS["arrow"]}' vis = f'' if i2.startswith('img:') else icon(i2, "ico ci") st += f'
    {vis}{t}' + (f'

    {d}

    ' if d else '') + '
    ' head = f'' if ic.startswith('img:') else icon(ic, "ico lhi") return f'
    {head}

    {title}

    {sub}

    {st}
    ' page('12b-shipping-and-delivery-lanes', '
    ' + 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')]) + '
    ', 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', '
    ' + ''.join( f'
    {slot(f"reader-{i}", "Photo", "400 × 400", "qp")}

    “{q}”

    ' f'

    {n} · {r}

    ' for i, (q, n, r) in enumerate(QUOTES, 1)) + '
    ', 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'
    {slot("team-" + (h[1:].lower() if h else ("osinachi-patrick" if n.startswith("Osinachi") else "academy-lead")), "Photo", "800 × 800", "tp")}' f'

    {n}

    ' + (f'

    {h}

    ' if h else '') + f'

    {r}

    ' 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, Fractional C-Suite'), ('Name goes here', 'Co-author, Technology & RAAI')] AUTHOR_HTML = ''.join(f'
    {slot(f"author-{i}", "Author photo goes here", "800 × 800", "ap")}

    {n}

    {r}

    ' for i, (n, r) in enumerate(AUTHORS, 1)) page('14b-authors-and-team', f'''

    The authors

    {AUTHOR_HTML}

    The team

    {team_html}
    ''', 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'''
    {slot("headshot-template", "Author photo goes here", "800 × 800 px · square", "hs")}
    ''', 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))