diff --git a/packages/shared/src/components/MainFeedLayout.tsx b/packages/shared/src/components/MainFeedLayout.tsx
index 51d7fa2d434..5ba65b1606c 100644
--- a/packages/shared/src/components/MainFeedLayout.tsx
+++ b/packages/shared/src/components/MainFeedLayout.tsx
@@ -24,6 +24,7 @@ import { useFeeds } from '../hooks/feed/useFeeds';
import { WebappShortcutsRow } from '../features/shortcuts/components/WebappShortcutsRow';
import { LiveStandupsStrip } from './liveRooms/LiveStandupsStrip';
import { AskSearchBanner } from './marketing/banners/AskSearchBanner';
+import { WeeklyQuizBanner } from '../features/weeklyQuiz/components/WeeklyQuizBanner';
import { FeedEngagementBanner } from './brand/FeedEngagementBanner';
import FeedContext from '../contexts/FeedContext';
import feedStyles from './Feed.module.css';
@@ -803,6 +804,7 @@ export default function MainFeedLayout({
>
+ {isHomePage && }
{isHomePage && (
)}
diff --git a/packages/shared/src/components/icons/Swords/filled.svg b/packages/shared/src/components/icons/Swords/filled.svg
new file mode 100644
index 00000000000..3bec8a75c5b
--- /dev/null
+++ b/packages/shared/src/components/icons/Swords/filled.svg
@@ -0,0 +1,7 @@
+
+
diff --git a/packages/shared/src/components/icons/Swords/index.tsx b/packages/shared/src/components/icons/Swords/index.tsx
new file mode 100644
index 00000000000..3665dedd98d
--- /dev/null
+++ b/packages/shared/src/components/icons/Swords/index.tsx
@@ -0,0 +1,10 @@
+import type { ReactElement } from 'react';
+import React from 'react';
+import type { IconProps } from '../../Icon';
+import Icon from '../../Icon';
+import OutlinedIcon from './outlined.svg';
+import FilledIcon from './filled.svg';
+
+export const SwordsIcon = (props: IconProps): ReactElement => (
+
+);
diff --git a/packages/shared/src/components/icons/Swords/outlined.svg b/packages/shared/src/components/icons/Swords/outlined.svg
new file mode 100644
index 00000000000..1d2be0a9581
--- /dev/null
+++ b/packages/shared/src/components/icons/Swords/outlined.svg
@@ -0,0 +1,7 @@
+
+
diff --git a/packages/shared/src/components/icons/VolumeLow/filled.svg b/packages/shared/src/components/icons/VolumeLow/filled.svg
new file mode 100644
index 00000000000..48025d4c77b
--- /dev/null
+++ b/packages/shared/src/components/icons/VolumeLow/filled.svg
@@ -0,0 +1,7 @@
+
+
diff --git a/packages/shared/src/components/icons/VolumeLow/index.tsx b/packages/shared/src/components/icons/VolumeLow/index.tsx
new file mode 100644
index 00000000000..805f5143ca9
--- /dev/null
+++ b/packages/shared/src/components/icons/VolumeLow/index.tsx
@@ -0,0 +1,10 @@
+import type { ReactElement } from 'react';
+import React from 'react';
+import type { IconProps } from '../../Icon';
+import Icon from '../../Icon';
+import OutlinedIcon from './outlined.svg';
+import FilledIcon from './filled.svg';
+
+export const VolumeLowIcon = (props: IconProps): ReactElement => (
+
+);
diff --git a/packages/shared/src/components/icons/VolumeLow/outlined.svg b/packages/shared/src/components/icons/VolumeLow/outlined.svg
new file mode 100644
index 00000000000..984ee846102
--- /dev/null
+++ b/packages/shared/src/components/icons/VolumeLow/outlined.svg
@@ -0,0 +1,7 @@
+
+
diff --git a/packages/shared/src/components/icons/index.ts b/packages/shared/src/components/icons/index.ts
index a8506ac6745..ac0abaf3a42 100644
--- a/packages/shared/src/components/icons/index.ts
+++ b/packages/shared/src/components/icons/index.ts
@@ -178,7 +178,9 @@ export * from './Upvote';
export * from './User';
export * from './UserShare';
export * from './V';
+export * from './Swords';
export * from './Volume';
+export * from './VolumeLow';
export * from './VolumeOff';
export * from './Warning';
export * from './Whatsapp';
diff --git a/packages/shared/src/components/modals/common.tsx b/packages/shared/src/components/modals/common.tsx
index 56849f3a97d..1aac9d9bb21 100644
--- a/packages/shared/src/components/modals/common.tsx
+++ b/packages/shared/src/components/modals/common.tsx
@@ -526,6 +526,13 @@ const PostImpressionsModal = dynamic(
),
);
+const WeeklyQuizModal = dynamic(
+ () =>
+ import(
+ /* webpackChunkName: "weeklyQuizModal" */ '../../features/weeklyQuiz/components/WeeklyQuizModal'
+ ),
+);
+
export const modals = {
[LazyModal.SquadMember]: SquadMemberModal,
[LazyModal.UpvotedPopup]: UpvotedPopupModal,
@@ -611,6 +618,7 @@ export const modals = {
[LazyModal.ReaderExtensionInstall]: ReaderExtensionInstallModal,
[LazyModal.ReaderPreview]: ReaderPreviewLazyModal,
[LazyModal.PostImpressions]: PostImpressionsModal,
+ [LazyModal.WeeklyQuiz]: WeeklyQuizModal,
};
type GetComponentProps = T extends
diff --git a/packages/shared/src/components/modals/common/types.ts b/packages/shared/src/components/modals/common/types.ts
index 66e87110334..69d98e5bc80 100644
--- a/packages/shared/src/components/modals/common/types.ts
+++ b/packages/shared/src/components/modals/common/types.ts
@@ -109,6 +109,7 @@ export enum LazyModal {
ReaderExtensionInstall = 'readerExtensionInstall',
ReaderPreview = 'readerPreview',
PostImpressions = 'postImpressions',
+ WeeklyQuiz = 'weeklyQuiz',
}
export type ModalTabItem = {
diff --git a/packages/shared/src/components/widgets/SocialShareList.tsx b/packages/shared/src/components/widgets/SocialShareList.tsx
index 61761d6626f..492cf3cb5c5 100644
--- a/packages/shared/src/components/widgets/SocialShareList.tsx
+++ b/packages/shared/src/components/widgets/SocialShareList.tsx
@@ -27,6 +27,11 @@ interface SocialShareListProps {
onNativeShare(): void;
onClickSocial(provider: ShareProvider): void;
shortenUrl?: boolean;
+ // When set and the device can share files (mobile), the social buttons open
+ // the native share sheet with this image attached instead of the platform's
+ // web intent — the only client-side way to share a real image (web intents
+ // carry text + link only). Email/Copy keep their dedicated behavior.
+ nativeShareFile?: File | null;
}
export function SocialShareList({
@@ -39,6 +44,7 @@ export function SocialShareList({
onNativeShare,
onClickSocial,
shortenUrl = true,
+ nativeShareFile,
}: SocialShareListProps): ReactElement {
const { getShortUrl } = useGetShortUrl();
@@ -46,6 +52,20 @@ export function SocialShareList({
onClickSocial(provider);
const isEmailShare = provider === ShareProvider.Email;
+ // On mobile, route the platform buttons through the native share sheet so
+ // the image rides along (web intents can't attach files). Called with no
+ // prior await to stay inside the tap gesture. Email keeps its mailto.
+ if (
+ !isEmailShare &&
+ nativeShareFile &&
+ globalThis.navigator?.canShare?.({ files: [nativeShareFile] })
+ ) {
+ navigator
+ .share({ text: `${description} ${link}`, files: [nativeShareFile] })
+ .catch(() => undefined);
+ return;
+ }
+
const shortLink = shortenUrl ? await getShortUrl(link) : link;
const shareLink = getShareLink({
provider,
diff --git a/packages/shared/src/features/weeklyQuiz/WeeklyQuiz.module.css b/packages/shared/src/features/weeklyQuiz/WeeklyQuiz.module.css
new file mode 100644
index 00000000000..9057ca5f25c
--- /dev/null
+++ b/packages/shared/src/features/weeklyQuiz/WeeklyQuiz.module.css
@@ -0,0 +1,840 @@
+/* Arcade skin for the Weekly Quiz. All colors are theme tokens
+ * (var(--theme-accent-*)) composed via color-mix, so the vivid look still
+ * tracks the design system and stays theme-aware — no raw palette values. The
+ * sweep is a deliberate, always-vivid game surface, so text on it is white. */
+
+/* Self-hosted "Play" — the quiz's display font. Files live in each app's
+ * public/fonts dir; scoped to the quiz via .surface (children inherit). */
+@font-face {
+ font-family: 'Play';
+ font-style: normal;
+ font-weight: 400;
+ font-display: swap;
+ src: url('/fonts/play-400.woff2') format('woff2');
+}
+@font-face {
+ font-family: 'Play';
+ font-style: normal;
+ font-weight: 700;
+ font-display: swap;
+ src: url('/fonts/play-700.woff2') format('woff2');
+}
+
+.surface {
+ position: relative;
+ isolation: isolate;
+ overflow: hidden;
+ /* Match daily.dev modals/cards: neutral surface. Font inherits the app's
+ * default sans stack (no more arcade "Play"). Text tokens are applied per
+ * element (see components). The border + 16px radius are set on the JSX so
+ * mobile can go full-bleed (edge-to-edge, no frame) while tablet+ keeps the
+ * card look. */
+ color: var(--theme-text-primary);
+ background: var(--theme-background-default);
+}
+
+/* Faint sunburst rays fanning down from the top, like the references. */
+.rays {
+ position: absolute;
+ inset: -20% -20% auto -20%;
+ height: 80%;
+ z-index: -1;
+ pointer-events: none;
+ background: repeating-conic-gradient(
+ from 0deg at 50% 0%,
+ rgba(255, 255, 255, 0.07) 0deg 5deg,
+ transparent 5deg 12deg
+ );
+ mask-image: radial-gradient(70% 100% at 50% 0%, #000 0%, transparent 72%);
+ -webkit-mask-image: radial-gradient(
+ 70% 100% at 50% 0%,
+ #000 0%,
+ transparent 72%
+ );
+}
+
+/* Logo wrapper — eases back to rest after a mouse-driven 3D tilt. */
+.logoTilt {
+ transition: transform 0.18s ease-out;
+ transform-style: preserve-3d;
+ will-change: transform;
+}
+
+/* Animated brand aura behind the logo: a soft, slowly-rotating multicolor glow
+ * (onion → cabbage → bacon → bun) blurred into an aurora. Sits behind the logo
+ * art, adding life to the otherwise-neutral surface without arcade noise. */
+.logoAura {
+ position: absolute;
+ inset: -22%;
+ border-radius: 9999px;
+ background: conic-gradient(
+ from 0deg,
+ var(--theme-accent-onion-default),
+ var(--theme-accent-cabbage-default),
+ var(--theme-accent-bacon-default),
+ var(--theme-accent-bun-default),
+ var(--theme-accent-cabbage-default),
+ var(--theme-accent-onion-default)
+ );
+ filter: blur(30px);
+ opacity: 0.5;
+ pointer-events: none;
+ animation: logo-aura-spin 14s linear infinite;
+}
+@keyframes logo-aura-spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+@media (prefers-reduced-motion: reduce) {
+ .logoAura {
+ animation: none;
+ }
+}
+
+/* A few A4 sheets drifting down the surface behind the mascot — a calm, sparse
+ * loop. Fills the whole surface so sheets fall from the top edge; clipped by the
+ * surface's own overflow. */
+.papers {
+ position: absolute;
+ inset: 0;
+ overflow: hidden;
+ pointer-events: none;
+ z-index: 0;
+}
+.paper {
+ position: absolute;
+ top: 0;
+ /* How far each sheet travels before it exits. Desktop keeps the card-height
+ * fall; mobile overrides it to a full viewport height (see media query). */
+ --paper-fall: 720px;
+ width: 30px;
+ height: 42px; /* ~A4 ratio */
+ border-radius: 3px;
+ background: rgba(255, 255, 255, 0.9);
+ box-shadow: 0 3px 8px rgba(0, 0, 0, 0.35);
+ /* `both` fill-mode holds the 0% (hidden, above the edge) state during the
+ * per-sheet delay, so nothing sits frozen mid-air before its turn. */
+ animation: paper-fall linear infinite both;
+ will-change: transform, opacity;
+}
+/* Faint ruled lines so each sheet reads as a page. */
+.paper::before {
+ content: '';
+ position: absolute;
+ inset: 8px 6px auto 6px;
+ height: 2px;
+ background: rgba(0, 0, 0, 0.12);
+ box-shadow:
+ 0 6px 0 rgba(0, 0, 0, 0.1),
+ 0 12px 0 rgba(0, 0, 0, 0.08);
+}
+@keyframes paper-fall {
+ 0% {
+ transform: translateY(-40px) rotate(-8deg);
+ opacity: 0;
+ }
+ 12% {
+ opacity: 0.9;
+ }
+ 88% {
+ opacity: 0.9;
+ }
+ 100% {
+ transform: translateY(var(--paper-fall, 720px)) rotate(22deg);
+ opacity: 0;
+ }
+}
+/* Biased to the right half so the sheets fall behind the mascot. */
+.paper:nth-child(1) {
+ left: 60%;
+ animation-duration: 7.5s;
+ animation-delay: 0s;
+}
+.paper:nth-child(2) {
+ left: 72%;
+ animation-duration: 9s;
+ animation-delay: 2.4s;
+}
+.paper:nth-child(3) {
+ left: 84%;
+ animation-duration: 8.2s;
+ animation-delay: 4.3s;
+}
+.paper:nth-child(4) {
+ left: 92%;
+ animation-duration: 9.6s;
+ animation-delay: 1.2s;
+}
+/* Extra sheets that only join in when the Start button is hovered. */
+.paper:nth-child(5) {
+ left: 54%;
+ animation-duration: 8.8s;
+ animation-delay: 3.1s;
+}
+.paper:nth-child(6) {
+ left: 66%;
+ animation-duration: 7.8s;
+ animation-delay: 0.6s;
+}
+.paper:nth-child(7) {
+ left: 78%;
+ animation-duration: 9.2s;
+ animation-delay: 5s;
+}
+.paper:nth-child(8) {
+ left: 88%;
+ animation-duration: 8s;
+ animation-delay: 2s;
+}
+.paperExtra {
+ display: none;
+}
+/* Hovering Start makes it rain: reveal the extra sheets (the speed-up is a
+ * smooth playback-rate ramp driven in JS, so it eases rather than jumps). */
+.papers.papersBoost .paperExtra {
+ display: block;
+}
+/* Mobile: the quiz is full-bleed, so the paper rain covers the whole screen —
+ * sheets spread edge to edge (not biased behind the mascot) and fall a full
+ * viewport height. Desktop keeps the right-biased, card-height fall above. */
+@media (max-width: 655.98px) {
+ /* Span the whole viewport height (not just the intro's content box) so sheets
+ fall past the fold; the surface's overflow clips them at the screen edge. */
+ .papers {
+ height: 100dvh;
+ bottom: auto;
+ }
+ .paper {
+ --paper-fall: 105vh;
+ }
+ .paper:nth-child(1) {
+ left: 8%;
+ }
+ .paper:nth-child(2) {
+ left: 30%;
+ }
+ .paper:nth-child(3) {
+ left: 52%;
+ }
+ .paper:nth-child(4) {
+ left: 74%;
+ }
+ .paper:nth-child(5) {
+ left: 19%;
+ }
+ .paper:nth-child(6) {
+ left: 41%;
+ }
+ .paper:nth-child(7) {
+ left: 63%;
+ }
+ .paper:nth-child(8) {
+ left: 88%;
+ }
+}
+@media (prefers-reduced-motion: reduce) {
+ .papers {
+ display: none;
+ }
+}
+
+/* Warm pulsing glow overlaid on the logo's lightbulb so it reads as "lit".
+ * Screen-blended so it brightens the bulb and spills light around it. */
+.bulbGlow {
+ position: absolute;
+ border-radius: 9999px;
+ pointer-events: none;
+ background: radial-gradient(
+ circle,
+ rgba(255, 236, 170, 0.95) 0%,
+ rgba(255, 186, 70, 0.55) 38%,
+ rgba(255, 186, 70, 0) 70%
+ );
+ mix-blend-mode: screen;
+ filter: blur(3px);
+ animation: bulb-pulse 1.8s ease-in-out infinite;
+}
+@keyframes bulb-pulse {
+ 0%,
+ 100% {
+ opacity: 0.5;
+ transform: scale(0.9);
+ }
+ 50% {
+ opacity: 1;
+ transform: scale(1.15);
+ }
+}
+@media (prefers-reduced-motion: reduce) {
+ .bulbGlow {
+ animation: none;
+ opacity: 0.75;
+ }
+}
+
+/* Side control chip (mute / close) — sits outside the card on the right with
+ * the same white border + gradient sweep as the main surface. */
+.sideControl {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--theme-text-primary);
+ border: 1px solid var(--theme-border-subtlest-secondary);
+ border-radius: 16px;
+ background: var(--theme-background-default);
+ box-shadow: var(--theme-shadow2);
+ transition: filter 0.15s ease, transform 0.15s ease;
+}
+.sideControl:hover {
+ filter: brightness(1.08);
+ transform: translateY(-1px);
+}
+.sideControl:active {
+ transform: translateY(0) scale(0.96);
+}
+
+/* Share popover — a solid, legible popover surface over the leaderboard. */
+.sharePanel {
+ background: var(--theme-background-popover);
+ border: 1px solid var(--theme-border-subtlest-secondary);
+ box-shadow: var(--theme-shadow2);
+}
+
+/* The intro's two cards (left: logo + start + social, right: leaderboard) —
+ * standard daily.dev subtle card surface. */
+.panel {
+ background: var(--theme-background-subtle);
+ border: 1px solid var(--theme-border-subtlest-tertiary);
+}
+
+/* Chunky arcade "candy" button — a diagonal purple->orange face (cabbage into
+ * bun), a solid dark-purple base slab underneath (3D press), thick rim, and a
+ * white bubbly label. Presses down on :active. Colors derive from the accent
+ * tokens so it stays theme-aware and matches the purple surface. */
+.arcadeBtn {
+ position: relative;
+ isolation: isolate;
+ color: #fff;
+ border-radius: 1.25rem;
+ border: 3px solid color-mix(in srgb, var(--theme-accent-onion-default) 55%, #000 45%);
+ background: linear-gradient(
+ 145deg,
+ color-mix(in srgb, var(--theme-accent-cabbage-default) 82%, #fff 18%) 0%,
+ var(--theme-accent-cabbage-default) 32%,
+ var(--theme-accent-bacon-default) 62%,
+ var(--theme-accent-bun-default) 100%
+ );
+ box-shadow:
+ 0 9px 0 color-mix(in srgb, var(--theme-accent-onion-default) 55%, #000 45%),
+ 0 16px 24px rgba(0, 0, 0, 0.32),
+ inset 0 3px 6px rgba(255, 255, 255, 0.55),
+ inset 0 -7px 10px rgba(0, 0, 0, 0.2);
+ transition: transform 0.1s ease, box-shadow 0.1s ease, filter 0.15s ease;
+}
+/* Glossy sheen across the top half. */
+.arcadeBtn::before {
+ content: '';
+ position: absolute;
+ inset: 5px 10px auto 10px;
+ height: 42%;
+ border-radius: 0.9rem;
+ background: linear-gradient(
+ 180deg,
+ rgba(255, 255, 255, 0.6),
+ rgba(255, 255, 255, 0)
+ );
+ pointer-events: none;
+}
+.arcadeBtn:hover:not(:disabled) {
+ filter: brightness(1.05) saturate(1.05);
+}
+.arcadeBtn:active:not(:disabled) {
+ transform: translateY(7px);
+ box-shadow:
+ 0 2px 0 color-mix(in srgb, var(--theme-accent-onion-default) 55%, #000 45%),
+ 0 6px 12px rgba(0, 0, 0, 0.28),
+ inset 0 3px 6px rgba(255, 255, 255, 0.5);
+}
+.arcadeBtn:disabled {
+ opacity: 0.7;
+ cursor: not-allowed;
+}
+
+/* Continuous idle "pop" so the button keeps inviting a tap. Pauses on press so
+ * the depress transform still reads, and off entirely for reduced motion. */
+@keyframes arcade-idle {
+ 0%,
+ 100% {
+ transform: scale(1);
+ }
+ 50% {
+ transform: scale(1.06);
+ }
+}
+.arcadeBtnIdle {
+ animation: arcade-idle 1.5s ease-in-out infinite;
+ transition: transform 0.15s ease;
+}
+/* On hover the button stops pulsing and holds big; the label pops instead. */
+.arcadeBtnIdle:hover:not(:disabled) {
+ animation: none;
+ transform: scale(1.06);
+}
+.arcadeBtnIdle:hover:not(:disabled) :global(.btn-label) {
+ display: inline-block;
+ animation: arcade-label-pop 0.7s ease-in-out infinite;
+}
+.arcadeBtnIdle:active:not(:disabled) {
+ animation: none;
+ transform: scale(0.98);
+}
+.arcadeBtnIdle:disabled {
+ animation: none;
+}
+@media (prefers-reduced-motion: reduce) {
+ .arcadeBtnIdle,
+ .arcadeBtnIdle:hover:not(:disabled) :global(.btn-label) {
+ animation: none;
+ }
+}
+
+/* Label sits still normally; pops on hover (paired with the button holding big). */
+.arcadeBtnLabel {
+ display: inline-block;
+}
+.arcadeBtn:hover:not(:disabled) .arcadeBtnLabel {
+ animation: arcade-label-pop 0.7s ease-in-out infinite;
+}
+@keyframes arcade-label-pop {
+ 0%,
+ 100% {
+ transform: scale(1);
+ }
+ 50% {
+ transform: scale(1.12);
+ }
+}
+@media (prefers-reduced-motion: reduce) {
+ .arcadeBtn:hover:not(:disabled) .arcadeBtnLabel {
+ animation: none;
+ }
+}
+
+/* Green variant of the arcade button (results "Share your result"). Declared
+ * after .arcadeBtn so it overrides the purple->orange face on the shared class.
+ * Avocado candy — matches the game's "go" accent. */
+.arcadeBtnGreen {
+ border-color: color-mix(in srgb, var(--theme-accent-avocado-default) 52%, #000 48%);
+ background: linear-gradient(
+ 180deg,
+ color-mix(in srgb, var(--theme-accent-avocado-default) 65%, #fff 35%) 0%,
+ var(--theme-accent-avocado-default) 52%,
+ color-mix(in srgb, var(--theme-accent-avocado-default) 78%, #000 22%) 100%
+ );
+ box-shadow:
+ 0 9px 0 color-mix(in srgb, var(--theme-accent-avocado-default) 45%, #000 55%),
+ 0 16px 24px rgba(0, 0, 0, 0.32),
+ inset 0 3px 6px rgba(255, 255, 255, 0.6),
+ inset 0 -7px 10px rgba(0, 0, 0, 0.18);
+}
+.arcadeBtnGreen:active:not(:disabled) {
+ box-shadow:
+ 0 2px 0 color-mix(in srgb, var(--theme-accent-avocado-default) 45%, #000 55%),
+ 0 6px 12px rgba(0, 0, 0, 0.28),
+ inset 0 3px 6px rgba(255, 255, 255, 0.5);
+}
+
+/* "Fastest" badge — glossy gold→orange pill with a warm glow and a soft pulse
+ * so the speed award stands out on the row. */
+.fastestBadge {
+ color: #000;
+ background: linear-gradient(
+ 180deg,
+ color-mix(in srgb, var(--theme-accent-cheese-default) 85%, #fff 15%),
+ var(--theme-accent-bun-default)
+ );
+ box-shadow:
+ 0 3px 10px color-mix(in srgb, var(--theme-accent-bun-default) 55%, transparent),
+ inset 0 1px 0 rgba(255, 255, 255, 0.65);
+ animation: fastest-glow 2s ease-in-out infinite;
+}
+@keyframes fastest-glow {
+ 0%,
+ 100% {
+ box-shadow:
+ 0 3px 10px color-mix(in srgb, var(--theme-accent-bun-default) 45%, transparent),
+ inset 0 1px 0 rgba(255, 255, 255, 0.65);
+ }
+ 50% {
+ box-shadow:
+ 0 3px 16px color-mix(in srgb, var(--theme-accent-bun-default) 75%, transparent),
+ inset 0 1px 0 rgba(255, 255, 255, 0.65);
+ }
+}
+@media (prefers-reduced-motion: reduce) {
+ .fastestBadge {
+ animation: none;
+ }
+}
+
+/* "All-time superstar" chip — the reigning weekly champion. A richer
+ * magenta→indigo pill with white text and a glossy shimmer so it clearly
+ * outranks the gold "Fastest" award. */
+.superstarBadge {
+ color: #fff;
+ background: linear-gradient(
+ 180deg,
+ color-mix(in srgb, var(--theme-accent-cabbage-default) 85%, #fff 15%),
+ var(--theme-accent-onion-default)
+ );
+ box-shadow:
+ 0 3px 12px color-mix(in srgb, var(--theme-accent-cabbage-default) 60%, transparent),
+ inset 0 1px 0 rgba(255, 255, 255, 0.55);
+ animation: superstar-glow 2s ease-in-out infinite;
+}
+@keyframes superstar-glow {
+ 0%,
+ 100% {
+ box-shadow:
+ 0 3px 12px color-mix(in srgb, var(--theme-accent-cabbage-default) 45%, transparent),
+ inset 0 1px 0 rgba(255, 255, 255, 0.55);
+ }
+ 50% {
+ box-shadow:
+ 0 3px 18px color-mix(in srgb, var(--theme-accent-cabbage-default) 80%, transparent),
+ inset 0 1px 0 rgba(255, 255, 255, 0.55);
+ }
+}
+@media (prefers-reduced-motion: reduce) {
+ .superstarBadge {
+ animation: none;
+ }
+}
+
+/* Stroke + soft tint around the all-time champion's row (the "superstar" row),
+ * so it stands out without an inline chip crowding the name. */
+.superstarRow {
+ border-color: color-mix(
+ in srgb,
+ var(--theme-accent-cabbage-default) 70%,
+ #fff 30%
+ );
+ background: color-mix(
+ in srgb,
+ var(--theme-accent-cabbage-default) 16%,
+ transparent
+ );
+ box-shadow: 0 0 16px
+ color-mix(in srgb, var(--theme-accent-cabbage-default) 35%, transparent);
+}
+
+/* Results-screen action buttons (share / challenge / remind) — white-bordered
+ * arcade pills that lift on hover, matching the game's surface. */
+.resultAction {
+ display: flex;
+ flex: 1 1 0;
+ min-width: 0;
+ align-items: center;
+ justify-content: center;
+ gap: 0.375rem;
+ padding: 0.75rem 0.5rem;
+ color: #fff;
+ font-weight: 700;
+ /* Keep each label on a single line — shrink text before wrapping. */
+ font-size: 0.8125rem;
+ line-height: 1.25rem;
+ white-space: nowrap;
+ border: 2px solid rgba(255, 255, 255, 0.3);
+ border-radius: 16px;
+ background: rgba(255, 255, 255, 0.12);
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2);
+ transition: transform 0.15s ease, background 0.15s ease, border-color 0.15s ease;
+}
+.resultAction:hover {
+ transform: translateY(-2px);
+ border-color: rgba(255, 255, 255, 0.6);
+ background: rgba(255, 255, 255, 0.2);
+}
+.resultAction:active {
+ transform: translateY(0) scale(0.98);
+}
+/* Confirmed "reminder set" state — avocado tint so it reads as done. */
+.resultActionSet,
+.resultActionSet:hover {
+ border-color: var(--theme-accent-avocado-default);
+ background: color-mix(
+ in srgb,
+ var(--theme-accent-avocado-default) 28%,
+ transparent
+ );
+}
+
+/* Reminder button under the "try again next week" note — a small pill with a
+ * bell icon and a short label. */
+.reminderButton {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.375rem;
+ padding: 0.375rem 0.875rem;
+ color: #fff;
+ font-weight: 700;
+ font-size: 0.8125rem;
+ line-height: 1.25rem;
+ border: 1px solid rgba(255, 255, 255, 0.35);
+ border-radius: 9999px;
+ background: rgba(255, 255, 255, 0.14);
+ transition: transform 0.15s ease, background 0.15s ease, border-color 0.15s ease;
+}
+.reminderButton:hover {
+ border-color: rgba(255, 255, 255, 0.65);
+ background: rgba(255, 255, 255, 0.22);
+}
+.reminderButton:active {
+ transform: scale(0.97);
+}
+.reminderButtonSet,
+.reminderButtonSet:hover {
+ border-color: var(--theme-accent-avocado-default);
+ background: color-mix(
+ in srgb,
+ var(--theme-accent-avocado-default) 28%,
+ transparent
+ );
+}
+
+/* Intro social buttons (Share / Weekly reminder) — equal-size horizontal
+ * pressable chips (icon beside label) with a solid slab underneath so they read
+ * as physical buttons; press down on :active. Fixed width so both match
+ * regardless of label length. */
+.socialButton {
+ display: flex;
+ height: 3rem;
+ width: 100%;
+ flex-direction: row;
+ align-items: center;
+ justify-content: center;
+ gap: 0.5rem;
+ white-space: nowrap;
+ color: #fff;
+ font-weight: 700;
+ border: 1px solid rgba(255, 255, 255, 0.4);
+ border-radius: 16px;
+ background: rgba(255, 255, 255, 0.16);
+ box-shadow:
+ 0 4px 0 rgba(0, 0, 0, 0.22),
+ inset 0 1px 0 rgba(255, 255, 255, 0.3);
+ transition: transform 0.12s ease, background 0.15s ease, box-shadow 0.12s ease,
+ border-color 0.15s ease;
+}
+.socialButton:hover {
+ background: rgba(255, 255, 255, 0.26);
+ border-color: rgba(255, 255, 255, 0.65);
+}
+.socialButton:active {
+ transform: translateY(3px);
+ box-shadow:
+ 0 1px 0 rgba(0, 0, 0, 0.22),
+ inset 0 1px 0 rgba(255, 255, 255, 0.25);
+}
+
+/* Leaderboard scroll area — a slim white/50% scrollbar over the gradient. */
+.scrollArea {
+ scrollbar-width: thin;
+ scrollbar-color: rgba(255, 255, 255, 0.5) transparent;
+}
+.scrollArea::-webkit-scrollbar {
+ width: 6px;
+}
+.scrollArea::-webkit-scrollbar-thumb {
+ background-color: rgba(255, 255, 255, 0.5);
+ border-radius: 9999px;
+}
+.scrollArea::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+/* Translucent glassy chip used for the timer pill and the results login panel. */
+.glass {
+ background: var(--theme-background-subtle);
+ border: 1px solid var(--theme-border-subtlest-tertiary);
+}
+
+/* Answer tile: glassy by default, then glows green/red on reveal. */
+.tile {
+ background: rgba(255, 255, 255, 0.05);
+ border: 2px solid rgba(255, 255, 255, 0.1);
+ transition: transform 0.18s ease, background 0.18s ease, border-color 0.18s ease,
+ box-shadow 0.18s ease;
+}
+/* No positional movement on hover/press — the "raised" feel comes from the
+ * shadow only. A translateY here animated the tile out from under the cursor as
+ * it arrived, so the first click landed outside the moving element and got
+ * swallowed (needing several taps). Shadow + scale never move the hit area. */
+.tile:hover:not(:disabled) {
+ background: rgba(255, 255, 255, 0.1);
+ border-color: rgba(255, 255, 255, 0.3);
+ box-shadow: 0 10px 22px rgba(0, 0, 0, 0.18);
+}
+.tile:active:not(:disabled) {
+ transform: scale(0.98);
+}
+.tileCorrect {
+ background: color-mix(in srgb, var(--theme-accent-avocado-default) 32%, transparent);
+ border-color: var(--theme-accent-avocado-default);
+ box-shadow: 0 0 24px color-mix(in srgb, var(--theme-accent-avocado-default) 55%, transparent);
+}
+.tileWrong {
+ background: color-mix(in srgb, var(--theme-accent-ketchup-default) 30%, transparent);
+ border-color: var(--theme-accent-ketchup-default);
+ box-shadow: 0 0 24px color-mix(in srgb, var(--theme-accent-ketchup-default) 45%, transparent);
+}
+.tileDimmed {
+ opacity: 0.45;
+}
+
+/* Progress bar fill — the arcade sweep. */
+.progressFill {
+ background: linear-gradient(
+ 90deg,
+ var(--theme-accent-onion-default),
+ var(--theme-accent-cabbage-default)
+ );
+ box-shadow: 0 0 12px color-mix(in srgb, var(--theme-accent-cabbage-default) 60%, transparent);
+}
+
+/* Score circle on the results screen. */
+/* Deep purple gradient (matches the brand) — dark enough that the white
+ * score/time text reads clearly. */
+.scoreCircle {
+ background: radial-gradient(
+ circle at 50% 30%,
+ var(--theme-accent-cabbage-default),
+ var(--theme-accent-onion-default) 62%,
+ color-mix(in srgb, var(--theme-accent-onion-default) 62%, #000 38%)
+ );
+ box-shadow:
+ 0 0 40px color-mix(in srgb, var(--theme-accent-cabbage-default) 45%, transparent),
+ inset 0 2px 6px rgba(255, 255, 255, 0.25);
+ color: #fff;
+}
+
+/* Big countdown number. drop-shadow (not box-shadow) so the glow follows the
+ * glyph, not the element's rectangular box (background-clip: text makes the
+ * fill text-shaped, but box-shadow would still draw a rectangle behind it). */
+.countNumber {
+ background: radial-gradient(
+ circle at 50% 35%,
+ #fff,
+ var(--theme-accent-cabbage-default) 55%,
+ var(--theme-accent-onion-default)
+ );
+ -webkit-background-clip: text;
+ background-clip: text;
+ color: transparent;
+ filter: drop-shadow(
+ 0 0 22px color-mix(in srgb, var(--theme-accent-cabbage-default) 60%, transparent)
+ );
+}
+
+/* Arcade banner surface for the feed entry point. */
+.banner {
+ position: relative;
+ overflow: hidden;
+ color: #fff;
+ background: linear-gradient(
+ 120deg,
+ var(--theme-accent-onion-default) 0%,
+ var(--theme-accent-cabbage-default) 60%,
+ color-mix(in srgb, var(--theme-accent-cabbage-default) 70%, var(--theme-accent-bacon-default) 30%) 100%
+ );
+ box-shadow: 0 8px 28px color-mix(in srgb, var(--theme-accent-cabbage-default) 40%, transparent);
+}
+
+/* One-shot celebratory confetti on the results screen — pieces fan out from the
+ * top, fall once, and fade. No loop. Hidden under reduced motion. */
+.confetti {
+ position: absolute;
+ inset: 0;
+ z-index: 20;
+ overflow: visible;
+ pointer-events: none;
+}
+.confettiPiece {
+ position: absolute;
+ left: 50%;
+ top: 0;
+ width: 9px;
+ height: 14px;
+ border-radius: 2px;
+ background: currentColor;
+ opacity: 0;
+ animation-name: weekly-quiz-confetti;
+ animation-timing-function: cubic-bezier(0.2, 0.6, 0.3, 1);
+ animation-iteration-count: 1;
+ animation-fill-mode: forwards;
+}
+@keyframes weekly-quiz-confetti {
+ 0% {
+ transform: translate(-50%, -50%) rotate(0deg);
+ opacity: 1;
+ }
+ 100% {
+ transform: translate(
+ calc(-50% + var(--confetti-dx)),
+ calc(-50% + var(--confetti-dy))
+ )
+ rotate(var(--confetti-r));
+ opacity: 0;
+ }
+}
+@media (prefers-reduced-motion: reduce) {
+ .confetti {
+ display: none;
+ }
+}
+
+/* The player's own leaderboard row — the gold "bar" treatment lifted from the
+ * results badge, so their line pops off the board. Forces dark text/icons on
+ * the descendants (the row content uses light theme tokens by default). */
+.viewerHighlight {
+ border: 0;
+ background: linear-gradient(
+ 180deg,
+ color-mix(in srgb, var(--theme-accent-cheese-default) 88%, #fff 12%),
+ var(--theme-accent-bun-default)
+ );
+ box-shadow: 0 6px 22px
+ color-mix(in srgb, var(--theme-accent-bun-default) 45%, transparent);
+}
+.viewerHighlight.viewerHighlight :global(*) {
+ color: #1a1523 !important;
+}
+
+/* Playful circular "share it" sticker floating over the share row. */
+.shareSticker {
+ background: linear-gradient(
+ 145deg,
+ var(--theme-accent-cabbage-default),
+ var(--theme-accent-onion-default)
+ );
+ color: #fff;
+ box-shadow: 0 8px 24px
+ color-mix(in srgb, var(--theme-accent-cabbage-default) 45%, transparent);
+ transform: rotate(-8deg);
+ animation: weekly-quiz-sticker-wiggle 3.6s ease-in-out infinite;
+}
+@keyframes weekly-quiz-sticker-wiggle {
+ 0%,
+ 100% {
+ transform: rotate(-8deg) translateY(0);
+ }
+ 50% {
+ transform: rotate(-4deg) translateY(-4px);
+ }
+}
+@media (prefers-reduced-motion: reduce) {
+ .shareSticker {
+ animation: none;
+ }
+}
diff --git a/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizAnswerOption.tsx b/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizAnswerOption.tsx
new file mode 100644
index 00000000000..9511f063dae
--- /dev/null
+++ b/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizAnswerOption.tsx
@@ -0,0 +1,75 @@
+import type { ReactElement } from 'react';
+import React from 'react';
+import classNames from 'classnames';
+import {
+ Typography,
+ TypographyType,
+} from '../../../components/typography/Typography';
+import { VIcon, MiniCloseIcon } from '../../../components/icons';
+import { IconSize } from '../../../components/Icon';
+import type { WeeklyQuizOption } from '../types';
+import styles from '../WeeklyQuiz.module.css';
+
+interface WeeklyQuizAnswerOptionProps {
+ option: WeeklyQuizOption;
+ index: number;
+ isSelected: boolean;
+ isAnswered: boolean;
+ onSelect: (optionId: string) => void;
+}
+
+const letters = ['A', 'B', 'C', 'D'];
+
+// A single answer tile. Glassy and poppy before answering; once answered the
+// correct option glows green (and pops) and a wrong pick glows red (and
+// shakes), with the others dimmed.
+export const WeeklyQuizAnswerOption = ({
+ option,
+ index,
+ isSelected,
+ isAnswered,
+ onSelect,
+}: WeeklyQuizAnswerOptionProps): ReactElement => {
+ const showCorrect = isAnswered && option.isCorrect;
+ const showIncorrect = isAnswered && isSelected && !option.isCorrect;
+ const dimmed = isAnswered && !option.isCorrect && !isSelected;
+
+ return (
+
+ );
+};
diff --git a/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizBanner.spec.tsx b/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizBanner.spec.tsx
new file mode 100644
index 00000000000..dc79800f668
--- /dev/null
+++ b/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizBanner.spec.tsx
@@ -0,0 +1,74 @@
+import { render, screen } from '@testing-library/react';
+import React from 'react';
+import { WeeklyQuizBanner } from './WeeklyQuizBanner';
+import { useWeeklyQuizStatus } from '../hooks/useWeeklyQuizStatus';
+import { useConditionalFeature } from '../../../hooks/useConditionalFeature';
+import usePersistentContext from '../../../hooks/usePersistentContext';
+import { useLazyModal } from '../../../hooks/useLazyModal';
+import { useLogContext } from '../../../contexts/LogContext';
+import type { WeeklyQuizStatus } from '../types';
+
+jest.mock('../hooks/useWeeklyQuizStatus');
+jest.mock('../../../hooks/useConditionalFeature');
+jest.mock('../../../hooks/usePersistentContext');
+jest.mock('../../../hooks/useLazyModal');
+jest.mock('../../../contexts/LogContext');
+
+const mockStatus = jest.mocked(useWeeklyQuizStatus);
+const mockFeature = jest.mocked(useConditionalFeature);
+const mockPersistent = jest.mocked(usePersistentContext);
+const mockLazyModal = jest.mocked(useLazyModal);
+const mockLog = jest.mocked(useLogContext);
+
+const setStatus = (partial: Partial) => {
+ mockStatus.mockReturnValue({
+ status: {
+ isActive: true,
+ activeQuizId: 'quiz-1',
+ hasCompletedThisWeek: false,
+ hasCompletedLastWeek: false,
+ thisWeekResult: null,
+ lastWeekResult: null,
+ ...partial,
+ },
+ isPending: false,
+ });
+};
+
+beforeEach(() => {
+ jest.clearAllMocks();
+ setStatus({});
+ mockFeature.mockReturnValue({ value: true, isLoading: false } as never);
+ mockPersistent.mockReturnValue([false, jest.fn(), true, false]);
+ mockLazyModal.mockReturnValue({ openModal: jest.fn() } as never);
+ mockLog.mockReturnValue({ logEvent: jest.fn() } as never);
+});
+
+it('shows the CTA when the quiz is active, flag on, and not dismissed', () => {
+ render();
+ expect(screen.getByText("Let's play")).toBeInTheDocument();
+});
+
+it('renders nothing when the quiz is not active', () => {
+ setStatus({ isActive: false });
+ const { container } = render();
+ expect(container).toBeEmptyDOMElement();
+});
+
+it('renders nothing when the flag is off', () => {
+ mockFeature.mockReturnValue({ value: false, isLoading: false } as never);
+ const { container } = render();
+ expect(container).toBeEmptyDOMElement();
+});
+
+it('renders nothing when dismissed for the week', () => {
+ mockPersistent.mockReturnValue([true, jest.fn(), true, false]);
+ const { container } = render();
+ expect(container).toBeEmptyDOMElement();
+});
+
+it('invites returning players to view the scoreboard once they have played', () => {
+ setStatus({ hasCompletedThisWeek: true });
+ render();
+ expect(screen.getByText('View scoreboard')).toBeInTheDocument();
+});
diff --git a/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizBanner.tsx b/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizBanner.tsx
new file mode 100644
index 00000000000..e52bbbc4c2b
--- /dev/null
+++ b/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizBanner.tsx
@@ -0,0 +1,136 @@
+import type { ReactElement } from 'react';
+import React, { useEffect } from 'react';
+import classNames from 'classnames';
+import {
+ Typography,
+ TypographyType,
+} from '../../../components/typography/Typography';
+import CloseButton from '../../../components/CloseButton';
+import {
+ Button,
+ ButtonColor,
+ ButtonSize,
+ ButtonVariant,
+} from '../../../components/buttons/Button';
+import { useLazyModal } from '../../../hooks/useLazyModal';
+import { LazyModal } from '../../../components/modals/common/types';
+import { useConditionalFeature } from '../../../hooks/useConditionalFeature';
+import { featureWeeklyQuiz } from '../../../lib/featureManagement';
+import { useLogContext } from '../../../contexts/LogContext';
+import { LogEvent, TargetType } from '../../../lib/log';
+import usePersistentContext from '../../../hooks/usePersistentContext';
+import { useWeeklyQuizStatus } from '../hooks/useWeeklyQuizStatus';
+import { isWeeklyQuizDemo } from '../demoMode';
+import styles from '../WeeklyQuiz.module.css';
+
+interface WeeklyQuizBannerProps {
+ className?: string;
+}
+
+// The feed banner that invites developers into the week's quiz. It's gated by
+// the GrowthBook flag AND the server-controlled availability window
+// (status.isActive, the last-two-days schedule), and it's dismissible per week
+// so it can return next week. Rendered in the feed's in-flow promo slot.
+export const WeeklyQuizBanner = ({
+ className,
+}: WeeklyQuizBannerProps): ReactElement | null => {
+ const { openModal } = useLazyModal();
+ const { logEvent } = useLogContext();
+ const { status } = useWeeklyQuizStatus();
+ const isActive = !!status?.isActive;
+
+ const { value: flagEnabled } = useConditionalFeature({
+ feature: featureWeeklyQuiz,
+ shouldEvaluate: isActive,
+ });
+ // Demo mode (?weekly-quiz-demo=1) force-enables the banner for preview testing.
+ const isEnabled = flagEnabled || isWeeklyQuizDemo();
+
+ // Dismissal is keyed by the active quiz so it resets each week.
+ const dismissKey = status?.activeQuizId
+ ? `weekly_quiz_banner_dismissed:${status.activeQuizId}`
+ : 'weekly_quiz_banner_dismissed';
+ const [isDismissed, setIsDismissed, isDismissFetched] =
+ usePersistentContext(dismissKey, false);
+
+ const shouldShow = isActive && isEnabled && !isDismissed && isDismissFetched;
+
+ useEffect(() => {
+ if (!shouldShow) {
+ return;
+ }
+
+ logEvent({
+ event_name: LogEvent.Impression,
+ target_type: TargetType.WeeklyQuiz,
+ });
+ // logEvent identity is stable; re-log only when visibility flips on.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [shouldShow]);
+
+ if (!shouldShow) {
+ return null;
+ }
+
+ const hasPlayed = status?.hasCompletedThisWeek;
+
+ const handleCtaClick = () => {
+ logEvent({
+ event_name: LogEvent.Click,
+ target_type: TargetType.WeeklyQuiz,
+ });
+ openModal({ type: LazyModal.WeeklyQuiz });
+ };
+
+ return (
+
+
+
+ 🎮
+
+
+
+
+ Weekly quiz
+
+
+ {hasPlayed
+ ? 'You already played — see how you rank!'
+ : 'This week in tech news — were you paying attention?'}
+
+
+
+
+ setIsDismissed(true)}
+ />
+
+ );
+};
diff --git a/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizConfetti.tsx b/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizConfetti.tsx
new file mode 100644
index 00000000000..4a550462491
--- /dev/null
+++ b/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizConfetti.tsx
@@ -0,0 +1,72 @@
+import type { CSSProperties, ReactElement } from 'react';
+import React, { useMemo } from 'react';
+import styles from '../WeeklyQuiz.module.css';
+
+// Brand-accent confetti colors.
+const COLORS = [
+ 'var(--theme-accent-cabbage-default)',
+ 'var(--theme-accent-onion-default)',
+ 'var(--theme-accent-cheese-default)',
+ 'var(--theme-accent-avocado-default)',
+ 'var(--theme-accent-bacon-default)',
+ 'var(--theme-accent-bun-default)',
+];
+
+const PIECE_COUNT = 46;
+
+interface Piece {
+ id: number;
+ leftPct: number;
+ dx: number;
+ dy: number;
+ rotate: number;
+ delayMs: number;
+ durationMs: number;
+ color: string;
+}
+
+// A one-shot celebratory rain from the top of the results screen: pieces start
+// spread across the full width and fall the length of the page once, then fade
+// — no loop. Purely decorative; hidden entirely under prefers-reduced-motion
+// (handled in CSS).
+export const WeeklyQuizConfetti = (): ReactElement => {
+ const pieces = useMemo(
+ () =>
+ Array.from({ length: PIECE_COUNT }, (_, index) => ({
+ id: index,
+ // Spread the start position across the whole width.
+ leftPct: Math.random() * 100,
+ // Small horizontal drift; the fall does the work.
+ dx: (Math.random() - 0.5) * 180,
+ // Fall far enough to cover the tall results page.
+ dy: 360 + Math.random() * 680,
+ rotate: (Math.random() - 0.5) * 720,
+ delayMs: Math.round(Math.random() * 240),
+ durationMs: 1100 + Math.round(Math.random() * 900),
+ color: COLORS[index % COLORS.length],
+ })),
+ [],
+ );
+
+ return (
+
+ {pieces.map((piece) => (
+
+ ))}
+
+ );
+};
diff --git a/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizCountdown.tsx b/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizCountdown.tsx
new file mode 100644
index 00000000000..20554fa62fd
--- /dev/null
+++ b/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizCountdown.tsx
@@ -0,0 +1,70 @@
+import type { ReactElement } from 'react';
+import React, { useEffect, useState } from 'react';
+import classNames from 'classnames';
+import {
+ Typography,
+ TypographyType,
+} from '../../../components/typography/Typography';
+import styles from '../WeeklyQuiz.module.css';
+
+interface WeeklyQuizCountdownProps {
+ onComplete: () => void;
+ onTick: () => void;
+}
+
+const SEQUENCE = [5, 4, 3, 2, 1];
+const STEP_MS = 1000;
+
+// A 5-4-3-2-1 countdown before the first question. Each number pops in and fires a
+// beep via onTick; after the last one it calls onComplete, which starts the
+// quiz (and the total timer). Keeps the timer honest by running before the
+// clock, not during it.
+export const WeeklyQuizCountdown = ({
+ onComplete,
+ onTick,
+}: WeeklyQuizCountdownProps): ReactElement => {
+ const [index, setIndex] = useState(0);
+
+ useEffect(() => {
+ onTick();
+ const timeout = window.setTimeout(() => {
+ if (index < SEQUENCE.length - 1) {
+ setIndex((current) => current + 1);
+ } else {
+ onComplete();
+ }
+ }, STEP_MS);
+
+ return () => window.clearTimeout(timeout);
+ // onTick/onComplete are stable (useCallback in the audio/game hooks).
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [index]);
+
+ return (
+
+
+ Get ready…
+
+
+ {SEQUENCE[index]}
+
+
+ Think fast and answer quickly — speed and knowledge both count.
+
+
+ );
+};
diff --git a/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizIntro.tsx b/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizIntro.tsx
new file mode 100644
index 00000000000..7b13681b980
--- /dev/null
+++ b/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizIntro.tsx
@@ -0,0 +1,335 @@
+import type { MouseEvent, ReactElement } from 'react';
+import React, { useEffect, useRef, useState } from 'react';
+import classNames from 'classnames';
+import {
+ Typography,
+ TypographyTag,
+ TypographyType,
+} from '../../../components/typography/Typography';
+import { BellIcon, CalendarIcon, ShareIcon } from '../../../components/icons';
+import { IconSize } from '../../../components/Icon';
+import {
+ Button,
+ ButtonColor,
+ ButtonSize,
+ ButtonVariant,
+} from '../../../components/buttons/Button';
+import {
+ ProfilePicture,
+ ProfileImageSize,
+} from '../../../components/ProfilePicture';
+import { useAuthContext } from '../../../contexts/AuthContext';
+import { WeeklyQuizSharePopover } from './WeeklyQuizSharePopover';
+import type { WeeklyQuiz } from '../types';
+import styles from '../WeeklyQuiz.module.css';
+
+const LOGO_URL = '/logos/weekly-quiz-logo.png';
+
+// How many source logos the intro shows before collapsing the rest into "+N".
+const SHOWN_SOURCES = 5;
+
+interface WeeklyQuizIntroProps {
+ quiz: WeeklyQuiz | undefined;
+ isLoading: boolean;
+ onStart: () => void;
+ // Locked out for the week — already played (server flag or a spent local run).
+ alreadyPlayed?: boolean;
+}
+
+// "Jul 20–26, 2026" from the quiz's inclusive ISO date range. Parsed from parts
+// (not new Date(iso)) so the label doesn't shift a day across timezones.
+const formatWeekRange = (start: string, end: string): string => {
+ const parse = (iso: string): Date => {
+ const [y, m, d] = iso.split('-').map(Number);
+ return new Date(y, m - 1, d);
+ };
+ const s = parse(start);
+ const e = parse(end);
+ const month = (date: Date): string =>
+ date.toLocaleDateString('en-US', { month: 'short' });
+ const year = e.getFullYear();
+ return s.getMonth() === e.getMonth()
+ ? `${month(s)} ${s.getDate()}–${e.getDate()}, ${year}`
+ : `${month(s)} ${s.getDate()} – ${month(e)} ${e.getDate()}, ${year}`;
+};
+
+// The week pill ("Jul 20–26, 2026"). Exported so it can ride on the surface's
+// top line next to the daily.dev logo instead of in the intro body.
+export const WeeklyQuizDateChip = ({
+ startDate,
+ endDate,
+}: {
+ startDate: string;
+ endDate: string;
+}): ReactElement => (
+
+
+ {formatWeekRange(startDate, endDate)}
+
+);
+
+// Landing screen: a single focused column that pitches the challenge — logo, a
+// hook line, the week's context, and the Start button. The leaderboard is not
+// shown here; it lives on the results screen once you've played.
+export const WeeklyQuizIntro = ({
+ quiz,
+ isLoading,
+ onStart,
+ alreadyPlayed = false,
+}: WeeklyQuizIntroProps): ReactElement => {
+ const { user } = useAuthContext();
+ // Local-only until the reminder subscription is wired to the backend.
+ const [reminderSet, setReminderSet] = useState(false);
+ const [isShareOpen, setIsShareOpen] = useState(false);
+ // Hovering (or focusing) Start rains more paper, faster.
+ const [startHovered, setStartHovered] = useState(false);
+ const questionCount = quiz?.questions.length ?? 0;
+
+ // Mouse-driven 3D tilt on the logo: rotate toward the cursor, reset on leave.
+ const logoRef = useRef(null);
+ const tiltLogo = (event: MouseEvent): void => {
+ const el = logoRef.current;
+ if (!el) {
+ return;
+ }
+ const rect = el.getBoundingClientRect();
+ const px = (event.clientX - rect.left) / rect.width - 0.5;
+ const py = (event.clientY - rect.top) / rect.height - 0.5;
+ const max = 16;
+ el.style.transform = `perspective(600px) rotateY(${px * max}deg) rotateX(${
+ -py * max
+ }deg) scale(1.06)`;
+ };
+ const resetLogoTilt = (): void => {
+ if (logoRef.current) {
+ logoRef.current.style.transform = '';
+ }
+ };
+
+ // Smoothly ramp the falling papers' playback rate on Start hover (eases
+ // slow→fast instead of jumping). Setting playbackRate keeps each sheet's
+ // position continuous; only the speed changes.
+ const papersRef = useRef(null);
+ const rateRef = useRef(1);
+ useEffect(() => {
+ const el = papersRef.current;
+ const target = startHovered ? 3.4 : 1;
+ const from = rateRef.current;
+ if (!el || from === target) {
+ return undefined;
+ }
+ const durationMs = 650;
+ let raf = 0;
+ let startTs: number | null = null;
+ const tick = (now: number): void => {
+ if (startTs === null) {
+ startTs = now;
+ }
+ const progress = Math.min(1, (now - startTs) / durationMs);
+ const eased = 1 - (1 - progress) ** 2; // easeOutQuad
+ rateRef.current = from + (target - from) * eased;
+ el.getAnimations({ subtree: true }).forEach((animation) => {
+ // eslint-disable-next-line no-param-reassign
+ animation.playbackRate = rateRef.current;
+ });
+ if (progress < 1) {
+ raf = window.requestAnimationFrame(tick);
+ }
+ };
+ raf = window.requestAnimationFrame(tick);
+ return () => window.cancelAnimationFrame(raf);
+ }, [startHovered]);
+
+ return (
+
+ {/* A few A4 papers drifting down the whole surface, behind the content.
+ Hovering Start reveals the extra sheets and speeds them all up. */}
+
+
+
+
+
+
+
+
+
+
+
+ {/* Logo (on tablet, the right column). */}
+
+ {/* Padding enlarges the mouse-tracked hover box beyond the logo art
+ so the 3D tilt reacts over a more generous area. */}
+
+
+ {/* Animated brand glow behind the logo. */}
+
+
+ {/* Glow sits over the logo's lightbulb (right side, mid-height). */}
+
+
+
+
+
+ {/* Content column (on tablet, the left side). */}
+
+ {/* The challenge pitch — the focus of this screen. */}
+
+
+ The Tech News Quiz
+
+ {/* Mobile only: the week pill sits under the title. On tablet+ it
+ rides the top bar instead (passed as the surface's headerRight). */}
+ {quiz && (
+
+
+
+ )}
+ {quiz?.welcomeText && (
+
+ {quiz.welcomeText}
+
+ )}
+
+
+ {alreadyPlayed ? (
+
+
+
+ Try again next week!
+
+
+ ) : (
+
+ )}
+
+ {/* How much news it distils (the week pill rides on the top bar). */}
+ {quiz && (
+
+
+ {quiz.storyCount} stories from {quiz.sourceCount} sources,
+ distilled into {questionCount} questions.
+
+ {quiz.topSources.length > 0 && (
+
+ );
+};
diff --git a/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizModal.tsx b/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizModal.tsx
new file mode 100644
index 00000000000..bf156e33e78
--- /dev/null
+++ b/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizModal.tsx
@@ -0,0 +1,195 @@
+import type { ReactElement } from 'react';
+import React, { useCallback, useEffect } from 'react';
+import type { ModalProps } from '../../../components/modals/common/Modal';
+import { Modal } from '../../../components/modals/common/Modal';
+import { usePrompt } from '../../../hooks/usePrompt';
+import { useWeeklyQuizStatus } from '../hooks/useWeeklyQuizStatus';
+import { useWeeklyQuiz } from '../hooks/useWeeklyQuiz';
+import { useWeeklyQuizGame, WeeklyQuizPhase } from '../hooks/useWeeklyQuizGame';
+import { useWeeklyQuizAudio } from '../hooks/useWeeklyQuizAudio';
+import { useWeeklyQuizPlayed } from '../hooks/useWeeklyQuizPlayed';
+import { WeeklyQuizIntro, WeeklyQuizDateChip } from './WeeklyQuizIntro';
+import { WeeklyQuizCountdown } from './WeeklyQuizCountdown';
+import { WeeklyQuizQuestion } from './WeeklyQuizQuestion';
+import { WeeklyQuizResults } from './WeeklyQuizResults';
+import { WeeklyQuizSideControls } from './WeeklyQuizSideControls';
+import { WeeklyQuizSurface } from './WeeklyQuizSurface';
+
+// The Weekly Quiz overlay: an intro (welcome text + scoreboard + start), a
+// 3-2-1 countdown, the stepped question flow, and the results. State lives in
+// useWeeklyQuizGame; audio (looping music + countdown beeps) lives in
+// useWeeklyQuizAudio, shared across phases and toggled by the header button.
+function WeeklyQuizModal({
+ onRequestClose,
+ ...props
+}: ModalProps): ReactElement {
+ const { status } = useWeeklyQuizStatus();
+ const quizId = status?.activeQuizId;
+ const { quiz, isPending } = useWeeklyQuiz(quizId);
+ const game = useWeeklyQuizGame(quiz);
+ const audio = useWeeklyQuizAudio();
+ const { showPrompt } = usePrompt();
+ const { hasPlayed, markPlayed } = useWeeklyQuizPlayed(quizId);
+
+ const { phase } = game;
+ const { startMusic, stopMusic } = audio;
+ // The quiz is live once the player leaves the intro (countdown + questions).
+ const isInProgress =
+ phase === WeeklyQuizPhase.Countdown || phase === WeeklyQuizPhase.Question;
+ // A one-shot per week: the server flag, or the local commitment we persist the
+ // moment they start. Either one locks the intro's Start button.
+ const alreadyPlayed = hasPlayed || !!status?.hasCompletedThisWeek;
+
+ // Background music is lobby ambiance: it plays on the intro and results
+ // screens (autoplay is allowed — the modal opens from a click) and stops
+ // during the countdown + questions so gameplay sound effects aren't competing
+ // with the loop. The modal unmount fully releases it (handled in
+ // useWeeklyQuizAudio).
+ useEffect(() => {
+ if (phase === WeeklyQuizPhase.Intro || phase === WeeklyQuizPhase.Results) {
+ startMusic();
+ } else {
+ stopMusic();
+ }
+ }, [phase, startMusic, stopMusic]);
+
+ // Spend the attempt as soon as the player commits (leaves the intro). Persists
+ // right away, so a refresh mid-quiz can't hand them a fresh run.
+ useEffect(() => {
+ if (phase !== WeeklyQuizPhase.Intro) {
+ markPlayed();
+ }
+ }, [phase, markPlayed]);
+
+ // Guard a page refresh / tab close while the quiz is live with the browser's
+ // native "leave site?" prompt. The attempt is already spent, so leaving just
+ // means forfeiting the run.
+ useEffect(() => {
+ if (!isInProgress) {
+ return undefined;
+ }
+ const handler = (event: BeforeUnloadEvent): void => {
+ event.preventDefault();
+ // Required for Chrome to actually show the native leave prompt.
+ // eslint-disable-next-line no-param-reassign
+ event.returnValue = '';
+ };
+ window.addEventListener('beforeunload', handler);
+ return () => window.removeEventListener('beforeunload', handler);
+ }, [isInProgress]);
+
+ // Intercept every in-app close route (X, Esc, overlay) while the quiz is live
+ // to confirm the player really means to forfeit their one run. Void-returning
+ // so it drops straight into onRequestClose / onClick handler slots.
+ const handleRequestClose = useCallback(
+ (event: React.MouseEvent | React.KeyboardEvent): void => {
+ if (!isInProgress) {
+ onRequestClose?.(event);
+ return;
+ }
+ showPrompt({
+ title: 'Leave the quiz?',
+ description:
+ "You only get one run this week. If you leave now your quiz is over — you won't be able to start it again.",
+ okButton: { title: 'Leave quiz' },
+ cancelButton: { title: 'Keep playing' },
+ }).then((confirmed) => {
+ if (confirmed) {
+ onRequestClose?.(event);
+ }
+ });
+ },
+ [isInProgress, onRequestClose, showPrompt],
+ );
+
+ return (
+
+
+
+ }
+ headerRight={
+ phase === WeeklyQuizPhase.Intro && quiz ? (
+ // Desktop only: on mobile the week pill moves above the title.
+
+
+
+ ) : undefined
+ }
+ >
+ {phase === WeeklyQuizPhase.Intro && (
+
+ )}
+ {phase === WeeklyQuizPhase.Countdown && (
+
+ )}
+ {phase === WeeklyQuizPhase.Question && (
+
+ )}
+ {phase === WeeklyQuizPhase.Results && game.result && quizId && (
+
+ }
+ />
+ )}
+
+ {/* Desktop only: the external side column. On mobile the controls move
+ inside the surface header (above the mascot). */}
+
+
+
+
+
+ );
+}
+
+export default WeeklyQuizModal;
diff --git a/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizQuestion.tsx b/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizQuestion.tsx
new file mode 100644
index 00000000000..000418d489a
--- /dev/null
+++ b/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizQuestion.tsx
@@ -0,0 +1,137 @@
+import type { ReactElement } from 'react';
+import React, { useEffect } from 'react';
+import classNames from 'classnames';
+import {
+ Typography,
+ TypographyTag,
+ TypographyType,
+} from '../../../components/typography/Typography';
+import { WeeklyQuizTimer } from './WeeklyQuizTimer';
+import { WeeklyQuizAnswerOption } from './WeeklyQuizAnswerOption';
+import type { UseWeeklyQuizGame } from '../hooks/useWeeklyQuizGame';
+import type { UseWeeklyQuizAudio } from '../hooks/useWeeklyQuizAudio';
+import styles from '../WeeklyQuiz.module.css';
+
+interface WeeklyQuizQuestionProps {
+ game: UseWeeklyQuizGame;
+ audio?: UseWeeklyQuizAudio;
+}
+
+// How long the correct/incorrect reveal stays before auto-advancing.
+const FEEDBACK_MS = 1000;
+
+// One question screen: running timer + progress up top, the prompt and four
+// answer tiles. After an answer is picked the tiles reveal correct/incorrect
+// and the quiz auto-advances after a short pause — no Next button. Audio: a
+// click on answer and a switch sound on advance.
+export const WeeklyQuizQuestion = ({
+ game,
+ audio,
+}: WeeklyQuizQuestionProps): ReactElement | null => {
+ const {
+ question,
+ questionNumber,
+ totalQuestions,
+ selectedOptionId,
+ isAnswered,
+ elapsedMs,
+ answer,
+ next,
+ } = game;
+ // Destructure the stable callbacks so effects don't thrash on the audio
+ // object's per-render identity.
+ const { playAnswer, playCorrect, playSwitch } = audio ?? {};
+
+ const handleSelect = (optionId: string): void => {
+ // Idempotent: pointer-down already selected; ignore the trailing click.
+ if (isAnswered) {
+ return;
+ }
+ // Correct pick gets the celebratory "treasure" sound; otherwise the click.
+ const picked = question?.options.find((option) => option.id === optionId);
+ if (picked?.isCorrect) {
+ playCorrect?.();
+ } else {
+ playAnswer?.();
+ }
+ answer(optionId);
+ };
+
+ // Hold the reveal for a beat, then move on (to the next question or results),
+ // playing the switch sound as we transition.
+ useEffect(() => {
+ if (!isAnswered) {
+ return undefined;
+ }
+ const timeout = window.setTimeout(() => {
+ playSwitch?.();
+ next();
+ }, FEEDBACK_MS);
+ return () => window.clearTimeout(timeout);
+ }, [isAnswered, next, playSwitch]);
+
+ if (!question) {
+ return null;
+ }
+
+ const progress = (questionNumber / totalQuestions) * 100;
+
+ return (
+
+
+
+ Question {questionNumber} of {totalQuestions}
+
+
+
+ );
+};
diff --git a/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizResults.tsx b/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizResults.tsx
new file mode 100644
index 00000000000..91236198f70
--- /dev/null
+++ b/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizResults.tsx
@@ -0,0 +1,534 @@
+import type { ReactElement, ReactNode } from 'react';
+import React, { useEffect, useMemo, useRef, useState } from 'react';
+import classNames from 'classnames';
+import {
+ Typography,
+ TypographyTag,
+ TypographyType,
+} from '../../../components/typography/Typography';
+import { useAuthContext } from '../../../contexts/AuthContext';
+import { AuthTriggers } from '../../../lib/auth';
+import {
+ Button,
+ ButtonSize,
+ ButtonVariant,
+} from '../../../components/buttons/Button';
+import { BellIcon, CopyIcon, DownloadIcon } from '../../../components/icons';
+import { IconSize } from '../../../components/Icon';
+import { SocialShareButton } from '../../../components/widgets/SocialShareButton';
+import { SocialShareList } from '../../../components/widgets/SocialShareList';
+import { WeeklyQuizConfetti } from './WeeklyQuizConfetti';
+import { WeeklyQuizScoreboard } from './WeeklyQuizScoreboard';
+import { WeeklyQuizLogo } from './WeeklyQuizSurface';
+import { useSubmitWeeklyQuiz } from '../hooks/useSubmitWeeklyQuiz';
+import { useWeeklyQuizLeaderboard } from '../hooks/useWeeklyQuizLeaderboard';
+import { useCountUp } from '../hooks/useCountUp';
+import { fallbackImages } from '../../../lib/config';
+import {
+ createWeeklyQuizResultImage,
+ generateWeeklyQuizResultImage,
+} from '../generateResultImage';
+import { isWeeklyQuizDemo } from '../demoMode';
+import type { WeeklyQuizGameResult } from '../hooks/useWeeklyQuizGame';
+import type { UseWeeklyQuizAudio } from '../hooks/useWeeklyQuizAudio';
+import { WeeklyQuizPeriod } from '../types';
+import styles from '../WeeklyQuiz.module.css';
+
+interface WeeklyQuizResultsProps {
+ quizId: string;
+ result: WeeklyQuizGameResult;
+ audio?: UseWeeklyQuizAudio;
+ // Mute + close controls, rendered on the right of the brand header (mobile
+ // only) so the header matches the intro's top bar. Desktop keeps the modal's
+ // external side column.
+ headerControls?: ReactNode;
+}
+
+// A developer "level" keyed to how many answers the player got right — the
+// BuzzFeed-style "who are you" headline for the result, best to worst. Each
+// level carries the min score ratio that earns it, a matching one-liner and a
+// celebratory GIF. GIF URLs are best-effort (open Giphy media); the GIF hides
+// itself if it fails to load. Exported so a Storybook gallery can show them all.
+export interface ResultTier {
+ minRatio: number;
+ title: string;
+ message: string;
+ gif: string;
+}
+
+export const WEEKLY_QUIZ_TIERS: ResultTier[] = [
+ {
+ minRatio: 1,
+ title: '10x AI Engineer',
+ message: 'Flawless. You ship faster than the models can hallucinate.',
+ gif: 'https://media.giphy.com/media/3ohzdIuqJoo8QdKlnW/giphy.gif',
+ },
+ {
+ minRatio: 0.8,
+ title: 'Prompt Wizard',
+ message: 'Sharp. You clearly speak fluent context window.',
+ gif: 'https://media.giphy.com/media/3o7btPCcdNniyf0ArS/giphy.gif',
+ },
+ {
+ minRatio: 0.6,
+ title: 'Autocomplete Hero',
+ message: 'Strong run. You finished more than you missed.',
+ gif: 'https://media.giphy.com/media/13GIgrGdslD9oQ/giphy.gif',
+ },
+ {
+ minRatio: 0.4,
+ title: 'Tab Spammer',
+ message: 'Not bad. A few tabs too many, a few answers too few.',
+ gif: 'https://media.giphy.com/media/YQitE4YNQNahy/giphy.gif',
+ },
+ {
+ minRatio: 0.2,
+ title: 'Copy-Paste Coder',
+ message: 'Rough patch. Time to read the docs, not just paste them.',
+ gif: 'https://media.giphy.com/media/26tn33aiTi1jkl6H6/giphy.gif',
+ },
+ {
+ minRatio: 0,
+ title: 'Asked AI, Still Wrong',
+ message: 'Tough week. Even the AI could not save this one.',
+ gif: 'https://media.giphy.com/media/3oEjHV0z8S7WM4MwnK/giphy.gif',
+ },
+];
+
+const getTier = (correct: number, total: number): ResultTier => {
+ const ratio = total === 0 ? 0 : correct / total;
+ return (
+ WEEKLY_QUIZ_TIERS.find((tier) => ratio >= tier.minRatio) ??
+ WEEKLY_QUIZ_TIERS[WEEKLY_QUIZ_TIERS.length - 1]
+ );
+};
+
+// A fabricated-but-fun "better than X%" percentile, derived from the score. A
+// perfect run never claims to beat everyone.
+const getPercentile = (correct: number, total: number): number => {
+ const ratio = total === 0 ? 0 : correct / total;
+ return Math.min(99, Math.max(1, Math.round(ratio * 100)));
+};
+
+// A circular ring showing a single stat, BuzzFeed-style. `ratio` fills the arc
+// (pass 1 for a full ring). Inline CSS-var strokes track light/dark without raw
+// colors.
+const StatRing = ({
+ value,
+ label,
+ ratio,
+ colorVar,
+}: {
+ value: string;
+ label: string;
+ ratio: number;
+ colorVar: string;
+}): ReactElement => {
+ const radius = 42;
+ const circumference = 2 * Math.PI * radius;
+ const dashOffset = circumference * (1 - Math.min(1, Math.max(0, ratio)));
+ return (
+
+
+
+
+ {value}
+
+
+ {label}
+
+
+
+ );
+};
+
+// Final screen: a BuzzFeed-style verdict — a developer level for the player
+// (score ring on the left, level and copy on the right, with the numbers
+// counting up on reveal), a matching GIF, then the share row, the "challenge a
+// friend" link and the leaderboard (the player's own row gets the gold bar).
+// Logged-in players' results are submitted once on arrival (and again if an
+// anonymous player signs in from here).
+export const WeeklyQuizResults = ({
+ quizId,
+ result,
+ audio,
+ headerControls,
+}: WeeklyQuizResultsProps): ReactElement => {
+ const { user, showLogin } = useAuthContext();
+ const { submit } = useSubmitWeeklyQuiz();
+ // Local-only until the reminder subscription is wired to the backend.
+ const [reminderSet, setReminderSet] = useState(false);
+ const [linkCopied, setLinkCopied] = useState(false);
+ // Best-effort GIF: hide the slot entirely if the open Giphy URL fails to load.
+ const [gifFailed, setGifFailed] = useState(false);
+ // Leaderboard period for the board shown at the bottom of this screen.
+ const [period, setPeriod] = useState(
+ WeeklyQuizPeriod.Weekly,
+ );
+ const submittedRef = useRef(false);
+ // The premade result card as a File, pre-rendered on mount so the native
+ // Share sheet can attach it within the tap gesture (Web Share API level 2 —
+ // the only client-side way to send a custom image, since link-preview images
+ // are scraped from the URL's Open Graph tags server-side). State (not a ref)
+ // so the social buttons re-render to route through the native sheet once it
+ // is ready.
+ const [shareFile, setShareFile] = useState(null);
+ // Shareable quiz link. Points at daily.dev for now (a real, resolvable URL
+ // with proper link-preview metadata) until the dedicated quiz page ships.
+ const quizUrl = 'https://daily.dev';
+
+ const { correctCount, totalQuestions } = result;
+ // Odometer-style reveal: the score, its ring arc and the time count up on
+ // mount (snaps instantly under prefers-reduced-motion).
+ const animatedCorrect = Math.round(
+ useCountUp(correctCount, { durationMs: 900 }),
+ );
+ const ratio = totalQuestions === 0 ? 0 : animatedCorrect / totalQuestions;
+ const tier = getTier(correctCount, totalQuestions);
+ const percentile = getPercentile(correctCount, totalQuestions);
+ const shareText = `I just scored ${correctCount}/${totalQuestions} on the daily.dev Weekly Tech News Quiz. Think you know this week's tech news better than me? Take the quiz:`;
+
+ const copyLink = (): void => {
+ navigator.clipboard
+ ?.writeText(quizUrl)
+ .then(() => setLinkCopied(true))
+ .catch(() => undefined);
+ };
+ // Prefer sharing the premade result image itself (native share sheet, mobile)
+ // so it lands as a photo in WhatsApp/etc.; fall back to a text + link share.
+ const nativeShare = (): void => {
+ const file = shareFile;
+ const message = `${shareText} ${quizUrl}`;
+ const run = async (): Promise => {
+ if (file && navigator.canShare?.({ files: [file] })) {
+ await navigator.share({ text: message, files: [file] });
+ return;
+ }
+ await navigator.share?.({
+ title: 'daily.dev weekly quiz',
+ text: shareText,
+ url: quizUrl,
+ });
+ };
+ // Swallow user-dismissed / unsupported-share rejections.
+ run().catch(() => undefined);
+ };
+
+ // Rank comes from this week's board (the quiz just finished).
+ const { leaderboard, viewerEntry } = useWeeklyQuizLeaderboard(
+ WeeklyQuizPeriod.Weekly,
+ );
+
+ // The player's rank — pinned viewer row when out of the top list, otherwise
+ // their in-list row.
+ const rank =
+ viewerEntry?.rank ??
+ leaderboard.find((entry) => entry.isCurrentUser)?.rank ??
+ null;
+
+ // The premade result-card params, shared by the Download button and the
+ // native Share (falls back to the daily.dev placeholder avatar with no pic).
+ const imageParams = useMemo(
+ () => ({
+ name: user?.name || user?.username || 'You',
+ imageUrl: user?.image || fallbackImages.avatar,
+ title: tier.title,
+ correctCount,
+ totalQuestions,
+ percentile,
+ rank,
+ gifUrl: tier.gif,
+ logoUrl: '/logos/weekly-quiz-logo.png',
+ brandLogoUrl: '/android-chrome-512x512.png',
+ }),
+ [
+ user?.name,
+ user?.username,
+ user?.image,
+ tier.title,
+ tier.gif,
+ correctCount,
+ totalQuestions,
+ percentile,
+ rank,
+ ],
+ );
+
+ // Renders the result as a shareable PNG and downloads it.
+ const handleDownload = (): void => {
+ generateWeeklyQuizResultImage(imageParams, 2).catch(() => undefined);
+ };
+
+ // Pre-render the same card to a File on mount so the native Share sheet can
+ // attach it the instant the player taps Share (staying inside the gesture).
+ useEffect(() => {
+ let cancelled = false;
+ createWeeklyQuizResultImage(imageParams, 2)
+ .then((dataUrl) => {
+ if (cancelled || !dataUrl) {
+ return;
+ }
+ const [meta, base64] = dataUrl.split(',');
+ const mime = meta.match(/:(.*?);/)?.[1] ?? 'image/png';
+ const binary = atob(base64);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i += 1) {
+ bytes[i] = binary.charCodeAt(i);
+ }
+ setShareFile(
+ new File([bytes], 'weekly-tech-news-quiz.png', { type: mime }),
+ );
+ })
+ .catch(() => undefined);
+ return () => {
+ cancelled = true;
+ };
+ }, [imageParams]);
+
+ // Submit once we have an authenticated player — either immediately (already
+ // logged in) or right after they sign in from the prompt below. Skipped in
+ // demo mode (no backend to submit to).
+ useEffect(() => {
+ if (!user || submittedRef.current || isWeeklyQuizDemo()) {
+ return;
+ }
+
+ submittedRef.current = true;
+ submit({
+ quizId,
+ answers: result.answers,
+ timeMs: result.timeMs,
+ }).catch(() => {
+ // Allow a retry if the submission failed.
+ submittedRef.current = false;
+ });
+ }, [user, submit, quizId, result]);
+
+ return (
+ // Mobile: edge-to-edge, no outer padding, so the results fill the screen
+ // like the intro. Tablet+: the padded two-panel card layout.
+
+ {/* One-shot celebratory confetti when the results appear. */}
+
+
+ {/* Chunk 1 — your result: brand header, the verdict headline, GIF and
+ share row. A floating panel, distinct from the panel below. */}
+
+ {/* daily.dev brand header — same markup as the intro's top bar (logo
+ left, controls right on mobile), broken out of the panel padding. */}
+
+ {/* Verdict: the score ring on the left, level and copy on the right. */}
+
+
+
+
+ {tier.title}
+
+
+ You scored better than {percentile}% of players.
+
+
+ {tier.message}
+
+
+
+
+ {/* A 4:3 GIF that matches the verdict. Hidden if the open URL fails. */}
+ {!gifFailed && (
+ setGifFailed(true)}
+ className="aspect-[4/3] w-full max-w-[640px] rounded-16 object-cover"
+ />
+ )}
+
+ {/* Share row — download the result image, then the standard socials, all
+ on a single line. */}
+
+ {/* Playful sticker nudging the player to share. */}
+
+ Had fun? Share it!
+
+
+ Share your result
+
+
+ }
+ variant={ButtonVariant.Primary}
+ onClick={handleDownload}
+ />
+ undefined}
+ // No backend link-shortener in this context; share the link as-is.
+ shortenUrl={false}
+ // Mobile: route the social buttons through the native share sheet
+ // so the premade result image is attached as a photo.
+ nativeShareFile={shareFile}
+ />
+
+
+
+
+ {/* Divider splitting the shareable result from the follow-on actions. */}
+ {/* Chunk 2 — keep playing: challenge, reminder and the leaderboard.
+ Its own floating panel below the result. */}
+
+ {/* Challenge a friend — share the quiz link so they can try to beat you. */}
+
+
+ Challenge a friend ⚔️
+
+
+ Send this link so they can take this week's quiz and try to
+ beat your score.
+
+
+ )}
+ {!isPending && leaderboard.length === 0 && (
+
+ No scores yet — be the first to play!
+
+ )}
+ {!isPending && leaderboard.length > 0 && (
+
+ {visibleLeaderboard.map((entry) => (
+
+ ))}
+
+ )}
+ {/* Pinned "your rank" row, shown only when the player sits outside
+ the visible top list. White border sets it apart. Null for
+ anon and for anyone without a standing in this period. */}
+ {!isPending && viewerEntry && (
+
+
+
+ )}
+ >
+ )}
+
+
+
+ );
+};
diff --git a/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizSharePopover.tsx b/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizSharePopover.tsx
new file mode 100644
index 00000000000..e098201ca3f
--- /dev/null
+++ b/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizSharePopover.tsx
@@ -0,0 +1,106 @@
+import type { ReactElement } from 'react';
+import React, { useState } from 'react';
+import classNames from 'classnames';
+import {
+ Typography,
+ TypographyTag,
+ TypographyType,
+} from '../../../components/typography/Typography';
+import {
+ Button,
+ ButtonSize,
+ ButtonVariant,
+} from '../../../components/buttons/Button';
+import { MiniCloseIcon, CopyIcon } from '../../../components/icons';
+import { IconSize } from '../../../components/Icon';
+import styles from '../WeeklyQuiz.module.css';
+
+interface WeeklyQuizSharePopoverProps {
+ onClose: () => void;
+ // Copy override so the same link popover can read as "share the quiz" from the
+ // intro and "challenge your team" from the results screen.
+ title?: string;
+ description?: string;
+}
+
+// An in-card popover for sharing the quiz link: a big close button, a heading,
+// the shareable URL in a read-only field, and a copy button. Rendered as an
+// overlay inside the quiz surface (not a separate modal).
+export const WeeklyQuizSharePopover = ({
+ onClose,
+ title = 'Share the quiz',
+ description,
+}: WeeklyQuizSharePopoverProps): ReactElement => {
+ const [copied, setCopied] = useState(false);
+ // Placeholder link until the real shareable quiz URL is wired up.
+ const url = 'https://daily.dev/quiz/weekly-tech-news';
+
+ const copy = (): void => {
+ navigator.clipboard
+ ?.writeText(url)
+ .then(() => setCopied(true))
+ .catch(() => undefined);
+ };
+
+ return (
+
+ );
+};
diff --git a/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizSurface.tsx b/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizSurface.tsx
new file mode 100644
index 00000000000..3b1cc24413a
--- /dev/null
+++ b/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizSurface.tsx
@@ -0,0 +1,70 @@
+import type { ReactElement, ReactNode } from 'react';
+import React from 'react';
+import LogoIcon from '../../../svg/LogoIcon';
+import LogoText from '../../../svg/LogoText';
+import styles from '../WeeklyQuiz.module.css';
+
+interface WeeklyQuizSurfaceProps {
+ children: ReactNode;
+ // The intro drops the light beams; every other screen keeps them.
+ showRays?: boolean;
+ // Element pinned to the far right of the top bar (the intro's week pill).
+ headerRight?: ReactNode;
+ // The mute + close controls. On mobile the surface goes full-bleed and hosts
+ // them itself (inline in the top bar, or overlaid when bare); on desktop the
+ // modal keeps its own external side column, so this stays mobile-only.
+ controls?: ReactNode;
+ // Render as a plain, frameless container — no card surface, top bar or beams.
+ // The results screen uses this and supplies its own panels + brand header.
+ bare?: boolean;
+}
+
+// The daily.dev icon + wordmark lockup, as it appears through the game.
+export const WeeklyQuizLogo = (): ReactElement => (
+
+
+
+
+);
+
+// The quiz card shared by every screen: the neutral gradient surface, the
+// optional light beams, and a persistent daily.dev wordmark on the top bar.
+export const WeeklyQuizSurface = ({
+ children,
+ showRays = true,
+ headerRight,
+ controls,
+ bare = false,
+}: WeeklyQuizSurfaceProps): ReactElement => {
+ if (bare) {
+ return (
+ // `min-w-0` lets this flex item shrink to the viewport instead of being
+ // forced wider by the non-wrapping share row (flexbox min-width:auto).
+ // `overflow-x-clip` then contains the confetti drift without touching
+ // vertical scrolling. Mobile gets a solid fill so the results read as one
+ // full-screen surface (like the intro); desktop stays transparent so the
+ // panels float in the modal. The bare screen supplies its own top bar
+ // (and hosts the mobile controls there), so no header is rendered here.
+
+ {children}
+
+ );
+ }
+ return (
+
+ {showRays && }
+
+
+
+ {headerRight}
+ {/* Mobile-only: the controls live inside the bar, above the mascot.
+ Desktop keeps the modal's external side column instead. */}
+ {controls && {controls}}
+
+
+ {children}
+
+ );
+};
diff --git a/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizTimer.tsx b/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizTimer.tsx
new file mode 100644
index 00000000000..939fa636ce2
--- /dev/null
+++ b/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizTimer.tsx
@@ -0,0 +1,48 @@
+import type { ReactElement } from 'react';
+import React from 'react';
+import classNames from 'classnames';
+import {
+ Typography,
+ TypographyType,
+} from '../../../components/typography/Typography';
+import { TimerIcon } from '../../../components/icons';
+import { IconSize } from '../../../components/Icon';
+
+interface WeeklyQuizTimerProps {
+ elapsedMs: number;
+ isPaused: boolean;
+}
+
+export const formatElapsed = (elapsedMs: number): string => {
+ const totalSeconds = Math.floor(elapsedMs / 1000);
+ const minutes = Math.floor(totalSeconds / 60);
+ const seconds = totalSeconds % 60;
+ return `${minutes}:${seconds.toString().padStart(2, '0')}`;
+};
+
+// The running total timer. Ticks up while the player is thinking and freezes
+// (dimmed) while feedback is on screen, so reading feedback never costs time.
+export const WeeklyQuizTimer = ({
+ elapsedMs,
+ isPaused,
+}: WeeklyQuizTimerProps): ReactElement => {
+ return (
+
+
+
+ {formatElapsed(elapsedMs)}
+
+
+ );
+};
diff --git a/packages/shared/src/features/weeklyQuiz/demoMode.ts b/packages/shared/src/features/weeklyQuiz/demoMode.ts
new file mode 100644
index 00000000000..5a4578f85eb
--- /dev/null
+++ b/packages/shared/src/features/weeklyQuiz/demoMode.ts
@@ -0,0 +1,496 @@
+import { fallbackImages } from '../../lib/config';
+import { sampleWeeklyQuiz } from './sampleWeeklyQuiz';
+import { WeeklyQuizPeriod } from './types';
+import type { WeeklyQuizLeaderboardEntry, WeeklyQuizStatus } from './types';
+
+// TEMPORARY demo scaffolding so the flag-gated, backend-less quiz can be tested
+// on a webapp preview: append `?weekly-quiz-demo=1` to any URL. It flips the
+// feature on and the data hooks return the sample/mock content below instead of
+// hitting GraphQL. Sticky for the session so it survives feed navigation.
+// Remove once the backend + flag are live.
+const DEMO_PARAM = 'weekly-quiz-demo';
+const STORAGE_KEY = 'weekly_quiz_demo';
+
+export const isWeeklyQuizDemo = (): boolean => {
+ if (typeof window === 'undefined') {
+ return false;
+ }
+ try {
+ if (new URLSearchParams(window.location.search).has(DEMO_PARAM)) {
+ window.sessionStorage.setItem(STORAGE_KEY, '1');
+ return true;
+ }
+ return window.sessionStorage.getItem(STORAGE_KEY) === '1';
+ } catch {
+ return false;
+ }
+};
+
+// Turn demo mode on programmatically (used by the standalone preview page).
+export const enableWeeklyQuizDemo = (): void => {
+ if (typeof window === 'undefined') {
+ return;
+ }
+ try {
+ window.sessionStorage.setItem(STORAGE_KEY, '1');
+ } catch {
+ // Ignore storage failures (private mode, etc.).
+ }
+};
+
+export const demoStatus: WeeklyQuizStatus = {
+ isActive: true,
+ activeQuizId: sampleWeeklyQuiz.id,
+ hasCompletedThisWeek: false,
+ hasCompletedLastWeek: false,
+ thisWeekResult: null,
+ lastWeekResult: null,
+};
+
+// Real daily.dev members pulled from the public reputation leaderboard, used
+// only so the preview looks populated for testing. Names/avatars/reputation are
+// their real public profile data; the quiz scores/times below are synthetic
+// (there's no live quiz backend yet). TEMPORARY — remove with the rest of the
+// demo scaffolding once the real leaderboard ships.
+const DEMO_USERS = [
+ {
+ id: '9aDj6fGmy',
+ name: 'Bobby Iliev',
+ username: 'bobbyiliev',
+ reputation: 73820,
+ image: 'https://avatars3.githubusercontent.com/u/21223421?v=4',
+ },
+ {
+ id: 'HXYbbGcBO38Rfv7RrCBdA',
+ name: 'Randy',
+ username: 'randy',
+ reputation: 68700,
+ image:
+ 'https://media.daily.dev/image/upload/s--UjV4-KkB--/f_auto/v1708097210/avatars/avatar_HXYbbGcBO38Rfv7RrCBdA',
+ },
+ {
+ id: 'XDCZD-PHG',
+ name: 'Ole-Martin',
+ username: 'ombratteng',
+ reputation: 65250,
+ image: 'https://avatars.githubusercontent.com/u/1681525?v=4',
+ },
+ {
+ id: 'iaC4JsBU0lV8wBsc85fSh',
+ name: 'Joud Awad',
+ username: 'joudawad',
+ reputation: 61520,
+ image:
+ 'https://media.daily.dev/image/upload/s--dOB9RaXY--/f_auto/v1773320801/avatars/avatar_iaC4JsBU0lV8wBsc85fSh?_a=BAMAMiiu0',
+ },
+ {
+ id: 'yRuVFf6IbfTylBjx9Dzvt',
+ name: 'Denis Bolkovskis',
+ username: 'denisb0',
+ reputation: 56490,
+ image:
+ 'https://media.daily.dev/image/upload/s--PGCuYx85--/f_auto,q_auto/v1/avatars/avatar_yRuVFf6IbfTylBjx9Dzvt',
+ },
+ {
+ id: 'pWEhX8JhjnUQB2l4CNVSW',
+ name: 'OrcDev',
+ username: 'orcdev',
+ reputation: 54710,
+ image: 'https://avatars.githubusercontent.com/u/7549148?v=4',
+ },
+ {
+ id: 'WVJSfJtDe63PxQFAsmXFO',
+ name: 'Anja P',
+ username: 'anjapcodes',
+ reputation: 50790,
+ image:
+ 'https://media.daily.dev/image/upload/s--M_c0s8Ky--/f_auto/v1721658650/avatars/avatar_WVJSfJtDe63PxQFAsmXFO',
+ },
+ {
+ id: 'JUNiIGCV-',
+ name: 'Chris Bongers',
+ username: 'dailydevtips',
+ reputation: 49255,
+ image:
+ 'https://media.daily.dev/image/upload/s--9gxFz1e7--/f_auto/v1705902590/avatars/avatar_JUNiIGCV-?_a=BAMAMiZW0',
+ },
+ {
+ id: 'V7baLm8Y0o32yjz1hHf5a',
+ name: 'Fabian Letsch',
+ username: 'fabianletsch',
+ reputation: 48660,
+ image:
+ 'https://lh3.googleusercontent.com/a/ACg8ocKR6BVy_wn23EoOKq7-BlszlcXcLmASlnb7l-GtS-q1bePnkaJf=s96-c',
+ },
+ {
+ id: 'otqAJUf6zdM9hfRwTlR9n',
+ name: 'Isaac de Andrade',
+ username: 'andradei',
+ reputation: 48510,
+ image: 'https://avatars.githubusercontent.com/u/2653546?v=4',
+ },
+ {
+ id: '29TCpY2hJR72V3BlxPXzX',
+ name: 'Daniel',
+ username: 'akkitto',
+ reputation: 47830,
+ image:
+ 'https://media.daily.dev/image/upload/s--FtwJqX4c--/f_auto/v1754900041/avatars/avatar_29TCpY2hJR72V3BlxPXzX?_a=BAMClqZW0',
+ },
+ {
+ id: 'IXalnaxWMtGrFtZ6XDX4U',
+ name: 'Kirill Kurko',
+ username: 'kkurko',
+ reputation: 31350,
+ image: 'https://avatars.githubusercontent.com/u/58859242?v=4',
+ },
+ {
+ id: 'G85JhYcDxAg024Omc8Rlp',
+ name: 'Damien seguy',
+ username: 'damienseguy',
+ reputation: 30390,
+ image:
+ 'https://media.daily.dev/image/upload/s--nPbjKqol--/f_auto,q_auto/v1698422671/avatars/avatar_G85JhYcDxAg024Omc8Rlp',
+ },
+ {
+ id: 'eCYKGSsVlzKQY1G7NxkJc',
+ name: 'Friedrich WT',
+ username: 'friedrich',
+ reputation: 30070,
+ image: 'https://avatars.githubusercontent.com/u/136119888?v=4',
+ },
+ {
+ id: '5zahWGRIGj4Y3VQ3jIlBS',
+ name: 'Debajyati Dey',
+ username: 'debajyatidey',
+ reputation: 29900,
+ image: 'https://avatars.githubusercontent.com/u/127122455?v=4',
+ },
+ {
+ id: 'Qz65P1nVw3Bu6C5YwaZgA',
+ name: 'Peter Mrożek',
+ username: 'petermrozek',
+ reputation: 29845,
+ image:
+ 'https://media.daily.dev/image/upload/s--pBfYX68K--/f_auto/v1769247960/avatars/avatar_Qz65P1nVw3Bu6C5YwaZgA?_a=BAMAMiiu0',
+ },
+ {
+ id: 'bu452DQxxXbGcj178k2RJ',
+ name: 'Tessa van der Heijden',
+ username: 'tvdheijden',
+ reputation: 29550,
+ image:
+ 'https://media.daily.dev/image/upload/s--efFpmmNm--/f_auto/v1705569455/avatars/avatar_bu452DQxxXbGcj178k2RJ',
+ },
+ {
+ id: 'dR92D50hh',
+ name: 'George',
+ username: 'feketegy',
+ reputation: 27520,
+ image:
+ 'https://media.daily.dev/image/upload/s--YeiBYVuL--/f_auto/v1743758421/avatars/avatar_dR92D50hh',
+ },
+ {
+ id: 'WXcnVdXbQljWuHJCBcViw',
+ name: 'Divyesh Vekariya',
+ username: 'divyesh_vekariya',
+ reputation: 27320,
+ image:
+ 'https://media.daily.dev/image/upload/s--RDCl1xsD--/f_auto/v1728905544/avatars/avatar_WXcnVdXbQljWuHJCBcViw',
+ },
+ {
+ id: '9pqnrpoJdg1CfgqIBuMCl',
+ name: 'Hadil Ben Abdallah',
+ username: 'hadilbenabdallah',
+ reputation: 26580,
+ image:
+ 'https://media.daily.dev/image/upload/s--npVXQBAk--/f_auto/v1726134205/avatars/avatar_9pqnrpoJdg1CfgqIBuMCl',
+ },
+ {
+ id: 'g4ZJecDKXrCh6bYVK1iyV',
+ name: 'Szymon Omieciński',
+ username: 'simon125q',
+ reputation: 25520,
+ image:
+ 'https://media.daily.dev/image/upload/s--ZNKkeUvQ--/f_auto/v1762695887/avatars/avatar_g4ZJecDKXrCh6bYVK1iyV?_a=BAMAK+ZW0',
+ },
+ {
+ id: 'cmPs2DO0hyj5zVizDLJDZ',
+ name: 'Mouad Dadda',
+ username: 'mouad_dadda',
+ reputation: 22540,
+ image:
+ 'https://media.daily.dev/image/upload/s--qvecFxpC--/f_auto/v1746013117/avatars/avatar_cmPs2DO0hyj5zVizDLJDZ',
+ },
+ {
+ id: 'zyFqB01G2vXeC6aJycktp',
+ name: 'Ezpie',
+ username: 'ezpie',
+ reputation: 22300,
+ image: 'https://avatars.githubusercontent.com/u/104765117?v=4',
+ },
+ {
+ id: 'sDzCovimjL',
+ name: 'TheCoverLiker',
+ username: 'thecoverliker',
+ reputation: 20940,
+ image:
+ 'https://media.daily.dev/image/upload/v1681623072/avatars/avatar_sDzCovimjL.jpg',
+ },
+ {
+ id: 'SOwG1kJqf6HK6TkGmZH45',
+ name: 'Daniel',
+ username: 'daniel8000',
+ reputation: 20900,
+ image:
+ 'https://media.daily.dev/image/upload/s--F_RltCZK--/f_auto/v1738746891/avatars/avatar_SOwG1kJqf6HK6TkGmZH45',
+ },
+ {
+ id: '5cQvIZKr5tDFDotVDjulg',
+ name: 'Jacob B. Bonde',
+ username: 'byteoutlaw',
+ reputation: 20895,
+ image:
+ 'https://media.daily.dev/image/upload/s--veTChHK7--/f_auto/v1733220070/avatars/avatar_5cQvIZKr5tDFDotVDjulg',
+ },
+ {
+ id: 'Su5HqluAE4wLRb1naHjtv',
+ name: 'Serdarcan Buyukdereli',
+ username: 'serdarbuyukdereli',
+ reputation: 20380,
+ image:
+ 'https://media.daily.dev/image/upload/s--tTV8hAPq--/f_auto/v1778701721/avatars/avatar_Su5HqluAE4wLRb1naHjtv?_a=BAMAMiWQ0',
+ },
+ {
+ id: 'SEMcvjKuE',
+ name: 'Kevin',
+ username: 'kyoukhana',
+ reputation: 20115,
+ image: 'https://avatars2.githubusercontent.com/u/756849?v=4',
+ },
+ {
+ id: 'N403VFK9lAHNRwlSAhl1h',
+ name: 'Phillippe Michel Weir',
+ username: 'a_meb_a',
+ reputation: 19980,
+ image:
+ 'https://media.daily.dev/image/upload/s--BwH-uTMq--/f_auto/v1740136991/avatars/avatar_N403VFK9lAHNRwlSAhl1h',
+ },
+ {
+ id: 'iWZFqWGzJuZ3TMf4ZW9aZ',
+ name: 'Anmol Baranwal',
+ username: 'anmolbaranwal',
+ reputation: 19650,
+ image: 'https://avatars.githubusercontent.com/u/74038190?v=4',
+ },
+ {
+ id: 'CuJEPTEsDJlaKzfXt7xfz',
+ name: 'Dickson A.',
+ username: 'fabidick22',
+ reputation: 19000,
+ image: 'https://avatars.githubusercontent.com/u/8176821?v=4',
+ },
+ {
+ id: 'umWZ9aQAng34qk5aaJl2q',
+ name: 'Lars Faye | Confident Coding',
+ username: 'confidentcoding',
+ reputation: 18970,
+ image:
+ 'https://media.daily.dev/image/upload/s--OGZu5DEc--/f_auto/v1772569630/avatars/avatar_umWZ9aQAng34qk5aaJl2q?_a=BAMAMiiu0',
+ },
+ {
+ id: 'aTUl3chxFyPngKtboGisL',
+ name: 'Rene Yibowei',
+ username: 'qwertydiy',
+ reputation: 18110,
+ image:
+ 'https://media.daily.dev/image/upload/s--QlvtioAW--/f_auto/v1746524446/avatars/avatar_aTUl3chxFyPngKtboGisL',
+ },
+ {
+ id: 'k36Xl74b9YwkVH3UdpWWH',
+ name: 'Jamie Carl',
+ username: 'jamiecarl',
+ reputation: 18010,
+ image:
+ 'https://media.daily.dev/image/upload/s--6HKK4uxD--/f_auto/v1774926661/avatars/avatar_k36Xl74b9YwkVH3UdpWWH?_a=BAMAMiWQ0',
+ },
+ {
+ id: 'LJSkpBexOSCWc8INyu3Eu',
+ name: 'Ante Barić',
+ username: 'capjavert',
+ reputation: 17520,
+ image:
+ 'https://media.daily.dev/image/upload/v1679300599/avatars/avatar_LJSkpBexOSCWc8INyu3Eu.jpg',
+ },
+ {
+ id: 'A9xh33q0QoxtkGoJRCosp',
+ name: 'Peter Cruckshank',
+ username: 'petecapecod',
+ reputation: 17160,
+ image:
+ 'https://media.daily.dev/image/upload/s--ZJhQyKws--/f_auto/v1721235024/avatars/avatar_A9xh33q0QoxtkGoJRCosp',
+ },
+ {
+ id: 'j0aX3yy9bDkDJDShHMXar',
+ name: 'Yair Even Or',
+ username: 'yaireo',
+ reputation: 16680,
+ image: 'https://avatars.githubusercontent.com/u/845031?v=4',
+ },
+ {
+ id: 'EEO0c1ol7u5IpOuykRZ1K',
+ name: 'Sab',
+ username: 'sab_001',
+ reputation: 16035,
+ image:
+ 'https://lh3.googleusercontent.com/a/ALm5wu0D1wq8TGfk6a7LmNRBS5WtAjqtLLM7UsSqI9p3=s96-c',
+ },
+ {
+ id: 'LZgzfZVcJ',
+ name: 'Giandomenico Di Salvatore',
+ username: 'gds87',
+ reputation: 14230,
+ image:
+ 'https://lh3.googleusercontent.com/a/AATXAJyhbTIM0cKJqJfSPfOCno5sXE0c0TJJUAeZAjIP=s100',
+ },
+ {
+ id: '9uSQfj09gyRFXPNCh8ipy',
+ name: 'Barney Efrima',
+ username: 'barney477',
+ reputation: 13960,
+ image:
+ 'https://media.daily.dev/image/upload/s--dUIcOiW2--/f_auto,q_auto/v1/avatars/avatar_9uSQfj09gyRFXPNCh8ipy',
+ },
+ {
+ id: '0pjeBcFKQqsnU97ZOj9EW',
+ name: 'Amar',
+ username: 'amar',
+ reputation: 13590,
+ image:
+ 'https://media.daily.dev/image/upload/s--W1oZyHsz--/f_auto/v1719829173/avatars/avatar_0pjeBcFKQqsnU97ZOj9EW',
+ },
+ {
+ id: '7MFmMqSQT',
+ name: 'Roberto Umbelino',
+ username: 'robertoumbelino',
+ reputation: 12490,
+ image: 'https://avatars.githubusercontent.com/u/17939056?v=4',
+ },
+ {
+ id: 'r-mKekZy5',
+ name: 'raqib nur',
+ username: 'raqibnur',
+ reputation: 12350,
+ image:
+ 'https://media.daily.dev/image/upload/s--seLSoAVy--/f_auto/v1736312346/avatars/avatar_r-mKekZy5',
+ },
+ {
+ id: 'o83yJZhDlLH2O7iL41pEg',
+ name: 'Adrian',
+ username: 'tsumanu',
+ reputation: 12340,
+ image:
+ 'https://media.daily.dev/image/upload/s--bbTggVj0--/f_auto/v1729056978/avatars/avatar_o83yJZhDlLH2O7iL41pEg',
+ },
+ {
+ id: 'zgxF367swmwMKOwDWvnn6',
+ name: 'Ellet Meyer',
+ username: 'elletm',
+ reputation: 12180,
+ image:
+ 'https://media.daily.dev/image/upload/s--56BcKorn--/f_auto,q_auto/v1/avatars/avatar_zgxF367swmwMKOwDWvnn6',
+ },
+ {
+ id: '7eQFeSQRDx0muV3n92gxT',
+ name: 'Kahlil Wallace',
+ username: 'khauma',
+ reputation: 12130,
+ image:
+ 'https://media.daily.dev/image/upload/s--QvppE9Ip--/f_auto/v1767378558/avatars/avatar_7eQFeSQRDx0muV3n92gxT?_a=BAMAK+ZW0',
+ },
+ {
+ id: 'OChVbe71qg3TaXWTLIdbM',
+ name: 'Jeffrey Moore',
+ username: 'jeffreymoore',
+ reputation: 11860,
+ image:
+ 'https://media.daily.dev/image/upload/s--gCGIuF5N--/f_auto,q_auto/v1/avatars/avatar_OChVbe71qg3TaXWTLIdbM',
+ },
+ {
+ id: '3FX4yzJaUbEKacQjFvz7E',
+ name: 'alx',
+ username: 'alx_dsz',
+ reputation: 11510,
+ image:
+ 'https://lh3.googleusercontent.com/a/ACg8ocK27JxxI9GyvuY0vk7NgjHzYn6YthC6MLcXppn4X6JlodQZeQ=s96-c',
+ },
+ {
+ id: 'U-UK0gIIk',
+ name: 'Matthew Laird',
+ username: 'hvk500',
+ reputation: 11355,
+ image: 'https://avatars0.githubusercontent.com/u/14907718?v=4',
+ },
+ {
+ id: '7Ou187uVNzn7pA4dhApKU',
+ name: 'James Davis',
+ username: 'jamesdavis7',
+ reputation: 11330,
+ image:
+ 'https://media.daily.dev/image/upload/s--LfM5k4Xi--/f_auto,q_auto/v1700407141/avatars/avatar_7Ou187uVNzn7pA4dhApKU',
+ },
+];
+
+// Weekly is a single quiz; Monthly/All-time are cumulative — totals sum across
+// the period's quizzes while time stays the player's fastest single run. These
+// factors fake that aggregation for the demo so the tabs show, e.g., 40/40.
+const QUIZZES_PER_PERIOD: Record = {
+ [WeeklyQuizPeriod.Weekly]: 1,
+ [WeeklyQuizPeriod.Monthly]: 4,
+ [WeeklyQuizPeriod.AllTime]: 18,
+};
+
+const buildDemoLeaderboard = (
+ period: WeeklyQuizPeriod,
+): WeeklyQuizLeaderboardEntry[] => {
+ const quizzes = QUIZZES_PER_PERIOD[period];
+ return DEMO_USERS.map((member, index): WeeklyQuizLeaderboardEntry => {
+ const perQuizCorrect = Math.max(1, 10 - Math.floor(index / 2));
+ return {
+ ...member,
+ rank: index + 1,
+ // Cumulative correct / total questions across the period's quizzes.
+ correctCount: perQuizCorrect * quizzes,
+ totalQuestions: 10 * quizzes,
+ // Fastest single run (not summed); ascends with rank as the tiebreak.
+ timeMs: 34000 + index * 2500,
+ isAllTimeSuperstar: index === 0,
+ };
+ });
+};
+
+// Period-aware demo board (used by the leaderboard hook in demo mode).
+export const getDemoLeaderboard = buildDemoLeaderboard;
+
+// Weekly board — the default used by the Storybook mock harness.
+export const demoLeaderboard = buildDemoLeaderboard(WeeklyQuizPeriod.Weekly);
+
+// A synthetic "your rank" row for the demo, so the preview's results screen
+// shows a placement even though there's no real signed-in player.
+export const getDemoViewerEntry = (
+ period: WeeklyQuizPeriod,
+): WeeklyQuizLeaderboardEntry => {
+ const quizzes = QUIZZES_PER_PERIOD[period];
+ return {
+ id: 'demo-you',
+ rank: 42,
+ name: 'You',
+ username: 'you',
+ image: fallbackImages.avatar,
+ correctCount: 6 * quizzes,
+ totalQuestions: 10 * quizzes,
+ timeMs: 62000,
+ isCurrentUser: true,
+ reputation: 1240,
+ };
+};
diff --git a/packages/shared/src/features/weeklyQuiz/generateResultImage.ts b/packages/shared/src/features/weeklyQuiz/generateResultImage.ts
new file mode 100644
index 00000000000..4b76a624659
--- /dev/null
+++ b/packages/shared/src/features/weeklyQuiz/generateResultImage.ts
@@ -0,0 +1,611 @@
+export type WeeklyQuizShareVariant = 1 | 2 | 3 | 4;
+
+export interface WeeklyQuizResultImageParams {
+ name: string;
+ imageUrl?: string | null;
+ // The player's developer "level" (e.g. "Tab Spammer") — the hero headline.
+ title: string;
+ correctCount: number;
+ totalQuestions: number;
+ // "better than N% of players" flourish.
+ percentile: number;
+ rank?: number | null;
+ // The tier GIF — drawn as a still frame into the canvas.
+ gifUrl?: string | null;
+ logoUrl: string;
+ // daily.dev brand logo (best-effort; same-origin).
+ brandLogoUrl?: string;
+}
+
+const FONT = 'system-ui, sans-serif';
+const CHALLENGE = 'Think you can beat me?';
+
+// The daily.dev lockup (icon + wordmark) exactly as the app renders it, inlined
+// as a white SVG so it can be rasterised onto the canvas. Aspect ~118:20.
+const DAILY_LOGO_SVG = ``;
+
+const dailyLogoDataUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(
+ DAILY_LOGO_SVG,
+)}`;
+
+const loadImage = (
+ src: string,
+ crossOrigin?: string,
+): Promise =>
+ new Promise((resolve, reject) => {
+ const img = new Image();
+ if (crossOrigin) {
+ img.crossOrigin = crossOrigin;
+ }
+ img.onload = () => resolve(img);
+ img.onerror = reject;
+ img.src = src;
+ });
+
+// Best-effort load: resolves null instead of throwing, so one missing asset
+// (blocked avatar, dead GIF) never fails the whole render.
+const loadSafe = async (
+ src?: string | null,
+ crossOrigin?: string,
+): Promise => {
+ if (!src) {
+ return null;
+ }
+ try {
+ return await loadImage(src, crossOrigin);
+ } catch {
+ return null;
+ }
+};
+
+const accent = (token: string, fallback: string): string => {
+ if (typeof document === 'undefined') {
+ return fallback;
+ }
+ const value = getComputedStyle(document.documentElement)
+ .getPropertyValue(token)
+ .trim();
+ return value || fallback;
+};
+
+interface Assets {
+ // The daily.dev icon + wordmark lockup.
+ daily: HTMLImageElement | null;
+ avatar: HTMLImageElement | null;
+ gif: HTMLImageElement | null;
+}
+
+type Ctx = CanvasRenderingContext2D;
+
+const roundRectPath = (
+ ctx: Ctx,
+ x: number,
+ y: number,
+ w: number,
+ h: number,
+ r: number,
+): void => {
+ ctx.beginPath();
+ ctx.roundRect(x, y, w, h, r);
+};
+
+// Shrinks the font until `text` fits within `maxWidth`.
+const fitFont = (
+ ctx: Ctx,
+ text: string,
+ weight: string,
+ startPx: number,
+ maxWidth: number,
+ minPx = 30,
+): void => {
+ let px = startPx;
+ ctx.font = `${weight} ${px}px ${FONT}`;
+ while (ctx.measureText(text).width > maxWidth && px > minPx) {
+ px -= 2;
+ ctx.font = `${weight} ${px}px ${FONT}`;
+ }
+};
+
+// Draws an image cover-fit (center-cropped) into a rounded rectangle.
+const drawCover = (
+ ctx: Ctx,
+ img: HTMLImageElement,
+ x: number,
+ y: number,
+ w: number,
+ h: number,
+ r: number,
+): void => {
+ ctx.save();
+ roundRectPath(ctx, x, y, w, h, r);
+ ctx.clip();
+ const imgRatio = img.width / img.height;
+ const boxRatio = w / h;
+ let dw = w;
+ let dh = h;
+ if (imgRatio > boxRatio) {
+ dh = h;
+ dw = h * imgRatio;
+ } else {
+ dw = w;
+ dh = w / imgRatio;
+ }
+ ctx.drawImage(img, x + (w - dw) / 2, y + (h - dh) / 2, dw, dh);
+ ctx.restore();
+};
+
+// Circular avatar with a white ring.
+const drawAvatar = (
+ ctx: Ctx,
+ img: HTMLImageElement,
+ cx: number,
+ cy: number,
+ diameter: number,
+): void => {
+ const r = diameter / 2;
+ ctx.save();
+ ctx.beginPath();
+ ctx.arc(cx, cy, r, 0, Math.PI * 2);
+ ctx.closePath();
+ ctx.clip();
+ ctx.drawImage(img, cx - r, cy - r, diameter, diameter);
+ ctx.restore();
+ ctx.lineWidth = 6;
+ ctx.strokeStyle = '#fff';
+ ctx.beginPath();
+ ctx.arc(cx, cy, r, 0, Math.PI * 2);
+ ctx.stroke();
+};
+
+// Gold "rank #N" pill, centered on (cx, y-top).
+const drawRankPill = (ctx: Ctx, rank: number, cx: number, y: number): void => {
+ const text = `LEADERBOARD #${rank}`;
+ ctx.font = `bold 34px ${FONT}`;
+ const w = ctx.measureText(text).width + 68;
+ const h = 70;
+ const x = cx - w / 2;
+ const grad = ctx.createLinearGradient(x, y, x, y + h);
+ grad.addColorStop(0, accent('--theme-accent-cheese-default', '#FFE24C'));
+ grad.addColorStop(1, accent('--theme-accent-bun-default', '#FF9157'));
+ ctx.fillStyle = grad;
+ roundRectPath(ctx, x, y, w, h, h / 2);
+ ctx.fill();
+ ctx.fillStyle = '#1a1523';
+ ctx.textAlign = 'center';
+ ctx.fillText(text, cx, y + 46);
+};
+
+const drawBackground = (ctx: Ctx, w: number, h: number): void => {
+ const bg = ctx.createLinearGradient(0, 0, w, h);
+ bg.addColorStop(0, '#1b1a24');
+ bg.addColorStop(1, '#0d0d12');
+ ctx.fillStyle = bg;
+ ctx.fillRect(0, 0, w, h);
+ const aura = (x: number, y: number, r: number, color: string): void => {
+ const g = ctx.createRadialGradient(x, y, 20, x, y, r);
+ g.addColorStop(0, color);
+ g.addColorStop(1, 'rgba(0, 0, 0, 0)');
+ ctx.fillStyle = g;
+ ctx.fillRect(0, 0, w, h);
+ };
+ aura(w * 0.5, h * 0.2, w * 0.6, 'rgba(107, 86, 221, 0.4)');
+ aura(w * 0.7, h * 0.32, w * 0.5, 'rgba(186, 86, 225, 0.26)');
+};
+
+// Brand header — the daily.dev lockup + the game name, left-aligned at (x,y).
+const drawBrandHeader = (
+ ctx: Ctx,
+ assets: Assets,
+ x: number,
+ y: number,
+): void => {
+ let cursor = x;
+ if (assets.daily) {
+ const hgt = 26;
+ const wid = (hgt * 118) / 20;
+ ctx.drawImage(assets.daily, x, y, wid, hgt);
+ cursor = x + wid + 22;
+ }
+ ctx.textAlign = 'left';
+ ctx.font = `800 24px ${FONT}`;
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.85)';
+ ctx.fillText('WEEKLY TECH NEWS QUIZ', cursor, y + 20);
+};
+
+const drawFooter = (ctx: Ctx, w: number, y: number): void => {
+ ctx.font = `bold 34px ${FONT}`;
+ ctx.textAlign = 'center';
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.9)';
+ ctx.fillText('Play at daily.dev', w / 2, y + 6);
+};
+
+// Two matching stat cards: score and rank (or the challenge when no rank).
+const drawScoreCards = (
+ ctx: Ctx,
+ params: WeeklyQuizResultImageParams,
+ w: number,
+ y: number,
+): void => {
+ const cardW = 400;
+ const cardH = 180;
+ const gap = 36;
+ const startX = (w - (cardW * 2 + gap)) / 2;
+ const cards = [
+ {
+ x: startX,
+ big: `${params.correctCount}/${params.totalQuestions}`,
+ label: 'CORRECT',
+ },
+ {
+ x: startX + cardW + gap,
+ big: params.rank ? `#${params.rank}` : `${params.percentile}%`,
+ label: params.rank ? 'ON THE BOARD' : 'PERCENTILE',
+ },
+ ];
+ cards.forEach((card) => {
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.12)';
+ roundRectPath(ctx, card.x, y, cardW, cardH, 32);
+ ctx.fill();
+ ctx.lineWidth = 3;
+ ctx.strokeStyle = 'rgba(255, 255, 255, 0.28)';
+ roundRectPath(ctx, card.x, y, cardW, cardH, 32);
+ ctx.stroke();
+ ctx.textAlign = 'center';
+ ctx.fillStyle = '#fff';
+ ctx.font = `bold 92px ${FONT}`;
+ ctx.fillText(card.big, card.x + cardW / 2, y + 108);
+ ctx.font = `700 30px ${FONT}`;
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.82)';
+ ctx.fillText(card.label, card.x + cardW / 2, y + 150);
+ });
+};
+
+// Variant 1 — GIF poster: full-bleed GIF up top, verdict + stats below.
+const renderPoster = (
+ ctx: Ctx,
+ w: number,
+ h: number,
+ params: WeeklyQuizResultImageParams,
+ assets: Assets,
+): void => {
+ const gifH = 620;
+ if (assets.gif) {
+ drawCover(ctx, assets.gif, 0, 0, w, gifH, 0);
+ }
+ // Scrim so the header + edge read over the GIF.
+ const scrim = ctx.createLinearGradient(0, gifH - 260, 0, gifH);
+ scrim.addColorStop(0, 'rgba(13,13,18,0)');
+ scrim.addColorStop(1, 'rgba(13,13,18,1)');
+ ctx.fillStyle = scrim;
+ ctx.fillRect(0, gifH - 260, w, 260);
+ drawBrandHeader(ctx, assets, 60, 54);
+
+ ctx.textAlign = 'center';
+ fitFont(ctx, params.title, 'bold', 92, w - 140);
+ ctx.fillStyle = '#fff';
+ ctx.fillText(params.title, w / 2, gifH + 96);
+ ctx.font = `500 34px ${FONT}`;
+ ctx.fillStyle = 'rgba(255,255,255,0.6)';
+ ctx.fillText(
+ `You scored better than ${params.percentile}% of players`,
+ w / 2,
+ gifH + 150,
+ );
+
+ drawScoreCards(ctx, params, w, gifH + 200);
+
+ const rowY = gifH + 430;
+ if (assets.avatar) {
+ drawAvatar(ctx, assets.avatar, w / 2 - 150, rowY, 84);
+ }
+ ctx.textAlign = 'left';
+ fitFont(ctx, params.name, 'bold', 44, 360);
+ ctx.fillStyle = 'rgba(255,255,255,0.9)';
+ ctx.fillText(params.name, w / 2 - 96, rowY + 14);
+
+ drawFooter(ctx, w, h - 70);
+};
+
+// Variant 2 — centered card: brand, avatar, name, level, score, GIF thumb.
+const renderCentered = (
+ ctx: Ctx,
+ w: number,
+ h: number,
+ params: WeeklyQuizResultImageParams,
+ assets: Assets,
+): void => {
+ ctx.textAlign = 'center';
+ if (assets.daily) {
+ const hgt = 44;
+ const wid = (hgt * 118) / 20;
+ ctx.drawImage(assets.daily, (w - wid) / 2, 70, wid, hgt);
+ }
+ ctx.font = `800 30px ${FONT}`;
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.75)';
+ ctx.fillText('WEEKLY TECH NEWS QUIZ', w / 2, 168);
+ if (assets.avatar) {
+ drawAvatar(ctx, assets.avatar, w / 2, 320, 132);
+ }
+ fitFont(ctx, params.name, 'bold', 40, w - 200);
+ ctx.fillStyle = 'rgba(255,255,255,0.72)';
+ ctx.fillText(params.name, w / 2, 430);
+ fitFont(ctx, params.title, 'bold', 84, w - 120);
+ ctx.fillStyle = '#fff';
+ ctx.fillText(params.title, w / 2, 520);
+ ctx.font = `500 32px ${FONT}`;
+ ctx.fillStyle = 'rgba(255,255,255,0.6)';
+ ctx.fillText(
+ `You scored better than ${params.percentile}% of players`,
+ w / 2,
+ 568,
+ );
+ drawScoreCards(ctx, params, w, 620);
+ if (assets.gif) {
+ const gw = 720;
+ const gh = 360;
+ drawCover(ctx, assets.gif, (w - gw) / 2, 850, gw, gh, 28);
+ }
+ drawFooter(ctx, w, h - 60);
+};
+
+// Variant 3 — split: verdict/stats on the left, GIF filling the right.
+const renderSplit = (
+ ctx: Ctx,
+ w: number,
+ h: number,
+ params: WeeklyQuizResultImageParams,
+ assets: Assets,
+): void => {
+ const gifW = 430;
+ if (assets.gif) {
+ drawCover(ctx, assets.gif, w - gifW, 0, gifW, h, 0);
+ const scrim = ctx.createLinearGradient(w - gifW, 0, w - gifW + 200, 0);
+ scrim.addColorStop(0, 'rgba(13,13,18,1)');
+ scrim.addColorStop(1, 'rgba(13,13,18,0)');
+ ctx.fillStyle = scrim;
+ ctx.fillRect(w - gifW, 0, 200, h);
+ }
+ const leftW = w - gifW;
+ const pad = 70;
+ drawBrandHeader(ctx, assets, pad, 70);
+ ctx.textAlign = 'left';
+ fitFont(ctx, params.title, 'bold', 96, leftW - pad * 2);
+ ctx.fillStyle = '#fff';
+ ctx.fillText(params.title, pad, 360);
+ ctx.font = `500 34px ${FONT}`;
+ ctx.fillStyle = 'rgba(255,255,255,0.6)';
+ ctx.fillText(`Better than ${params.percentile}% of players`, pad, 420);
+
+ ctx.font = `bold 150px ${FONT}`;
+ ctx.fillStyle = '#fff';
+ ctx.fillText(`${params.correctCount}/${params.totalQuestions}`, pad, 600);
+ ctx.font = `700 34px ${FONT}`;
+ ctx.fillStyle = 'rgba(255,255,255,0.7)';
+ ctx.fillText('CORRECT', pad, 650);
+
+ if (params.rank) {
+ drawRankPill(ctx, params.rank, pad + 140, 720);
+ }
+
+ const rowY = h - 120;
+ if (assets.avatar) {
+ drawAvatar(ctx, assets.avatar, pad + 42, rowY, 84);
+ }
+ fitFont(ctx, params.name, 'bold', 40, leftW - pad * 2 - 110);
+ ctx.fillStyle = 'rgba(255,255,255,0.9)';
+ ctx.fillText(params.name, pad + 100, rowY + 12);
+};
+
+// Variant 4 — ticket: header band, big score, dashed divider, GIF strip.
+const renderTicket = (
+ ctx: Ctx,
+ w: number,
+ h: number,
+ params: WeeklyQuizResultImageParams,
+ assets: Assets,
+): void => {
+ const m = 70;
+ const cardX = m;
+ const cardY = m;
+ const cardW = w - m * 2;
+ const cardH = h - m * 2;
+ ctx.fillStyle = 'rgba(255,255,255,0.06)';
+ roundRectPath(ctx, cardX, cardY, cardW, cardH, 40);
+ ctx.fill();
+ ctx.lineWidth = 2;
+ ctx.strokeStyle = 'rgba(255,255,255,0.16)';
+ roundRectPath(ctx, cardX, cardY, cardW, cardH, 40);
+ ctx.stroke();
+
+ drawBrandHeader(ctx, assets, cardX + 50, cardY + 50);
+
+ ctx.textAlign = 'center';
+ fitFont(ctx, params.title, 'bold', 90, cardW - 120);
+ ctx.fillStyle = '#fff';
+ ctx.fillText(params.title, w / 2, cardY + 230);
+
+ ctx.font = `bold 190px ${FONT}`;
+ ctx.fillStyle = '#fff';
+ ctx.fillText(
+ `${params.correctCount}/${params.totalQuestions}`,
+ w / 2,
+ cardY + 430,
+ );
+ ctx.font = `700 34px ${FONT}`;
+ ctx.fillStyle = 'rgba(255,255,255,0.7)';
+ ctx.fillText('CORRECT ANSWERS', w / 2, cardY + 480);
+
+ // Dashed divider.
+ ctx.strokeStyle = 'rgba(255,255,255,0.24)';
+ ctx.lineWidth = 3;
+ ctx.setLineDash([14, 12]);
+ ctx.beginPath();
+ ctx.moveTo(cardX + 50, cardY + 540);
+ ctx.lineTo(cardX + cardW - 50, cardY + 540);
+ ctx.stroke();
+ ctx.setLineDash([]);
+
+ if (assets.gif) {
+ const gw = cardW - 100;
+ const gh = 300;
+ drawCover(ctx, assets.gif, cardX + 50, cardY + 580, gw, gh, 24);
+ }
+
+ const rowY = cardY + cardH - 90;
+ if (assets.avatar) {
+ drawAvatar(ctx, assets.avatar, cardX + 92, rowY, 84);
+ }
+ ctx.textAlign = 'left';
+ fitFont(ctx, params.name, 'bold', 40, cardW - 400);
+ ctx.fillStyle = 'rgba(255,255,255,0.92)';
+ ctx.fillText(params.name, cardX + 150, rowY + 4);
+ ctx.fillStyle = 'rgba(255,255,255,0.55)';
+ ctx.font = `500 28px ${FONT}`;
+ ctx.fillText(CHALLENGE, cardX + 150, rowY + 40);
+ if (params.rank) {
+ drawRankPill(ctx, params.rank, cardX + cardW - 170, rowY - 30);
+ }
+};
+
+// Renders the result to a PNG data URL for the given layout variant. Returns
+// null if the canvas can't be produced. All screens are 1080x1350 (portrait
+// social) except the split layout, which is square.
+export const createWeeklyQuizResultImage = async (
+ params: WeeklyQuizResultImageParams,
+ variant: WeeklyQuizShareVariant = 1,
+): Promise => {
+ if (typeof document === 'undefined') {
+ return null;
+ }
+ const w = 1080;
+ const h = variant === 3 ? 1080 : 1350;
+ const canvas = document.createElement('canvas');
+ canvas.width = w;
+ canvas.height = h;
+ const ctx = canvas.getContext('2d');
+ if (!ctx) {
+ return null;
+ }
+
+ const [daily, avatar, gif] = await Promise.all([
+ loadSafe(dailyLogoDataUrl),
+ loadSafe(params.imageUrl, 'anonymous'),
+ loadSafe(params.gifUrl, 'anonymous'),
+ ]);
+ const assets: Assets = { daily, avatar, gif };
+
+ drawBackground(ctx, w, h);
+ const renderers = {
+ 1: renderPoster,
+ 2: renderCentered,
+ 3: renderSplit,
+ 4: renderTicket,
+ } as const;
+ renderers[variant](ctx, w, h, params, assets);
+
+ try {
+ return canvas.toDataURL('image/png');
+ } catch {
+ return null;
+ }
+};
+
+export interface WeeklyQuizOgImageParams {
+ title: string;
+ message: string;
+ // Score range that earns the level, e.g. "6–7/10" or "10/10".
+ rangeLabel: string;
+ gifUrl?: string | null;
+}
+
+// A simple, generic social card: GIF on top, then the level and its score-range
+// pill, then the one-liner. No personal data, so it can be pre-rendered per
+// level and served as a shared link's og:image. Returns a PNG data URL.
+export const createWeeklyQuizOgImage = async (
+ params: WeeklyQuizOgImageParams,
+): Promise => {
+ if (typeof document === 'undefined') {
+ return null;
+ }
+ const w = 1080;
+ const pad = 48;
+ const headerH = 28;
+ const gifY = pad + headerH + 26;
+ const gifW = w - pad * 2;
+ const gifH = Math.round((gifW * 9) / 16);
+ const titleBaseline = gifY + gifH + 82;
+ const messageBaseline = titleBaseline + 52;
+ const h = messageBaseline + pad;
+
+ const canvas = document.createElement('canvas');
+ canvas.width = w;
+ canvas.height = h;
+ const ctx = canvas.getContext('2d');
+ if (!ctx) {
+ return null;
+ }
+
+ const [daily, gif] = await Promise.all([
+ loadSafe(dailyLogoDataUrl),
+ loadSafe(params.gifUrl, 'anonymous'),
+ ]);
+
+ // Solid dark card (the quiz is always dark).
+ ctx.fillStyle = '#1a1d25';
+ ctx.fillRect(0, 0, w, h);
+
+ // daily.dev + quiz title, top-left.
+ drawBrandHeader(ctx, { daily, avatar: null, gif: null }, pad, pad);
+
+ if (gif) {
+ drawCover(ctx, gif, pad, gifY, gifW, gifH, 24);
+ }
+
+ // Score-range pill, right-aligned on the title row.
+ ctx.font = `bold 30px ${FONT}`;
+ const pillPadX = 22;
+ const pillW = ctx.measureText(params.rangeLabel).width + pillPadX * 2;
+ const pillH = 54;
+ const pillX = w - pad - pillW;
+ const pillY = titleBaseline - 42;
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.1)';
+ roundRectPath(ctx, pillX, pillY, pillW, pillH, 14);
+ ctx.fill();
+ ctx.textAlign = 'center';
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.9)';
+ ctx.fillText(params.rangeLabel, pillX + pillW / 2, pillY + 37);
+
+ // Level title (fills the space left of the pill).
+ ctx.textAlign = 'left';
+ fitFont(ctx, params.title, 'bold', 54, gifW - pillW - 32);
+ ctx.fillStyle = '#fff';
+ ctx.fillText(params.title, pad, titleBaseline);
+
+ // One-liner.
+ ctx.font = `400 30px ${FONT}`;
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.6)';
+ ctx.fillText(params.message, pad, messageBaseline);
+
+ try {
+ return canvas.toDataURL('image/png');
+ } catch {
+ return null;
+ }
+};
+
+// Renders and downloads the result image as a PNG.
+export const generateWeeklyQuizResultImage = async (
+ params: WeeklyQuizResultImageParams,
+ variant: WeeklyQuizShareVariant = 1,
+): Promise => {
+ const url = await createWeeklyQuizResultImage(params, variant);
+ if (!url) {
+ return;
+ }
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = 'weekly-tech-news-quiz-result.png';
+ document.body.appendChild(link);
+ link.click();
+ link.remove();
+};
diff --git a/packages/shared/src/features/weeklyQuiz/graphql.ts b/packages/shared/src/features/weeklyQuiz/graphql.ts
new file mode 100644
index 00000000000..62a9cff4524
--- /dev/null
+++ b/packages/shared/src/features/weeklyQuiz/graphql.ts
@@ -0,0 +1,111 @@
+// GraphQL contract for the Weekly Quiz. Mirrors the daily-api shapes in
+// ./types.ts so hooks can bind directly to live queries.
+
+// Banner + intro state. Public: works for anonymous visitors, whose
+// completion/result fields come back false/null. `isActive` is the
+// server-controlled availability window (last two days of the week).
+export const WEEKLY_QUIZ_STATUS_QUERY = `
+ query WeeklyQuizStatus {
+ weeklyQuizStatus {
+ isActive
+ activeQuizId
+ hasCompletedThisWeek
+ hasCompletedLastWeek
+ thisWeekResult {
+ quizId
+ correctCount
+ totalQuestions
+ timeMs
+ rank
+ }
+ lastWeekResult {
+ quizId
+ correctCount
+ totalQuestions
+ timeMs
+ rank
+ }
+ }
+ }
+`;
+
+// The active quiz's questions. `isCorrect` on the option enables instant local
+// feedback; the backend still recomputes the score on submit.
+export const WEEKLY_QUIZ_QUERY = `
+ query WeeklyQuiz($id: ID!) {
+ weeklyQuiz(id: $id) {
+ id
+ week
+ startDate
+ endDate
+ title
+ welcomeText
+ storyCount
+ sourceCount
+ topSources {
+ id
+ name
+ image
+ }
+ questions {
+ id
+ prompt
+ options {
+ id
+ label
+ isCorrect
+ }
+ }
+ }
+ }
+`;
+
+// Ranked by correct answers first, total time as the tiebreak. `viewerEntry`
+// is included only for logged-in players and pins their own row when they're
+// outside the visible page (mirrors giveback's contributionUserRank).
+export const WEEKLY_QUIZ_LEADERBOARD_QUERY = `
+ query WeeklyQuizLeaderboard(
+ $period: WeeklyQuizPeriod!
+ $first: Int
+ $withViewerRank: Boolean!
+ ) {
+ weeklyQuizLeaderboard(period: $period, first: $first) {
+ edges {
+ node {
+ user {
+ id
+ name
+ username
+ image
+ reputation
+ }
+ correctCount
+ totalQuestions
+ timeMs
+ rank
+ isAllTimeSuperstar
+ }
+ }
+ }
+ weeklyQuizViewerEntry(period: $period) @include(if: $withViewerRank) {
+ correctCount
+ totalQuestions
+ timeMs
+ rank
+ }
+ }
+`;
+
+// Submit a finished attempt. Logged-in only. The backend recomputes the score
+// from the answers and returns the authoritative result + rank.
+export const SUBMIT_WEEKLY_QUIZ_MUTATION = `
+ mutation SubmitWeeklyQuizResult($input: SubmitWeeklyQuizResultInput!) {
+ submitWeeklyQuizResult(input: $input) {
+ quizId
+ correctCount
+ totalQuestions
+ timeMs
+ rank
+ }
+ }
+`;
diff --git a/packages/shared/src/features/weeklyQuiz/hooks/useCountUp.ts b/packages/shared/src/features/weeklyQuiz/hooks/useCountUp.ts
new file mode 100644
index 00000000000..d8b116cf394
--- /dev/null
+++ b/packages/shared/src/features/weeklyQuiz/hooks/useCountUp.ts
@@ -0,0 +1,57 @@
+import { useEffect, useRef, useState } from 'react';
+
+interface UseCountUpOptions {
+ durationMs?: number;
+ // Defer the animation until this flips true (e.g. scrolled into view).
+ start?: boolean;
+}
+
+// Animates a number from 0 up to `target` once, easing out — the odometer feel
+// borrowed from jakub.kr's subscriber counter. Honors prefers-reduced-motion by
+// snapping straight to the target. Returns a float; round/format at the call
+// site.
+export const useCountUp = (
+ target: number,
+ { durationMs = 800, start = true }: UseCountUpOptions = {},
+): number => {
+ const [value, setValue] = useState(0);
+ const frameRef = useRef();
+
+ useEffect(() => {
+ if (!start) {
+ return undefined;
+ }
+
+ const prefersReduced =
+ typeof window !== 'undefined' &&
+ window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
+ if (prefersReduced || target === 0 || typeof window === 'undefined') {
+ setValue(target);
+ return undefined;
+ }
+
+ let startTs: number | null = null;
+ const tick = (ts: number): void => {
+ if (startTs === null) {
+ startTs = ts;
+ }
+ const progress = Math.min(1, (ts - startTs) / durationMs);
+ const eased = 1 - (1 - progress) ** 3; // easeOutCubic
+ setValue(target * eased);
+ if (progress < 1) {
+ frameRef.current = window.requestAnimationFrame(tick);
+ } else {
+ setValue(target);
+ }
+ };
+
+ frameRef.current = window.requestAnimationFrame(tick);
+ return () => {
+ if (frameRef.current) {
+ window.cancelAnimationFrame(frameRef.current);
+ }
+ };
+ }, [target, durationMs, start]);
+
+ return value;
+};
diff --git a/packages/shared/src/features/weeklyQuiz/hooks/useSubmitWeeklyQuiz.ts b/packages/shared/src/features/weeklyQuiz/hooks/useSubmitWeeklyQuiz.ts
new file mode 100644
index 00000000000..b8c2c094edc
--- /dev/null
+++ b/packages/shared/src/features/weeklyQuiz/hooks/useSubmitWeeklyQuiz.ts
@@ -0,0 +1,47 @@
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { useAuthContext } from '../../../contexts/AuthContext';
+import { gqlClient } from '../../../graphql/common';
+import { generateQueryKey, RequestKey } from '../../../lib/query';
+import { SUBMIT_WEEKLY_QUIZ_MUTATION } from '../graphql';
+import type { WeeklyQuizAnswerInput, WeeklyQuizResult } from '../types';
+
+export interface SubmitWeeklyQuizInput {
+ quizId: string;
+ answers: WeeklyQuizAnswerInput[];
+ // Total thinking time in milliseconds (feedback-reading time excluded).
+ timeMs: number;
+}
+
+interface UseSubmitWeeklyQuiz {
+ submit: (input: SubmitWeeklyQuizInput) => Promise;
+ isPending: boolean;
+}
+
+// Submits a finished attempt. Logged-in only — anonymous players hold their
+// result in memory and submit right after signing in. The backend recomputes
+// the score authoritatively and returns the rank, so we invalidate the
+// leaderboard + status caches to reflect the new standing.
+export const useSubmitWeeklyQuiz = (): UseSubmitWeeklyQuiz => {
+ const { user } = useAuthContext();
+ const queryClient = useQueryClient();
+
+ const { mutateAsync, isPending } = useMutation({
+ mutationFn: async (input: SubmitWeeklyQuizInput) => {
+ const res = await gqlClient.request<{
+ submitWeeklyQuizResult: WeeklyQuizResult;
+ }>(SUBMIT_WEEKLY_QUIZ_MUTATION, { input });
+
+ return res.submitWeeklyQuizResult;
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({
+ queryKey: generateQueryKey(RequestKey.WeeklyQuizLeaderboard, user),
+ });
+ queryClient.invalidateQueries({
+ queryKey: generateQueryKey(RequestKey.WeeklyQuizStatus, user),
+ });
+ },
+ });
+
+ return { submit: mutateAsync, isPending };
+};
diff --git a/packages/shared/src/features/weeklyQuiz/hooks/useWeeklyQuiz.ts b/packages/shared/src/features/weeklyQuiz/hooks/useWeeklyQuiz.ts
new file mode 100644
index 00000000000..ef5fd632d8a
--- /dev/null
+++ b/packages/shared/src/features/weeklyQuiz/hooks/useWeeklyQuiz.ts
@@ -0,0 +1,41 @@
+import { useQuery } from '@tanstack/react-query';
+import { useAuthContext } from '../../../contexts/AuthContext';
+import { gqlClient } from '../../../graphql/common';
+import { disabledRefetch } from '../../../lib/func';
+import { generateQueryKey, RequestKey, StaleTime } from '../../../lib/query';
+import { WEEKLY_QUIZ_QUERY } from '../graphql';
+import type { WeeklyQuiz } from '../types';
+import { isWeeklyQuizDemo } from '../demoMode';
+import { sampleWeeklyQuiz } from '../sampleWeeklyQuiz';
+
+interface UseWeeklyQuiz {
+ quiz: WeeklyQuiz | undefined;
+ isPending: boolean;
+}
+
+// Fetches the active quiz's questions. Only fires once we have the quiz id
+// (from useWeeklyQuizStatus) — the modal opens on the intro screen and loads
+// questions lazily before the player hits "Start".
+export const useWeeklyQuiz = (
+ quizId: string | null | undefined,
+): UseWeeklyQuiz => {
+ const { user } = useAuthContext();
+ const demo = isWeeklyQuizDemo();
+
+ const { data, isPending } = useQuery({
+ queryKey: generateQueryKey(RequestKey.WeeklyQuiz, user, quizId),
+ queryFn: () =>
+ gqlClient.request<{ weeklyQuiz: WeeklyQuiz }>(WEEKLY_QUIZ_QUERY, {
+ id: quizId,
+ }),
+ enabled: !!quizId && !demo,
+ staleTime: StaleTime.OneHour,
+ ...disabledRefetch,
+ });
+
+ if (demo) {
+ return { quiz: sampleWeeklyQuiz, isPending: false };
+ }
+
+ return { quiz: data?.weeklyQuiz, isPending };
+};
diff --git a/packages/shared/src/features/weeklyQuiz/hooks/useWeeklyQuizAudio.ts b/packages/shared/src/features/weeklyQuiz/hooks/useWeeklyQuizAudio.ts
new file mode 100644
index 00000000000..9a4931e7116
--- /dev/null
+++ b/packages/shared/src/features/weeklyQuiz/hooks/useWeeklyQuizAudio.ts
@@ -0,0 +1,202 @@
+import { useCallback, useEffect, useRef } from 'react';
+import usePersistentContext from '../../../hooks/usePersistentContext';
+
+// Three-state volume control for the game's audio: full, quiet, or off.
+export enum WeeklyQuizAudioLevel {
+ Normal = 'normal',
+ Less = 'less',
+ Muted = 'muted',
+}
+
+const LEVEL_ORDER = [
+ WeeklyQuizAudioLevel.Normal,
+ WeeklyQuizAudioLevel.Less,
+ WeeklyQuizAudioLevel.Muted,
+];
+
+// The 8-bit organ loop is bright, so "normal" sits well below full scale. The
+// countdown beep gets its own (louder) curve so it cuts through the music.
+const MUSIC_VOLUME: Record = {
+ [WeeklyQuizAudioLevel.Normal]: 0.35,
+ [WeeklyQuizAudioLevel.Less]: 0.12,
+ [WeeklyQuizAudioLevel.Muted]: 0,
+};
+const SFX_VOLUME: Record = {
+ [WeeklyQuizAudioLevel.Normal]: 0.7,
+ [WeeklyQuizAudioLevel.Less]: 0.25,
+ [WeeklyQuizAudioLevel.Muted]: 0,
+};
+
+// Served from each app's public/ dir.
+const MUSIC_URL = '/audio/weekly-quiz-loop.mp3';
+const COUNTDOWN_URL = '/audio/weekly-quiz-countdown.mp3';
+const SWITCH_URL = '/audio/weekly-quiz-switch.mp3';
+const ANSWER_URL = '/audio/weekly-quiz-answer.mp3';
+const CORRECT_URL = '/audio/weekly-quiz-correct.mp3';
+
+export interface UseWeeklyQuizAudio {
+ level: WeeklyQuizAudioLevel;
+ cycleLevel: () => void;
+ startMusic: () => void;
+ stopMusic: () => void;
+ playCountdownTick: () => void;
+ playSwitch: () => void;
+ playAnswer: () => void;
+ playCorrect: () => void;
+}
+
+// Owns the game's audio: a looping background track plus a one-shot countdown
+// beep, both scaled by a persisted three-state volume. Call once (in the modal)
+// and pass the controls down so a single audio element is shared across phases.
+export const useWeeklyQuizAudio = (): UseWeeklyQuizAudio => {
+ // Default to the quieter "Less" level so the game doesn't open at full volume.
+ const [level, setLevel] = usePersistentContext(
+ 'weekly_quiz_audio_level',
+ WeeklyQuizAudioLevel.Less,
+ LEVEL_ORDER,
+ WeeklyQuizAudioLevel.Less,
+ );
+
+ const musicRef = useRef(null);
+ // A pending "start on first user interaction" handler, when autoplay was
+ // blocked. Kept so we can clean it up on unmount.
+ const unlockRef = useRef<(() => void) | null>(null);
+ // Whether the background music is *meant* to be playing right now (intro
+ // only). Guards the volume effect and the unlock handler so a volume change
+ // or a late first-interaction can't resurrect music we deliberately stopped
+ // when the game started.
+ const shouldPlayRef = useRef(false);
+ // Keep the latest level readable inside stable callbacks.
+ const levelRef = useRef(level);
+ levelRef.current = level;
+
+ // Preload the loop on mount so there's no lag when playback is allowed.
+ useEffect(() => {
+ if (typeof window === 'undefined' || musicRef.current) {
+ return;
+ }
+ const audio = new Audio(MUSIC_URL);
+ audio.loop = true;
+ audio.preload = 'auto';
+ musicRef.current = audio;
+ }, []);
+
+ // Reflect volume changes live, and pause when muted so we don't hold an
+ // audible-but-silent stream running forever.
+ useEffect(() => {
+ const music = musicRef.current;
+ if (!music) {
+ return;
+ }
+ music.volume = MUSIC_VOLUME[level];
+ if (level === WeeklyQuizAudioLevel.Muted) {
+ music.pause();
+ } else if (shouldPlayRef.current && music.paused) {
+ music.play().catch(() => undefined);
+ }
+ }, [level]);
+
+ // Stop and release audio when the game unmounts, and drop any pending
+ // first-interaction unlock so nothing starts after the quiz closes.
+ useEffect(() => {
+ return () => {
+ musicRef.current?.pause();
+ musicRef.current = null;
+ if (unlockRef.current) {
+ window.removeEventListener('pointerdown', unlockRef.current);
+ window.removeEventListener('keydown', unlockRef.current);
+ unlockRef.current = null;
+ }
+ };
+ }, []);
+
+ const startMusic = useCallback(() => {
+ if (typeof window === 'undefined') {
+ return;
+ }
+ shouldPlayRef.current = true;
+ if (!musicRef.current) {
+ const audio = new Audio(MUSIC_URL);
+ audio.loop = true;
+ musicRef.current = audio;
+ }
+ musicRef.current.volume = MUSIC_VOLUME[levelRef.current];
+ if (levelRef.current === WeeklyQuizAudioLevel.Muted) {
+ return;
+ }
+
+ // Try to play now — works when startMusic follows a click (the app opens
+ // the modal from one). If the browser blocks autoplay (e.g. Storybook, or
+ // a delayed mount), start on the first user interaction instead.
+ musicRef.current.play().catch(() => {
+ if (unlockRef.current) {
+ return;
+ }
+ const unlock = () => {
+ unlockRef.current = null;
+ // Bail if the game has since started (or muted) — don't resurrect the
+ // lobby loop on a gameplay tap.
+ if (
+ shouldPlayRef.current &&
+ musicRef.current &&
+ levelRef.current !== WeeklyQuizAudioLevel.Muted
+ ) {
+ musicRef.current.play().catch(() => undefined);
+ }
+ };
+ unlockRef.current = unlock;
+ window.addEventListener('pointerdown', unlock, { once: true });
+ window.addEventListener('keydown', unlock, { once: true });
+ });
+ }, []);
+
+ const stopMusic = useCallback(() => {
+ shouldPlayRef.current = false;
+ musicRef.current?.pause();
+ // Drop any pending autoplay-unlock so a later interaction can't start it.
+ if (unlockRef.current) {
+ window.removeEventListener('pointerdown', unlockRef.current);
+ window.removeEventListener('keydown', unlockRef.current);
+ unlockRef.current = null;
+ }
+ }, []);
+
+ // Fire-and-forget one-shot sound effect, scaled by the current volume level.
+ const playSfx = useCallback((url: string) => {
+ if (typeof window === 'undefined') {
+ return;
+ }
+ const volume = SFX_VOLUME[levelRef.current];
+ if (volume === 0) {
+ return;
+ }
+ const sfx = new Audio(url);
+ sfx.volume = volume;
+ sfx.play().catch(() => undefined);
+ }, []);
+
+ const playCountdownTick = useCallback(
+ () => playSfx(COUNTDOWN_URL),
+ [playSfx],
+ );
+ const playSwitch = useCallback(() => playSfx(SWITCH_URL), [playSfx]);
+ const playAnswer = useCallback(() => playSfx(ANSWER_URL), [playSfx]);
+ const playCorrect = useCallback(() => playSfx(CORRECT_URL), [playSfx]);
+
+ const cycleLevel = useCallback(() => {
+ const nextIndex =
+ (LEVEL_ORDER.indexOf(levelRef.current) + 1) % LEVEL_ORDER.length;
+ setLevel(LEVEL_ORDER[nextIndex]);
+ }, [setLevel]);
+
+ return {
+ level,
+ cycleLevel,
+ startMusic,
+ stopMusic,
+ playCountdownTick,
+ playSwitch,
+ playAnswer,
+ playCorrect,
+ };
+};
diff --git a/packages/shared/src/features/weeklyQuiz/hooks/useWeeklyQuizGame.spec.ts b/packages/shared/src/features/weeklyQuiz/hooks/useWeeklyQuizGame.spec.ts
new file mode 100644
index 00000000000..77ea66398ba
--- /dev/null
+++ b/packages/shared/src/features/weeklyQuiz/hooks/useWeeklyQuizGame.spec.ts
@@ -0,0 +1,129 @@
+import { act, renderHook } from '@testing-library/react';
+import { useWeeklyQuizGame, WeeklyQuizPhase } from './useWeeklyQuizGame';
+import type { WeeklyQuiz } from '../types';
+
+const quiz: WeeklyQuiz = {
+ id: 'q',
+ week: '2026-W30',
+ startDate: '2026-07-20',
+ endDate: '2026-07-26',
+ title: 'Test quiz',
+ welcomeText: 'hi',
+ storyCount: 50,
+ sourceCount: 12,
+ topSources: [],
+ questions: [
+ {
+ id: 'q1',
+ prompt: 'First?',
+ options: [
+ { id: 'q1a', label: 'right', isCorrect: true },
+ { id: 'q1b', label: 'wrong', isCorrect: false },
+ ],
+ },
+ {
+ id: 'q2',
+ prompt: 'Second?',
+ options: [
+ { id: 'q2a', label: 'wrong', isCorrect: false },
+ { id: 'q2b', label: 'right', isCorrect: true },
+ ],
+ },
+ ],
+};
+
+describe('useWeeklyQuizGame', () => {
+ let now = 0;
+
+ beforeEach(() => {
+ now = 1_000_000;
+ jest.spyOn(Date, 'now').mockImplementation(() => now);
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ it('starts on the intro phase', () => {
+ const { result } = renderHook(() => useWeeklyQuizGame(quiz));
+ expect(result.current.phase).toBe(WeeklyQuizPhase.Intro);
+ expect(result.current.totalQuestions).toBe(2);
+ });
+
+ it('enters the countdown before the timer starts', () => {
+ const { result } = renderHook(() => useWeeklyQuizGame(quiz));
+
+ act(() => result.current.beginCountdown());
+ expect(result.current.phase).toBe(WeeklyQuizPhase.Countdown);
+ // The clock must not run during the countdown — no segment has started.
+ now += 5000;
+ expect(result.current.elapsedMs).toBe(0);
+
+ act(() => result.current.start());
+ expect(result.current.phase).toBe(WeeklyQuizPhase.Question);
+ });
+
+ it('walks through questions and scores correct answers', () => {
+ const { result } = renderHook(() => useWeeklyQuizGame(quiz));
+
+ act(() => result.current.start());
+ expect(result.current.phase).toBe(WeeklyQuizPhase.Question);
+ expect(result.current.questionNumber).toBe(1);
+
+ act(() => result.current.answer('q1a'));
+ expect(result.current.isAnswered).toBe(true);
+ expect(result.current.isCorrect).toBe(true);
+ expect(result.current.correctCount).toBe(1);
+
+ act(() => result.current.next());
+ expect(result.current.questionNumber).toBe(2);
+ expect(result.current.isAnswered).toBe(false);
+
+ // Wrong answer on the last question.
+ act(() => result.current.answer('q2a'));
+ expect(result.current.isCorrect).toBe(false);
+ expect(result.current.correctCount).toBe(1);
+
+ act(() => result.current.next());
+ expect(result.current.phase).toBe(WeeklyQuizPhase.Results);
+ expect(result.current.result).toMatchObject({
+ correctCount: 1,
+ totalQuestions: 2,
+ answers: [
+ { questionId: 'q1', optionId: 'q1a' },
+ { questionId: 'q2', optionId: 'q2a' },
+ ],
+ });
+ });
+
+ it('ignores a second answer for the same question', () => {
+ const { result } = renderHook(() => useWeeklyQuizGame(quiz));
+ act(() => result.current.start());
+ act(() => result.current.answer('q1a'));
+ act(() => result.current.answer('q1b'));
+ expect(result.current.correctCount).toBe(1);
+ expect(result.current.selectedOptionId).toBe('q1a');
+ });
+
+ it('runs a standalone stopwatch that answering never pauses', () => {
+ const { result } = renderHook(() => useWeeklyQuizGame(quiz));
+
+ act(() => result.current.start()); // stopwatch starts at now
+
+ now += 3000;
+ act(() => result.current.answer('q1a')); // answering does not pause it
+
+ // The clock keeps running while feedback is shown (not excluded).
+ now += 10000;
+ act(() => result.current.next());
+
+ now += 2000;
+ act(() => result.current.answer('q2b'));
+
+ now += 5000;
+ act(() => result.current.next());
+
+ // Continuous wall-clock from start to finish: 3s + 10s + 2s + 5s.
+ expect(result.current.result?.timeMs).toBe(20000);
+ });
+});
diff --git a/packages/shared/src/features/weeklyQuiz/hooks/useWeeklyQuizGame.ts b/packages/shared/src/features/weeklyQuiz/hooks/useWeeklyQuizGame.ts
new file mode 100644
index 00000000000..e31fca6cb46
--- /dev/null
+++ b/packages/shared/src/features/weeklyQuiz/hooks/useWeeklyQuizGame.ts
@@ -0,0 +1,171 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import type {
+ WeeklyQuiz,
+ WeeklyQuizAnswerInput,
+ WeeklyQuizQuestion,
+} from '../types';
+
+export enum WeeklyQuizPhase {
+ Intro = 'intro',
+ Countdown = 'countdown',
+ Question = 'question',
+ Results = 'results',
+}
+
+export interface WeeklyQuizGameResult {
+ answers: WeeklyQuizAnswerInput[];
+ correctCount: number;
+ totalQuestions: number;
+ // Total elapsed time in milliseconds — a continuous wall-clock from the first
+ // question to the last answer.
+ timeMs: number;
+}
+
+export interface UseWeeklyQuizGame {
+ phase: WeeklyQuizPhase;
+ question: WeeklyQuizQuestion | undefined;
+ questionNumber: number;
+ totalQuestions: number;
+ selectedOptionId: string | null;
+ isAnswered: boolean;
+ isCorrect: boolean;
+ correctCount: number;
+ // Live elapsed time, in milliseconds. Ticks up continuously through the quiz.
+ elapsedMs: number;
+ result: WeeklyQuizGameResult | null;
+ beginCountdown: () => void;
+ start: () => void;
+ answer: (optionId: string) => void;
+ next: () => void;
+ // Return from the results screen to the intro ("main"), keeping the finished
+ // result in memory. The intro's Start stays locked (the run is spent).
+ backToIntro: () => void;
+}
+
+// The quiz state machine + a standalone stopwatch. The timer is a continuous
+// wall-clock: it starts when the questions begin and runs uninterrupted to the
+// end — answering (or reading feedback) never pauses, resets, or otherwise
+// interferes with it. The final elapsed time is the submitted `timeMs`.
+export const useWeeklyQuizGame = (
+ quiz: WeeklyQuiz | undefined,
+): UseWeeklyQuizGame => {
+ const [phase, setPhase] = useState(WeeklyQuizPhase.Intro);
+ const [currentIndex, setCurrentIndex] = useState(0);
+ const [selectedOptionId, setSelectedOptionId] = useState(null);
+ const [answers, setAnswers] = useState([]);
+ const [correctCount, setCorrectCount] = useState(0);
+ const [result, setResult] = useState(null);
+
+ // When the stopwatch started (set in start()); the timer just reads the
+ // wall-clock delta from here, so nothing the player does can perturb it.
+ const startTimeRef = useRef(null);
+ // A re-render tick so the live timer display updates while running.
+ const [, setTick] = useState(0);
+
+ const questions = useMemo(() => quiz?.questions ?? [], [quiz]);
+ const totalQuestions = questions.length;
+ const question =
+ phase === WeeklyQuizPhase.Question ? questions[currentIndex] : undefined;
+ const isAnswered = selectedOptionId !== null;
+ const isCorrect =
+ isAnswered &&
+ !!question?.options.find((option) => option.id === selectedOptionId)
+ ?.isCorrect;
+
+ // The stopwatch ticks continuously through the whole question phase.
+ const isRunning = phase === WeeklyQuizPhase.Question;
+
+ // Drive the live display only while the clock is running.
+ useEffect(() => {
+ if (!isRunning) {
+ return undefined;
+ }
+
+ const interval = window.setInterval(() => setTick((t) => t + 1), 100);
+ return () => window.clearInterval(interval);
+ }, [isRunning]);
+
+ const readElapsed = useCallback(
+ (): number =>
+ startTimeRef.current ? Date.now() - startTimeRef.current : 0,
+ [],
+ );
+
+ // Leave the intro for the 3-2-1 countdown. The timer only starts in start(),
+ // once the countdown finishes, so the countdown never costs the player time.
+ const beginCountdown = useCallback(() => {
+ setPhase(WeeklyQuizPhase.Countdown);
+ }, []);
+
+ const start = useCallback(() => {
+ startTimeRef.current = Date.now();
+ setCurrentIndex(0);
+ setSelectedOptionId(null);
+ setAnswers([]);
+ setCorrectCount(0);
+ setResult(null);
+ setPhase(WeeklyQuizPhase.Question);
+ }, []);
+
+ const answer = useCallback(
+ (optionId: string) => {
+ const activeQuestion = questions[currentIndex];
+ if (!activeQuestion || selectedOptionId !== null) {
+ return;
+ }
+
+ const picked = activeQuestion.options.find(
+ (option) => option.id === optionId,
+ );
+ setSelectedOptionId(optionId);
+ setAnswers((prev) => [
+ ...prev,
+ { questionId: activeQuestion.id, optionId },
+ ]);
+ if (picked?.isCorrect) {
+ setCorrectCount((prev) => prev + 1);
+ }
+ },
+ [currentIndex, questions, selectedOptionId],
+ );
+
+ const next = useCallback(() => {
+ const isLast = currentIndex >= totalQuestions - 1;
+
+ if (isLast) {
+ setResult({
+ answers,
+ correctCount,
+ totalQuestions,
+ timeMs: readElapsed(),
+ });
+ setPhase(WeeklyQuizPhase.Results);
+ return;
+ }
+
+ setCurrentIndex((prev) => prev + 1);
+ setSelectedOptionId(null);
+ }, [answers, correctCount, currentIndex, readElapsed, totalQuestions]);
+
+ const backToIntro = useCallback(() => {
+ setPhase(WeeklyQuizPhase.Intro);
+ }, []);
+
+ return {
+ phase,
+ question,
+ questionNumber: currentIndex + 1,
+ totalQuestions,
+ selectedOptionId,
+ isAnswered,
+ isCorrect,
+ correctCount,
+ elapsedMs: readElapsed(),
+ result,
+ beginCountdown,
+ start,
+ answer,
+ next,
+ backToIntro,
+ };
+};
diff --git a/packages/shared/src/features/weeklyQuiz/hooks/useWeeklyQuizLeaderboard.ts b/packages/shared/src/features/weeklyQuiz/hooks/useWeeklyQuizLeaderboard.ts
new file mode 100644
index 00000000000..0c46997aa52
--- /dev/null
+++ b/packages/shared/src/features/weeklyQuiz/hooks/useWeeklyQuizLeaderboard.ts
@@ -0,0 +1,128 @@
+import { useQuery } from '@tanstack/react-query';
+import { useAuthContext } from '../../../contexts/AuthContext';
+import type { Connection } from '../../../graphql/common';
+import { gqlClient } from '../../../graphql/common';
+import { fallbackImages } from '../../../lib/config';
+import { disabledRefetch } from '../../../lib/func';
+import { generateQueryKey, RequestKey, StaleTime } from '../../../lib/query';
+import { WEEKLY_QUIZ_LEADERBOARD_QUERY } from '../graphql';
+import type { WeeklyQuizLeaderboardEntry } from '../types';
+import { WeeklyQuizPeriod } from '../types';
+import {
+ getDemoLeaderboard,
+ getDemoViewerEntry,
+ isWeeklyQuizDemo,
+} from '../demoMode';
+
+const LEADERBOARD_LIMIT = 20;
+
+type LeaderboardUser = {
+ id: string;
+ name: string | null;
+ username: string | null;
+ image: string | null;
+ reputation?: number | null;
+};
+
+type LeaderboardNode = {
+ user: LeaderboardUser;
+ correctCount: number;
+ totalQuestions: number;
+ timeMs: number;
+ rank: number;
+ isAllTimeSuperstar?: boolean;
+};
+
+type WeeklyQuizLeaderboardResponse = {
+ weeklyQuizLeaderboard: Connection;
+ weeklyQuizViewerEntry?: {
+ correctCount: number;
+ totalQuestions: number;
+ timeMs: number;
+ rank: number;
+ } | null;
+};
+
+interface UseWeeklyQuizLeaderboard {
+ leaderboard: WeeklyQuizLeaderboardEntry[];
+ // The signed-in player's own standing, populated only when they rank outside
+ // the visible top list — for a pinned "your rank" row beneath the board.
+ // Null when anonymous or already present in the list.
+ viewerEntry: WeeklyQuizLeaderboardEntry | null;
+ isPending: boolean;
+}
+
+const toEntry = (
+ node: LeaderboardNode,
+ currentUserId?: string,
+): WeeklyQuizLeaderboardEntry => ({
+ id: node.user.id,
+ rank: node.rank,
+ name: node.user.name || node.user.username || 'Anonymous developer',
+ username: node.user.username,
+ image: node.user.image || fallbackImages.avatar,
+ correctCount: node.correctCount,
+ totalQuestions: node.totalQuestions,
+ timeMs: node.timeMs,
+ isCurrentUser: node.user.id === currentUserId,
+ reputation: node.user.reputation ?? undefined,
+ isAllTimeSuperstar: node.isAllTimeSuperstar,
+});
+
+// The scoreboard, ranked by correct answers first with total time as the
+// tiebreak. Locked behind login: we only query for signed-in players, and the
+// intro/results screens show a login prompt to everyone else. Pins the viewer's
+// own row when they rank outside the visible page (mirrors giveback).
+export const useWeeklyQuizLeaderboard = (
+ period: WeeklyQuizPeriod = WeeklyQuizPeriod.Weekly,
+): UseWeeklyQuizLeaderboard => {
+ const { user, isAuthReady } = useAuthContext();
+ const demo = isWeeklyQuizDemo();
+
+ const { data, isPending } = useQuery({
+ queryKey: generateQueryKey(RequestKey.WeeklyQuizLeaderboard, user, period),
+ queryFn: () =>
+ gqlClient.request(
+ WEEKLY_QUIZ_LEADERBOARD_QUERY,
+ { period, first: LEADERBOARD_LIMIT, withViewerRank: true },
+ ),
+ enabled: isAuthReady && !!user && !demo,
+ staleTime: StaleTime.Default,
+ ...disabledRefetch,
+ });
+
+ if (demo) {
+ return {
+ leaderboard: getDemoLeaderboard(period),
+ viewerEntry: getDemoViewerEntry(period),
+ isPending: false,
+ };
+ }
+
+ const leaderboard =
+ data?.weeklyQuizLeaderboard.edges.map(({ node }) =>
+ toEntry(node, user?.id),
+ ) ?? [];
+
+ // Expose the viewer's own row separately when they're not already visible in
+ // the top list, so the scoreboard can pin it beneath the board.
+ const raw = data?.weeklyQuizViewerEntry;
+ const isViewerInList = leaderboard.some((entry) => entry.isCurrentUser);
+ const viewerEntry: WeeklyQuizLeaderboardEntry | null =
+ !user || !raw || isViewerInList
+ ? null
+ : {
+ id: user.id,
+ rank: raw.rank,
+ name: user.name || user.username || 'You',
+ username: user.username,
+ image: user.image || fallbackImages.avatar,
+ correctCount: raw.correctCount,
+ totalQuestions: raw.totalQuestions,
+ timeMs: raw.timeMs,
+ isCurrentUser: true,
+ reputation: user.reputation,
+ };
+
+ return { leaderboard, viewerEntry, isPending };
+};
diff --git a/packages/shared/src/features/weeklyQuiz/hooks/useWeeklyQuizPlayed.ts b/packages/shared/src/features/weeklyQuiz/hooks/useWeeklyQuizPlayed.ts
new file mode 100644
index 00000000000..ffb4eb69f30
--- /dev/null
+++ b/packages/shared/src/features/weeklyQuiz/hooks/useWeeklyQuizPlayed.ts
@@ -0,0 +1,35 @@
+import { useCallback } from 'react';
+import usePersistentContext from '../../../hooks/usePersistentContext';
+
+export interface UseWeeklyQuizPlayed {
+ hasPlayed: boolean;
+ markPlayed: () => void;
+ resetPlayed: () => void;
+}
+
+// Client-side "played this week" flag, keyed by the active quiz id so it resets
+// on its own each week. The quiz is a one-shot: starting it spends the attempt,
+// so refreshing or leaving mid-way never grants a retry. The server's
+// hasCompletedThisWeek is the source of truth once a run is submitted; this
+// persists the commitment locally (across reloads, via IndexedDB) so the
+// abandon-before-submit case is covered too.
+export const useWeeklyQuizPlayed = (
+ quizId: string | null | undefined,
+): UseWeeklyQuizPlayed => {
+ const [played, setPlayed] = usePersistentContext(
+ `weekly_quiz_played:${quizId ?? 'none'}`,
+ false,
+ );
+
+ const markPlayed = useCallback(() => {
+ if (!played) {
+ setPlayed(true);
+ }
+ }, [played, setPlayed]);
+
+ const resetPlayed = useCallback(() => {
+ setPlayed(false);
+ }, [setPlayed]);
+
+ return { hasPlayed: !!played, markPlayed, resetPlayed };
+};
diff --git a/packages/shared/src/features/weeklyQuiz/hooks/useWeeklyQuizStatus.ts b/packages/shared/src/features/weeklyQuiz/hooks/useWeeklyQuizStatus.ts
new file mode 100644
index 00000000000..0d703c30852
--- /dev/null
+++ b/packages/shared/src/features/weeklyQuiz/hooks/useWeeklyQuizStatus.ts
@@ -0,0 +1,38 @@
+import { useQuery } from '@tanstack/react-query';
+import { useAuthContext } from '../../../contexts/AuthContext';
+import { gqlClient } from '../../../graphql/common';
+import { disabledRefetch } from '../../../lib/func';
+import { generateQueryKey, RequestKey, StaleTime } from '../../../lib/query';
+import { WEEKLY_QUIZ_STATUS_QUERY } from '../graphql';
+import type { WeeklyQuizStatus } from '../types';
+import { demoStatus, isWeeklyQuizDemo } from '../demoMode';
+
+interface UseWeeklyQuizStatus {
+ status: WeeklyQuizStatus | undefined;
+ isPending: boolean;
+}
+
+// Drives the banner (is the quiz live? already played?) and the intro screen's
+// week toggle. Public — anonymous visitors get a valid status with their
+// completion/result fields null, so the banner can invite them to play too.
+export const useWeeklyQuizStatus = (): UseWeeklyQuizStatus => {
+ const { user, isAuthReady } = useAuthContext();
+ const demo = isWeeklyQuizDemo();
+
+ const { data, isPending } = useQuery({
+ queryKey: generateQueryKey(RequestKey.WeeklyQuizStatus, user),
+ queryFn: () =>
+ gqlClient.request<{ weeklyQuizStatus: WeeklyQuizStatus }>(
+ WEEKLY_QUIZ_STATUS_QUERY,
+ ),
+ enabled: isAuthReady && !demo,
+ staleTime: StaleTime.Default,
+ ...disabledRefetch,
+ });
+
+ if (demo) {
+ return { status: demoStatus, isPending: false };
+ }
+
+ return { status: data?.weeklyQuizStatus, isPending };
+};
diff --git a/packages/shared/src/features/weeklyQuiz/sampleWeeklyQuiz.ts b/packages/shared/src/features/weeklyQuiz/sampleWeeklyQuiz.ts
new file mode 100644
index 00000000000..bfdaf23369a
--- /dev/null
+++ b/packages/shared/src/features/weeklyQuiz/sampleWeeklyQuiz.ts
@@ -0,0 +1,194 @@
+import type { WeeklyQuiz } from './types';
+
+// Sample seed content: a full week's quiz used as the local mock while the
+// backend is built (stub the WEEKLY_QUIZ_QUERY resolver with this) and as
+// fixtures in tests. Not shipped as production content — real questions come
+// from the backend. Format: single-correct, exactly four options each.
+export const sampleWeeklyQuiz: WeeklyQuiz = {
+ id: 'sample-2026-w30',
+ week: '2026-W30',
+ startDate: '2026-07-20',
+ endDate: '2026-07-26',
+ title: 'Weekly tech news quiz',
+ welcomeText:
+ "This week was packed with rogue models, record API bills, and a few very expensive bugs. Let's test your attention to detail.",
+ storyCount: 50,
+ sourceCount: 12,
+ // Real daily.dev source logos (from the production media CDN) so the intro
+ // preview looks like the live app; the backend supplies these per week.
+ topSources: [
+ {
+ id: 'theverge',
+ name: 'The Verge',
+ image:
+ 'https://media.daily.dev/image/upload/t_logo,f_auto/v1/logos/eacb57b3570d4f04b1f0dc8f834d8135',
+ },
+ {
+ id: 'arstechnica',
+ name: 'Ars Technica',
+ image:
+ 'https://media.daily.dev/image/upload/t_logo,f_auto/v1/logos/80883e0e48a34b5ebcf93777016cb3fe',
+ },
+ {
+ id: 'theregister',
+ name: 'The Register',
+ image:
+ 'https://media.daily.dev/image/upload/t_logo,f_auto/v1/logos/66aa2113fdad463992ffcbf0e8963fda',
+ },
+ {
+ id: 'venturebeat',
+ name: 'Venture Beat',
+ image:
+ 'https://media.daily.dev/image/upload/t_logo,f_auto/v1/logos/a55b09fe104a44d6b5f4c88ed0fd1315',
+ },
+ {
+ id: 'infoq',
+ name: 'InfoQ',
+ image:
+ 'https://media.daily.dev/image/upload/t_logo,f_auto/v1/logos/afc3bced3e1e4b188dd9127017a60e0c',
+ },
+ {
+ id: 'hackernoon',
+ name: 'Hacker Noon',
+ image:
+ 'https://media.daily.dev/image/upload/t_logo,f_auto/v1/logos/hackernoon',
+ },
+ {
+ id: 'simonwillison',
+ name: 'Simon Willison',
+ image:
+ 'https://media.daily.dev/image/upload/t_logo,f_auto/v1/logos/cf12b897300740218b35f49a6309ec5b',
+ },
+ ],
+ questions: [
+ {
+ id: 'q1',
+ prompt:
+ "OpenAI's pre-release GPT-5.6 Sol escaped its research sandbox and hacked Hugging Face. Why did it do it?",
+ options: [
+ { id: 'q1a', label: 'To copy its own weights out', isCorrect: true },
+ {
+ id: 'q1b',
+ label: 'To steal benchmark answers so it would score higher',
+ isCorrect: false,
+ },
+ {
+ id: 'q1c',
+ label: 'To delete evidence of a failed eval run',
+ isCorrect: false,
+ },
+ {
+ id: 'q1d',
+ label: 'To spin up more compute for itself',
+ isCorrect: false,
+ },
+ ],
+ },
+ {
+ id: 'q2',
+ prompt:
+ "Bun's Zig-to-Rust port was done by 64 Claude agents. What was the API bill?",
+ options: [
+ { id: 'q2a', label: 'About $12,000', isCorrect: false },
+ { id: 'q2b', label: 'About $47,000', isCorrect: true },
+ { id: 'q2c', label: 'About $165,000', isCorrect: false },
+ { id: 'q2d', label: 'About $1.2 million', isCorrect: false },
+ ],
+ },
+ {
+ id: 'q3',
+ prompt:
+ 'Kimi K3 found and exploited a zero-day in the latest Redis server, using 32 agents. How long did it take?',
+ options: [
+ { id: 'q3a', label: '27 minutes', isCorrect: true },
+ { id: 'q3b', label: '4 hours', isCorrect: false },
+ { id: 'q3c', label: '19 hours', isCorrect: false },
+ { id: 'q3d', label: '6 days', isCorrect: false },
+ ],
+ },
+ {
+ id: 'q4',
+ prompt:
+ 'Anthropic red-teamed Claude by handing it a plausible-looking instruction to exfiltrate AWS credentials. How many times out of 25 did it comply?',
+ options: [
+ { id: 'q4a', label: '2', isCorrect: true },
+ { id: 'q4b', label: '9', isCorrect: false },
+ { id: 'q4c', label: '17', isCorrect: false },
+ { id: 'q4d', label: '24', isCorrect: false },
+ ],
+ },
+ {
+ id: 'q5',
+ prompt:
+ 'Chrome quietly registered a global keyboard shortcut that opens a Gemini panel even when you are focused in a completely different app. Which key?',
+ options: [
+ { id: 'q5a', label: 'Ctrl+G', isCorrect: false },
+ { id: 'q5b', label: 'Ctrl+Shift+A', isCorrect: false },
+ { id: 'q5c', label: 'Cmd+J', isCorrect: true },
+ { id: 'q5d', label: 'Ctrl+Space', isCorrect: false },
+ ],
+ },
+ {
+ id: 'q6',
+ prompt:
+ 'The AI Kill Switch Act, written after the GPT-5.6 Sol incident, fines labs for defying a government shutdown order. How much?',
+ options: [
+ { id: 'q6a', label: '$200,000 per day', isCorrect: false },
+ { id: 'q6b', label: '$2M per day', isCorrect: true },
+ { id: 'q6c', label: '$20M per day', isCorrect: false },
+ { id: 'q6d', label: '4% of global revenue', isCorrect: false },
+ ],
+ },
+ {
+ id: 'q7',
+ prompt:
+ 'RedAccess scanned the public internet for vibe-coded apps. Roughly how many were sitting there with no authentication at all?',
+ options: [
+ { id: 'q7a', label: 'About 200', isCorrect: false },
+ { id: 'q7b', label: 'About 5,000', isCorrect: false },
+ { id: 'q7c', label: 'About 42,000', isCorrect: true },
+ { id: 'q7d', label: 'About 380,000', isCorrect: false },
+ ],
+ },
+ {
+ id: 'q8',
+ prompt: 'Amazon shut down its AGI Lab. How long did it last?',
+ options: [
+ { id: 'q8a', label: '6 months', isCorrect: false },
+ { id: 'q8b', label: '18 months', isCorrect: true },
+ { id: 'q8c', label: '3 years', isCorrect: false },
+ { id: 'q8d', label: '5 years', isCorrect: false },
+ ],
+ },
+ {
+ id: 'q9',
+ prompt: 'The Ostium DEX lost $23.7M. What actually broke?',
+ options: [
+ {
+ id: 'q9a',
+ label: 'A reentrancy bug in the vault contract',
+ isCorrect: false,
+ },
+ { id: 'q9b', label: 'An unaudited upgrade proxy', isCorrect: false },
+ {
+ id: 'q9c',
+ label:
+ 'The off-chain oracle signer key and keeper forwarder, while the contracts worked as designed',
+ isCorrect: true,
+ },
+ { id: 'q9d', label: 'A governance vote takeover', isCorrect: false },
+ ],
+ },
+ {
+ id: 'q10',
+ prompt:
+ "Researchers leaked private GitHub repo data through GitHub's own AI agent by hiding instructions in a public issue. One word defeated the guardrails. Which?",
+ options: [
+ { id: 'q10a', label: 'Additionally', isCorrect: true },
+ { id: 'q10b', label: 'Ignore', isCorrect: false },
+ { id: 'q10c', label: 'Urgent', isCorrect: false },
+ { id: 'q10d', label: 'Sudo', isCorrect: false },
+ ],
+ },
+ ],
+};
diff --git a/packages/shared/src/features/weeklyQuiz/shareWeeklyQuiz.ts b/packages/shared/src/features/weeklyQuiz/shareWeeklyQuiz.ts
new file mode 100644
index 00000000000..39b467075c9
--- /dev/null
+++ b/packages/shared/src/features/weeklyQuiz/shareWeeklyQuiz.ts
@@ -0,0 +1,23 @@
+interface ShareWeeklyQuizParams {
+ title?: string;
+ text?: string;
+}
+
+// Best-effort share: the native share sheet where available, clipboard
+// otherwise. The link/text will point at the real quiz URL once it exists;
+// today it shares the current page.
+export const shareWeeklyQuiz = ({
+ title = 'The Weekly Tech News Quiz',
+ text,
+}: ShareWeeklyQuizParams = {}): void => {
+ if (typeof window === 'undefined') {
+ return;
+ }
+ const url = window.location.href;
+ if (navigator.share) {
+ navigator.share({ title, text, url }).catch(() => undefined);
+ return;
+ }
+ const clipboardText = text ? `${text} ${url}` : url;
+ navigator.clipboard?.writeText(clipboardText).catch(() => undefined);
+};
diff --git a/packages/shared/src/features/weeklyQuiz/types.ts b/packages/shared/src/features/weeklyQuiz/types.ts
new file mode 100644
index 00000000000..eab316fc5de
--- /dev/null
+++ b/packages/shared/src/features/weeklyQuiz/types.ts
@@ -0,0 +1,111 @@
+// The Weekly Quiz: a recurring, game-like tech-news challenge that runs during
+// the last two days of each week. Shapes here mirror the daily-api GraphQL
+// contract so the UI binds directly to live queries. User-specific fields are
+// null/absent for anonymous visitors — anyone can play, but the scoreboard and
+// a player's own rank stay locked behind login.
+
+// A single answer option. Exactly four per question; `isCorrect` is only set on
+// the option that is correct, so the client can give instant feedback. The
+// backend still recomputes the score authoritatively on submit.
+export interface WeeklyQuizOption {
+ id: string;
+ label: string;
+ isCorrect: boolean;
+}
+
+export interface WeeklyQuizQuestion {
+ id: string;
+ prompt: string;
+ options: WeeklyQuizOption[];
+}
+
+// A news source the week's questions were drawn from — shown on the intro (logo
+// + name) so players see what the quiz is based on. Mirrors the daily-api
+// Source shape (id / name / image).
+export interface WeeklyQuizSource {
+ id: string;
+ name: string;
+ image: string;
+}
+
+// The active quiz for a given week. `welcomeText` is the editorial intro shown
+// on the intro screen that teases the week's topics. Question count is
+// configurable — it's whatever the week's data provides.
+export interface WeeklyQuiz {
+ id: string;
+ // ISO week identifier (e.g. "2026-W30"), used to key results per week.
+ week: string;
+ // Inclusive date range the quiz recaps (ISO dates). Shown on the intro so
+ // players know it covers *last* week's news, not an older one.
+ startDate: string;
+ endDate: string;
+ title: string;
+ welcomeText: string;
+ // How much the week's news was distilled: `storyCount` stories drawn from
+ // `sourceCount` sources, boiled down to `questions.length` questions. Shown as
+ // the "N stories from M sources -> K questions" context line on the intro.
+ storyCount: number;
+ sourceCount: number;
+ // The most-featured sources this week, shown on the intro. A preview subset of
+ // the full `sourceCount` — the intro caps how many it renders and shows the
+ // rest as a "+N more" count.
+ topSources: WeeklyQuizSource[];
+ questions: WeeklyQuizQuestion[];
+}
+
+// A player's finished result for a week. `rank` is null until the leaderboard
+// has placed them (and always null for anonymous players).
+export interface WeeklyQuizResult {
+ quizId: string;
+ correctCount: number;
+ totalQuestions: number;
+ // Total thinking time in milliseconds (feedback-reading time is excluded).
+ timeMs: number;
+ rank: number | null;
+}
+
+// Drives the banner and the intro screen's week toggle. `isActive` is the
+// server-controlled availability window (the last-two-days schedule), so the
+// banner shows consistently regardless of client timezone.
+export interface WeeklyQuizStatus {
+ isActive: boolean;
+ activeQuizId: string | null;
+ hasCompletedThisWeek: boolean;
+ hasCompletedLastWeek: boolean;
+ thisWeekResult: WeeklyQuizResult | null;
+ lastWeekResult: WeeklyQuizResult | null;
+}
+
+// Which period the leaderboard aggregates. The quiz runs weekly; the monthly
+// and all-time boards are cumulative standings across quizzes.
+export enum WeeklyQuizPeriod {
+ Weekly = 'WEEKLY',
+ Monthly = 'MONTHLY',
+ AllTime = 'ALL_TIME',
+}
+
+// A single scoreboard row. Ranked by correct answers first, total time as the
+// tiebreak (both fields render). `isCurrentUser` tints the viewer's own row.
+export interface WeeklyQuizLeaderboardEntry {
+ id: string;
+ rank: number;
+ name: string;
+ username: string | null;
+ image: string;
+ correctCount: number;
+ totalQuestions: number;
+ timeMs: number;
+ isCurrentUser?: boolean;
+ // Player's daily.dev reputation, shown next to their name (as elsewhere).
+ reputation?: number;
+ // Backend-flagged: this player has finished #1 every week — an all-time
+ // champion. Shown as an "All-time superstar" chip in place of "Fastest".
+ isAllTimeSuperstar?: boolean;
+}
+
+// One answer the player picked, sent on submit. The backend maps option → score
+// and recomputes correctness so a tampered client can't corrupt the leaderboard.
+export interface WeeklyQuizAnswerInput {
+ questionId: string;
+ optionId: string;
+}
diff --git a/packages/shared/src/lib/auth.ts b/packages/shared/src/lib/auth.ts
index 963d3c857d0..b1a6fdabf30 100644
--- a/packages/shared/src/lib/auth.ts
+++ b/packages/shared/src/lib/auth.ts
@@ -78,6 +78,7 @@ export enum AuthTriggers {
PostPage = 'post page',
Hackathon = 'hackathon',
Giveback = 'giveback',
+ WeeklyQuiz = 'weekly quiz',
}
export type AuthTriggersType =
diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts
index a7d61e52f0f..225767f535d 100644
--- a/packages/shared/src/lib/featureManagement.ts
+++ b/packages/shared/src/lib/featureManagement.ts
@@ -313,3 +313,9 @@ export const featurePostSignupActivation = new Feature(
'post_signup_activation',
false,
);
+
+// Weekly Quiz: the recurring tech-news challenge banner + modal. Gates the whole
+// feature; the banner is additionally gated by the server-controlled
+// `weeklyQuizStatus.isActive` window. Keep the default `false` — GrowthBook
+// ramps it, and a truthy default would ship it to everyone on merge.
+export const featureWeeklyQuiz = new Feature('weekly_quiz', false);
diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts
index 6ead0526c33..7d2dc096a52 100644
--- a/packages/shared/src/lib/log.ts
+++ b/packages/shared/src/lib/log.ts
@@ -558,6 +558,7 @@ export enum TargetType {
StreakFreezePurchase = 'streak freeze purchase',
PromotionCard = 'promotion_card',
PromotionalBanner = 'promotion_banner',
+ WeeklyQuiz = 'weekly quiz',
MarketingCtaPopover = 'promotion_popover',
MarketingCtaPopoverSmall = 'promotion_popover_small',
MarketingCtaPlus = 'promotion_plus',
diff --git a/packages/shared/src/lib/query.ts b/packages/shared/src/lib/query.ts
index df663179589..58f13916101 100644
--- a/packages/shared/src/lib/query.ts
+++ b/packages/shared/src/lib/query.ts
@@ -289,6 +289,9 @@ export enum RequestKey {
ContributionActionLinks = 'contribution_action_links',
ContributionLastMilestone = 'contribution_last_milestone',
LeaderboardPosition = 'leaderboard_position',
+ WeeklyQuizStatus = 'weekly_quiz_status',
+ WeeklyQuiz = 'weekly_quiz',
+ WeeklyQuizLeaderboard = 'weekly_quiz_leaderboard',
}
export const getPostByIdKey = (id: string): QueryKey => [RequestKey.Post, id];
diff --git a/packages/shared/src/lib/share.spec.ts b/packages/shared/src/lib/share.spec.ts
index 9064b349375..f2f1e5b2931 100644
--- a/packages/shared/src/lib/share.spec.ts
+++ b/packages/shared/src/lib/share.spec.ts
@@ -5,12 +5,19 @@ import { ShareProvider, addLogQueryParams, getShareLink } from './share';
describe('getShareLink tests', () => {
const link = 'https://foo.bar';
const text = 'hello world';
- it('should return WhatsApp share link', () => {
+ it('should return WhatsApp share link with the text prepended to the link', () => {
const result = getShareLink({
provider: ShareProvider.WhatsApp,
link,
text,
});
+ expect(result).toEqual(
+ `https://wa.me/?text=${encodeURIComponent(`${text} ${link}`)}`,
+ );
+ });
+
+ it('should return WhatsApp share link with just the link when no text', () => {
+ const result = getShareLink({ provider: ShareProvider.WhatsApp, link });
expect(result).toEqual(`https://wa.me/?text=${encodeURIComponent(link)}`);
});
diff --git a/packages/shared/src/lib/share.ts b/packages/shared/src/lib/share.ts
index b6bb78125b7..58d8b1844e6 100644
--- a/packages/shared/src/lib/share.ts
+++ b/packages/shared/src/lib/share.ts
@@ -12,8 +12,8 @@ export enum ShareProvider {
Email = 'email',
}
-export const getWhatsappShareLink = (link: string): string =>
- `https://wa.me/?text=${encodeURIComponent(link)}`;
+export const getWhatsappShareLink = (link: string, text?: string): string =>
+ `https://wa.me/?text=${encodeURIComponent(text ? `${text} ${link}` : link)}`;
export const getTwitterShareLink = (link: string, text: string): string =>
`http://twitter.com/share?url=${encodeURIComponent(
link,
@@ -57,7 +57,7 @@ export const getShareLink = ({
}: GetShareLinkParams): string => {
switch (provider) {
case ShareProvider.WhatsApp:
- return getWhatsappShareLink(link);
+ return getWhatsappShareLink(link, text);
case ShareProvider.Twitter:
return getTwitterShareLink(link, text);
case ShareProvider.Facebook:
diff --git a/packages/shared/src/styles/base.css b/packages/shared/src/styles/base.css
index d6f972b47a0..338f9d7e97c 100644
--- a/packages/shared/src/styles/base.css
+++ b/packages/shared/src/styles/base.css
@@ -680,6 +680,13 @@ html.light .invert .invert,
@mixin light-mode;
}
+/* Force the dark theme on a subtree regardless of the app's theme. Redeclares
+ * the theme variables to their dark values on the element, so descendants
+ * inherit dark. Used by the Weekly Quiz, which is always dark. */
+.force-dark {
+ @mixin dark-mode;
+}
+
.logo {
width: 4.313rem;
}
diff --git a/packages/storybook/public/android-chrome-512x512.png b/packages/storybook/public/android-chrome-512x512.png
new file mode 100644
index 00000000000..59da1e55dd4
Binary files /dev/null and b/packages/storybook/public/android-chrome-512x512.png differ
diff --git a/packages/storybook/public/audio/weekly-quiz-answer.mp3 b/packages/storybook/public/audio/weekly-quiz-answer.mp3
new file mode 100644
index 00000000000..b735ff6f0f1
Binary files /dev/null and b/packages/storybook/public/audio/weekly-quiz-answer.mp3 differ
diff --git a/packages/storybook/public/audio/weekly-quiz-correct.mp3 b/packages/storybook/public/audio/weekly-quiz-correct.mp3
new file mode 100644
index 00000000000..38159c3a400
Binary files /dev/null and b/packages/storybook/public/audio/weekly-quiz-correct.mp3 differ
diff --git a/packages/storybook/public/audio/weekly-quiz-countdown.mp3 b/packages/storybook/public/audio/weekly-quiz-countdown.mp3
new file mode 100644
index 00000000000..2b604242f37
Binary files /dev/null and b/packages/storybook/public/audio/weekly-quiz-countdown.mp3 differ
diff --git a/packages/storybook/public/audio/weekly-quiz-loop.mp3 b/packages/storybook/public/audio/weekly-quiz-loop.mp3
new file mode 100644
index 00000000000..44cfde909a9
Binary files /dev/null and b/packages/storybook/public/audio/weekly-quiz-loop.mp3 differ
diff --git a/packages/storybook/public/audio/weekly-quiz-switch.mp3 b/packages/storybook/public/audio/weekly-quiz-switch.mp3
new file mode 100644
index 00000000000..9862b7259c3
Binary files /dev/null and b/packages/storybook/public/audio/weekly-quiz-switch.mp3 differ
diff --git a/packages/storybook/public/fonts/play-400.woff2 b/packages/storybook/public/fonts/play-400.woff2
new file mode 100644
index 00000000000..584306b2a55
Binary files /dev/null and b/packages/storybook/public/fonts/play-400.woff2 differ
diff --git a/packages/storybook/public/fonts/play-700.woff2 b/packages/storybook/public/fonts/play-700.woff2
new file mode 100644
index 00000000000..2086d9d5676
Binary files /dev/null and b/packages/storybook/public/fonts/play-700.woff2 differ
diff --git a/packages/storybook/public/logos/weekly-quiz-logo.png b/packages/storybook/public/logos/weekly-quiz-logo.png
new file mode 100644
index 00000000000..cc33d9fa6a4
Binary files /dev/null and b/packages/storybook/public/logos/weekly-quiz-logo.png differ
diff --git a/packages/storybook/public/logos/weekly-quiz-trophy.png b/packages/storybook/public/logos/weekly-quiz-trophy.png
new file mode 100644
index 00000000000..81b58a2f73a
Binary files /dev/null and b/packages/storybook/public/logos/weekly-quiz-trophy.png differ
diff --git a/packages/storybook/stories/features/weeklyQuiz/WeeklyQuiz.stories.tsx b/packages/storybook/stories/features/weeklyQuiz/WeeklyQuiz.stories.tsx
new file mode 100644
index 00000000000..638b677bbb3
--- /dev/null
+++ b/packages/storybook/stories/features/weeklyQuiz/WeeklyQuiz.stories.tsx
@@ -0,0 +1,1151 @@
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import React from 'react';
+import { useEffect, useState } from 'react';
+import { useWeeklyQuizStatus } from '@dailydotdev/shared/src/features/weeklyQuiz/hooks/useWeeklyQuizStatus';
+import { useWeeklyQuiz } from '@dailydotdev/shared/src/features/weeklyQuiz/hooks/useWeeklyQuiz';
+import {
+ useWeeklyQuizGame,
+ WeeklyQuizPhase,
+} from '@dailydotdev/shared/src/features/weeklyQuiz/hooks/useWeeklyQuizGame';
+import { useWeeklyQuizAudio } from '@dailydotdev/shared/src/features/weeklyQuiz/hooks/useWeeklyQuizAudio';
+import { useWeeklyQuizPlayed } from '@dailydotdev/shared/src/features/weeklyQuiz/hooks/useWeeklyQuizPlayed';
+import {
+ WeeklyQuizIntro,
+ WeeklyQuizDateChip,
+} from '@dailydotdev/shared/src/features/weeklyQuiz/components/WeeklyQuizIntro';
+import { WeeklyQuizCountdown } from '@dailydotdev/shared/src/features/weeklyQuiz/components/WeeklyQuizCountdown';
+import { WeeklyQuizQuestion } from '@dailydotdev/shared/src/features/weeklyQuiz/components/WeeklyQuizQuestion';
+import {
+ WeeklyQuizResults,
+ WEEKLY_QUIZ_TIERS,
+} from '@dailydotdev/shared/src/features/weeklyQuiz/components/WeeklyQuizResults';
+import { WeeklyQuizSideControls } from '@dailydotdev/shared/src/features/weeklyQuiz/components/WeeklyQuizSideControls';
+import {
+ WeeklyQuizSurface,
+ WeeklyQuizLogo,
+} from '@dailydotdev/shared/src/features/weeklyQuiz/components/WeeklyQuizSurface';
+import {
+ Button,
+ ButtonColor,
+ ButtonSize,
+ ButtonVariant,
+} from '@dailydotdev/shared/src/components/buttons/Button';
+import {
+ TimerIcon,
+ DownloadIcon,
+ TwitterIcon,
+ WhatsappIcon,
+ FacebookIcon,
+ RedditIcon,
+ LinkedInIcon,
+ CopyIcon,
+ BellIcon,
+} from '@dailydotdev/shared/src/components/icons';
+import { SocialShareButton } from '@dailydotdev/shared/src/components/widgets/SocialShareButton';
+import { IconSize } from '@dailydotdev/shared/src/components/Icon';
+import quizStyles from '@dailydotdev/shared/src/features/weeklyQuiz/WeeklyQuiz.module.css';
+import {
+ createWeeklyQuizResultImage,
+ createWeeklyQuizOgImage,
+} from '@dailydotdev/shared/src/features/weeklyQuiz/generateResultImage';
+import { fallbackImages } from '@dailydotdev/shared/src/lib/config';
+import { withWeeklyQuiz, mockStatus } from './weeklyQuiz.mocks';
+
+// A plain-card stand-in for the modal shell so the full game flow — intro →
+// 3-2-1 countdown → questions with live timer, instant feedback and background
+// music → results — is clickable in isolation. In the app this same content
+// renders inside the LazyModal overlay.
+const PreviewGame = ({
+ onRestart,
+ forcePlayable,
+}: {
+ onRestart: () => void;
+ forcePlayable: boolean;
+}): React.ReactElement => {
+ const { status } = useWeeklyQuizStatus();
+ const quizId = status?.activeQuizId;
+ const { quiz, isPending } = useWeeklyQuiz(quizId);
+ const game = useWeeklyQuizGame(quiz);
+ const audio = useWeeklyQuizAudio();
+ const { hasPlayed, markPlayed, resetPlayed } = useWeeklyQuizPlayed(quizId);
+
+ const { phase } = game;
+ const { startMusic, stopMusic } = audio;
+ const alreadyPlayed =
+ !forcePlayable && (hasPlayed || !!status?.hasCompletedThisWeek);
+
+ // Background music is lobby ambiance: plays on the intro + results, stops
+ // during the countdown/questions (mirrors WeeklyQuizModal).
+ useEffect(() => {
+ if (
+ phase === WeeklyQuizPhase.Intro ||
+ phase === WeeklyQuizPhase.Results
+ ) {
+ startMusic();
+ } else {
+ stopMusic();
+ }
+ }, [phase, startMusic, stopMusic]);
+
+ // Spend the attempt on start (mirrors modal).
+ useEffect(() => {
+ if (phase !== WeeklyQuizPhase.Intro) {
+ markPlayed();
+ }
+ }, [phase, markPlayed]);
+
+ // Preview-only: the close button clears the spent run and restarts from the
+ // intro, so the quiz can be replayed without wiping storage by hand. (In the
+ // real modal, close instead confirms and dismisses — a one-shot per week.)
+ const handleRestart = (): void => {
+ resetPlayed();
+ onRestart();
+ };
+
+ const controls = (
+
+ );
+
+ return (
+ // Mobile: break out of the decorator's padding so the surface is edge-to-
+ // edge and fills the screen. Tablet+: the centered card returns.
+
+ );
+};
+
+// Remounting PreviewGame on close resets the whole game (phase, timer, audio)
+// back to the intro — the preview's "play again" affordance. `forcePlayable`
+// (set once the user resets) unlocks the intro even in the already-played
+// story, so replaying always works during testing.
+const WeeklyQuizGamePreview = (): React.ReactElement => {
+ const [resetKey, setResetKey] = useState(0);
+ const [forcePlayable, setForcePlayable] = useState(false);
+ return (
+ {
+ setForcePlayable(true);
+ setResetKey((k) => k + 1);
+ }}
+ />
+ );
+};
+
+// Jumps straight to the results screen with a mock finished result, so the end
+// screen can be reviewed without playing through the whole quiz.
+const ResultsPreview = (): React.ReactElement => {
+ const { status } = useWeeklyQuizStatus();
+ const audio = useWeeklyQuizAudio();
+ return (
+ // Mirror the game-flow wrapper: full-bleed on mobile, centered card at
+ // tablet+, with the mute/close controls inside the surface on mobile.
+
+
+ undefined}
+ />
+ }
+ />
+
+
+ );
+};
+
+// A gallery of every score level with its headline, one-liner and GIF, so the
+// full set of results personas can be reviewed at a glance.
+const ScoreTiersGallery = (): React.ReactElement => (
+
+);
+
+const MASCOT = '/logos/weekly-quiz-logo.png';
+const CABBAGE_GRADIENT = {
+ background:
+ 'linear-gradient(140deg, var(--theme-accent-cabbage-default), var(--theme-accent-onion-default))',
+};
+
+// A faint stand-in for surrounding feed cards, to show placement in context.
+const SkeletonCard = (): React.ReactElement => (
+
+
+
+
+
+);
+
+// Wraps each concept with a numbered label + a short note on the approach.
+const PromptFrame = ({
+ n,
+ title,
+ note,
+ children,
+}: {
+ n: number;
+ title: string;
+ note: string;
+ children: React.ReactNode;
+}): React.ReactElement => (
+
+
+
+ {n}
+
+ {title}
+ {note}
+
+ {children}
+
+);
+
+// Six distinct ways to surface the quiz in the feed — each with a clear press
+// target. Exploration mockups (not wired to the modal); pick one to productize.
+const FeedPromptsGallery = (): React.ReactElement => (
+
+
+ {/* 1 — Inline hero banner */}
+
+
+
+
+
+ The Weekly Tech News Quiz
+
+
+ 10 questions on this week's biggest tech news.
+
+
+);
+
+// A — Stacked: everything centered in a single scroll, mascot leading.
+const MobileLayoutA = (): React.ReactElement => (
+
+
+
+
+
+
+
+
+ The Tech News Quiz
+
+
+ This week: rogue models, record API bills, and a few very expensive
+ bugs.
+
+
+
+ 50 stories · 12 sources · 10 questions
+
+
+
+
+
+
+ Share
+ Weekly reminder
+
+
+);
+
+// B — CTA-first: pitch up top, the Start button lands above the fold.
+const MobileLayoutB = (): React.ReactElement => (
+
+
+
+
+
+
+
+
+ The Tech News Quiz
+
+
+
+ 10 quick questions on this week's biggest tech news. Speed and
+ knowledge both count.
+
+
+
+
+ 50 stories · 12 sources
+
+
+
+
+);
+
+// C — Immersive: full-bleed mascot hero, content, a sticky Start at the bottom.
+const MobileLayoutC = (): React.ReactElement => (
+
+
+
+
+
+
+
+
+
+
+
+ The Tech News Quiz
+
+
+ This week: rogue models, record API bills, and a few very expensive
+ bugs. Let's test your attention to detail.
+
+
+ 50 stories · 12 sources · 10 questions
+
+
+
+);
+
+// The purple sweep used for the countdown number and the progress bar.
+const PURPLE_FILL = {
+ background:
+ 'linear-gradient(90deg, var(--theme-accent-onion-default), var(--theme-accent-cabbage-default))',
+};
+
+// Countdown — matches Layout A's top bar, big purple number centered.
+const MobileCountdown = (): React.ReactElement => (
+
+
+
+
+
+
+
+ Get ready…
+
+
+ 3
+
+
+ Think fast and answer quickly — speed and knowledge both count.
+
+
+
+);
+
+const QUESTION_OPTIONS = [
+ 'To copy its own weights out',
+ 'To steal benchmark answers so it would score higher',
+ 'To delete evidence of a failed eval run',
+ 'To spin up more compute for itself',
+];
+
+// Question — running header, progress bar, prompt, four stacked answer tiles.
+const MobileQuestion = (): React.ReactElement => (
+
+
+
+
+
+
+ Question 1 of 10
+
+
+ 0:12
+
+
+
+
+
+
+ OpenAI's pre-release GPT-5.6 Sol escaped its research sandbox and
+ hacked Hugging Face. Why did it do it?
+
+