live-quiz 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,256 @@
1
+ # live-quiz
2
+
3
+ Add live audience quizzes to your [Reveal.js](https://revealjs.com) presentations. Powered by [AnyCable](https://anycable.io).
4
+
5
+ ## What You Get
6
+
7
+ You build a Reveal.js deck with quiz slides, deploy it to the web, and present it. When you land on a quiz slide, your audience sees a QR code, scans it on their phones, and votes — results animate on your slides in real time.
8
+
9
+ - **Multiple-choice questions** with up to 4 options
10
+ - **QR code** auto-generated on each quiz slide so the audience can join instantly
11
+ - **Live bar chart** that updates as votes come in (sub-second via WebSockets)
12
+ - **Participant counter** showing how many people are connected
13
+ - **Mobile-friendly voting page** — no app install, just a browser
14
+ - **Theming** — inherits your Reveal.js theme's fonts and colors automatically
15
+
16
+ ## How It Works
17
+
18
+ Your presentation needs to be **deployed to the web** (not just opened locally) because the audience connects to it from their phones. The setup has three parts:
19
+
20
+ 1. **AnyCable** — a managed WebSocket service that relays votes between the audience and your slides. The free tier supports up to **2,000 concurrent connections**, which is plenty for conference talks and meetups.
21
+
22
+ 2. **Your presentation** — a static site (HTML + JS) deployed to **Netlify** or **Vercel**. The plugin adds quiz UI to your slides automatically.
23
+
24
+ 3. **Serverless functions** — 3 small files (~60 lines total) that run on Netlify or Vercel. They receive votes from the audience and broadcast results via AnyCable. Secrets stay in environment variables, never in your code.
25
+
26
+ ```
27
+ Presenter's slides AnyCable Audience phones
28
+ │ │ │
29
+ │ show quiz slide │ │
30
+ ├──── broadcast state ─────►│───── push state ──────►│
31
+ │ │ │
32
+ │ │◄──── submit vote ──────┤
33
+ │◄── broadcast results ─────┤ (serverless fn) │
34
+ │ update bar chart │ │
35
+ ```
36
+
37
+ ## Getting Started
38
+
39
+ There are two ways to set up: the **interactive CLI** (recommended) or **manual setup**.
40
+
41
+ Both follow the same steps:
42
+
43
+ 1. Create a free AnyCable Plus app (provides the WebSocket infrastructure)
44
+ 2. Scaffold a Reveal.js project with quiz slides
45
+ 3. Deploy to Netlify or Vercel
46
+
47
+ ### Option A: Interactive CLI (recommended)
48
+
49
+ One command that walks you through everything — creates your AnyCable app, scaffolds the project, and optionally deploys it:
50
+
51
+ ```bash
52
+ npx create-live-quiz
53
+ ```
54
+
55
+ The CLI will:
56
+ 1. Open [plus.anycable.io](https://plus.anycable.io) and guide you through creating an AnyCable app
57
+ 2. Ask for your **WebSocket URL** and **Broadcast URL** (the two values AnyCable gives you)
58
+ 3. Scaffold a complete project with quiz slides, audience page, and serverless functions
59
+ 4. Install dependencies and initialize git
60
+ 5. Deploy via Netlify/Vercel CLI (if installed) or show manual deploy instructions
61
+
62
+ ### Option B: Add to an existing Reveal.js presentation
63
+
64
+ If you already have a Reveal.js deck, you can add live quizzes to it manually.
65
+
66
+ #### 1. Create an AnyCable Plus app
67
+
68
+ 1. Sign in at [plus.anycable.io](https://plus.anycable.io) with GitHub
69
+ 2. Click **New Cable**, name it anything, pick **JavaScript** as your backend
70
+ 3. On the Application secret screen, **clear the secret** (empty the input) — this enables public streams mode
71
+ 4. After deploy, copy the **WebSocket URL** and **Broadcast URL**
72
+
73
+ #### 2. Install the plugin
74
+
75
+ ```bash
76
+ npm install live-quiz
77
+ ```
78
+
79
+ #### 3. Wire up the plugin
80
+
81
+ Add two imports and the `liveQuiz` config to your existing `Reveal.initialize()` call:
82
+
83
+ ```js
84
+ import RevealLiveQuiz from 'live-quiz';
85
+ import 'live-quiz/style.css';
86
+
87
+ // In your existing Reveal.initialize() call, add:
88
+ Reveal.initialize({
89
+ plugins: [RevealLiveQuiz], // add to your plugins array
90
+ liveQuiz: {
91
+ wsUrl: 'wss://your-cable.anycable.io/cable', // ← from step 1
92
+ quizGroupId: 'my-talk',
93
+ quizUrl: `${window.location.origin}/quiz.html`,
94
+ },
95
+ // ...your existing config
96
+ });
97
+ ```
98
+
99
+ `quizUrl` resolves dynamically — it will point to the right domain wherever you deploy.
100
+
101
+ #### 4. Add quiz slides
102
+
103
+ Add data attributes to your slides — the plugin injects all the UI automatically:
104
+
105
+ ```html
106
+ <!-- Quiz question slide -->
107
+ <section data-quiz-id="q1"
108
+ data-quiz-question="Where are you joining from?"
109
+ data-quiz-options='[
110
+ {"label":"A","text":"San Francisco"},
111
+ {"label":"B","text":"New York"},
112
+ {"label":"C","text":"Europe"},
113
+ {"label":"D","text":"Elsewhere"}
114
+ ]'>
115
+ </section>
116
+
117
+ <!-- Results slide (references the quiz above) -->
118
+ <section data-quiz-results="q1"
119
+ data-quiz-question="Where are you joining from?"
120
+ data-quiz-options='[
121
+ {"label":"A","text":"San Francisco"},
122
+ {"label":"B","text":"New York"},
123
+ {"label":"C","text":"Europe"},
124
+ {"label":"D","text":"Elsewhere"}
125
+ ]'>
126
+ </section>
127
+ ```
128
+
129
+ #### 5. Create the audience page
130
+
131
+ The audience needs a separate page to vote from their phones. Create a `quiz.html` and a script that mounts the participant widget:
132
+
133
+ ```js
134
+ import { createParticipantUI } from 'live-quiz/participant';
135
+ import 'live-quiz/participant.css';
136
+
137
+ createParticipantUI('#quiz-root', {
138
+ wsUrl: 'wss://your-cable.anycable.io/cable',
139
+ quizGroupId: 'my-talk',
140
+ questions: [
141
+ {
142
+ quizId: 'q1',
143
+ question: 'Where are you joining from?',
144
+ options: [
145
+ { label: 'A', text: 'San Francisco' },
146
+ { label: 'B', text: 'New York' },
147
+ { label: 'C', text: 'Europe' },
148
+ { label: 'D', text: 'Elsewhere' },
149
+ ]
150
+ }
151
+ ]
152
+ });
153
+ ```
154
+
155
+ #### 6. Add serverless functions and deploy
156
+
157
+ Your presentation must be deployed — the audience needs to reach it from their phones.
158
+
159
+ Copy the serverless functions from `functions/netlify/` or `functions/vercel/` into your project and set one environment variable:
160
+
161
+ | Variable | Description |
162
+ |---|---|
163
+ | `ANYCABLE_BROADCAST_URL` | Broadcast URL from step 1 |
164
+
165
+ See [functions/README.md](./functions/README.md) for step-by-step deploy instructions for each platform.
166
+
167
+ ## AnyCable Plus
168
+
169
+ This plugin uses [AnyCable Plus](https://plus.anycable.io) — a managed WebSocket service. The free tier includes:
170
+
171
+ - Up to **2,000 concurrent connections**
172
+ - Public streams mode (no backend auth needed)
173
+ - WebSocket + HTTP broadcast endpoints
174
+
175
+ ### A note on public streams
176
+
177
+ By default, the plugin uses **public streams** — WebSocket messages are not authenticated. This means anyone who knows the channel name could technically observe or interact with the quiz data. For most use cases (conference talks, meetups, workshops) this is perfectly fine — quiz votes aren't sensitive.
178
+
179
+ If your votes are confidential or you need to restrict who can participate, see [Appendix: Authorized Streams](#appendix-authorized-streams).
180
+
181
+ ## Configuration
182
+
183
+ ### Plugin Options (`liveQuiz`)
184
+
185
+ | Option | Type | Required | Description |
186
+ |---|---|---|---|
187
+ | `wsUrl` | `string` | Yes | AnyCable WebSocket URL |
188
+ | `quizGroupId` | `string` | Yes | Unique ID grouping quizzes in this talk |
189
+ | `quizUrl` | `string` | No | Audience page URL (shown as QR code) |
190
+ | `endpoints` | `object` | No | Custom endpoint paths (default: `/.netlify/functions/*`) |
191
+
192
+ ### Custom Endpoints
193
+
194
+ For Vercel, override the default Netlify paths:
195
+
196
+ ```js
197
+ liveQuiz: {
198
+ endpoints: {
199
+ answer: '/api/quiz-answer',
200
+ sync: '/api/quiz-sync',
201
+ }
202
+ }
203
+ ```
204
+
205
+ ## Theming
206
+
207
+ The plugin inherits your Reveal.js theme's fonts and colors via CSS custom properties. Override `--lq-*` variables to fine-tune:
208
+
209
+ ```css
210
+ :root {
211
+ --lq-accent: #e11d48;
212
+ --lq-text-muted: #a1a1aa;
213
+ --lq-font: "Inter", sans-serif;
214
+ --lq-mono: "JetBrains Mono", monospace;
215
+ --lq-bar-fill: #52525b;
216
+ --lq-bar-correct: #22c55e;
217
+ --lq-bar-track: rgba(255, 255, 255, 0.1);
218
+ --lq-border-radius: 0.25rem;
219
+ }
220
+ ```
221
+
222
+ Participant widget uses `--lq-p-*` variables — see `participant/participant.css` for the full list.
223
+
224
+ ## Data Attributes Reference
225
+
226
+ ### Question Slide
227
+
228
+ | Attribute | Description |
229
+ |---|---|
230
+ | `data-quiz-id` | Unique quiz identifier |
231
+ | `data-quiz-question` | Question text |
232
+ | `data-quiz-options` | JSON array of `{label, text, correct?}` |
233
+
234
+ ### Results Slide
235
+
236
+ | Attribute | Description |
237
+ |---|---|
238
+ | `data-quiz-results` | Quiz ID to show results for |
239
+ | `data-quiz-question` | Question text (shown as title) |
240
+ | `data-quiz-options` | JSON array of `{label, text, correct?}` |
241
+
242
+ ## Limitations
243
+
244
+ - **Multiple choice only** — up to 4 options per question. No free text, ratings, or word clouds (yet).
245
+ - **Requires deployment** — the audience connects over the internet, so the presentation must be hosted, not served locally.
246
+ - **AnyCable free tier** — supports up to 2,000 concurrent connections. For larger audiences, upgrade to a paid AnyCable Plus plan.
247
+ - **No persistent storage** — quiz results live in memory during the presentation. Once the presenter closes the tab, results are gone.
248
+ - **Netlify and Vercel only** — the serverless functions are provided for these two platforms. Other platforms (Cloudflare Workers, AWS Lambda) would need manual porting.
249
+
250
+ ## Appendix: Authorized Streams
251
+
252
+ > **TODO** — Instructions for setting up AnyCable [signed streams](https://docs.anycable.io/anycable-go/signed_streams) for private quizzes. Coming soon.
253
+
254
+ ## License
255
+
256
+ MIT
@@ -0,0 +1 @@
1
+ :root{--lq-accent: var(--r-link-color, #f59e0b);--lq-text: var(--r-main-color, inherit);--lq-text-muted: color-mix(in srgb, var(--lq-text) 50%, transparent);--lq-heading-font: var(--r-heading-font, inherit);--lq-font: var(--r-main-font, inherit);--lq-mono: var(--r-code-font, ui-monospace, "Cascadia Code", "Fira Code", monospace);--lq-bar-track: color-mix(in srgb, var(--lq-text) 10%, transparent);--lq-bar-fill: color-mix(in srgb, var(--lq-text) 35%, transparent);--lq-bar-correct: var(--lq-accent);--lq-border-radius: .5rem;--lq-option-border: color-mix(in srgb, var(--lq-text) 30%, transparent)}.reveal .lq-question{display:flex;flex-direction:column;align-items:center;width:100%;height:100%;padding:1rem 0;gap:1.5rem;color:var(--lq-text)}.reveal .lq-question__title{font-family:var(--lq-heading-font);font-size:clamp(2rem,5vw,4rem);font-weight:var(--r-heading-font-weight, 800);line-height:var(--r-heading-line-height, 1);letter-spacing:var(--r-heading-letter-spacing, -.02em);text-transform:var(--r-heading-text-transform, none);text-align:center}.reveal .lq-question__body{display:flex;align-items:flex-start;gap:3rem;width:100%}.reveal .lq-question__qr-side{display:flex;flex-direction:column;align-items:center;gap:.75rem;flex-shrink:0}.reveal .lq-qr{border-radius:var(--lq-border-radius);image-rendering:crisp-edges}.reveal .lq-question__url{font-family:var(--lq-mono);font-size:clamp(.6rem,1.2vw,.85rem);color:var(--lq-text-muted);text-align:center;word-break:break-all}.reveal .lq-question__content{display:flex;flex-direction:column;gap:1.5rem;flex:1}.reveal .lq-question__text{font-family:var(--lq-font);font-size:clamp(1rem,2.2vw,1.5rem);font-weight:600;line-height:1.3}.reveal .lq-question__options{display:grid;grid-template-columns:1fr 1fr;gap:.75rem}.reveal .lq-question__option{display:flex;align-items:center;gap:.75rem;padding:.6em 1em;border:1px solid var(--lq-option-border);border-radius:var(--lq-border-radius);font-family:var(--lq-font);font-size:clamp(.8rem,1.6vw,1.1rem)}.reveal .lq-question__option-label{font-family:var(--lq-mono);font-weight:700;font-size:.85em;color:var(--lq-accent);flex-shrink:0}.reveal .lq-question__option-text{font-weight:500}.reveal .lq-question__counter{font-family:var(--lq-mono);font-size:clamp(.9rem,1.8vw,1.2rem);color:var(--lq-text-muted);font-variant-numeric:tabular-nums}.reveal .lq-answered{color:var(--lq-accent);font-weight:700}.reveal .lq-results{display:flex;flex-direction:column;gap:2rem;width:100%;color:var(--lq-text)}.reveal .lq-results__title{font-family:var(--lq-heading-font);font-size:clamp(1.2rem,2.5vw,1.8rem);font-weight:600;line-height:1.3;text-align:center}.reveal .lq-results__bars{display:flex;flex-direction:column;gap:1rem;width:100%}.reveal .lq-result-bar{display:grid;grid-template-columns:12rem 1fr 5rem;align-items:center;gap:1rem}.reveal .lq-result-bar__label{display:flex;align-items:center;gap:.75rem}.reveal .lq-result-bar__letter{font-family:var(--lq-mono);font-weight:700;font-size:clamp(.8rem,1.4vw,1rem);color:var(--lq-text-muted);flex-shrink:0}.reveal .lq-result-bar--correct .lq-result-bar__letter{color:var(--lq-accent)}.reveal .lq-result-bar__text{font-family:var(--lq-font);font-size:clamp(.85rem,1.6vw,1.1rem);font-weight:500}.reveal .lq-result-bar--correct .lq-result-bar__text{font-weight:700;color:var(--lq-accent)}.reveal .lq-result-bar__track{height:2.2rem;background:var(--lq-bar-track);border-radius:.35rem;overflow:hidden;position:relative}.reveal .lq-result-bar__fill{height:100%;border-radius:.35rem;background:var(--lq-bar-fill);transition:width 1.2s cubic-bezier(.4,0,.2,1)}.reveal .lq-result-bar--correct .lq-result-bar__fill{background:var(--lq-bar-correct)}.reveal .lq-result-bar__stats{display:flex;gap:.5rem;align-items:baseline;font-family:var(--lq-mono);font-variant-numeric:tabular-nums;font-size:clamp(.7rem,1.2vw,.9rem)}.reveal .lq-result-bar__pct{font-weight:700}.reveal .lq-result-bar--correct .lq-result-bar__pct{color:var(--lq-accent)}.reveal .lq-result-bar__count{color:var(--lq-text-muted)}@media(prefers-reduced-motion:reduce){.reveal .lq-result-bar__fill{transition:none}}