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 quizzly.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 quizzly.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) onQZ Question,show_explanation(Check) onQZ 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
quizzly.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 Quizzly 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 onqz_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/quizzly/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, soqz_join/qz_leavenever register and no socket ever joins the session room. - Fix:
apps/quizzly/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 quizzly_handlers). Needs a socketio restart to take effect (the running server cached the failed load).
run-tests --app quizzly: 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("QZ Participant", {name: {"rank": rank}})instead of oneset_valueper player.bulk_updatebuilds chunked CASE-WHEN UPDATEs and does not commit internally, so the ticker'sqz_ticksavepoint isolation (Phase 2) is intact. It also no-ops on an empty dict, so a question nobody answered is safe.
run-tests --app quizzly: 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 aqz_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 dedupedqz_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 oneqz_tickerjob the entire run (max concurrent qz_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="qz_ticker",deduplicate=True, queuelong). Each pass readsqz: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 againstquizzly.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 quizzly.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 quizzly.localhost run-tests --app quizzly: 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: quizzly 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-quizzly-${{ 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 quizzly.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 quizzly.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 quizzly.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.
QZ 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_owneronQZ 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
QZ 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
quizzly/avatar_packs/holding the roster, the background palette, and the framing;site_config.quizzly_avatar_packpicks the active one.quizzly/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 underquizzly/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.QZ 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
quizzly/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.
quizzly/avatars.py(module) andquizzly/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
quizzly/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
qz_joinwas only emitted on mount. Found in E2E when a backgrounded host tab stopped receiving events and missed the podium.useSessionRoomnow re-emitsqz_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.
quizzly/engine.py: RQ game loop (queue="long",job_id=qz_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:
qz:{session}:state(dict, TTL window+30s),qz:{session}:answered:{question_row}(set, duplicate pre-check), plusqz:{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:
QZ Quiz(+ childQZ Question),QZ Session,QZ Participant,QZ Answer. Permissions as specified: Quiz Hostif_owneron Quiz/Session, System Manager only on Participant/Answer.QZ Answergets a DB-level unique index on (participant, question_row) viaon_doctype_update. quizzly/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 roomqz_session_{pin}withafter_commit=True.- Nickname uniqueness (per session, non-kicked) validated in the
QZ Participantcontroller; kicked nicknames are freed for reuse. quizzly/www/quizzly.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
quizzly/tests/test_api.py, all green (bench --site quizzly.localhost run-tests --module quizzly.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
quizzlyinstalled onquizzly.localhost(moduleQuizzly). - Role
Quiz Hostcreated via fixture (quizzly/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 toquizzly/public/frontendand writesquizzly/www/quizzly.html. Served at/quizzly/*viawebsite_route_rules. Verified:yarn buildpasses,/quizzly/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.quizzly/realtime/handlers.jsregistersqz_join/qz_leave, which join/leave roomqz_session_{pin}(PIN validated as 6 digits). - Test: node socket.io-client connected as Guest, emitted
qz_join 123456, thenfrappe.publish_realtime(event="qz_session_123456", room="qz_session_123456")from the server. Event received by the guest client.
Consequence: players subscribe over socket.io (qz_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 'quizzly'on every request).- Found and cleared a stale global
maintenance_mode: 1incommon_site_config.jsonthat 503'd every site on the bench.