Self-hosted multi-platform broadcast newsroom for activist collectives: publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style column wall with hostile-content-safe rendering, live control room with Auto Director, bridged chat governance with polls and quorum, distributed sandboxed transcode workers, encrypted content-addressed archive, monthly analytics. MIT licensed. See README.md and docs/.
430 lines
22 KiB
Python
430 lines
22 KiB
Python
#!/usr/bin/env python3
|
|
"""SuperPoster User Manual PDF — with embedded screenshots from the running app."""
|
|
import os
|
|
from reportlab.lib.pagesizes import A4
|
|
from reportlab.lib.units import inch, mm
|
|
from reportlab.lib.colors import HexColor
|
|
from reportlab.lib.styles import ParagraphStyle
|
|
from reportlab.lib.enums import TA_LEFT, TA_CENTER
|
|
from reportlab.platypus import (
|
|
SimpleDocTemplate, Paragraph, Spacer, PageBreak, Image, Table, TableStyle,
|
|
HRFlowable, KeepTogether, NextPageTemplate, PageTemplate, Frame,
|
|
)
|
|
from reportlab.lib.utils import ImageReader
|
|
|
|
SHOTS = "apps/web/runs/manual/shots"
|
|
OUT = "docs/SuperPoster-User-Manual.pdf"
|
|
|
|
# Colors (match the app's dark-first identity)
|
|
BG = HexColor("#0a0a0b")
|
|
SURFACE = HexColor("#1a1a1e")
|
|
ACCENT = HexColor("#6c7cff")
|
|
TEXT = HexColor("#e0e0e6")
|
|
MUTED = HexColor("#8b8b96")
|
|
WHITE = HexColor("#ffffff")
|
|
DARK_TEXT = HexColor("#1a1a2e")
|
|
DARK_MUTED = HexColor("#555566")
|
|
BRAND_INDIGO = HexColor("#5b6cff")
|
|
BRAND_DARK = HexColor("#111118")
|
|
LIGHT_BG = HexColor("#f5f5fa")
|
|
|
|
# Styles
|
|
title_s = ParagraphStyle("title", fontName="Helvetica-Bold", fontSize=32, textColor=BRAND_INDIGO, spaceAfter=6, leading=38)
|
|
subtitle_s = ParagraphStyle("subtitle", fontName="Helvetica", fontSize=14, textColor=DARK_MUTED, spaceAfter=20)
|
|
h1_s = ParagraphStyle("h1", fontName="Helvetica-Bold", fontSize=22, textColor=BRAND_INDIGO, spaceBefore=30, spaceAfter=10, leading=26)
|
|
h2_s = ParagraphStyle("h2", fontName="Helvetica-Bold", fontSize=16, textColor=DARK_TEXT, spaceBefore=20, spaceAfter=8)
|
|
body_s = ParagraphStyle("body", fontName="Helvetica", fontSize=10.5, textColor=DARK_TEXT, leading=16, spaceAfter=8)
|
|
step_s = ParagraphStyle("step", fontName="Helvetica-Bold", fontSize=11, textColor=BRAND_INDIGO, spaceBefore=14, spaceAfter=6)
|
|
note_s = ParagraphStyle("note", fontName="Helvetica-Oblique", fontSize=9.5, textColor=DARK_MUTED, leading=14, spaceAfter=8)
|
|
caption_s = ParagraphStyle("caption", fontName="Helvetica-Oblique", fontSize=9, textColor=DARK_MUTED, spaceBefore=4, spaceAfter=12)
|
|
kbd_s = ParagraphStyle("kbd", fontName="Courier-Bold", fontSize=10, textColor=BRAND_INDIGO, backColor=LIGHT_BG, borderPadding=4, spaceAfter=8)
|
|
toc_s = ParagraphStyle("toc", fontName="Helvetica", fontSize=11, textColor=DARK_TEXT, leading=22)
|
|
|
|
def img(name, width=6.5*inch):
|
|
path = os.path.join(SHOTS, name)
|
|
if not os.path.exists(path):
|
|
return Paragraph(f"[screenshot: {name}]", note_s)
|
|
img = ImageReader(path)
|
|
iw, ih = img.getSize()
|
|
h = width * ih / iw
|
|
return Image(path, width=width, height=h)
|
|
|
|
def screenshot(name, caption):
|
|
return KeepTogether([img(name), Paragraph(caption, caption_s)])
|
|
|
|
doc = SimpleDocTemplate(OUT, pagesize=A4,
|
|
leftMargin=25*mm, rightMargin=25*mm, topMargin=20*mm, bottomMargin=20*mm,
|
|
title="SuperPoster User Manual", author="SuperPoster")
|
|
|
|
story = []
|
|
|
|
# ══════════════════════════════════════ COVER ══════════════════════════════════════
|
|
story.append(Spacer(1, 80))
|
|
story.append(Paragraph("SuperPoster", title_s))
|
|
story.append(Paragraph("User Manual", subtitle_s))
|
|
story.append(HRFlowable(width="100%", color=BRAND_INDIGO, thickness=2, spaceAfter=20))
|
|
story.append(Paragraph("""
|
|
Version 1.0 · October 2026<br/><br/>
|
|
This guide covers everything you need to get started: creating your workspace,
|
|
inviting your team, connecting social accounts, monitoring streams, composing
|
|
and scheduling posts, and using the audit trail.<br/><br/>
|
|
<b>Design identity reference:</b> Canva Visual Kit · Canva Post Kit · Canva Graphic Resources
|
|
""", body_s))
|
|
story.append(Spacer(1, 40))
|
|
story.append(screenshot("streams-01-overview.png", "SuperPoster — Streams view, the monitoring hub"))
|
|
story.append(PageBreak())
|
|
|
|
# ══════════════════════════════════════ CONTENTS ══════════════════════════════════════
|
|
story.append(Paragraph("Contents", h1_s))
|
|
story.append(HRFlowable(width="100%", color=BRAND_INDIGO, thickness=1, spaceAfter=14))
|
|
toc_items = [
|
|
("1.", "Getting Started — Onboarding"),
|
|
("2.", "Understanding Streams"),
|
|
("3.", "Composing and Scheduling Posts"),
|
|
("4.", "Managing Connected Accounts"),
|
|
("5.", "Team, Groups and Permissions"),
|
|
("6.", "Inbox — Direct Messages"),
|
|
("7.", "Audit Trail — Who Did What"),
|
|
("8.", "Keyboard Shortcuts"),
|
|
("9.", "Troubleshooting"),
|
|
]
|
|
for num, title in toc_items:
|
|
story.append(Paragraph(f"{num} {title}", toc_s))
|
|
story.append(PageBreak())
|
|
|
|
# ══════════════════════════════════════ 1. GETTING STARTED ══════════════════════════════════════
|
|
story.append(Paragraph("1. Getting Started", h1_s))
|
|
story.append(HRFlowable(width="100%", color=BRAND_INDIGO, thickness=1, spaceAfter=14))
|
|
|
|
story.append(Paragraph("Creating Your Workspace", h2_s))
|
|
story.append(Paragraph("""
|
|
The first time you use SuperPoster, you'll see the onboarding wizard. A workspace
|
|
is your team's tenant boundary — everything (accounts, drafts, posts, audit)
|
|
belongs to exactly one workspace and is isolated from all others.
|
|
""", body_s))
|
|
|
|
story.append(Paragraph("Step 1 — Name your workspace", step_s))
|
|
story.append(screenshot("onboard-01-start.png", "The onboarding wizard starts here"))
|
|
story.append(Paragraph("Type a name that identifies your team or organisation.", body_s))
|
|
story.append(screenshot("onboard-02-named.png", "Enter a descriptive name — you can change it later in Settings"))
|
|
|
|
story.append(Paragraph("Step 2 — Invite your team", step_s))
|
|
story.append(screenshot("onboard-03-invite.png", "Enter email addresses separated by commas"))
|
|
story.append(Paragraph("""
|
|
Add teammates by email. They'll receive an invitation and can join with the role
|
|
you assign them (or the default role). You can always change roles later from
|
|
the Team screen.
|
|
""", body_s))
|
|
story.append(screenshot("onboard-04-emails-filled.png", "Multiple invitations entered"))
|
|
|
|
story.append(Paragraph("Step 3 — Connect your first accounts", step_s))
|
|
story.append(screenshot("onboard-05-connect.png", "Choose platforms to connect — OAuth only, no passwords stored"))
|
|
story.append(Paragraph("""
|
|
Click <b>Connect</b> on any platform. You'll be redirected to that platform's own
|
|
OAuth page where you log in securely. SuperPoster never sees or stores your
|
|
platform password — only an encrypted access token.
|
|
""", body_s))
|
|
story.append(Paragraph("""
|
|
<b>Important:</b> SuperPoster stores OAuth tokens only. Your account password never
|
|
transits or rests in this system. You can revoke access at any time from either
|
|
SuperPoster or the platform itself.
|
|
""", note_s))
|
|
story.append(PageBreak())
|
|
|
|
# ══════════════════════════════════════ 2. STREAMS ══════════════════════════════════════
|
|
story.append(Paragraph("2. Understanding Streams", h1_s))
|
|
story.append(HRFlowable(width="100%", color=BRAND_INDIGO, thickness=1, spaceAfter=14))
|
|
|
|
story.append(Paragraph("Your Monitoring Hub", h2_s))
|
|
story.append(screenshot("streams-01-overview.png", "Streams — three columns, each tracking a different feed"))
|
|
story.append(Paragraph("""
|
|
The Streams screen is your command centre. Each column tracks a feed from a
|
|
connected platform. Cards show the author, age, text, and engagement. Hover over
|
|
any card to reveal its amplify bar.
|
|
""", body_s))
|
|
|
|
story.append(Paragraph("Selecting Items", h2_s))
|
|
story.append(screenshot("streams-02-item-selected.png", "An item is selected (highlighted in blue) — its amplify bar is now visible"))
|
|
story.append(Paragraph("""
|
|
Press <b>j</b> to move to the next item or <b>k</b> to go back. You can also click any
|
|
card. The selected card shows a blue highlight and reveals its amplify buttons.
|
|
""", body_s))
|
|
|
|
story.append(Paragraph("Amplifying — Boost, Quote and Share", h2_s))
|
|
story.append(screenshot("streams-03-repost-toast.png", "Pressing r boosts the selected item — a toast confirms the action"))
|
|
story.append(Paragraph("""
|
|
Each platform's amplify bar shows <b>only what that API truly supports</b>:
|
|
""", body_s))
|
|
story.append(Paragraph("""
|
|
<b>Mastodon:</b> Boost (native reblog) and Quote (native quote-post)<br/>
|
|
<b>Telegram:</b> Forward<br/>
|
|
<b>Bluesky:</b> Repost and Quote<br/>
|
|
<b>Instagram:</b> No native reshare — but Quote… with attribution always works<br/>
|
|
<b>Facebook:</b> Share link (approximation)<br/>
|
|
<b>Reddit:</b> Crosspost
|
|
""", body_s))
|
|
|
|
story.append(screenshot("streams-04-quote-popover.png", "Pressing q opens the quote composer"))
|
|
story.append(Paragraph("""
|
|
Type your commentary. The character limit matches the platform you're quoting
|
|
on. The original post is shown above for reference.
|
|
""", body_s))
|
|
story.append(screenshot("streams-05-quote-typed.png", "Your commentary appears above the quoted content"))
|
|
|
|
story.append(Paragraph("Quote with Attribution", h2_s))
|
|
story.append(Paragraph("""
|
|
On platforms without a native quote API (like Instagram or Facebook), SuperPoster
|
|
offers <b>Quote… with attribution</b> — this composes a new original post that
|
|
references the source with proper attribution. It's indicated by a dashed-border
|
|
button. This isn't a native API call; it's your own post with attribution text,
|
|
which every platform allows.
|
|
""", body_s))
|
|
story.append(PageBreak())
|
|
|
|
# ══════════════════════════════════════ 3. COMPOSING ══════════════════════════════════════
|
|
story.append(Paragraph("3. Composing and Scheduling Posts", h1_s))
|
|
story.append(HRFlowable(width="100%", color=BRAND_INDIGO, thickness=1, spaceAfter=14))
|
|
|
|
story.append(Paragraph("Writing a Post", h2_s))
|
|
story.append(screenshot("compose-01-blank.png", "The Compose screen — a rich editor with markdown support"))
|
|
story.append(Paragraph("""
|
|
Type your post in the rich editor. Markdown formatting is supported (bold with
|
|
**text**, headings with #, etc.). As you type, the character counter updates.
|
|
""", body_s))
|
|
|
|
story.append(screenshot("compose-02-typed.png", "A composed post — note the character counter and platform previews"))
|
|
|
|
story.append(Paragraph("Targeting Platforms", h2_s))
|
|
story.append(Paragraph("""
|
|
Click platform chips (Mastodon, Telegram, Facebook, Instagram) to toggle targets.
|
|
Each target shows its own character limit. The overall counter reflects the
|
|
<b>tightest</b> limit among your selected targets.
|
|
""", body_s))
|
|
|
|
story.append(Paragraph("The Approval Flow", h2_s))
|
|
story.append(Paragraph("""
|
|
If your workspace uses approvals, the flow is:<br/><br/>
|
|
<b>Draft</b> → Submit for Review → <b>In Review</b> → Approve/Reject → <b>Approved</b> → Schedule/Publish
|
|
""", body_s))
|
|
story.append(Paragraph("""
|
|
Every transition is permission-checked and recorded in the audit trail with a
|
|
full diff of what changed.
|
|
""", note_s))
|
|
|
|
story.append(Paragraph("Scheduling", h2_s))
|
|
story.append(Paragraph("""
|
|
Set a date and time in the Schedule field. Your post enters the Temporal-backed
|
|
scheduling pipeline — durable, idempotent, and survives restarts. If Temporal
|
|
is briefly unavailable, your scheduled post sits safely as "pending" until the
|
|
worker picks it up.
|
|
""", body_s))
|
|
story.append(PageBreak())
|
|
|
|
# ══════════════════════════════════════ 4. CONNECTIONS ══════════════════════════════════════
|
|
story.append(Paragraph("4. Managing Connected Accounts", h1_s))
|
|
story.append(HRFlowable(width="100%", color=BRAND_INDIGO, thickness=1, spaceAfter=14))
|
|
|
|
story.append(screenshot("connections-01-overview.png", "The Channels screen — each card shows platform, handle, capabilities and health"))
|
|
story.append(Paragraph("""
|
|
Every connected account appears as a card showing:
|
|
""", body_s))
|
|
story.append(Paragraph("""
|
|
<b>Platform and handle</b> — which account this is<br/>
|
|
<b>Capability chips</b> — what this platform's API actually supports<br/>
|
|
<b>Health badge</b> — healthy (green) or token expiring (amber)<br/>
|
|
<b>Capacity chip</b> — how many posts you have left in your rolling 24h window
|
|
""", body_s))
|
|
|
|
story.append(Paragraph("Post Capacity", h2_s))
|
|
story.append(Paragraph("""
|
|
Click the capacity chip (e.g., <b>17/25 posts left</b>) to see your usage bar,
|
|
when the next slot frees, and any tier notes. Provider caps are rolling 24-hour
|
|
windows — slots free up continuously as older posts age out, not at midnight.
|
|
""", body_s))
|
|
|
|
story.append(Paragraph("Connecting a New Account", h2_s))
|
|
story.append(Paragraph("""
|
|
1. Click <b>Connect account</b> (or the Connect button on a disconnected card)<br/>
|
|
2. You'll be redirected to the platform's OAuth page<br/>
|
|
3. Log in on the platform's own page and authorize SuperPoster<br/>
|
|
4. You're redirected back — the connection appears with its capabilities
|
|
""", body_s))
|
|
story.append(Paragraph("""
|
|
<b>Security guarantee:</b> OAuth tokens are envelope-encrypted in the vault.
|
|
Nobody — not even workspace Owners — can read raw tokens through any interface.
|
|
""", note_s))
|
|
story.append(PageBreak())
|
|
|
|
# ══════════════════════════════════════ 5. TEAM ══════════════════════════════════════
|
|
story.append(Paragraph("5. Team, Groups and Permissions", h1_s))
|
|
story.append(HRFlowable(width="100%", color=BRAND_INDIGO, thickness=1, spaceAfter=14))
|
|
|
|
story.append(screenshot("team-01-overview.png", "The Team screen — members, roles and the shared asset library"))
|
|
|
|
story.append(Paragraph("Roles", h2_s))
|
|
story.append(Paragraph("""
|
|
SuperPoster ships with 10 seeded roles. Each role is a bundle of permissions —
|
|
the system checks permissions, never role names, so you can customise roles
|
|
without breaking anything:
|
|
""", body_s))
|
|
|
|
role_table = Table([
|
|
["Role", "Can Do"],
|
|
["Owner", "Everything, including credential management"],
|
|
["Admin", "Everything except credential management"],
|
|
["Publisher", "Compose, schedule, publish, manage media"],
|
|
["Editor", "Compose and edit drafts, manage media"],
|
|
["Approver", "Review and approve/reject submitted drafts"],
|
|
["Analyst", "View analytics and audit trail"],
|
|
["Moderator", "Manage inbox, live chat moderation"],
|
|
["Video Publisher", "Compose, schedule and publish video content"],
|
|
["Stream Operator", "Manage livestreams"],
|
|
["Broadcast Operator", "Virtual channel control, director actions, live chat"],
|
|
], colWidths=[1.6*inch, 4.9*inch])
|
|
role_table.setStyle(TableStyle([
|
|
("FONTNAME", (0,0), (-1,0), "Helvetica-Bold"),
|
|
("FONTSIZE", (0,0), (-1,-1), 9),
|
|
("TEXTCOLOR", (0,0), (0,-1), BRAND_INDIGO),
|
|
("TEXTCOLOR", (1,0), (1,-1), DARK_TEXT),
|
|
("VALIGN", (0,0), (-1,-1), "TOP"),
|
|
("BOTTOMPADDING", (0,0), (-1,-1), 6),
|
|
("TOPPADDING", (0,0), (-1,-1), 6),
|
|
("LINEBELOW", (0,0), (-1,-2), 0.5, HexColor("#e0e0e8")),
|
|
("BACKGROUND", (0,0), (-1,0), LIGHT_BG),
|
|
]))
|
|
story.append(role_table)
|
|
|
|
story.append(Paragraph("Changing Roles and Removing Members", h2_s))
|
|
story.append(Paragraph("""
|
|
Use the role dropdown on any member row. Removing a member takes effect
|
|
immediately — their active sessions fail closed on their next request thanks
|
|
to the authorization epoch system. They are logged out instantly; no stale
|
|
access lingers.
|
|
""", body_s))
|
|
|
|
story.append(Paragraph("Groups (Coming Soon)", h2_s))
|
|
story.append(Paragraph("""
|
|
Groups let you scope which connected accounts and media assets each sub-team
|
|
can use. For example: the "Breaking News" group gets access to the main
|
|
Mastodon and Bluesky accounts, while the "Lifestyle" group only sees the
|
|
Instagram and Facebook pages. A member's publish targets filter automatically
|
|
to show only the accounts their groups permit.
|
|
""", body_s))
|
|
|
|
story.append(Paragraph("Asset Library", h2_s))
|
|
story.append(Paragraph("""
|
|
The shared asset library holds your team's images, videos and documents.
|
|
Assets can be scoped to groups (shown with a lock icon) — members outside the
|
|
group see the lock but cannot access the file.
|
|
""", body_s))
|
|
story.append(PageBreak())
|
|
|
|
# ══════════════════════════════════════ 6. INBOX ══════════════════════════════════════
|
|
story.append(Paragraph("6. Inbox — Direct Messages", h1_s))
|
|
story.append(HRFlowable(width="100%", color=BRAND_INDIGO, thickness=1, spaceAfter=14))
|
|
|
|
story.append(screenshot("inbox-01-overview.png", "The Inbox — unified conversations across all connected accounts"))
|
|
story.append(Paragraph("""
|
|
All direct-message conversations from your connected accounts appear in one list.
|
|
Unread badges show new messages; assignee initials show who's handling each thread.
|
|
""", body_s))
|
|
story.append(Paragraph("""
|
|
<b>Reply windows:</b> Messaging APIs allow replies within each platform's window
|
|
(typically 24 hours for Meta platforms). Outside the window, only pre-approved
|
|
template messages are allowed. SuperPoster shows you exactly what's possible
|
|
rather than failing silently.
|
|
""", note_s))
|
|
story.append(PageBreak())
|
|
|
|
# ══════════════════════════════════════ 7. AUDIT ══════════════════════════════════════
|
|
story.append(Paragraph("7. Audit Trail — Who Did What", h1_s))
|
|
story.append(HRFlowable(width="100%", color=BRAND_INDIGO, thickness=1, spaceAfter=14))
|
|
|
|
story.append(screenshot("audit-01-overview.png", "The Audit Trail — filterable, hash-chained, tamper-evident"))
|
|
story.append(Paragraph("""
|
|
Every meaningful action is recorded: posts published, roles changed, credentials
|
|
rotated, connections added. The chain is <b>hash-linked</b> — each entry's hash
|
|
incorporates the previous entry's hash, so any tampering is mathematically
|
|
detectable.
|
|
""", body_s))
|
|
|
|
story.append(screenshot("audit-02-diff-open.png", "Click any row to see the exact field-by-field diff"))
|
|
story.append(Paragraph("""
|
|
The diff viewer shows <b>before → after</b> values with red/green highlighting.
|
|
Secret values always appear as <b>[REDACTED]</b> — they are stripped before the
|
|
audit entry is created, not hidden by the UI. This is enforced at the database
|
|
function level.
|
|
""", body_s))
|
|
story.append(Paragraph("""
|
|
Use the filter bar to narrow by actor or action. Export to CSV for compliance
|
|
reporting.
|
|
""", body_s))
|
|
story.append(PageBreak())
|
|
|
|
# ══════════════════════════════════════ 8. SHORTCUTS ══════════════════════════════════════
|
|
story.append(Paragraph("8. Keyboard Shortcuts", h1_s))
|
|
story.append(HRFlowable(width="100%", color=BRAND_INDIGO, thickness=1, spaceAfter=14))
|
|
|
|
keys = Table([
|
|
["Shortcut", "Action", "Where"],
|
|
["j / ↓", "Next item", "Streams"],
|
|
["k / ↑", "Previous item", "Streams"],
|
|
["r", "Repost/boost selected item", "Streams"],
|
|
["q", "Quote selected item", "Streams"],
|
|
["Esc", "Close popover/dialog", "Everywhere"],
|
|
["⌘K / Ctrl+K", "Command palette", "Everywhere"],
|
|
], colWidths=[1.4*inch, 3.3*inch, 1.8*inch])
|
|
keys.setStyle(TableStyle([
|
|
("FONTNAME", (0,0), (-1,0), "Helvetica-Bold"),
|
|
("FONTSIZE", (0,0), (-1,-1), 10),
|
|
("FONTNAME", (0,1), (0,-1), "Courier-Bold"),
|
|
("TEXTCOLOR", (0,1), (0,-1), BRAND_INDIGO),
|
|
("TEXTCOLOR", (1,0), (1,-1), DARK_TEXT),
|
|
("VALIGN", (0,0), (-1,-1), "TOP"),
|
|
("BOTTOMPADDING", (0,0), (-1,-1), 8),
|
|
("TOPPADDING", (0,0), (-1,-1), 8),
|
|
("LINEBELOW", (0,0), (-1,-2), 0.5, HexColor("#e0e0e8")),
|
|
("BACKGROUND", (0,0), (-1,0), LIGHT_BG),
|
|
]))
|
|
story.append(keys)
|
|
story.append(Spacer(1, 20))
|
|
story.append(Paragraph("""
|
|
The command palette (⌘K) lets you jump to any screen, compose a new post, or
|
|
trigger actions without touching the mouse.
|
|
""", body_s))
|
|
story.append(PageBreak())
|
|
|
|
# ══════════════════════════════════════ 9. TROUBLESHOOTING ══════════════════════════════════════
|
|
story.append(Paragraph("9. Troubleshooting", h1_s))
|
|
story.append(HRFlowable(width="100%", color=BRAND_INDIGO, thickness=1, spaceAfter=14))
|
|
|
|
faq = Table([
|
|
["Question", "Answer"],
|
|
["Why can't I boost on some columns?", "That platform's API doesn't expose resharing. The button is simply not there — we never fake capabilities. Use Quote… with attribution instead."],
|
|
["Why did everyone get logged out?", "A role or membership change bumped the workspace's authorization epoch. Stale sessions fail closed immediately. One re-login fixes it."],
|
|
["Where are my passwords?", "Nowhere. SuperPoster stores OAuth tokens only, envelope-encrypted in the vault. Revoke access from the platform or in SuperPoster at any time."],
|
|
["Who can see my stream keys?", "Nobody through any interface. Keys are redacted from every screen, log, API response, and audit entry by database-level enforcement."],
|
|
["Why is my capacity at 0?", "You've hit your API tier's rolling 24h cap. The capacity chip shows when the next slot frees — as your oldest post exits the window."],
|
|
["Posts stuck as 'pending'?", "The scheduler worker picks them up automatically. Check that the Temporal service is running (your admin can verify via /readyz)."],
|
|
], colWidths=[1.8*inch, 4.7*inch])
|
|
faq.setStyle(TableStyle([
|
|
("FONTNAME", (0,0), (-1,0), "Helvetica-Bold"),
|
|
("FONTSIZE", (0,0), (-1,-1), 9.5),
|
|
("TEXTCOLOR", (0,1), (0,-1), BRAND_INDIGO),
|
|
("TEXTCOLOR", (1,1), (1,-1), DARK_TEXT),
|
|
("VALIGN", (0,0), (-1,-1), "TOP"),
|
|
("BOTTOMPADDING", (0,0), (-1,-1), 10),
|
|
("TOPPADDING", (0,0), (-1,-1), 10),
|
|
("LINEBELOW", (0,0), (-1,-2), 0.5, HexColor("#e0e0e8")),
|
|
("BACKGROUND", (0,0), (-1,0), LIGHT_BG),
|
|
]))
|
|
story.append(faq)
|
|
|
|
story.append(Spacer(1, 30))
|
|
story.append(HRFlowable(width="100%", color=BRAND_INDIGO, thickness=1, spaceAfter=10))
|
|
story.append(Paragraph("SuperPoster · Version 1.0 · October 2026", ParagraphStyle(
|
|
"footer", fontName="Helvetica", fontSize=9, textColor=DARK_MUTED, alignment=TA_CENTER
|
|
)))
|
|
|
|
doc.build(story)
|
|
print(f"PDF: {OUT} ({os.path.getsize(OUT):,} bytes)")
|