@3halves-labs/score-trivia-widget
v0.5.1
Published
Embeddable widget for the Ruck & Recall trivia session game (/api/trivia/*) — web component + React, endless-run play with server-side scoring
Maintainers
Readme
@3halves-labs/score-trivia-widget
An embeddable widget for the Ruck & Recall trivia session game (/api/trivia/*). It drives the stateful, endless-run loop — open a session → serve a question → collect an answer → submit (server-scored, answer key withheld) → feedback → next / finish → summary. Karma is awarded asynchronously to the fan's loyalty balance.
On a wrong answer the feedback reveals the correct answer and the explanation the server returns (correctAnswer + explanation on the submit response) — so the widget teaches, not just scores.
Distinct from
@3halves-labs/score-interactions, which renders the one-shotscore.game"interactions" path (/api/interactions/:id). This one plays the R&R session game.
Ships three ways to embed, mirroring the interactions widget:
1. Web component (script-tag drop-in)
<script type="module" src="https://…/score-trivia-widget.esm.js"></script>
<score-trivia track="learning_the_game"
endpoint="https://api.urc.anything-loyalty.3halves-labs.com"></score-trivia>import { defineTriviaElement } from '@3halves-labs/score-trivia-widget';
defineTriviaElement(); // registers <score-trivia>The trivia API needs a fan bearer token, so set a .client with a token provider (a bare endpoint attribute is unauthenticated):
import { createHttpClient } from '@3halves-labs/score-trivia-widget';
const elm = document.querySelector('score-trivia');
elm.client = createHttpClient({ endpoint, getToken: () => fanToken });
elm.addEventListener('complete', (e) => console.log(e.detail)); // { answered, correct, score, state }2. Vanilla renderer
import { renderTriviaSession, createHttpClient } from '@3halves-labs/score-trivia-widget';
const ctl = renderTriviaSession(container, {
client: createHttpClient({ endpoint, getToken: () => fanToken }),
track: 'learning_the_game',
questionTimeMs: 20_000, // per-question countdown (a question's own timeLimit wins)
autoStart: true, // false → show a Start button instead
capabilities: undefined, // what to tell the server this client can render
theme: { vars: { primary: '#e4002b' } },
onAnswer: (r) => {}, // { correct, score }
onComplete: (r) => {}, // { answered, correct, score, state }
});
// ctl.finish() ends the session early; ctl.setTheme(t) restyles in place;
// ctl.destroy() tears down.3. React
import { TriviaWidget } from '@3halves-labs/score-trivia-widget/react';
import { createHttpClient } from '@3halves-labs/score-trivia-widget';
<TriviaWidget client={createHttpClient({ endpoint, getToken })} track="learning_the_game" useDefaultTheme />Question types
Fifteen types are advertised on session create. Twelve are text
(RENDERABLE_QUESTION_TYPES), each with a control in answerControls.ts; the
other three lines below are the image pair, IMAGE_QUESTION_TYPES.
multiple_choice true_false fill_blank odd_one_out multi_select
two_truths_one_lie higher_lower numeric_estimate cloze_passage
ordering matching categorise
image_identify a headshot and four names (grades like multiple_choice)
image_true_false a headshot and one claim (grades like true_false)The image types come with a catch worth knowing: the server drops them unless
the client advertises both the type and media: ['image']. defaultCapabilities()
sends the pair, so they work out of the box. The picture arrives as a signed,
time-boxed imageUrl on the API host — never the upstream asset URL, because
the filename usually contains the player's name — and createHttpClient
resolves it to an absolute URL before the renderer draws the <img>.
Upgrading from 0.3: this is a behaviour change under a caret range. 0.3 advertised the twelve text types; 0.4 advertises the image types too, by default, so an existing embedder starts being served headshot questions with no code change. Keep the old behaviour with
capabilities: { types: [...RENDERABLE_QUESTION_TYPES] }(or theno-imagesattribute). Decline them deliberately if you have an accessibility obligation: as below, an image question cannot be answered by a screen-reader user, and that limitation now arrives by default.
To decline pictures, narrow the capabilities:
import { RENDERABLE_QUESTION_TYPES } from '@3halves-labs/score-trivia-widget';
renderTriviaSession(el, { client, capabilities: { types: [...RENDERABLE_QUESTION_TYPES] } });<score-trivia no-images endpoint="…"></score-trivia>Two caveats. A signed URL expires (an hour by default); a load failure
degrades to a short note and the question stays answerable, but the fix is to
re-fetch the question. And an image question is not answerable by a
screen-reader user — alt is a fixed generic string on purpose, since
anything descriptive would give the answer away, and the spec's accessible
equivalent is a server-side gap. A host with a hard a11y requirement should
decline the image types.
Styling
Four layers, smallest first. All of them key off the part names — every element
carries part="…", data-st="…" and a matching .st-… class, and those names
are stable across releases.
1 · CSS variables. The default theme colours everything from --st-*, and
custom properties pierce shadow DOM, so a page stylesheet reaches inside
<score-trivia>:
score-trivia { --st-primary: #e4002b; --st-radius: 16px; }
score-trivia::part(option) { text-transform: uppercase; }2 · A theme object, for the same thing from JS plus two things CSS alone
cannot do here:
const ctl = renderTriviaSession(el, {
client,
theme: {
vars: { primary: '#e4002b', radius: '16px' }, // → --st-primary, --st-radius
css: '.st-stem { letter-spacing: -.02em }', // injected after the default sheet
classNames: { option: 'rounded-xl hover:bg-slate-100', stem: 'text-2xl font-bold' },
},
});3 · classNames is the Tailwind hook — a part → class-string map, applied
as elements are created, including the markup a control rebuilds mid-question.
Your classes win without !important. A utility ties with the built-in class on
specificity and takes the tie on document order, because the shipped sheet is
injected first in the head — anything the page links or injects afterwards
comes later and wins. The same holds for theme.css (injected last, into the
same root) and for your own .st-* rules.
The shipped rules use ordinary class specificity rather than
:where(), which 0.5.0 tried. Zero specificity meant a host'sbutton { … }reset — or Tailwind's preflight, which every Tailwind app has — outranked the widget's own button styling, so in the light DOM the buttons rendered as bare transparent boxes. A class beats an element selector; injection order handles the rest.
Add replaceClasses: true to drop the built-in st-* classes on the parts you
name and start from bare markup — the deterministic option if you would rather
not depend on order at all. State classes (st-selected, the feedback verdict)
still land, so a utility theme does not lose the selected state.
Tailwind and the shadow DOM: a page's compiled Tailwind sheet does not reach inside
<score-trivia>. UserenderTriviaSessionor the React wrapper (both render in the light DOM), or pass compiled utilities viatheme.css.
4 · Restyle a live widget — no re-mount, so an in-flight question survives:
ctl.setTheme({ vars: { primary: '#0ea5e9' } }); // vanilla renderer
document.querySelector('score-trivia').setTheme({ … }); // web component
<TriviaWidget theme={theme} … /> // React: prop changes apply in placeapplyTheme(element, theme) applies vars + css to an already-rendered
widget on its own and returns a disposer. Set no-default-theme (web component)
or omit useDefaultTheme (React) to skip the shipped sheet entirely.
The full part list is the TriviaPart union in theme.ts.
Dev / offline
createMockClient(questions, grade?) serves a fixed question list in an endless loop and grades locally — for building UI without a backend:
import { renderTriviaSession, createMockClient } from '@3halves-labs/score-trivia-widget';
renderTriviaSession(el, { client: createMockClient(myQuestions, (q, a) => a === answerKey[q.id]) });Scripts
npm run build # tsup → CJS + ESM + d.ts (index + /react)
npm run typecheck # tsc --noEmit
npm test # vitest (jsdom)The session flow is powered by the same /api/trivia/* routes wrapped by @3halves-labs/score-loyalty-sdk (≥1.6.0); this package is the UI on top.
