T-HUB/scripts/build-manual.py
glass beadian aa5e262f3a Tactical Media Broadcast Hub — initial release
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/.
2026-10-10 11:56:42 +00:00

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}&nbsp;&nbsp;{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)")