← Corpus / astro-knots / blueprint
Maintain an Interactive Polling System
Implementation blueprint for embeddable, theme-aware interactive polls on Astro SSG sites — covering Session/Poll data model, Astro DB on Turso storage, DB→markdown materialization for archive, live-meeting authoring motions, and Svelte + GSAP UI contracts. v0.0.1 ships four templates and one live session.
- Path
- blueprints/Maintain-an-Interactive-Polling-System--v2.md
- Authors
- Michael Staton
- Augmented with
- Oz on auto
- Tags
- Polling · Interactive · Live-Meetings · Astro-Islands · Svelte · GSAP · Realtime · OAuth · Astro-DB · Turso · Content-Materialization
Blueprint for Developing and Maintaining Interactive Polling Systems
This blueprint codifies the contract for an interactive polling system that can be dropped into any Astro-Knots site without the site having to invent its own data model, real-time layer, or authoring motions. Components, tokens, and runtime utilities are stable across sites; only the wiring and the content change.
A poll is not just a UI widget. It is a small content type with a lifecycle, an integrity contract (one vote per identified user), a real-time presentation surface (the host’s screen during a live meeting), and an archive afterlife (the durable URL where the meeting’s poll results live forever as content). All four concerns have to be designed together or the system collapses into per-site one-offs.
This v2 reorganizes the v1 draft so the v0.0.1 working spec sits at the top, deferred-but-spec’d content sits in the middle, and wish-list / open-question content sits at the bottom.
TOP — v0.0.1 Essentials (the working spec for tomorrow)
1. Goals
- Embeddable anywhere. A poll renders the same way on a marketing page, an article, a member dashboard, and a live-meeting projection surface.
- Stack discipline. Astro SSG by default; Svelte islands for interactivity; GSAP for transitions. No new framework dependency introduced by polling.
- Identity-bound voting. Every vote is attributed to an authenticated user (OAuth) and resolved against a person record. Anonymous polls are a display choice, not a data choice (anonymous voting is parked — see §21.1).
- Live → Archive content materialization. Live polls run on Astro DB (SSR). Once the session concludes, data is materialized into markdown content collections (SSG). One pattern, two phases.
- Real-time without WebSockets unless required. Default to short-interval HTTP polling (3–5s). SSE / WebSockets are deferred (§17).
- Theme & mode aware. Poll components consume
--color-*and--fx-*tokens (see Maintain Themes & Modes Across CSS and Tailwind) and render correctly in light / dark / vibrant. - Verifiable. Every shipping template has an integration test covering open/close transitions, vote idempotency, and result reconciliation.
2. This Run of the Blueprint
We have developed a new website called FullStack VC and we want to introduce interactive polling. The FullStack VC community of venture professionals organizes around live web meetings, and we want to enable interactive polling during these meetings.
Our religious preference on our “stack” is defaulting to Astro SSG, but we use Svelte where it makes sense for interactivity and animations. On several projects, we have introduced GSAP to add sophisticated animations and transitions. So, there is no going outside of those boundaries.
2.1 Pre-development requirements (recap)
This blueprint requires:
- OAuth-based user authentication. Authentication is set up through a homegrown, lean OAuth implementation, so authenticating or creating an account should be seamless and quick — friction-free enough that we don’t need an anonymous-voting escape hatch in v0.0.1 (see §21.1 for the parked discussion).
- User profile or people-data and collection management.
For FullStack VC we have both, though our user-profile system is nascent and may need an upgrade to a true database-backed user-profile system instead of using markdown and JSON files committed to the repository (see §3.1).
2.2 Why polling specifically, and why now
Live web meetings of venture professionals have a known failure mode: the panel speaks, the audience listens, and engagement falls off after ~12 minutes. Interactive polls re-anchor attention every 8–15 minutes and produce durable artifacts (results, quotes, follow-up questions) that can be re-published as content after the meeting. The meeting becomes a content engine, not a one-time event.
2.3 v0.0.1 Scope (Locked) — what ships tomorrow
The temptation is to commit to every named template and every motion at once. We’re not doing that. v0.0.1 ships exactly the minimum to support tomorrow’s live meeting:
- One live
Sessionofkind: 'live', containing 1–3 polls. - Four templates only:
PollQuestionTemplate__Boolean.svelte(§7.1)PollQuestionTemplate__SingleSelect.svelte(§7.2)PollQuestionTemplate__MultiSelect.svelte(§7.3)PollQuestionTemplate__SlidingScale.svelte(§7.4)
- Astro DB on Turso for live writes (§8).
- Manual sync command only for materialization (
pnpm sync:session <id>); no auto-trigger, no cron (§9.4). - Tier 1 interval polling for real-time updates; no SSE, no WebSockets (§10.1).
- No admin panel. Hosts open polls by direct DB action (seed file or simple CLI). Manual close button is deferred.
- The wow factor: participants see real-time data viz develop on screen as the room votes. That is the centerpiece of v0.0.1.
2.4 v0.0.2+ Scope (deferred)
Promoted out of v0.0.1, but already spec’d in this document so engineering can build toward them:
kind: 'time-bound'sessions (async distribution via email / WhatsApp / Twitter / LinkedIn).- Auto-trigger sync on grace-period expiry (§9.5).
- Manual close button on live sessions.
- Real admin/host console (§19).
- Additional templates per §16.
- Higher-tier real-time mechanics per §17.
Why this scope size is right: the riskiest work is not the question UI. It’s the lifecycle, OAuth-bound vote integrity, the data materialization motion, and the real-time results loop. Those have to land regardless of how many templates ship. Four templates, one session, one manual sync command lets us prove that backbone with a real meeting.
3. Pre-Development Requirements
Do not start building polls until these are in place. Each one is a hard dependency.
- OAuth identity. A reliable session with a stable, opaque user ID. Anything less and “one vote per user” is unenforceable. The user ID is the primary key for every vote row.
- Person/profile resolution. A way to map a user ID to a person record (name, avatar, role, optional org). This will necessitate user profiles of some limited type to be created on the live production databse. Polls reference people, not raw OAuth tokens, in any presenter-facing UI.
- A meeting / session entity. Even a thin one —
slug,starts_at,host_user_id— is enough to gate session activation. (After this blueprint,Sessionis that entity; see §5.1.) - A results endpoint pattern. The site must already serve at least one JSON endpoint from an Astro server route. For FullStack VC and any future Astro-Knots site, this is satisfied by Astro DB on Turso (§8.2); polling is the forcing function that lands this stack if it isn’t already in the site.
- Theme & mode contract.
data-theme/data-modeon<html>and the--color-*/--fx-*token system are present. Poll UI is built against those tokens, not bespoke CSS.
If any of these are missing, the right move is to land them as separate milestones first. Polling is the wrong place to also debut authentication.
3.1 The User Profile Upgrade Question
Flat-file user profiles (markdown/JSON committed to the repo) work for editorial people pages. They do not work for vote attribution, because:
- New attendees can’t vote until a maintainer commits a profile and redeploys.
- Vote rows can’t reference a user who doesn’t yet exist in the repo.
- Polls become eventually-consistent in the worst possible way: results change after a redeploy.
The upgrade path: OAuth-issued user IDs are the source of truth; flat-file profiles become an enrichment layer keyed by user ID. A user can vote the moment they sign in; their profile is filled in lazily. Polling is the forcing function for this upgrade on most sites.
4. Architecture & Stack Boundaries
The stack is fixed. The boundaries below are not preferences, they are the contract.
4.1 Render boundary (SSG + SSR split)
- Astro SSG renders the static shell of any page that contains a poll: heading, prose, surrounding content. The page may server-render an initial poll snapshot via
astro:dbat request time when SSR is enabled for that route. - Svelte island (
<PollEmbed client:load />orclient:visible) renders the poll itself. The island is the only piece that hydrates. - GSAP handles transitions inside the island. GSAP is never loaded on pages without an active poll island.
- SSR is opt-in per route. Marketing pages, articles, and archived sessions stay SSG. Live session pages (
/sessions/[slug]), host console (deferred), and the JSON API routes are SSR. See §8.1 for the rationale.
4.2 Data boundary
- Read path (live session, SSR): the page renders with an initial snapshot from
astro:db; the Svelte island fetches/api/polls/[id]/results.jsonon an interval for live updates. - Read path (archived session, SSG): the page renders entirely from markdown frontmatter; no client requests required.
- Write path: the island POSTs to
/api/polls/[id]/voteswith the OAuth session cookie. The server validates identity, idempotency, and poll state.
4.3 What does NOT belong in the polling system
[!NOTE] These blueprints are not yet codified, some don’t exist. This is only to declare the scope of this discrete task we want to build now.
- Authentication (lives in the auth blueprint).
- Person/profile editing (lives in the people-data blueprint).
- Meeting/event scheduling beyond
Session(lives in the events blueprint). - Notifications/email (lives in the comms blueprint).
Polling consumes these, it does not own them.
4.4 Canonical URL routes
- Live (SSR):
/sessions/[slug]— the session and its polls during the live meeting. - Archive (SSG):
/sessions/archive/[slug]— the materialized markdown archive. - The live URL 301s to the archive URL once the session is
archived. - Pre-event marketing landing pages stay at
/webinars/[slug](different concern; out of scope here). For v0.0.1, the existing/webinarsroute is migrated to/sessions(same code, just a path rename).
5. Data Model
Five entities. Session is the outer container; Poll lives inside a session.
5.1 Session (outer entity)
id— opaque ID.slug— URL-safe identifier (used in/sessions/[slug]).title,description— editorial metadata.kind—'live'|'time-bound'.'live': opened by admin, runs during a meeting, auto-archives after the session-level grace period (§9.3). v0.0.1 ships this only.'time-bound': requiresstarts_atandends_at; auto-archives onceends_atis reached. v0.0.2+.
status—'draft'|'active'|'archived'.starts_at,ends_at— ISO timestamps. Optional for'live'; required for'time-bound'.last_activity_at— bumped on every vote across any poll in the session. Drives the grace-period auto-trigger.host_user_id— the admin who opened the session.created_at,updated_at.
One Session → Many Polls. Typical N is 2–3; no hard cap.
5.2 Poll
id— slug-ish, stable across the lifecycle.session_id— required FK toSession.id.title,prompt— the question shown to voters.template— see §7 ('boolean'|'single-select'|'multi-select'|'sliding-scale'for v0.0.1).options[]— for choice-based templates; each option hasid,label, optionaldescription, optionalimage.status—'draft'|'scheduled'|'open'|'closed'. (No standalone'archived'— polls are archived as part of their parent session.)visibility—'public'|'members'|'session-attendees'.results_visibility—'live'|'on-close'|'host-only'.anonymous_display— boolean. Vote is still attributed in storage; only the UI is anonymous.last_vote_at— bumped on every vote. Drives the poll-level grace period.allow_revote— boolean. Whether voters can change their answer while the poll isopen.created_by,created_at,updated_at.
5.3 Vote
poll_id,user_id— together form a unique constraint enforcing one-vote-per-user (§13.1).option_ids[]— single-element forSingleSelect; multi-element forMultiSelect; ordered forRankedOrder(v1.1+).value— scalar response for templates that produce a single primitive (Boolean,SlidingScale; later:TextBox,StarRating,NPS,DatePick).response— generic typed-JSON column for templates whose answer doesn’t fitoption_idsorvalue(matrix and area-board templates, v1.1+). EachPollQuestionTemplate__*component defines its own response shape (§7); the server validates against the active template’s schema before write.created_at,updated_at—updated_atpermits vote changes while the poll is open ifallow_revoteis true.client_meta— coarse, optional:user_agent_class,is_presenter_view. No IP, no precise UA strings, no fingerprinting.
5.4 PollResult (derived, never authoritative)
A cached projection of votes for fast reads:
poll_id,tallies(option_id → count),total_votes,last_aggregated_at.- Recomputed on vote write or on a short cadence; never written to from the client.
5.5 PollEvent (audit)
poll_id,actor_user_id,kind('open'|'close'|'extend'|'reset'|'delete'),at,note.
Required (not optional). The moment a host makes a mistake during a live meeting, you need this to tell whether a vote count spike was a bug or a re-open. Without it, host actions are unauditable.
6. Lifecycle States
The system has two layers of lifecycle: Session (outer) and Poll (inner, within an active session).
6.1 Session lifecycle
draft ──▶ active ──▶ archived
│
└─ triggered by manual sync (v0.0.1) OR
grace-period expiry (v0.0.2+);
materializes session+polls to markdown,
flips Poll rows to read-only.
- draft — admin is authoring. Invisible to participants.
- active — admin has opened the session. Polls within it can be opened/closed by the admin. Participants see the session at
/sessions/[slug]. - archived — sync has run. Markdown lives at
src/content/sessions/<slug>.md. The DB row stays for retention; the markdown is canonical for the rendered archive.
6.2 Poll lifecycle (within an active session)
draft ──▶ scheduled ──▶ open ──▶ closed
▲ │
└──┐ ▼
extend (host can extend before grace expires)
- draft — editable, invisible to non-authors. No votes accepted.
- scheduled — visible as a teaser within the active session; voting disabled.
- open — accepting votes. Results visibility honors
results_visibility. - closed — no new votes. Reveal animation runs if
results_visibility = 'on-close'.
In v0.0.1, hosts move polls from scheduled → open by direct DB action (seed file edit or short CLI). Auto-close on poll-level grace expiry is v0.0.2+.
6.3 The transition that materializes (active → archived)
This is the phase change at the heart of the system. When a session moves to archived:
- Final aggregation runs on every poll in the session.
- A single markdown file is written to
src/content/sessions/<slug>.mdcontaining all polls’ final tallies as frontmatter (§9.6). Session.statusflips to'archived'.- Live URL
/sessions/[slug]301s to/sessions/archive/[slug]. - DB rows remain for the retention window; the markdown is canonical (§9.8).
In v0.0.1 this is triggered manually (§9.4). In v0.0.2+, the grace-period constants in §9.3 fire it automatically.
7. Poll Question Templates (v0.0.1 — four templates only)
Every question type is implemented as a dedicated Svelte component named PollQuestionTemplate__<Type>.svelte. The orchestrator component, <PollEmbed />, looks up the active poll’s template and renders the matching component.
Each template owns three things:
- Authoring schema — what an author fills in.
- Vote response schema — the exact shape stored on
Vote(see §5.3). - Display / aggregation rule — how the data is rendered live, on close, and in the archive.
Shared option type:
interface PollOption { id: string; label: string; description?: string; image?: string; }
Deferred templates (TextBox, MultiStringInput, MatrixSingleSelect, MatrixMultiSelect, AreaBoardOptionDrop, RankedOrder, StarRating, NPS, EmojiReaction, ImagePick, TwoAxisPlot, DatePick, etc.) are spec’d in §16 of the deferred section.
7.1 PollQuestionTemplate__Boolean.svelte
Yes/No or True/False. Common as quick gut-checks (“Is this team raising in the next 6 months?”).
// Authoring
{ template: 'boolean'; prompt: string; labels?: { true: string; false: string } } // defaults: Yes / No
// Vote response
{ value: boolean }
Display: two-bar tally with percent each side; optional “consensus” badge when one side exceeds 80%.
7.2 PollQuestionTemplate__SingleSelect.svelte
Radio-style. Exactly one option.
// Authoring
{ template: 'single-select'; prompt: string; options: PollOption[] }
// Vote response
{ option_ids: [string] } // length-1 tuple
Display: bar chart sorted by tally; winner gets the --fx-glow-* reveal on close (§12.4).
7.3 PollQuestionTemplate__MultiSelect.svelte
Checkbox-style. Voter picks 1+ options, with optional bounds.
// Authoring
{
template: 'multi-select';
prompt: string;
options: PollOption[];
min_selections?: number; // default 1
max_selections?: number; // default options.length
}
// Vote response
{ option_ids: string[] }
Display: horizontal stacked bars per option. Denominator is respondents, not total selections.
7.4 PollQuestionTemplate__SlidingScale.svelte
Numeric slider with bounds and step. The de-facto “how strongly” / “what’s your estimate” template.
// Authoring
{
template: 'sliding-scale';
prompt: string;
min: number;
max: number;
step?: number; // default 1
default_value?: number;
labels?: { min?: string; mid?: string; max?: string };
show_distribution?: boolean; // live histogram while open
}
// Vote response
{ value: number }
Display: histogram with median line and IQR bracket. In presenter mode, voter dots animate up into their bucket.
7.5 Cross-Template Contract
Every PollQuestionTemplate__*.svelte (shipping or deferred) MUST:
- Render the same six visual states as
<PollEmbed />(§12.2): loading, unauthenticated, open-unvoted, open-voted, closed, errored. - Read its colors and effects from the token contract in §12.3 — no template introduces hardcoded hex.
- Respect
prefers-reduced-motion(§12.4) — every GSAP timeline collapses to instant. - Provide a Vitest case in §14.1 covering authoring validation, vote payload validation, and a recompute-equals-sum check on its aggregator.
- Appear at least once on
/design-system/index.astroso theme/mode regressions are visible (Design System blueprint §4).
This is the contract that makes “add a new template later” actually safe.
8. Storage Layer (Astro DB on Turso)
Polling is the forcing function for adopting a real database on Astro-Knots sites that have, until now, run as pure SSG. Live polls write to Astro DB; archived sessions live in markdown content collections (§9). Both feed the same Svelte component.
8.1 SSG vs SSR — why polling forces a database
The Astro-Knots default has been SSG: pages rendered as HTML/CSS at build, hydrated lightly on the client. This works beautifully for marketing pages, articles, brand kits, design systems, and people pages whose data updates on editorial cadence.
Where it breaks: surfaces that need to write user-generated data and reflect those writes back to other users in near-real time. The recent attempt to keep “everything-as-SSG” via GitHub-App-driven commits on every data change confirmed that the hack works but is slow, fragile around concurrent edits, and produces commit-history pollution that’s painful to live with. Polling is the cleanest “no, you actually need a database” forcing function we’ll meet. This blueprint locks in the architectural split:
- SSG remains the default for marketing, articles, archived sessions, brand kits, design systems, and editorial-cadence content.
- SSR + database for any surface that needs sub-build-cycle freshness: live sessions, live polls, host console, projection page.
- No more GitHub-App write hacks for time-sensitive data. If a feature wants near-real-time writes, it goes in the DB.
8.2 Database choice: Astro DB on Turso (Locked)
Decision: Astro DB running against Turso in production, with a local libSQL file in development.
Why:
- Astro-native.
astro:dbexports a typeddbclient; tables live indb/config.ts; seed data indb/seed.ts. Astro DB wraps Drizzle internally; from the application’s perspective there is no separate ORM to install or configure. - libSQL = SQLite-compatible. Local development is a real database file in
.astro/content.db, regenerated fromdb/seed.tson every dev-server restart. Production is Turso (managed libSQL with edge replication). - Turso is already paid for. We have an underutilized Turso account; per-poll write volume is tiny.
- One choice for the whole monorepo. Future Astro-Knots sites that hit the SSG-can’t-do-this wall inherit this stack by default.
What we’re not doing: Postgres, D1, self-hosted SQLite. All viable in isolation; none pay back the cost of diverging from the Astro DB default.
8.3 Schema definition (db/config.ts)
// db/config.ts
import { defineDb, defineTable, column } from 'astro:db';
const Session = defineTable({
columns: {
id: column.text({ primaryKey: true }),
slug: column.text({ unique: true }),
title: column.text(),
description: column.text({ optional: true }),
kind: column.text(), // 'live' | 'time-bound'
status: column.text(), // 'draft' | 'active' | 'archived'
starts_at: column.date({ optional: true }),
ends_at: column.date({ optional: true }),
last_activity_at: column.date({ optional: true }),
host_user_id: column.text(),
created_at: column.date(),
updated_at: column.date(),
},
});
const Poll = defineTable({
columns: {
id: column.text({ primaryKey: true }),
session_id: column.text({ references: () => Session.columns.id }),
title: column.text(),
prompt: column.text(),
template: column.text(), // 'boolean' | 'single-select' | 'multi-select' | 'sliding-scale'
options: column.json({ optional: true }), // PollOption[]
status: column.text(), // 'draft' | 'scheduled' | 'open' | 'closed'
visibility: column.text(),
results_visibility: column.text(),
anonymous_display: column.boolean(),
allow_revote: column.boolean(),
last_vote_at: column.date({ optional: true }),
created_by: column.text(),
created_at: column.date(),
updated_at: column.date(),
},
});
const Vote = defineTable({
columns: {
poll_id: column.text({ references: () => Poll.columns.id }),
user_id: column.text(),
option_ids: column.json({ optional: true }), // string[]
value: column.json({ optional: true }), // primitive (boolean | number | string)
response: column.json({ optional: true }), // typed JSON for matrix / board templates (v1.1+)
created_at: column.date(),
updated_at: column.date(),
client_meta: column.json({ optional: true }),
},
indexes: {
poll_user_unique: { on: ['poll_id', 'user_id'], unique: true }, // §13.1 integrity contract
},
});
const PollResult = defineTable({
columns: {
poll_id: column.text({ primaryKey: true, references: () => Poll.columns.id }),
tallies: column.json(),
total_votes: column.number(),
last_aggregated_at: column.date(),
},
});
const PollEvent = defineTable({
columns: {
id: column.number({ primaryKey: true }),
poll_id: column.text({ references: () => Poll.columns.id }),
actor_user_id: column.text(),
kind: column.text(), // 'open' | 'close' | 'extend' | 'reset' | 'delete'
at: column.date(),
note: column.text({ optional: true }),
},
});
export default defineDb({ tables: { Session, Poll, Vote, PollResult, PollEvent } });
Vote uses a unique index on (poll_id, user_id) rather than a composite primary key because Astro DB’s primaryKey option is per-column today; the unique index enforces the same integrity contract from §13.1.
8.4 Local dev + production wiring
End-to-end setup:
pnpx astro add db(one-time per site).- Define tables in
db/config.ts(§8.3). - Add development seed data in
db/seed.tsso the local DB is populated on every dev-server start (include aSessionrow for tomorrow’s meeting and 3–4Pollrows attached to it). - Production (Turso):
turso db create fullstack-vcturso db show fullstack-vc→ copy URL intoASTRO_DB_REMOTE_URL.turso db tokens create fullstack-vc→ copy intoASTRO_DB_APP_TOKEN.- Add both env vars to the deployment platform.
astro db push --remoteto push the schema to Turso.- In
package.json, set"build": "astro build --remote".
Local dev is fully offline by default. Use astro dev --remote only when explicitly testing against production data.
8.5 Server-side queries via astro:db
// src/pages/api/polls/[id]/results.json.ts
import { db, PollResult, eq } from 'astro:db';
export async function GET({ params }) {
const result = await db
.select()
.from(PollResult)
.where(eq(PollResult.poll_id, params.id))
.get();
return new Response(JSON.stringify(result), {
headers: { 'content-type': 'application/json' },
});
}
8.6 Svelte islands access pattern (important nuance)
Svelte islands hydrate in the browser. They cannot import astro:db — that’s a server-only module. The integration pattern:
- Svelte → fetch → Astro API route →
astro:db. Islands call/api/polls/[id]/votes(POST) and/api/polls/[id]/results.json(GET). The API routes do all the DB work. - Initial state via SSR. Astro pages server-render
<PollEmbed pollId="..." initialState={...} />by fetching the snapshot fromastro:dbin the page frontmatter and passing it as a prop. The island hydrates with no first-paint flash and falls back to interval polling (§10.1) for live updates.
This architecture is cleaner than letting Svelte components query a DB directly:
- Auth, rate-limiting, and validation live in one place.
- Same API surface serves SSR (initial state) and live updates.
- Replacing the storage layer later doesn’t touch component code.
9. DB → Markdown Materialization (Live → Archive Sync)
This is the other half of the SSG/SSR split. Live data lives in Astro DB; once a session concludes, it’s projected into markdown content collections, where it becomes versionable, queryable-by-Content-Collections, editable by humans (and AI agents), and durable beyond the database’s lifetime.
This isn’t polling-specific. The same motion will likely apply to live people-data updates, live commentary, live Q&A, and any other surface where “real-time during the moment, content forever after.” For now we keep it inline; if it earns a second use-case, extract to its own blueprint.
9.1 The pattern
Phase 1 (Live, SSR): Session is active. Polls are open. Votes hit Astro DB. Results stream back to viewers via interval polling.
Phase 2 (Materialization): Session moves to archived. The materialization motion writes a single markdown file at src/content/sessions/<slug>.md containing all the session’s polls and final tallies as frontmatter. The DB row stays for retention; the markdown is canonical for the archive.
Phase 3 (Archive, SSG): /sessions/archive/<slug> renders entirely from markdown. No client requests, no DB hits, full SSG benefits. Editorial / AI agents enrich the body of the markdown with discussion summary, video embed, etc. (§18).
9.2 Sync direction is one-way
DB → markdown only. Never the reverse. After a session is materialized:
- The markdown is canonical for the rendered archive.
- The DB row stays for a configurable retention window.
- Marketing / content teams may edit the markdown body or fix small frontmatter issues — those edits are the new truth, not divergence to be reconciled.
- Single-source-of-truth is relaxed here on purpose. The cost of bidirectional sync isn’t worth its weight, and the polling feature is new enough that content teams will need to tweak rendered data for legitimate marketing reasons.
9.3 Grace-period constants (configurable)
Two named constants drive auto-archival in v0.0.2+. Declared at the top of src/config/polling.ts (or equivalent) so any admin can find and adjust them empirically:
// src/config/polling.ts
export const POLL_GRACE_MINUTES = 15;
export const SESSION_GRACE_MINUTES = 45;
POLL_GRACE_MINUTES = 15— a single poll concludes 15 minutes after itslast_vote_at.SESSION_GRACE_MINUTES = 45— a session concludes 45 minutes after itslast_activity_at(last vote across any poll within it).
The session-level grace must be longer than the poll-level grace because a typical live web meeting opens one poll early and another at the end; the gap between can be 30–40 minutes of discussion with no polling activity. 45 minutes is the safe ceiling.
These constants exist because humans are busy, distracted, and forgetful. The admin can run a manual sync to push results out faster (catching the post-meeting attention window), but the system shouldn’t depend on them remembering.
In v0.0.1, the constants are advisory only — they’re declared and used by the manual sync command for messaging (“This session has been quiet for 47 minutes; safe to sync”), but no auto-trigger fires. v0.0.2+ adds the cron.
9.4 v0.0.1: Manual sync command
The v0.0.1 implementation is a single CLI command:
pnpm sync:session <session_id_or_slug>
What it does:
- Verify session status is
active. Ifarchived, fail loudly (re-sync forbidden by default;--forceopt-in only). - For every poll in the session, run final aggregation; recompute
PollResultrows. - Write
src/content/sessions/<slug>.mdwith frontmatter per §9.6. - Flip
Session.statustoarchivedin the DB. - Print a one-line summary of what was archived and the local file path.
The next deploy materializes /sessions/archive/<slug> as SSG.
9.5 v0.0.2+: Auto-trigger
A small scheduled job (Astro scheduled action or external cron) runs every N minutes:
- Find sessions in
activestatus whoselast_activity_atexceedsSESSION_GRACE_MINUTES. - For each, run the same sync logic as the manual command.
The manual command remains as the override for “I want results out now.”
9.6 Markdown structure (frontmatter sync-owned, body human-owned)
This is the most important contract in §9.
src/content/sessions/<slug>.md:
---
session_id: sess_2026-04-29-fullstack-vc-q2
slug: 2026-04-29-fullstack-vc-q2
title: "FullStack VC — Q2 2026 Operator Roundtable"
kind: live
host_user_id: usr_michael
session_started_at: 2026-04-29T18:00:00Z
session_concluded_at: 2026-04-29T19:14:00Z
synced_from_db_at: 2026-04-29T19:30:00Z
participant_count: 47
polls:
- id: poll_q2-comfort
template: sliding-scale
prompt: "How comfortable is your fund's pacing for Q2?"
final_tally:
total_votes: 47
median: 6
iqr: [4, 8]
histogram: { 1: 1, 2: 0, 3: 2, 4: 5, 5: 9, 6: 12, 7: 8, 8: 6, 9: 3, 10: 1 }
audit:
opened_at: 2026-04-29T18:04:00Z
closed_at: 2026-04-29T18:09:00Z
events: []
- id: poll_q2-channels
template: multi-select
prompt: "Which channels did your portfolio companies double down on?"
options:
- { id: linkedin, label: "LinkedIn" }
- { id: events, label: "Events" }
- { id: pr, label: "PR" }
- { id: content, label: "Content marketing" }
final_tally:
total_respondents: 44
tallies: { linkedin: 31, events: 22, pr: 9, content: 27 }
---
<!-- Body is human-owned. Sync never touches anything below the closing --- -->
## Discussion summary
(filled in by an editor or AI agent processing the meeting transcript)
## Notable quotes
## Recording
The contract:
- The sync writes everything above the closing
---(frontmatter only). - The body remains untouched on re-syncs (when re-sync is allowed at all).
- An editor or AI agent can modify the body freely — the sync respects it.
9.7 What renders the archive page
src/pages/sessions/archive/[slug].astro is SSG, sources the markdown via Astro Content Collections, and renders:
- The session metadata (title, date, participant count) as a header.
- Each poll’s final tally via
<PollEmbed pollId={poll.id} initialState={poll.final_tally} variant="archive" />. Thearchivevariant skips API fetches and renders directly frominitialState. - The body Markdown as the discussion summary section.
The same <PollEmbed /> component renders both live (SSR + interval polling) and archive (SSG + static initial state). The only difference is the variant prop.
9.8 DB cleanup after sync
Once a session is archived, its data exists in two places (markdown + DB) for a retention window. v0.0.1 doesn’t prune at all — we keep everything. v0.0.2+ adds optional pruning:
Voterows: prune after 90 days. Cheap to keep; markdown has the aggregate.PollResult,PollEvent: prune withVote.Poll,Session: keep indefinitely as stubs.
9.9 Re-sync semantics
Default: re-syncing an already-archived session fails loudly. Snapshots are final. --force is allowed but only touches frontmatter — body preserved verbatim. Re-sync is an edge case we’re not designing around for v0.0.1; if it happens, treat it as a bug to investigate before doing it again.
10. Real-Time Update Mechanics (v0.0.1 = Tier 1 only)
10.1 Tier 1 — Interval polling (the default, ships in v0.0.1)
- Client
GET /api/polls/[id]/results.jsonevery 3–5s while the poll island is in the viewport and the poll status isopen. ETag/Last-Modifiedso the server can 304 most responses.- Pause when tab is hidden (
document.visibilityState). - Stop entirely when the poll closes; one final fetch confirms the closed snapshot.
Sufficient for ≤500 concurrent viewers and a 3-second perceived latency budget. For tomorrow’s 60-participant meeting this is more than enough.
10.2 Optimistic UI
On vote submission, the island updates the local tally immediately and animates with GSAP. The next results tick reconciles. If the server rejects (auth lapse, poll closed, rate limit), the island rolls back the optimistic update and surfaces the error inline.
Higher-tier mechanics (SSE, WebSockets) are deferred — see §17.
11. Authoring & Live-Meeting Motions
The motions are how the system stays alive.
11.1 v0.0.1 (tomorrow): the simplest path
- Author the session. For tomorrow, this is a hand-edited row in
db/seed.ts(kind: ‘live’, status: ‘draft’). Three poll rows attached. - Open the session. Admin runs a one-line CLI (or directly updates
Session.status = 'active'in the DB) when the meeting starts. This makes/sessions/<slug>accessible to participants. - Open polls. Admin flips
Poll.status = 'open'for each poll as the meeting reaches it. Direct DB action; no admin UI. - Participants vote. Live data viz develops on screen — the wow factor.
- Close polls. Admin flips
Poll.status = 'closed'when ready. Reveal animation runs. - End meeting, run sync. Admin runs
pnpm sync:session <slug>. Session archives; markdown lands; next deploy publishes/sessions/archive/<slug>. - Editorial enrichment (later, separate workflow) — body of the markdown gets discussion summary, video embed, etc. (§18).
11.2 v0.0.2+: the productionized path
Replace direct DB actions with a real admin/host console at /admin/sessions/[slug] and /admin/polls/[id]. Add scheduled sync trigger. Add kind: 'time-bound' sessions for async distribution.
12. UI Component Contract
One Svelte component family, one CSS contract. Every site uses the same.
12.1 <PollEmbed /> props
interface Props {
pollId: string; // resolves both the metadata fetch and the results fetch
variant?: 'inline' | 'card' | 'present' | 'archive'; // present = projection mode; archive = SSG render
initialState?: PollSnapshot; // optional SSR/SSG snapshot to avoid first-paint flash
resultsVisibility?: 'live' | 'on-close' | 'host-only';
onVote?: (payload: VotePayload) => void; // optional analytics hook
}
12.2 Required visual states
- Loading — skeleton matching final layout; no layout shift on hydrate.
- Unauthenticated — sign-in CTA inside the island. Never redirect away from the page mid-read.
- Open, unvoted — interactive controls.
- Open, voted — confirmation + tally (if
live) or “results at close” message. - Closed — final tally, optional winner highlight.
- Errored — inline message; retry button; never a silent failure.
12.3 CSS contract
The component reads only from semantic tokens. Required:
--color-primary,--color-primary-500,--color-secondary,--color-surface,--color-border,--color-foreground.--fx-card-bg,--fx-card-border,--fx-card-shadow,--fx-card-shadow-hover.--fx-glow-opacity,--fx-glow-spreadfor the “winning option” reveal in vibrant mode.- Tailwind utilities only via these tokens — no hardcoded hex.
12.4 GSAP usage rules
- Tally bars animate width with
gsap.to(...)over 400–600mspower2.out. - Vote-cast confirmation: 250ms scale pulse on the chosen option, then a small flourish.
- Reveal-on-close: stagger bars by 80ms with
power3.out; the leading option gets--fx-glow-*for 1.2s. - Presenter mode amplifies durations by ~1.5×.
- Respect
prefers-reduced-motion: collapse all of the above to instant transitions.
12.5 Theme/Mode integration
The component must render correctly in light, dark, and vibrant. The Brand Kit and Design System pages (see Maintain Design System and Brand Kit Motions §3 and §4) MUST include a live <PollEmbed variant="card" /> example so regressions are visible.
13. Vote Integrity, Privacy & Anti-Abuse
13.1 Integrity contract
- One vote per
(poll_id, user_id)for choice polls, enforced by the unique index in §8.3. - Vote changes allowed only while
status = openand only ifpoll.allow_revoteis true; updatesVote.option_ids/valueand bumpsupdated_at. - Server is authoritative about poll state. The client may believe a poll is open and submit a vote; the server rejects with
409 poll_closedif it isn’t. - Rate limit writes per user per poll (e.g., 10/min) to absorb double-clicks and small abuse.
13.2 Privacy posture
anonymous_display = truehides the voter list in every UI, including host console. Storage still attributes; this is non-negotiable for integrity.- Do not log IPs against votes. Do not store precise user agents. The
client_metafield in §5.3 is intentionally coarse.
13.3 Host abuse surface
Hosts can reset a poll. This is logged to PollEvent and visible in the archive page. If a host resets a closed poll, the archive shows both runs; we never silently overwrite history.
14. Verification
14.1 Unit / integration (Vitest)
Required coverage for v0.0.1:
- Lifecycle transitions — every legal Session and Poll transition from §6, every illegal transition rejected.
- One-vote-per-user — second vote without
allow_revoteis409; withallow_revoteupdates the row. - Type-specific payload validation —
single-selectrejects multiple option IDs;sliding-scalerejects out-of-range;multi-selecthonors min/max bounds;booleanaccepts only true/false. - Anonymity —
anonymous_display = truestrips voter identities from the API response shape. - Results aggregation — recompute equals sum of votes; cache invalidation on write.
- Materialization —
pnpm sync:sessionproduces a markdown file matching the §9.6 schema, and re-running the command on an already-archived session fails loudly. - SSR safety — component imports do not crash without
window.
14.2 End-to-end (Playwright)
One scripted live-session rehearsal:
- Author signs in, seeds a session and three polls.
- Two voter sessions join
/sessions/[slug]; both vote on each poll. - Host closes polls one at a time; reveal animation plays on both voter clients.
- Admin runs
pnpm sync:session <slug>. - Archive page renders the snapshot at the next build.
This is the canary. Run it on PRs that touch any polling code.
15. v0.0.1 Porting Checklist
When adding interactive polling to a new Astro-Knots site (or first-running it on FullStack VC):
- OAuth identity is live and a stable
user_idis exposed to server routes (§3). - User-profile resolution exists, even if minimal (§3.1).
-
data-theme/data-modeand the--color-*/--fx-*token system are in place (theme blueprint §2, §9). - Astro DB installed (
npx astro add db); tables defined indb/config.tsper §8.3. - Turso production database provisioned;
ASTRO_DB_REMOTE_URLandASTRO_DB_APP_TOKENset on the deployment platform;astro build --remoteconfigured inpackage.json(§8.4). -
db/seed.tspopulates a usable local development dataset (one Session, three Polls). - Implement
/api/polls/[id]/results.json(GET) and/api/polls/[id]/votes(POST) usingastro:db(§8.5). - Confirm Svelte islands fetch via API routes (never import
astro:dbdirectly) (§8.6). - Implement
pnpm sync:session <slug>CLI script per §9.4. -
src/config/polling.tsdeclaresPOLL_GRACE_MINUTESandSESSION_GRACE_MINUTES(§9.3). - Migrate existing
/webinarsroute to/sessions/[slug](live, SSR) and add/sessions/archive/[slug](SSG). - Drop in
<PollEmbed />Svelte component family with the four v0.0.1 templates; verify the required tokens render correctly in all three modes (§12.3). - Add a
<PollEmbed variant="card" />example to the site’s/design-system/index.astro(Design System blueprint §4). - Wire the Vitest harness (§14.1); the Playwright run (§14.2).
- Document the local env vars and the Turso CLI bootstrap commands in the site’s
README.md. - Confirm
prefers-reduced-motioncollapses all GSAP animations (§12.4). - Confirm
anonymous_displaypolls strip identities from every endpoint, including the host’s (§13.2).
MIDDLE — deferred but spec’d (v0.0.2 / v1.1+)
16. Deferred Poll Question Templates
These are spec’d here so engineering knows the contract and can build toward them. None ship in v0.0.1. Promote one out of this section when (a) the v0.0.1 four are stable through at least two live meetings, and (b) there’s a concrete authored poll waiting for the new template.
16.1 PollQuestionTemplate__TextBox.svelte
Free-text input. Two sub-modes selected by max_length:
- Short mode (≤24 chars): aggregates into a sized word cloud or top-N bar.
- Long mode: each submission is a card; presenter view paginates.
// Authoring
{
template: 'text-box';
prompt: string;
max_length?: number; // default 24 (short mode threshold)
min_length?: number;
placeholder?: string;
moderate?: 'auto' | 'host' | 'off';
}
// Vote response
{ value: string }
16.2 PollQuestionTemplate__MultiStringInput.svelte
A list-builder. The voter sees a single short input row, types a phrase, presses Enter or “Add”, and it slots into their growing list as a chip. They can remove entries to revise, and submit when done. Empty submissions are valid — “I have nothing to share right now” is real data.
Distinct from TextBox (§16.1): TextBox encourages one paragraph-shaped blob that has to be parsed afterwards; MultiStringInput encourages many short atomic phrases. Each phrase becomes a separate analyzable unit on the data side, and the act of “press Enter, see it slotted in, hit again” provokes the voter to keep mining their memory for one more entry. The right shape for prompts like “What are some hard-won wins with technology tools you’d be willing to share?” or “What are some challenges you’ve been facing where someone else has clearly already figured this out?” — wins and pains are plural by nature; one-blob inputs lose them in commas and run-on sentences.
// Authoring
{
template: 'multi-string-input';
prompt: string;
placeholder?: string; // shown in the empty input row
max_string_length?: number; // per entry; default 200
max_strings_per_voter?: number; // soft cap shown in UI; server enforces if set
moderate?: 'auto' | 'host' | 'off'; // matches TextBox §16.1
}
// Vote response (uses Vote.response, see §5.3)
{ values: string[] } // empty array is valid; trims whitespace; dedupes within a single voter
Realtime visibility model
Unlike the choice and scale templates, this template’s content is sensitive during a live meeting. Voters share half-formed wins, current pain points, even quiet admissions — content they may not want surfaced unless someone asks them to. So the realtime contract is split between counts (public) and content (host-controlled):
- Counts are public and update live:
total_stringsandtotal_contributors(e.g., “47 entries from 22 contributors”). These two scalars are what/api/polls/[id]/results.jsonreturns to non-host clients while the poll isopen. - Content is host-only by default during the live phase. The host sees the growing list (de-duplicated across voters; each entry tagged with voter ID unless
anonymous_display = true), so they can spot a gem and invite the voter to elaborate, lead a working group, or pair with someone struggling with the same thing. - Reveal is governed by
results_visibility(existing field, §5.2), interpreted for this template as:'live'— entries appear publicly as they’re added. Use sparingly; high social-pressure surface; appropriate for icebreakers and group brainstorms, not for “what are you struggling with?” prompts.'on-close'— content private to host during live; full list revealed when the poll closes. Recommended default for wins / challenges prompts.'host-only'— content stays private to the host even after close. For when the host plans to follow up offline and never publish the list (sensitive operational concerns, LP friction, etc.).
Display
- Voter view (open, not yet submitted):
- One-line input + “Add” button. Pressing Enter equals click.
- Below the input, the voter’s current list as removable chips.
- “Submit” button locks the list as their vote. Re-submission allowed only if
allow_revote = true; button label reads “Update list” once a prior submission exists.
- Voter view (open, post-submit, results=live):
- Voter’s own list shown as theirs; other voters’ entries shown anonymized (unless
anonymous_display = false) and de-duplicated bylower(trim())matching with a small ”× N” badge for multi-said phrases.
- Voter’s own list shown as theirs; other voters’ entries shown anonymized (unless
- Voter view (open, post-submit, results=on-close):
- Voter’s own list still visible to them. Aggregate = “47 entries from 22 contributors” only. No content from others.
- Host view (open):
- Full content stream, attribution per
anonymous_display. Optional “spotlight” hook (out of scope v0.0.1; reserve the prop) for promoting an entry to a presenter view.
- Full content stream, attribution per
- Closed:
- All entries displayed per
results_visibility. Light clustering UI (group near-duplicates) is presentation only — the underlying response array stays un-merged on the Vote row.
- All entries displayed per
Aggregation rule
Two scalars derived live; full list materializes on close.
total_strings= sum oflen(vote.response.values)across all votes for the poll.total_contributors= count of distinctvote.user_idwherelen(values) > 0. (Voters who submitted an empty list count towardtotal_votesfor completeness reporting but not towardtotal_contributors.)- On close: dedupe across voters by
lower(trim()); preserve original casing from first occurrence; keep counts per dedupe key (so the archive can render “5 people said ‘shipping faster’”).
These two scalars are what update on the Tier 1 polling cadence (§10.1) — the body of /api/polls/[id]/results.json stays small even when the actual content grows large.
Why this template is its own thing
It would be tempting to fold this into TextBox (§16.1) with a multi: true flag. Resist that. The privacy contract here (counts public, content host-controlled) is materially different from TextBox’s “every submission is a card.” Conflating them produces a template with two incompatible UI shapes and a confusing authoring schema. Keep them as siblings.
16.3 PollQuestionTemplate__MatrixSingleSelect.svelte
Rows × columns; each row gets exactly one column selected. Likert / rating grid.
// Authoring
{
template: 'matrix-single-select';
prompt: string;
rows: { id: string; label: string }[];
columns: { id: string; label: string }[];
require_all_rows?: boolean; // default true
}
// Vote response (uses Vote.response)
{ matrix: Record<string, string> } // row_id → column_id
16.4 PollQuestionTemplate__MatrixMultiSelect.svelte
Rows × columns; each row may have multiple columns selected.
// Authoring
{
template: 'matrix-multi-select';
prompt: string;
rows: { id: string; label: string }[];
columns: { id: string; label: string }[];
min_per_row?: number;
max_per_row?: number;
}
// Vote response
{ matrix: Record<string, string[]> } // row_id → column_ids
16.5 PollQuestionTemplate__AreaBoardOptionDrop.svelte
A chip tray + matrix grid. Voter drags option chips into cells of a labeled grid. The grid’s cells are deterministically generated from the cross-product of two ordered axis scales.
Canonical use case (skills self-assessment):
- Option chips:
Keyboard Shortcuts & Bindings,Terminal Commands & Bash,Extended Markdown Flavors,Data Analysis with Code,Data Visualizations with Code,Video Production & Editing. - Y-axis (proficiency):
Lagging, Undeveloped→Comfortable, Proficient→Ninja, Advanced. - X-axis (sentiment):
Drudgework, Avoid→Fine, Routine→Love it, Want More.
Each chip ends up in one of the nine cells, encoding both how good they are and how they feel about it in a single drop.
// Authoring
{
template: 'area-board-option-drop';
prompt: string;
options: PollOption[];
axes: {
x: { label: string; scale: string[] }; // ordered left → right
y: { label: string; scale: string[] }; // ordered bottom → top
};
require_all_options_placed?: boolean; // default true
cell_capacity?: number;
allow_revisit?: boolean; // default true while open
}
// Vote response (uses Vote.response)
{
placements: Record<string, { x: number; y: number }>;
// option_id → indices into axes.x.scale and axes.y.scale
}
Display:
- Per-respondent view: grid with the voter’s chips in their cells.
- Aggregate view: heatmap with count badges and top-K most-frequently-placed options per cell.
- Optional drilldown: click an option to highlight its distribution across the grid.
Touch / drag-and-drop UX needs its own design pass before this ships.
16.6 Recommended additions (high-confidence, post-v0.0.1)
These surface within the first ~3 meetings of running polls and resist being faked with the four shipping templates.
16.6.1 PollQuestionTemplate__RankedOrder.svelte
Drag-to-rank list.
{ template: 'ranked-order'; prompt: string; options: PollOption[]; max_rank?: number }
// Vote: { ranking: string[] }
Aggregation: Borda count or median rank.
16.6.2 PollQuestionTemplate__StarRating.svelte
1–N stars (typically 1–5).
{ template: 'star-rating'; prompt: string; max: number; allow_half?: boolean }
// Vote: { value: number }
16.6.3 PollQuestionTemplate__NPS.svelte
0–10 with auto-bucketing (Detractors 0–6 / Passives 7–8 / Promoters 9–10).
{ template: 'nps'; prompt: string }
// Vote: { value: number }
16.6.4 PollQuestionTemplate__EmojiReaction.svelte
Single-tap emoji bar — live-meeting “heartbeat.”
{ template: 'emoji-reaction'; prompt: string; options: PollOption[] }
// Vote: { option_ids: [string] }
16.6.5 PollQuestionTemplate__ImagePick.svelte
Visual single-select where each option is an image.
{ template: 'image-pick'; prompt: string; options: PollOption[] }
// Vote: { option_ids: [string] }
16.6.6 PollQuestionTemplate__TwoAxisPlot.svelte
Voter places themselves at one point on a labeled 2D plane.
{
template: 'two-axis-plot';
prompt: string;
axes: {
x: { label: string; min_label: string; max_label: string };
y: { label: string; min_label: string; max_label: string };
};
}
// Vote: { value: { x: number; y: number } } // each in 0..1
16.6.7 PollQuestionTemplate__DatePick.svelte
Pick a date or time slot from a constrained range.
{ template: 'date-pick'; prompt: string; min?: string; max?: string; granularity?: 'day' | 'hour' | 'slot'; slots?: string[] }
// Vote: { value: string } // ISO 8601
16.7 Open for discussion (schema not locked)
PollQuestionTemplate__PinDropOnImage.svelte— click to drop a pin on an image; heatmap of click locations.PollQuestionTemplate__CardSort.svelte— sort N cards into M buckets. Adjacent to MatrixMultiSelect / AreaBoardOptionDrop.PollQuestionTemplate__TierList.svelte— explicit S/A/B/C/D lanes. Now clearly a 1-axis specialization ofAreaBoardOptionDrop.PollQuestionTemplate__BudgetAllocate.svelte— distribute a fixed total across N options.PollQuestionTemplate__ConjointPair.svelte— repeated A-vs-B preference comparisons.
17. Higher-Tier Real-Time Mechanics (deferred)
17.1 Tier 2 — Server-Sent Events (SSE)
Reach for SSE when:
- Concurrent viewers exceed ~500, or
- Perceived latency must be sub-second (presenter projection during a high-stakes panel).
One GET /api/polls/[id]/stream endpoint, server pushes result events on each aggregation tick. Same JSON shape. Falls back to interval polling on connection error.
17.2 Tier 3 — WebSockets
Only when bidirectional low-latency is required (e.g., presenter pushing reveal cues to all clients). Overkill for most use cases.
18. Editorial Enrichment After Archive
The body of src/content/sessions/<slug>.md is human-owned. The sync never touches it. Typical enrichments after a session archives:
- Discussion summary — narrative recap of what was discussed alongside each poll’s result. Likely produced by an AI agent processing the meeting transcript.
- Notable quotes — pull quotes from participants.
- Video embed — recording link or embed code.
- Follow-up links — articles the meeting prompted, related sessions, etc.
This work is tracked separately from polling. The polling system’s responsibility ends at the frontmatter; everything below the closing --- is editorial / content-team / AI-agent territory.
19. Why Not a SaaS Polling Tool (Slido, Mentimeter, Polly)?
We tried. The honest tradeoff:
- What SaaS gives you: zero implementation, presenter-tested UI, instant visual polish, no infra.
- What it costs: a third-party brand sits inside your meeting; results live outside your content system; no theme/mode parity (your dark vibrant brand renders as their default light); attendees authenticate twice; archive URLs are theirs, not yours; the panel’s content engine ends at the meeting boundary.
- What an in-site polling system gives you: results are first-class content with your URLs; voters use the same OAuth session they already have; the projection screen renders in your brand; results re-publish as articles trivially via the materialization motion (§9); AI assistants can author, modify, and archive sessions in the same change as the surrounding content.
- What you give up: the SaaS’s polish on day one, and a few features (live Q&A, multi-room moderation) that polling-as-content doesn’t try to solve.
For venture-meeting-scale events with our content motion, the in-site system wins. If a site ever needs >2,000 concurrent voters or live multi-room moderation, revisit.
BOTTOM — wish list, edge cases, references
20. Next-Step Considerations
Reasonable next iterations of the system, post-v0.0.1:
- Real admin/host console at
/admin/sessions/[slug]and/admin/polls/[id]— replaces the direct-DB authoring path in v0.0.1. - Scheduled materialization cron (§9.5) — autopilot for grace-period sync.
kind: 'time-bound'sessions — async distribution via email, WhatsApp, Twitter, LinkedIn.- Cross-session narratives — bind multiple sessions into a series so cumulative polls render as a coherent results page.
- Presenter cues — a small DSL (
@cue open poll-3,@cue reveal poll-3) embedded in meeting agendas so the host console can advance the lifecycle in lockstep with the agenda. - Result embedding API —
<poll-result id="..." />web component that lets editorial articles inline a final tally at build time. - Accessibility audit — keyboard-only ranking for
RankedOrderis the hardest interaction; commission a focused a11y pass before promoting that template to GA.
21. Future Ideas & Wish List
Ideas raised during design that are deliberately not in scope for v0.0.1 or any near-term iteration. Parked here so they aren’t lost. Promote an item out when it earns a real user need + an owner.
21.1 Anonymous (unauthenticated) voting
Open question: should we allow voters who haven’t signed in via OAuth, to maximize participation on public-facing polls?
Deferred. The homegrown OAuth flow is fast enough that v0.0.1 doesn’t need an anonymous escape hatch, and admitting unauthenticated voters introduces a hard contradiction with the integrity contract in §13.1. When we revisit, the design space:
- (a) Browser-cookie identity — issue an opaque cookie ID; treat as
user_id. Cheap; trivially defeated. OK for low-stakes engagement polls. - (b) Magic-link ephemeral identity — voter enters an email, gets a one-time link that mints a temporary identity. Friction sits between cookie and OAuth.
- (c) Per-meeting access code — host hands out a short code; the code mints a per-meeting identity. Best for member meetings.
- (d) Accept ballot stuffing and document it — explicitly mark the poll as “directional, not authoritative.” Cheapest. Honest.
When this comes back: pick one (or a per-poll selector), add Poll.identity_mode, update §13.1.
21.2 Other parked ideas
- Cross-meeting longitudinal polling. Same prompt asked across N sessions; results display as a time series. Requires a
SessionSeriesentity aboveSession. - Voter-submitted options. Author seeds N options; voters can append their own (subject to moderation). Materially changes the data model and moderation UX.
- Multi-step / branched polls. Voter’s answer to Q1 routes them to Q2a vs. Q2b. Solves “surveys” — re-implementing Typeform. Probably belongs in a separate
Maintain-an-Embedded-Survey-System.mdblueprint. - Live commentary alongside results. A small chat or reaction stream pinned next to the projection view.
- Predictive market mode. Voters stake “points” on outcomes; results weighted by stake and resolved against ground truth later.
- Export to slide. A session’s archive page renders as an embeddable slide for the Maintain Embeddable Slides system.
- Generalize content materialization. §9 is a polling-shaped instance of a general pattern. If we use it twice (e.g., for live people-data, live Q&A), extract to its own blueprint:
Maintain-DB-to-Content-Materialization.md. Don’t pre-extract.
22. References
- Maintain Themes & Modes Across CSS and Tailwind — the token system every poll component consumes.
- Maintain Design System and Brand Kit Motions — the Design System index must include a live
<PollEmbed />example so theme/mode regressions are visible. - Maintain Embeddable Slides — sister blueprint for live-meeting content; sessions and slides often share a meeting and a presenter URL pattern.
- Astro DB documentation — the storage layer for live polling.
- Reference implementation (target):
sites/fullstack-vc/— first site to land the polling system end-to-end. - External prior art studied (and rejected as primary tooling): Slido, Mentimeter, Polly. Useful for UX inspiration; not used in production.