#!/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

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.

Design identity reference: 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 Connect 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(""" Important: 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 j to move to the next item or k 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 only what that API truly supports: """, body_s)) story.append(Paragraph(""" Mastodon: Boost (native reblog) and Quote (native quote-post)
Telegram: Forward
Bluesky: Repost and Quote
Instagram: No native reshare — but Quote… with attribution always works
Facebook: Share link (approximation)
Reddit: 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 Quote… with attribution — 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 tightest 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:

Draft → Submit for Review → In Review → Approve/Reject → Approved → 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(""" Platform and handle — which account this is
Capability chips — what this platform's API actually supports
Health badge — healthy (green) or token expiring (amber)
Capacity chip — 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., 17/25 posts left) 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 Connect account (or the Connect button on a disconnected card)
2. You'll be redirected to the platform's OAuth page
3. Log in on the platform's own page and authorize SuperPoster
4. You're redirected back — the connection appears with its capabilities """, body_s)) story.append(Paragraph(""" Security guarantee: 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(""" Reply windows: 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 hash-linked — 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 before → after values with red/green highlighting. Secret values always appear as [REDACTED] — 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)")