- Hero image is now
docs/images/host-lobby.gif: the real lobby filling with players, then a click that enlarges the QR. Captured as lossless 2x PNG frames over CDP screencast, cut and given a drawn cursor in Remotion, converted to a 1760px, 25fps GIF (6.3 MB). - Every screenshot retaken from one real game on the current UI (dark by default, light theme shown on the phone). Added the quiz library, the explanation screen and the player's final screen.
- Removed the old screenshots and the stray
frontend/join-dark.png.
- The Remotion project and capture scripts live in a separate project outside this repo, so the app repo carries only the output.
- Playwright's
recordVideois low-bitrate VP8 and headless screencast ignoresdeviceScaleFactor; launch Chromium with--force-device-scale-factor=2for 2x frames. - Park the headless mouse away from the chip row while recording: a hovered chip shows its kick button.
Spec: specs/phase-25-google-login.md.
trivia_tap.auth.login_with_googleredirects to frappe's Google authorize URL. The landing page stays inside the SPA. Frappe'slogin_via_googledoes the rest: verified email check, create or link the user, log in.- Boot flag
google_loginshows "Continue with Google" on the log in and sign up screens only when thegoogleSocial Login Key is enabled. brand_sitepatch (rerun as#2) sets Portal Settings default role to Quiz Host, so a Google sign up lands as a host (System User).- Setup steps in README.
Tests cover the redirect, the off-site guard, the not-set-up error and a Google sign up getting Quiz Host. Headless browser at 390px: button and "or with email" divider render; clicking it reaches accounts.google.com with the right client ID and redirect URI.
- Google rejects plain
httpredirect URIs on any host butlocalhost, sohttp://trivia-tap.localhost:8000cannot finish the flow. Local testing needs the site as bench default andhttp://localhost:8000, or an https tunnel. - A real Google account round trip is not verified yet: it needs a real client ID.
Spec: specs/phase-24-join-polish.md.
- Game PIN is six boxes from reka-ui
PinInput, styled likeCodeStep. Finishing the PIN, or arriving from the QR link with it pre-filled, focuses the nickname. - Chosen face shows at 56px beside the nickname input. A shuffle button inside the input replaces the "More" chip that wrapped onto its own line on a phone. The picked suggestion chip is highlighted.
- Carousel faces are 48px with faded strip edges.
- "Join game" stays disabled until the PIN has six digits and the nickname is not blank.
Headless browser at 390px and 1280px, dark and light: typed a live lobby PIN, focus moved to the nickname, picked a suggestion and a face, joined and landed in the lobby as that player. ?pin= pre-fills the boxes and focuses the nickname.
Spec: specs/phase-23-password-reset-link.md.
- Profile page: "Forgot?" next to Current password calls
send_reset_link, which mails the host's own inbox a/trivia-tap/reset-password?key=...link. The key and its expiry are Frappe's (User._reset_password,reset_password_link_expiry_duration). Administrator is refused. ResetPassword.vue: new password and confirm, styled like the login page, open to guests.reset_password_with_linkhands the key to Frappe'supdate_passwordand turns its 410 for a used or expired key into aValidationError.- Profile page polish on the same base: bigger avatar with an edit badge, stat tiles, password form collapsed behind "Change".
Headless browser as a throwaway Quiz Host: "Forgot?" mails the link to Mailpit, the link opens with cookies cleared, the new password logs in and lands on /host, the same link a second time shows the expired message, and a link without a key says it is broken. 96 app tests pass.
Spec: specs/security-whitelisted-methods.md.
- Quiz Host keeps only
if_ownerread on TT Session. The host methods inapi.pyare the only write path and save withignore_permissionsafterget_host_sessionchecks the caller. get_playerreplacesget_session_by_pinplusget_participant_by_tokenin the token-gated guest methods. A wrong PIN and a wrong token raise the samePermissionError.submit_answer,get_stateandget_resultare rate limited per IP only.get_stateandget_resultare POST only.login_with_coderefuses users who must pass Frappe 2FA.
Over HTTP as two Quiz Hosts: creating a session on another host's quiz and editing your own session's host through /api/resource both return PermissionError. A PIN scan with rotating tokens gets only 403s, then 429 after 60 requests. Headless browser as a plain Quiz Host: unlock lobby, guest joins, start, answer, podium.
Spec: specs/phase-22-slide-editor.md.
QuizEditor.vueis now three panes:QuestionRail(question cards),QuestionSlide(the selected question laid out like the projector, tick a tile to mark it correct) andQuestionSettings(time limit, points, explanation, duplicate, delete).- Rail cards reorder by drag, live as the card passes others, and by Alt+Up / Alt+Down. Incomplete cards get a red dot. Duplicate and delete show on hover or focus.
- Description, timers, explanations and host controls moved to
QuizSettingsDialog. - Save checks the quiz on the client first (
questionProbleminquiz.jsmirrorsTTQuiz.validate) and selects the broken question. Ctrl+S / Cmd+S saves. Unsaved changes are tracked against a snapshot of the payload and guard both route leave and tab close. ImageFieldreplaces the two copies of the upload, replace and remove buttons..fieldgets a mint border on focus instead of the browser's blue outline.
Headless browser: new quiz, client validation jumps to the broken question, Ctrl+S saves and swaps /new for the quiz name, order persisted in the DB after Alt+Up, duplicate, drag both ways and delete, leave guard, settings dialog, preview, 390px layout and light theme.
Spec: specs/phase-21-one-quiz-list.md.
- The Play and Library tabs are gone.
/hostshows "Your quizzes" fromQuizLibrary.vue: clicking a row opens the editor, Play on the right starts a session, a quiet trash button deletes. - Play stays a plain pill and lights up in brand green only on the hovered or focused row, so a column of green buttons does not drown out New quiz.
QuizList.vueis gone and/host/quizzesredirects to/host.
Headless browser: row click opens /host/quizzes/<name>, Play opens the lobby, Close lobby returns to the list, /host/quizzes lands on /host, layout holds at 390px.
Spec: specs/phase-20-animated-qr.md.
AnimatedQr.vue: a 2D canvas where every dark QR module is a dot. The dots pop in as the mascot outline (dark pixels of the logo), hold with a small bob, then arc into their module and settle to ink. The logo badge lands last.- Lobby code and fullscreen code both use it.
renderQrand the data URL are gone fromHost.vue. - Living badge:
scripts/build_mascot.pycuts the logo into background, head, eyes and sparks (connected components of the dark ink, background refitted as a smooth gradient) and writestrivia-tap-mascot.svgwith CSS keyframes: head bob, glance, blink, spark flash. Reduced motion stops it. The SVG lives infrontend/src/assetsso Vite hashes its URL: Frappe sendsmax-age=43200for/assets, and at a fixed URL a browser kept showing an older mascot for hours. - The light box around the badge has rounded corners, concentric with the tile (tile radius plus padding, 28.5%). The modules run under it, so no square hole shows at the corners.
- Fullscreen code:
rounded-3xlis not in the frappe-ui radius scale and rendered square, nowrounded-2xl. The dialog no longer draws a focus outline around itself.
Headless browser on a live lobby: mascot frame, flight, settled frame, then the badge keeps moving. Settled frames decode to the join URL (OpenCV and zxing-cpp before the live badge, zxing-cpp after).
Spec: specs/phase-19-email-code-auth.md.
trivia_tap/email_code.py:EmailCodekeeps a 6-digit code hashed in Redis for 10 minutes, 5 tries (atomic INCR), 30 second resend throttle per email.trivia_tap/auth.py:send_code(mail from a background job, same answer whether or not the email has an account),sign_upnow needs the code,reset_passwordhands a minted frappe reset key to frappe'supdate_password,login_with_code.- Login page: one code step (
CodeStep.vue) shared by sign up, forgot password and code log in. Sign up checks password strength before mailing. brand_sitepatch andafter_install: Website Settings app name and logo set to TriviaTap, so mails no longer carry the Frappe wordmark.- Tests: 14 in
trivia_tap/tests/test_auth.py.
Over HTTP with codes read from Mailpit, and in a headless browser: weak password stops before any mail, wrong code refused, sign up lands on /host, forgot password sets the new password and logs in, code log in logs in, quick resend shows "Wait a few seconds", an existing email on sign up gets the "already have an account" mail.
- Frappe skips the password policy when
frappe.in_test, so the test for "a failed sign up keeps the code" fails the insert with a patch. test_password_strengthrejectsuser_dataover HTTP (typed as tuple), so the SPA sends only the password. The server's sign up still checks with the name and email.
Spec: specs/phase-18-host-avatar-stats.md.
- The profile header avatar opens a picture dialog: a hover tooltip, then
left and right arrows (and arrow keys) through the initial and the avatar
pack, saved on Save. It stores the avatar URL in
User.user_image, so Desk shows it too. An uploaded Desk photo stays on offer as "Your photo". HostAvatarshows the image or the initial in the bar, the menu and the profile header.user_imagecomes with the page boot.trivia_tap.api.get_host_statscounts Ended games, players who were not kicked, and quizzes the host owns. The profile header card shows the three.- Tests in
test_host_stats.py: zeros for a new host, Cancelled lobbies and kicked players left out, another host's games not counted. Full suite: 79 tests pass.
yarn build and pre-commit pass. In the browser, desktop and phone, dark and
light: a saved pick shows at once in the bar, survives a reload and shows in
the menu. Stepping and Escape save nothing. An uploaded photo opens as
"Your photo · 1 / 26". Stats read 140, 2,835 and 5
for Administrator.
IntegrationTestCaserolls back per class, not per test, so the stats tests clear their hosts' rows insetUp.
Spec: specs/phase-17-host-account-menu.md.
- Host bar: Play and Library as text tabs with a mint underline, New quiz on the right (a plus icon on a phone), and an initial avatar that opens the account menu.
- Account menu is a native
popover: name and email, Profile, a three way Auto, Light, Dark theme choice, and Log out. The floating theme button is gone from host screens. - Profile page opens on a header card with the avatar, full name and email. Save name stays disabled until the name changes.
- The "Host" eyebrow is gone from the picker, quiz list and profile. New quiz moved from the quiz list header to the bar.
yarn build and pre-commit pass. In the browser, desktop and 390px phone, dark
and light: tabs mark the current page, the bar fits one row on a phone, the
menu opens, closes on Escape and on Profile, switches theme and logs out to
/join. Save name is disabled on load, enabled after an edit, disabled again
after save, and the header card shows the new name.
- The log in email field is
type="email", so the browser refusesAdministrator. Only the dev admin account hits it.
Spec: specs/phase-16-brand-theme.md.
- Ground moved from purple night to the logo's green-tinted ink, in both themes.
accentis mint (#20EEA0dark,#0A7A52light, 5.1:1 on the light ground).- Primary actions (Join game, Log in, Back to join,
.ctl-go) wear the logo gradient through one--brandvariable, exposed to Tailwind asbg-brand. Ember keeps answer 1, urgency and danger. sunk,card, QR colours andtheme-colorfollow the logo ink and white.- Join and Log in show the logo beside the wordmark.
LOGO_URLlives intheme.jsand replaces three copies of the path. - The lobby QR card gets a
hazering: in light mode it melted into the ground.
yarn build and pre-commit pass. In the browser: join (desktop and phone),
log in, host quiz picker, quiz list, lobby with a player, read-time screen, in
both themes.
- The running
bench workerpredates today's frappe pull and fails everytt_tickerrun withImportError: defer_enqueue_after_commit. Games freeze at read time untilbench startrestarts. Reveal and podium screens were not re-checked for that reason.
Spec: specs/phase-15-host-profile.md.
/host/profile, opened from a profile pill in the host bar: edit first and last name, change password (current, new, confirm, each with the eye button).trivia_tap.auth.change_passwordchecks the current password before calling frappe'supdate_password. Frappe clears the session cookies on anyAuthenticationError, so callingupdate_passworddirectly logged the host out when they mistyped their current password.- Host bar: logo, Host, Quizzes, then "Hi, " linking to the profile. Log out moved into the profile page, the theme button floats at the bottom right of host screens, and host pages keep room under their content for it. The bar used to wrap to two rows on a phone; it now stays on one.
PasswordInputno longer carries the login page styling; each page passes its own field classes.
74 tests pass. In the browser: name saves and survives a reload, a wrong current password shows "Current password is wrong." and the host stays logged in, a mismatch is caught before the call, a real change reloads with "Password changed." and the next save still works, and the new password logs in. Light and dark, desktop and phone width.
Spec: specs/phase-14-spa-login-signup.md. A new host goes from /trivia-tap
to a live game without leaving the SPA.
/trivia-tap/loginwith Log in and Sign up tabs, styled like the join page. Guests who open any/hostroute land there with?redirect=, and go back to that route after. A logged-in user who opens it goes to/host.trivia_tap.auth.sign_upcreates the user with the password they typed and theQuiz Hostrole, then logs them in. It keeps frappe's guards: sign up disabled, the hourly sign up cap, and the same vague message for a taken email. Its per-IP limit is frappe's ownmax_signups_per_minute. A first cut used 5 per hour, which a few weak-password retries used up, and which would lock out a school where every teacher shares one IP.- Password fields have an eye button (
PasswordInput). Sign up asks for the password twice and says "Passwords do not match." before calling the server. - The join page links hosts to the login page.
- Server messages are HTML (the password policy sends a
<ul>of hints), which showed up as raw tags.errorTextinapi.jsturns them into text for every screen that shows a server error.
72 tests pass. In the browser on trivia-tap.localhost: wrong password shows
"Invalid login credentials", a weak password shows the policy hint as text, a
new account lands on the host screen, writes a quiz, opens a lobby, a phone
joins by the copied link and the game plays to the podium. Logging back in from
/host/quizzes returns there. Light and dark, desktop and phone width.
trivia-tap.localhosthad sign up disabled in Website Settings. It is now on. Sites that keep it off refuse the sign up tab with a clear message.Quiz Hosthas desk access, so a new host is a System User. Their role only reaches TriviaTap DocTypes.
Spec: specs/fix-host-data-scope.md.
- Any Quiz Host could list every player (nickname, avatar, score, token hash)
and every answer from every host's games through desk or
frappe.client. Quizzes and sessions were already private. trivia_tap/permissions.pyscopesTT ParticipantandTT Answerto the host of their session, throughpermission_query_conditionsandhas_permission.- Verified with two hosts over the live site and in
tests/test_permissions.py, which fails without the hooks. 71 tests pass.
Spec: specs/phase-13-rename-trivia-tap.md. Quizzly is now TriviaTap, down to
the DocType prefix, the dev site and the repo.
- Package
quizzlyis nowtrivia_tap, moduleQuizzlyis nowTriviaTap(foldertrivia_tap/triviatap), workspaceTriviaTap. - DocTypes
QZ *are nowTT *; socket events, rooms, Redis keys, the ticker job and browser storage keys moved fromqztott. Two patches carry existing sites:rename_qz_doctypesrenames the DocTypes and their tables,rename_qz_quiz_namesturnsQZ-0092intoTT-0092. - SPA moved from
/quizzly/*to/trivia-tap/*. The page file iswww/trivia_tap.py; two route rules map the hyphenated path onto it. - New logo
public/images/trivia-tap-logo.png(256px, cropped from the source art) in the host bar, the QR badge, the favicon, the apps screen and README. - Site config key is
trivia_tap_avatar_pack, theme storage keytrivia-tap-theme. - Dev site
quizzly.localhostis nowtrivia-tap.localhost, moved in place with quiz data kept. Bench folder isapps/trivia_tap. - README points at
bwhtech/trivia_tap. Renaming the repo itself needs an org admin; this account has write access only. - Past specs, plan and progress entries use the new names too.
- README screenshots retaken from a real game. The results screen has been two
screens since phase 12, so the gallery gains
host-stats.pngbeside the scoreboard. - The join page inputs showed the forms plugin's blue focus ring over the ember
border.
focus:ring-0leaves the ember border alone. test_host_state_reaps_it_and_offers_a_fresh_gameasserted no live session at all, so any open lobby on the dev site failed it. It now asserts the abandoned game is not offered back.
69 tests pass. On trivia-tap.localhost: the five old quizzes open under TT-
names, a full five-question game plays with eight players (seven over the API,
one in a phone-sized browser), the lobby QR shows the new logo, desk shows the
TriviaTap icon and workspace.
- Frappe cannot migrate a site whose app package is gone, so other installed sites need the manual steps in the spec before the patches can run.
- A
format:autoname keeps its counter under an empty series key, so the quiz rename needs no series change. - The workspace JSON
modifiedhad to move forward, or migrate skips it and the sidebar keeps the old link labels. Workspace Sidebaris inert on this frappe version; nothing recreates it and nothing needs to.
Spec: specs/phase-12-scoreboard-screen.md. The screen that ends a question is
now two: the answer split, then the standings.
engine.py: newscoreboardphase, always the last beat before the next question, whichever side the explanation sits on.close_questionbuilds the standings (score, points won, new rank, previous rank) and parks them in Redis state;carry_overhands them through the phases in between untouched. The last question skips the screen: the podium is the standings.- The top five and the streak callouts move off the stats payload onto the scoreboard one. The stats screen is the distribution alone now.
get_host_statereturns the parked standings for a reload during the phase, and drops thetop_5nothing read any more.Host.vue: standings screen. Rows open in the old order with the old scores, then the points count up on every row while the rows move to their new places.TransitionGroupkeyed by nickname does the move, so row identity survives the re-sort. The rank column reads as list position until the rows land, or a player who fell out of the top five leaves a gap in the numbering.- Reload during the phase paints the settled board with no replay.
E2E on trivia-tap.localhost at 1920x1080, six players answering over the API, a
real worker driving the loop:
- Question closes to the distribution bars alone,
Show scoresmoves on. - Standings open in the previous order at the previous scores,
+974chips land and the rows overtake: ann and eve climb from 4th and 5th past cid, bob, dee. - Host reload mid-standings comes back to the same five rows, settled.
- Last question's button reads
Final resultsand goes straight to the podium.
- Engine tests cover the new phase both ways round the explanation and the
last-question skip: 31 green in
tests/test_engine.py, plus api/game_ux green. - A dev-bench aside, not this app: the single bench worker serves
longafterdefault, so a five-minute job from another site can starve the ticker and the game abandons itself. The host reload settles it, as designed.
Spec: specs/phase-11-quiz-preview.md. Preview in the editor plays the quiz
screens with no session and nobody in the lobby.
AnswerGrid.vue: the projector answer grid lifted out ofHost.vueunchanged, withcorrectOptionas the reveal switch (null while the answer is still out). The host screen and the preview now render the same component.QuizPreview.vue: fullscreen<dialog>over the editor that plays itself. Per question: read screen (3s), question (its own time limit), answer (5s), and the explanation before or after the answer perexplanation_position, skipped when the question has nothing to explain. Engine constants are mirrored client-side.- The ring drains for real and turns red under five seconds, so a too-short window shows up before a room sits through it.
- Pause holds the clock and resumes from where it stopped: the ring is measured
against the beat's window, not the countdown's, so
useCountdownis reused as-is instead of growing a pause. Back/Next and the arrow keys jump a beat, space toggles, the last beat offers Replay, Esc closes and stops the clock. - The walk clamps itself when an edit shortens the quiz while the dialog is open.
- Preview reads the editor's reactive state, not the saved doc: it covers unsaved edits, needs no API and works on a quiz that has never been saved.
- Editor header gets a Preview button, disabled until there is a question.
Every screen that only exists because players do: lobby, distribution bars, top 5, streaks, podium. Previewing those means inventing players and scores, and a made-up scoreboard teaches an author nothing. Sound too: a preview at a desk is not the room.
E2E on trivia-tap.localhost as Administrator, build served from the app:
- Preview on a 2-question quiz with explanations after results: 8 beats, played through unattended. Read screen counted 3 down, question ran its 10s window with the ring going red under five, answer screen held 5s with the correct option ticked and the rest dimmed, explanation held its 5s. Pause froze the clock at 4s across three seconds of wall time and resumed to 2s without refilling the ring. Replay restarted at 1/8, Next disabled on the last beat, Esc closed and stopped the clock.
- Regression on the live host screen after the
AnswerGridextraction: hosted a real session with a player joined, question screen and stats screen (tick, dim, distribution bars, top 5) render exactly as before.
Spec: specs/phase-10-answer-explanation.md. A screen between the buzzer and
the scoreboard that says why the answer is right.
- DocTypes:
explanation(Small Text) andexplanation_image(Attach Image) onTT Question,show_explanation(Check) onTT Quiz. - Engine (
engine.py): newexplanationphase betweenquestionandstats.close_questionstill settles scores, streaks, distribution and top 5 at the buzzer; whichever screen goes second is parked in Redis state by the one that goes first (closed_payloadorexplanation_after) and published unchanged, so nothing is computed twice. explanation_positionon the quiz puts the screen before the stats (default) or after them. The host buttons name the screen they actually open, offbefore_statson the explanation event andexplanation_nexton the stats event.hold_secondsis the shared rule for how long a phase waits: the quiz'sexplanation_time_limit(default 10s) with auto-advance on,ADVANCE_WAIT_CAPwith it off.- The phase is skipped when the toggle is off or the question has neither text nor image, so a half-authored quiz never lands on a blank screen.
- Reconnect:
get_host_stateand the playerget_statereturn the stored explanation payload while the phase is live, so a reload lands back on the explanation screen instead of skipping it. - Host screen: question, the correct answer in its shape colour, image, the explanation, a countdown ring (auto-advance only) and a Show results button. Player screen keeps its own verdict and shows the explanation under it, so nobody waits 10 seconds to find out they were right.
- Authoring: one
Explanations on/offtoggle on the quiz, a before/after button and a seconds field beside it, plus a text box and an image uploader per question that appear with it.
TestExplanationScreenintests/test_engine.py: the scoreboard is held back behind the explanation, scores are already settled when it shows, it expires into stats on its own, and it is skipped both when the quiz toggle is off and when the question has nothing to say.- E2E on
trivia-tap.localhostwith the demo bots: explanation -> results on both the host-driven and auto-advance paths, image and text-only questions, host reload and player reload mid-explanation, and the editor toggle round-trip.
- The bench's single
bench startworker serves every queue, so a long scheduled job from another app on this bench (navgold) blocked thelongqueue and left a game with no ticker. Not a TriviaTap bug: the game settled itself throughis_abandoned, which is exactly what that guard is for. A dedicatedbench worker --queue longunblocked testing.
Spec: specs/live-quiz-rework/phase-4-submit-and-count.md. Cut the last two
per-answer costs, and fixed the guest-socket blocker Phase 3 flagged.
submit_answer(api.py): insert now usesignore_links=True(session-active and participant-by-token are already validated; the(participant, question_row)unique index still backstops duplicates). Removed the per-submitanswer_countbroadcast block entirely.- Ticker (
engine.py):maybe_push_answer_countbroadcasts the live "N answered" count from the ticker instead, but only while a question is open, only on change, and at most everyANSWER_COUNT_THROTTLE(0.3s). Result: a few updates/sec total, flat regardless of player count. Throttle state + immutable pins live process-local inrun_ticker(single deduplicated job), pruned each pass to the live session set so finished games don't leak.
- Symptom (Phase 3 note): with the real async ticker, players never advance
get_ready->question; they only limp viaget_stateresync. - Reproduced with a guest socket.io client against the live socketio server: it
receives
website-room events but nothing ontt_session_<pin>, so no game events ever arrive live. Host "worked" only because the frontend resync watchdog papers over it for slow lobby changes, not fast question transitions. - Root cause:
apps/trivia_tap/package.jsonhas"type": "module", so Node loadsrealtime/handlers.jsas ESM. The frappe socketio serverrequire()s it as CommonJS and gets{}instead of the handler function;app_handler(socket)throws and is swallowed, sott_join/tt_leavenever register and no socket ever joins the session room. - Fix:
apps/trivia_tap/realtime/package.json={"type":"commonjs"}overrides the module type for just that directory, so the CJS handler loads again without disturbing the ESM app root the frontend build relies on. Verified at therequire()level (now returnsfunction trivia_tap_handlers). Needs a socketio restart to take effect (the running server cached the failed load).
run-tests --app trivia_tap: 59 green. The engine test that filtered outanswer_countevents is unaffected (no such events emitted now).- Lint: pre-commit clean on all changed files.
- Browser E2E (3+ guests: counter climbs via ticker, players advance to
questions and answer) is blocked on a
benchrestart to reload the socketio handler. Run after restart per the rework rule (no merge until browser green).
Spec: specs/live-quiz-rework/phase-3-batch-scoring.md. Killed the N+1 write
loops so reveal time stops scaling with player count.
close_question: the participant loop now builds two in-memory dicts and no longer does I/O per participant.answer_updates({answer: {is_correct, points}}) andparticipant_updates({participant: {score, streak}}, streak-reset-to-0 for non-answerers in the same dict) flush through twofrappe.db.bulk_updatecalls after the loop. Same commit +question_closedpush as before.finish_session: ranks persist via onefrappe.db.bulk_update("TT Participant", {name: {"rank": rank}})instead of oneset_valueper player.bulk_updatebuilds chunked CASE-WHEN UPDATEs and does not commit internally, so the ticker'stt_ticksavepoint isolation (Phase 2) is intact. It also no-ops on an empty dict, so a question nobody answered is safe.
run-tests --app trivia_tap: 22 engine + 15 game-ux tests green. Scores, streaks, ranks, and podium identical to before — batching changed no number.- Load check (
scripts-style console driver, 200 participants, half correct):close_questionwent 207.9 ms -> 36.7 ms. The residual is the answer fetch, not the writes; write time no longer scales with player count.
- Host + 3 guests (Alice/Bob/Cara) played "General Knowledge" driven by the real
async
longRQ worker (not the synchronous driver Phases 1-2 fell back to). The shared ticker marched through all 5 questions and rendered a correct podium with ranks 1/2/3 on both host and player screens.finish_session's batched rank write andclose_question's batched score writes ran with zero Error Log entries. - Pre-existing, out-of-scope: with the real async ticker, the player view
never advances from
get_readyto thequestionphase (options never render), so guests can't answer and everyone scores 0. Host advances fine and gets every event. This is a player realtime/state issue untouched by this diff (which only editsclose_question/finish_sessionDB writes) and predates it. Flagged for a separate fix; does not affect scoring correctness, which the unit suite gates.
Spec: specs/live-quiz-rework/phase-2-multi-game-scale.md. Proved the shared
ticker drives many concurrent games on the same workers with no starvation, the
payoff of Phase 1's redesign.
run_tickergained per-session isolation: each session's step runs under att_ticksavepoint inside its own try/except, commits on success, and on error rolls back to the savepoint +log_errors and continues. One bad game can no longer stall or kill the ticker for the others.- Fixed a real ticker bug the self-heal path was hiding:
get_stateread through Frappe's process-local cache (use_local_cachedefault True), so in the long-lived ticker a session whose state had expired in Redis still returned its last-seen dict forever. Theif not state: sremself-heal never fired and the ticker spun on the vanished session.get_statenow reads withuse_local_cache=False, likepop_controlalready does. - Lifecycle was already airtight from Phase 1 and confirmed so:
active_sessionsissrem'd on finish, onend, and on abandon (all route throughfinish_session); the tickersrems any session whose state has vanished; andenqueue_game_loopalways re-enqueues the dedupedtt_ticker, so starting any new game self-heals a dead ticker and picks up every registered session.is_abandonedstill settles a session the ticker somehow dropped.
test_ticker_survives_bad_session: seeds a bogus session that raises every pass alongside a real game; the real game still reaches the podium. Note: the guard must roll back to the savepoint, not call barefrappe.db.rollback(), which would discard the whole test transaction (and, in prod, sibling sessions' writes from the same pass).
- 5 concurrent games: 5 isolated browser guests joined 5 lobbies, all started, all rendered the same live question simultaneously (Q1 of 5), auto-advanced through all five questions, and every player reached the podium ("You won").
- Backend proof via
scripts/concurrent_games.py(console driver, phases driven by the reallongRQ worker): 5 games marched in lockstep get_ready -> question -> closed for q0..q4, with exactly onett_tickerjob the entire run (max concurrent tt_ticker jobs observed: 1).
Spec: specs/live-quiz-rework/phase-1-ticker-tracer.md. Replaced the per-game
busy-wait loop (run_game_loop, one RQ job per session) with a shared ticker
plus a per-session Redis state machine.
run_ticker: one self-looping RQ job (job_id="tt_ticker",deduplicate=True, queuelong). Each pass readstt:active_sessions, per session pops control and advances when a control fired ornow >= next_ts, commits, sleepsTICK_SECONDS(0.5). Exits when the active set is empty.advance_session(session_doc, state, control)dispatches onstate["phase"](get_ready->question->stats-> next / finish).endcontrol finishes any phase.get_ready/open_question/close_questionlost their internal while/sleep loops; they now just write state (extended withphaseandnext_ts) and push.close_questionreadsauto_advancefresh to size the stats wait.enqueue_game_loopseeds the firstget_readystate,sadds the session, then enqueues the shared ticker.finish_sessionsrems on the way out.- Deleted
run_game_loop,wait_question_window,wait_before_next,POLL_SECONDS. - Tests green (
test_engine,test_game_ux):TestGameLoopnow drives the game viaenqueue_game_loop+run_ticker; added coverage for auto-advance-off hold and advance-on-last-question finish.
- Full game played host + guest: get_ready pause -> question + countdown -> reveal -> auto-advance -> podium. Host skip closed the question within ~0.5s; the ticker exited with no lingering job after finish.
- Env note: this bench's
longRQ queue had no worker and was clogged with hanging jobs from another site, so the browser run droverun_tickersynchronously viabench execute(same realtime path). The RQ enqueue/dedup itself is unchanged standardfrappe.enqueue.
- Work committed on
feat/live-quiz-ticker(spec commit + implementation commit). - Branched
experimentaloff it and mergedfeat/live-quiz-tickerin (fast-forward, same commits). Both branches local only, not pushed.
Spec: specs/phase-9-documentation.md. The README was still the app-scaffold
default. It is now the front door: hero image, what-it-is, a collapsed gallery
of eight screenshots, features, stack, dev setup, testing, contributing.
- Eight screenshots in
docs/images/, all from one real session againsttrivia-tap.localhost(520 KB total). Host shots at 1440x900, player shots at 390x844 with DPR 2, one player shot in dark theme. Podium needed 1440x1010 to fit its leaderboard and button in a single frame. scripts/seed_demo.py: creates the "General Knowledge" demo quiz. Run withbench --site trivia-tap.localhost console < scripts/seed_demo.py.scripts/demo_bots.py: joins seven named players over the guest HTTP API and answers questions for them at plausible accuracies, so the lobby, distribution and podium look like a real game. Both scripts are throwaway tooling for pictures, not fixtures.- No CI badges: this repo has no git remote yet, so the badge URLs would not resolve. Add them with the remote.
bench --site trivia-tap.localhost run-tests --app trivia_tap: 56 tests, all green.
pre-commit run --all-files: clean. Every command in the README was run as
written.
- The bench runs a single
bench workerserving short, default and long. The game loop is enqueued onlong, and while that worker was busy with another site's scheduled jobs the loop never started, sois_abandonedfired at 30s and every session jumped straight to an all-zero podium. Captures needed a dedicatedfrappe worker --queue longalongside it. Not an app bug, but a real deployment constraint: a live game needs a worker that is not competing with scheduled jobs. - The podium screen scrolls inside its own container, not the window, so with eight players a 900px-tall viewport clips the tail of the leaderboard and the "New game" button. Reachable, but only if you know to scroll there.
Spec: specs/phase-8-ci.md, written after comparing our workflows against frappe/wiki. We already had wiki's server-test and linter jobs, on newer action and MariaDB versions than theirs. The one real gap is Playwright E2E, which wiki has and we don't, and which matters more here than there: trivia_tap is two browsers and a socket server, and the seam between them is exactly what bench run-tests cannot see.
The E2E harness is not built. It is half a day of work (root package.json, config, auth setup, two-context helpers, socketio under CI, countdown-timer flake), and /agent-browser already covers every fix manually, so the gap is "no unattended gate", not "untested app". Deferred until someone else contributes or a realtime regression ships unnoticed.
Three config fixes shipped now, no new dependency:
ci.ymlconcurrency group wasdevelop-trivia-tap-${{ github.event.number }}. That expands to empty onpush, so every push to develop shared one group key and cancelled the run before it. Now${{ github.event.number || github.ref }}.ci.ymlgainedpaths-ignorefor**.js,**.vue,**.css,**.ts. Frontend-only changes no longer build a bench to run python tests that cannot have changed.- Dropped the
cypress/.*exclude from the eslint pre-commit hook. Inherited from boilerplate; no such directory ever existed here.
Both files parse as YAML. paths-ignore and the concurrency key only demonstrate themselves on a real PR, so neither is verified beyond that.
The light theme shipped with a control on the host bar and in the lobby row, so only a logged-in host could switch. Players got whatever prefers-color-scheme said and no way out of it, which is backwards: the host is on one laptop they control, the players are on twenty phones in a room whose brightness nobody polled.
ThemeButton.vuewraps the existingcycleThemefromtheme.js. Icon-only (🌗 auto, ☀️ light, 🌙 dark) with the label inaria-labelandtitle, matching the mute button next to it rather than the host bar's text pill, because the player header is a phone header with no room for words.- Placed in the
Play.vueheader beside mute, and absolutely positioned top-right onJoin.vue, which has no chrome of its own.HostBar.vueandHost.vuethen dropped their "Theme: auto" text buttons for the same component, so one glyph means one thing everywhere in the app. - The component carries no styling of its own, only the glyph, the cycling and the label. The host wears it as a
ctlpill, the players as a bare header icon, and.ctlis plain CSS that sits after Tailwind's utilities inindex.css, so a component with default padding could not be overridden into a pill anyway. The player pages repeat the four utility classes rather than the component growing a variant prop. - No state added.
theme.jsalready persists tolocalStorageand applies on load, so a player's choice survives join, the whole game, and a reload.
Real join at 390x844: toggled on the join screen (auto → light), joined a live session with a PIN from create_session, and the choice carried into the play header, where cycling to dark repainted the lobby. Host side at 1280x800: the pill in the quizzes bar and the one in the lobby control row both cycle and repaint. Test sessions deleted afterwards.
Spec: specs/phase-7-avatar-carousel.md. Shipped as specced, Join.vue only, no backend.
-
Every slot keeps the large size and only the face inside scales (
scale-[0.62]unselected,scale-100selected). Sizing the button itself would reflow the row on every tap and jump the strip under the thumb; a transform does not reflow, so the row height and the join button below it stay put. -
The ring, background, scale and opacity all moved onto a
<span>inside the button. The button is now just the fixed slot, so the gold ring hugs the big face instead of a full-size slot around a shrunken one. -
Centring became a
watchon the selection withflush: "post"(plus the existingonMountedcall, since animmediatewatcher fires before the DOM exists).motion-safe:scroll-smoothanimates it, and ismotion-safebecause CSSscroll-behavioris not covered by the global reduced-motion transition-duration override. -
Strip moved out of the desktop right column (
md:col-span-2) and dropped itsmd:max-w-smcap, so desktop reads PIN, nickname, faces, join, the same order a phone already had. -
Then the desktop layout went away entirely:
Join.vuehas nomd:classes left. The two-column grid was only ever there to balance the tall 6-column avatar block, and once the faces were a strip the wide form was two stretched inputs next to each other and nothing else. One centredmax-w-smcolumn at every width, so what a host sees on a laptop is what the players see on their phones. Same reason the strip keeps its gutter bleed on desktop now: the cut-off faces at the edge are what say it scrolls, at any width. -
Strip trim:
.no-scrollbar(new utility inindex.css,scrollbar-width: noneplus the-webkit-scrollbarrule) hides the desktop bar under the row, which was redundant next to the cut-off faces at the gutter.pb-1becamepy-1.5, becauseoverflow-x: autoalso clips vertically and the selected face's ring was losing its top edge. Gap tightened togap-1, since a shrunken 27px face inside a 44px slot already carries most of the spacing.
Headless browser at 390x844 and 1280 wide. Phone: opening random pick lands centred and big, tapping the last face in the roster scrolls the strip to its end (scrollLeft 890 of max 890) with the join button unmoved. Desktop: strip is the full-width row between the inputs and the join button. pre-commit clean on the changed file.
Spec: specs/phase-5-visual-identity.md, both sections rewritten from "Deferred" to what shipped. Phase 5 now has nothing outstanding.
- The spec's plan was to make all eight tokens flip. That was the wrong split, and building it showed why: the four answer inks are the brand. A player learns "red is top-left" once, and a tile that changes hue with the room breaks the one thing the colour scheme exists to do. They do not theme at all.
- What themes is the ground and what sits on it:
night/dusk/haze/paper, plusalert/accent/ok. The second trio is the ember, gold and lagoon hues as text on the ground, which has to carry 4.5:1 and so darkens where the fill below it cannot. One token could not be both a vivid tile and legible small text on white; splitting the roles is what the original plan missed. - Fixed, never themed:
ember,lagoon,gold,orchid(the tiles),sunk(the ink they carry),card(the QR quiet zone, which needs a light ground in either theme). - Tokens are RGB channel triplets, not hex, so Tailwind's opacity modifiers keep working:
text-paper/40is used 13 times. prefers-color-schemepicks the default. A Theme control on the host bar and in the lobby control row overrides it in both directions and persists inlocalStorage. The OS preference alone was not enough: the case this exists for is a bright room, and the laptop driving the projector is usually still set to dark. It is in the lobby row as well as the bar because the bar is hidden once a game starts, which is exactly when a host notices the screen washing out.- Deleted
SHAPES[].hexin passing: dead since phase 5, nothing read it.
- One horizontal
overflow-x: autorow withsnap-x, cappedmax-w-smon desktop. It bleeds past the page gutter on a phone, because the faces cut off at both edges are the only thing that says it scrolls. - The opening pick is random and lands anywhere in the roster, so the selected button scrolls itself into view on mount. Without that the strip opens on the first face and nothing looks chosen.
Full game in a real browser, host at 1440x900 and two guests: picker, lobby, read time, question (with and without an image), reveal, leaderboard, podium, plus both player views, in light and again in dark to check for regressions. Join screen at a real 390x844 viewport in both themes: the strip is one row and the join button clears the fold. Toggle checked to persist across a reload. 56 tests green, pre-commit run --all-files clean.
--color-scheme emulation reverted to dark partway through a browser session more than once, so the OS-preference path was confirmed on the join page and the toggle carried the rest of the run. The two paths set the same tokens.
No spec. Host.vue only, no API and no doctype change. Every host screen was sized for a projector and broke at 390px.
- Lobby: PIN
text-6xlup tosm:text-8xl, QRsize-40up tosm:size-48, padding and gaps scaled, and the PIN block centres under the QR on a phone. The copy button moved inside the join-host<p>as an inline element, so it follows the last line when the URL wraps instead of floating beside the first. - Question: the question text takes its own full-width row below the timer ring and the answer counter (
flex-wrapplusorder-last w-full sm:order-none). Beside a 96px ring and a counter it had a column too narrow to read two words in. The question image caps at 22vh undersm, which keeps all four options on screen without scrolling. - Results and podium: display sizes scaled, podium columns
w-24up tosm:w-36, and leaderboard rows gotgap-3withtruncateon the nickname andshrink-0on the score, which used to collide.
Live on trivia-tap.localhost at 390x844: lobby with and without a player, get-ready, question, closed stats, podium. Desktop at 1440x900 renders identically to before on the lobby and question screens.
- The bench
longqueue is backed up with jobs from other sites behind a single worker, sorun_game_loopnever gets picked up: a started game sits on "Starting…" untilis_abandonedsettles it and the host lands on the podium. Testing ran the loop directly withbench execute trivia_tap.engine.run_game_loop. Bench config, not app code.
Spec: specs/phase-6-navigation.md. Frontend only, no API and no doctype change.
components/HostBar.vueon/host,/host/quizzes,/host/quizzes/:name: wordmark, Host/Quizzes links with the existingdata-onactive treatment, the logged-in user, and logout through the framework's ownlogoutmethod. Rendered by the three screens rather than by a layout route, becauseApp.vueis a bare<router-view />and turning it into a layout host would cost more than the three lines of markup it saves.- The bar is hidden the moment
/hostholds a live session. A lobby, question, or podium is what 40 people in a room look at, and nav on it competes with the PIN. - The per-screen back links it replaces are gone (
← Back to hosting,← All quizzes), as is the picker'sEdit quizzeslink; the picker keeps aNew quizCTA only while the host has no quizzes. readError(e)inapi.jsnow backs every host screen's catch. A non-host used to get whatever the framework said, which names doctypes and permissions; the editor did not translate it at all.End gamenames the cost: "End the game for all N players". A mid-game leave keeps the participant row on purpose (scores), so the count stays truthful after someone walks out.- Player
Leavemoved from the lobby and podium screens into the persistent header, so it exists during a question too. Hidden on the podium, whereBack to joinis already the primary action, and onkicked, where the header does not render.
End to end on trivia-tap.localhost: host nav across all three screens with the active pill correct, bar gone the instant a game started and back after New game, a player leaving mid-question and landing on /join, logout landing on /join, and a logged-out /host bouncing to login with the redirect intact. 56 tests green, pre-commit run --all-files clean.
- Session history nav.
/host/historyis specced in phase 4b and unbuilt. It becomes one more link in the bar and nothing else, which is the point of having the bar in one component now. - Mobile host bar. Plain text links fit at 390px. Collapse it when a fourth or fifth link makes it wrap.
Spec: specs/phase-4a-content-authoring.md. The last slice of phase 4, shipped after 4b/4c and phase 5. Removes the Desk-only authoring constraint: a host now writes and plays a quiz without leaving the SPA.
TT Question.image(Attach Image).question_payloadcarriesimage_url, null when absent, so an image-free quiz publishes exactly what it published before. Rendered contained above the answer grid on both screens: 40vh on the projector, 26vh on the phone, so options never leave the fold.- Authoring runs on
frappe.client.*(get,insert,save,delete), plus one endpoint,list_quizzes, for the per-quiz question count. Four hand-written CRUD APIs were built first and then deleted: they re-implemented the framework, and their by-hand ownership check duplicatedif_owneronTT Quiz. They were not a smaller attack surface either, sincefrappe.client.saveis whitelisted for every logged-in user regardless. - Two rules the standard path imposes on the client, both found by testing and now pinned: send the loaded doc back as it came (frappe refuses a save that drops
creationorowner, and rejects a stalemodified, which is concurrent-edit protection the hand-writtensave_quizsilently lacked), and rebuild the question rows withoutnameoridx(frappe keeps anidxit is given, so rows carrying the old one ignore a reorder). - Validation split by kind. The
TT Quizcontroller owns integrity (at least one question, non-blank text and options,correct_optionin 1..4) so nothing on any path can write a quiz the engine cannot play. The editor owns the 5..120 second range with nativemin/maxand a clamp, because it is an authoring taste: engine tests use 1 to 2 second questions on purpose, and putting the range in the controller broke 26 of them for no gain. /host/quizzes(list, create, delete) and/host/quizzes/:name(editor). Options are edited inside the four game-coloured pills with the correct-option radio in place, so the author sees the player's screen while writing. Image upload is frappe-ui'sFileUploaderagainst the framework'supload_file, no custom endpoint.- The
/hostquiz picker now readslist_quizzesinstead offrappe.client.get_listand links to the editor. - Tests: 5 in
tests/test_authoring.py, covering only what this app adds to the standard path (reorder throughfrappe.client.savelanding inidxorder, the controller's rejections, the question count,LinkExistsErroron a played quiz,image_urlpresent and null). 56 green across the app.
Quiz written entirely in the SPA (two questions, a checkerboard PNG uploaded onto the first), then hosted and played to podium with a real guest in a second browser: image on both host and player question screens, answers scored (1379), podium reached. Desk never opened. The image-free question rendered identically to before. pre-commit run --all-files clean.
- The editor showed
0in an unset time-limit field, because an unset Frappe Int reads back as 0. It now loads as empty and shows the quiz default as the placeholder. - Native radios inside the coloured answer pills rendered as a white disc with a blue dot: frappe-ui's stylesheet fills inputs, and
accent-coloralone could not fix the ground. They areappearance-nonecircles drawn from the border now. - A refused delete printed the framework's link error verbatim, HTML and all, so the host read raw
<a href>markup pointing into Desk. The list now catchesLinkExistsErrorbyexc_typeand says the quiz has been played.
- Drag-reorder. Up/down buttons instead, no dependency. Build drag when a host writes ~30-question quizzes and moving a row means crossing a screenful.
- Uploaded images are not attached to the parent quiz, because a new quiz has no name yet when the picture is picked. They are ordinary public
Filerows, so a removed image leaves a file behind. Attach them (and clean up) when the file list gets noisy.
Spec: specs/phase-5-visual-identity.md. Engine untouched; CSS, markup, and one new component.
- Night-sky direction replaces the Kahoot lookalike. Eight tokens in
tailwind.config.js(night,dusk,haze,paper,ember,lagoon,gold,orchid); the four answer inks differ in hue and lightness so they survive a washed-out projector and colourblind players. - Answer marks are now bolt, spark, moon, hex.
svgFillstays a spelled-out literal per shape, same reason as before: Tailwind only generates class names it can see. - Type: Bricolage Grotesque display, Instrument Sans body, Martian Mono for PIN, timers, scores, ranks. Loaded in
frontend/index.htmlwith system fallbacks. - Signature:
components/DrainRing.vue, one conic-gradient arc behind a radial mask. Time drains out of a ring instead of sliding along a bar, at three sizes across the phone and the projector, pulsing under 5 seconds. - Player question screen is full-bleed 2x2 tiles below a slim question strip, so a thumb reaches any corner.
- Host read time got its own centred screen with a gold countdown; it was a bare left-aligned line with no timer, because the host never started a countdown on
get_ready. Now it does, on both the live event andapplyState. - Answer distribution bars sit in
dusktracks aligned to the answer grid. Before, a 0-vote bar was a hairline floating in a void. - Host controls use
.ctl/.ctl-goinindex.cssrather than frappe-uiButton, which only ships a light theme. - Quality floor: one gold
:focus-visibleoutline for every control,prefers-reduced-motioncollapsing the ring pulse, podium rise, and bar growth, responsive to 390px. - Fixed a pre-existing ruff UP033 in passing:
avatars.load_packnow usesfunctools.cache.
Full 4-question game in a real browser, host at 1440x900 plus two guest phones at 390x844: quiz picker, lobby with QR and player chips, read time, question, locked-in, reveal with distribution and leaderboard, podium, both host and player views. pre-commit run --all-files clean.
Both are written up in the spec with the trigger condition, not just the idea.
- Light theme. App is dark-only. Build it when someone hosts in a bright room and reports it washing out; the answer inks would need their own light-ground values, not an inversion.
- Avatar picker as a scroll strip. The 6-column grid fits at 24 avatars. Build it when a pack ships more than ~30 and the join button drops below the fold.
A full host + two-player run in a real browser turned up three defects, all now fixed.
- Host stuck on a dead game. A session whose loop worker died stayed
Activeforever, andget_live_host_sessionkept handing it back, so the quiz picker never returned and "New game" was a no-op.end_sessionwas no better: it only set a Redis control flag that no loop was left to read.engine.is_abandonednow names the condition (Active, no loop state, older than the state TTL),get_live_host_sessionreaps every abandoned session it walks past,get_host_statesettles a remembered one into its podium, andend_active_sessionends a loopless game directly instead of flagging it. Five tests intest_game_ux.py, including one that a Lobby waiting for players is never reaped. - Countdown bar was invisible. Both the host and player timer bars used
bg-ink-gray-9, which frappe-ui defines as an ink (text) token only, so the fill computed torgba(0,0,0,0)and the bar always read as empty. Nowbg-surface-gray-7. - Locked-in shape rendered black. The confirmation shape built its SVG class at runtime with
fill.replace("bg-", "fill-"), so Tailwind never saw those class names and only generated the ones that happened to appear elsewhere: red worked, blue, amber and green came out black.SHAPESnow carries ansvgFillliteral per shape. - A quiet socket froze a screen for good. Two player tabs stopped receiving events mid-game and never recovered: the design has no polling fallback, and
useSessionRoomonly re-joined the room on aconnectevent that never came. It now tracks the time of the last event and re-joins (plus resyncs) after 20 seconds of silence, so a lost room membership or a reconnect that never lands costs oneget_stateinstead of the rest of the game. Long pauses between questions are normal, hence the generous threshold; verified in the browser that a silent stats pause triggers exactly one resync per interval and an active game triggers none. - Result badge was unreadable. The correct/wrong circle used
bg-surface-green-3andbg-surface-red-3, two tokens with opposite lightness, so no single glyph colour worked: a black ✓ on dark green, then a white ✕ on pale red. Both now use the strong game palette (bg-green-600/bg-red-500) with white glyphs. - Tests leaked their fixtures onto the site. Engine steps commit mid-test, so the framework rollback left every test quiz and session behind; the host's quiz picker had grown to 26 stray "Engine Quiz" entries and 6 sessions stuck
Active.GameTestCase.tearDownnow deletes what it created, and the existing junk was purged.
Full 4-question game, host plus two guests, played through get-ready, live answer counts, reveal with distribution, streak callout and podium. Scores matched the engine (Ada 2043, Grace 2041).
Phase 4 was split into four independently shippable slices (specs/phase-4a..4d); this is the third.
- Avatar packs. A pack is a JSON manifest in
trivia_tap/avatar_packs/holding the roster, the background palette, and the framing;site_config.trivia_tap_avatar_packpicks the active one.trivia_tap/avatars.pyloads it and hands it to the SPA through the existing portal boot context, so the roster has one source of truth and the join path costs no extra request. Shipped pack is DiceBearnotionists(CC0, 24 avatars). yarn build:avatarspre-renderskind: "dicebear"packs to static SVG undertrivia_tap/public/avatars/<pack>/, output committed. The DiceBear libraries are devDependencies only and never reach the runtime bundle; at runtime an avatar id is just an<img>URL, which is also how a boughtkind: "static"pack drops in with no code change.TT Participant.avatar, validated in the controller against the active roster.join_sessiontakes an optionalavatarand falls back to a crc32-of-nickname pick.avatarnow rides along on lobby updates, leaderboards, top-5, streak callouts, podium, andget_state.- Nickname generator: three suggestions with a reroll on the join screen. Word lists live in
trivia_tap/nicknames.pyand reach the SPA through the boot context. - Sound synthesised with Web Audio (
frontend/src/sound.js): countdown tick, submit blip, correct/wrong stings, podium arpeggio, plus a persisted mute toggle on both screens. - Tests: 9 new (
test_avatars.py,test_nicknames.py). 45 green across the app.
Full 4-question game in headless Chrome (host + two players) against the live site: both players picked distinct avatars and generated nicknames, and those avatars showed on the host lobby chips, the live leaderboard, the player header, and both podiums. No console errors from the audio path. Contact sheet of all 24 avatars reviewed at render size.
- No free avatar library matches the 3D-rendered reference look (Inner Teens); that style is a commercial category. The pack system exists so that decision stays reversible: swapping to a bought 3D pack is a manifest plus a folder.
notionistsdraws half-body portraits that read as a cropped torso in a circle. Framing (scale: 140,translateY: 25) is per-pack manifest data, chosen by rendering a comparison sheet.- An unknown avatar id is rejected rather than defaulted, so a stale client or a manifest entry that was never rendered fails loudly instead of showing a blank circle. A test asserts every manifest id has a file on disk.
trivia_tap/avatars.py(module) andtrivia_tap/avatar_packs/(data) are deliberately not the same name; a module and a package directory sharing a name in one directory breaks imports.- Players default to muted and the host defaults to audible: a classroom of phones all unmuting at once is a bad time.
- Lobby background music is dropped from scope. A listenable loop is a composition, not a synth line.
- Cleared three stale
Activesessions from earlier phase testing;get_live_host_sessionpicks the newest live session, so an abandoned one hides the quiz picker forever. Worth a real fix (auto-expire) if it recurs outside tests.
- Player screen (
Play.vue) is one state machine: lobby -> get-ready -> question -> locked-in -> result -> podium, plus a kicked terminal state. Kahoot shapes (triangle/diamond/circle/square, colour keyed to the canonical option id), local countdown bar, per-player answer shuffle seeded by the participant token, result interstitial with correct/wrong, points, streak, rank and top-5. - Host screen (
Host.vue): lobby with giant PIN, client-side QR (qrcode), join URL, name grid, lock/kick/auto-advance/start; game view with live answer count, timer bar, correct-answer reveal, distribution bar chart, top-5 and streak callouts, next/skip/end; podium with a 1-2-3 stand and the full leaderboard. - Engine: a 3-second
get_readyread-the-question pause before each question (own Redis phase, so reconnect lands in it too).questionpayloads now carrywindow_ms(clients count down from receipt, so client clock skew cannot matter) andrandomize_answer_order. - New APIs:
get_host_state(whole host screen in one call; finds the host's live session when no name is passed, so a reload restores mid-game),get_result(own outcome for the interstitial, keeping per-player data out of the broadcast),set_auto_advance(loop re-reads the flag each pause, so it can flip mid-game).get_stategained rank/leaderboard and now resolves Ended sessions so a player who reloads on the podium keeps it. - Nickname profanity filter in
trivia_tap/profanity.py, applied injoin_session: leetspeak folded, matched as a substring against a curated wordlist. - Tests: 9 new in
tests/test_game_ux.py(filter both ways, host state in lobby/mid-question/non-host, own result and rank, podium after reload). 36 green across the app.
Full 4-question game driven in headless Chrome with three browser sessions (host + two players) against the live site and a real RQ worker: get-ready countdown, shapes, per-player shuffle confirmed different for each player, correct/wrong interstitials with points, distribution chart, "Ada is on a 3 answer streak" callout, podium. Player and host both reloaded mid-question and landed back in the right phase with the right remaining time; both also restored the podium after reload. Profanity filter rejected Sh1tLord in the real join form.
- Socket reconnects used to go silently deaf: socket.io reconnects on its own but the server-side room membership is gone, and
tt_joinwas only emitted on mount. Found in E2E when a backgrounded host tab stopped receiving events and missed the podium.useSessionRoomnow re-emitstt_joinon everyconnectand resyncs from the state API. - A centered flex column (
justify-center) clips its own top when the content overflows; the host game view usesm-autoon an inner wrapper instead. - Percentage heights collapse inside an
items-endflex row (the parent's height is content-derived), which is why the first distribution chart rendered blank. - frappe-ui's tailwind preset caps
fontSizeat3xl;5xland6xljoined the existing4xl/8xloverrides. - The game loop occupies one
long-queue worker for the whole game. On this bench a single shared worker serves short/default/long, so an unrelated stuck job stalls every game; deployment wants dedicated long workers.
trivia_tap/engine.py: RQ game loop (queue="long",job_id=tt_session_{name},deduplicate, timeout sized to quiz length). Per question: Redis state write,questionpublish (no correct answer, serverdeadline_ts), sleep-with-poll until deadline + 1s grace, close, score,question_closedpublish (correct option, distribution, top-5, streak callouts >= 3), then auto-advance after 5s stats or wait for host (capped at 5 min, then advances anyway). After last question: ranks persisted,podiumpublished, status Ended, Redis state cleared.- Redis keys per spec:
tt:{session}:state(dict, TTL window+30s),tt:{session}:answered:{question_row}(set, duplicate pre-check), plustt:{session}:controlfor host commands (skip/advance/end) polled by the loop. Host controls never touch the loop process directly; the flag survives web/worker process boundary. - Scoring: Kahoot formula,
response_msclamped to window so grace submits floor at 500 base. Streak bonus capped at 250, multiplier 0/1/2. Non-answerers get streak reset at close. - APIs: host
start_session(Lobby -> Active, enqueue loop, rejects empty lobby),next_question,skip_question,end_session(Lobby -> Cancelled, Active -> control flag). Guestsubmit_answer(full gauntlet in spec order, returns only{"ok": true}, publishesanswer_count) andget_state(reconnect: phase, question sans answer,remaining_seconds, own score/answered). Both token-scoped rate-limited. - Tests: 19 in
tests/test_engine.py, all green. Whole spec checklist covered: late/duplicate/wrong-question/kicked rejection, DB unique constraint as final word (Redis pre-check bypassed), scoring boundaries + streak reset + multipliers, nocorrectsubstring in any pre-close payload, reconnect remaining time, full loop to podium with scripted answers.
Full game played start to podium over HTTP against the live site with the real RQ worker (2 players, 2 questions): questions arrived with correct remaining time, correct answer absent from payloads, duplicate submits got 417, scores/streaks/ranks persisted exactly per formula (checked in DB: 946 + 1982 = 2928, streak 2, rank 1), session Ended with podium event.
- Loop commits after each publish so
after_commitrealtime events flush from the worker; submits land in separate web transactions and are visible at close. wait_before_nextaccepts hostadvanceeven during the 5s stats pause; auto-advance mode ignores stray flags.- Host game-screen state API deliberately deferred to phase 3 (spec lists only guest
get_state); host reconnect currently rides on the socket events.
- All five DocTypes per spec:
TT Quiz(+ childTT Question),TT Session,TT Participant,TT Answer. Permissions as specified: Quiz Hostif_owneron Quiz/Session, System Manager only on Participant/Answer.TT Answergets a DB-level unique index on (participant, question_row) viaon_doctype_update. trivia_tap/api.py: host APIscreate_session,lock_lobby,unlock_lobby,kick_participant,get_lobby(host-only via session.host check); guest APIsjoin_session,leave_session(allow_guest, IP rate-limited 10/min). Tokens: 32-byte random, sha256 stored, raw returned once. Lobby changes publishlobby_update(andkicked) to roomtt_session_{pin}withafter_commit=True.- Nickname uniqueness (per session, non-kicked) validated in the
TT Participantcontroller; kicked nicknames are freed for reuse. trivia_tap/www/trivia_tap.pyboot context injectscsrf_token+site_nameso frappe-ui requests work for logged-in hosts.- Frontend:
Join.vue(PIN prefilled from?pin=, nickname, error display),Play.vue(waiting room, live lobby count, kicked banner, leave),Host.vue(quiz picker, giant PIN, join link, live participant chips, lock toggle, click-to-kick). Player identity kept in localStorage (player.js), thinapi.jswrapper overfrappeRequest. - Tests: 8 integration tests in
trivia_tap/tests/test_api.py, all green (bench --site trivia-tap.localhost run-tests --module trivia_tap.tests.test_api; neededset-config allow_tests trueonce).
All checked E2E: over HTTP+socket (node client: join publishes lobby_update into the room), and in a real headless browser (two tabs, host + player): PIN/QR-link join, name pops on host screen live, wrong PIN 404, locked lobby 417 (error shown in UI), duplicate nickname 409, kick 200 with player seeing "The host removed you" and the kicked token rejected (403).
- frappe-ui's tailwind preset caps
fontSizeat3xl(24px); display sizes (4xl,8xl) added intailwind.config.jsfor the big PIN. frappe.rate_limiter.rate_limitno-ops whenfrappe.requestis absent, so direct calls in tests skip rate limits.- Deliberate shortcuts: host lobby state is in-memory (page refresh loses the session view; rejoin comes with
get_statein phase 2);leave_sessiondeletes the participant row and only in Lobby status.
- App
trivia_tapinstalled ontrivia-tap.localhost(moduleTriviaTap). - Role
Quiz Hostcreated via fixture (trivia_tap/fixtures/role.json, synced on migrate). - SPA scaffold in
frontend/: Vue 3 + frappe-ui + Vite, socket.io-client, vue-router with/join,/play,/hoststubs. Production build outputs totrivia_tap/public/frontendand writestrivia_tap/www/trivia_tap.html. Served at/trivia-tap/*viawebsite_route_rules. Verified:yarn buildpasses,/trivia-tap/joinreturns the SPA,yarn devruns.
Question: can a guest (no login) socket.io connection receive events published to a custom room?
Answer: yes, verified empirically on this bench (frappe develop, v17):
- Guest sockets authenticate with the
sid=Guestcookie.frappe.realtime.get_user_inforeturnsinstalled_appsfrom the site (not the user), so app-level socket handlers load for guests too. - The realtime node server loads
apps/<app>/realtime/handlers.jsper connecting socket.trivia_tap/realtime/handlers.jsregisterstt_join/tt_leave, which join/leave roomtt_session_{pin}(PIN validated as 6 digits). - Test: node socket.io-client connected as Guest, emitted
tt_join 123456, thenfrappe.publish_realtime(event="tt_session_123456", room="tt_session_123456")from the server. Event received by the guest client.
Consequence: players subscribe over socket.io (tt_join after joining a session). No 1s polling fallback is built. get_state stays planned for reconnect only.
- Bench runs frappe v17.x-develop, not stable v16 as plan.md assumes. Spike result applies to this version.
bench startmust be restarted after installing a new app: web workers only pick up the editable install at interpreter startup (symptom:ModuleNotFoundError: No module named 'trivia_tap'on every request).- Found and cleared a stale global
maintenance_mode: 1incommon_site_config.jsonthat 503'd every site on the bench.