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/features/weeklyQuiz/WeeklyQuiz.module.css b/packages/shared/src/features/weeklyQuiz/WeeklyQuiz.module.css
new file mode 100644
index 00000000000..a170500fe0b
--- /dev/null
+++ b/packages/shared/src/features/weeklyQuiz/WeeklyQuiz.module.css
@@ -0,0 +1,589 @@
+/* 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;
+ color: #fff;
+ font-family: 'Play', 'Helvetica Neue', Helvetica, Arial, sans-serif;
+ border: 5px solid #fff;
+ border-radius: 28px;
+ /* daily.dev purple: the surface leads with onion→cabbage (the brand's
+ * indigo→magenta) so the quiz reads as part of the app, not a standalone
+ * arcade. A faint bacon toe at the very bottom keeps it from going flat. */
+ background:
+ radial-gradient(
+ 130% 80% at 50% -12%,
+ color-mix(in srgb, var(--theme-accent-cabbage-default) 55%, var(--theme-accent-onion-default) 45%),
+ transparent 60%
+ ),
+ linear-gradient(
+ 165deg,
+ color-mix(in srgb, var(--theme-accent-onion-default) 92%, #000 8%) 0%,
+ var(--theme-accent-onion-default) 32%,
+ var(--theme-accent-cabbage-default) 66%,
+ color-mix(in srgb, var(--theme-accent-cabbage-default) 68%, var(--theme-accent-bacon-default) 32%) 100%
+ );
+}
+
+/* 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;
+}
+
+/* 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: #fff;
+ border: 4px solid #fff;
+ border-radius: 16px;
+ background: linear-gradient(
+ 160deg,
+ color-mix(in srgb, var(--theme-accent-onion-default) 92%, #000 8%) 0%,
+ var(--theme-accent-cabbage-default) 100%
+ );
+ box-shadow: 0 6px 16px rgba(0, 0, 0, 0.28);
+ 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 panel (not the translucent .panel) so text
+ * and the URL field read clearly over the busy leaderboard behind it. */
+.sharePanel {
+ background: color-mix(in srgb, var(--theme-accent-onion-default) 92%, #000 8%);
+ border: 2px solid rgba(255, 255, 255, 0.28);
+ box-shadow: 0 18px 44px rgba(0, 0, 0, 0.45);
+}
+
+/* Floating glassy panel — used to split the intro into two separated cards
+ * (left: logo + start + social, right: leaderboard) over the gradient. */
+.panel {
+ background: rgba(255, 255, 255, 0.1);
+ border: 1px solid rgba(255, 255, 255, 0.18);
+ backdrop-filter: blur(4px);
+ box-shadow:
+ 0 10px 26px rgba(0, 0, 0, 0.2),
+ inset 0 1px 0 rgba(255, 255, 255, 0.25);
+}
+
+/* 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;
+}
+/* On hover the button stops pulsing and holds big; the label pops instead. */
+.arcadeBtnIdle:hover:not(:disabled) {
+ animation: none;
+ transform: scale(1.06);
+}
+.arcadeBtnIdle:active:not(:disabled) {
+ animation: none;
+ transform: translateY(7px);
+}
+.arcadeBtnIdle:disabled {
+ animation: none;
+}
+@media (prefers-reduced-motion: reduce) {
+ .arcadeBtnIdle {
+ 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: rgba(255, 255, 255, 0.14);
+ border: 1px solid rgba(255, 255, 255, 0.22);
+ backdrop-filter: blur(6px);
+}
+
+/* Answer tile: glassy by default, then glows green/red on reveal. */
+.tile {
+ background: rgba(255, 255, 255, 0.12);
+ border: 2px solid rgba(255, 255, 255, 0.22);
+ 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.2);
+ border-color: rgba(255, 255, 255, 0.6);
+ 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-cheese-default),
+ var(--theme-accent-bun-default),
+ var(--theme-accent-bacon-default)
+ );
+ box-shadow: 0 0 12px color-mix(in srgb, var(--theme-accent-bacon-default) 60%, transparent);
+}
+
+/* Score circle on the results screen. */
+.scoreCircle {
+ background: radial-gradient(
+ circle at 50% 30%,
+ color-mix(in srgb, var(--theme-accent-cheese-default) 90%, #fff),
+ var(--theme-accent-bun-default) 60%,
+ var(--theme-accent-bacon-default)
+ );
+ box-shadow:
+ 0 0 40px color-mix(in srgb, var(--theme-accent-bacon-default) 55%, transparent),
+ inset 0 2px 6px rgba(255, 255, 255, 0.4);
+ 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-cheese-default) 55%,
+ var(--theme-accent-bun-default)
+ );
+ -webkit-background-clip: text;
+ background-clip: text;
+ color: transparent;
+ filter: drop-shadow(
+ 0 0 22px color-mix(in srgb, var(--theme-accent-cheese-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);
+}
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..21d5336f096
--- /dev/null
+++ b/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizAnswerOption.tsx
@@ -0,0 +1,73 @@
+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..cce1a13b175
--- /dev/null
+++ b/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizBanner.tsx
@@ -0,0 +1,132 @@
+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/WeeklyQuizCountdown.tsx b/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizCountdown.tsx
new file mode 100644
index 00000000000..ab521e81360
--- /dev/null
+++ b/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizCountdown.tsx
@@ -0,0 +1,64 @@
+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 = [3, 2, 1];
+const STEP_MS = 850;
+
+// A 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]}
+
+
+ );
+};
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..e6fa4fa3560
--- /dev/null
+++ b/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizIntro.tsx
@@ -0,0 +1,252 @@
+import type { MouseEvent, ReactElement } from 'react';
+import React, { useRef, useState } from 'react';
+import classNames from 'classnames';
+import {
+ Typography,
+ TypographyType,
+} from '../../../components/typography/Typography';
+import { BellIcon, CalendarIcon, ShareIcon } from '../../../components/icons';
+import { IconSize } from '../../../components/Icon';
+import {
+ ProfilePicture,
+ ProfileImageSize,
+} from '../../../components/ProfilePicture';
+import { useAuthContext } from '../../../contexts/AuthContext';
+import { WeeklyQuizScoreboard } from './WeeklyQuizScoreboard';
+import { WeeklyQuizSharePopover } from './WeeklyQuizSharePopover';
+import { WeeklyQuizPeriod } from '../types';
+import type { WeeklyQuiz } from '../types';
+import type { UseWeeklyQuizAudio } from '../hooks/useWeeklyQuizAudio';
+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".
+// Kept low so the "+N" pill sits inline on the same row as the logos.
+const SHOWN_SOURCES = 5;
+
+interface WeeklyQuizIntroProps {
+ quiz: WeeklyQuiz | undefined;
+ isLoading: boolean;
+ onStart: () => void;
+ audio?: UseWeeklyQuizAudio;
+ // 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}`;
+};
+
+// Landing screen: two separated floating panels — left holds the logo, the
+// arcade Start button and social actions; right holds the leaderboard. Stacks
+// on mobile, side-by-side from tablet up.
+export const WeeklyQuizIntro = ({
+ quiz,
+ isLoading,
+ onStart,
+ audio,
+ alreadyPlayed = false,
+}: WeeklyQuizIntroProps): ReactElement => {
+ const { user } = useAuthContext();
+ const [period, setPeriod] = useState(
+ WeeklyQuizPeriod.Weekly,
+ );
+ // Local-only until the reminder subscription is wired to the backend.
+ const [reminderSet, setReminderSet] = useState(false);
+ const [isShareOpen, setIsShareOpen] = 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 = '';
+ }
+ };
+
+ return (
+
+
+
+ {/* Padding enlarges the mouse-tracked hover box beyond the logo art
+ so the 3D tilt reacts over a more generous area. */}
+
+
+
+ {/* Glow sits over the logo's lightbulb (right side, mid-height). */}
+
+
+
+
+
+ {alreadyPlayed ? (
+
+
+
+ Try again next week!
+
+
+ ) : (
+
+ )}
+
+ {/* Which week + how much news it distils — makes it clear the quiz
+ recaps the week that just ended, not an older one. */}
+ {quiz && (
+
+ );
+};
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..f16240374ab
--- /dev/null
+++ b/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizModal.tsx
@@ -0,0 +1,159 @@
+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 } from './WeeklyQuizIntro';
+import { WeeklyQuizCountdown } from './WeeklyQuizCountdown';
+import { WeeklyQuizQuestion } from './WeeklyQuizQuestion';
+import { WeeklyQuizResults } from './WeeklyQuizResults';
+import { WeeklyQuizSideControls } from './WeeklyQuizSideControls';
+import styles from '../WeeklyQuiz.module.css';
+
+// 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 (
+
+
+ );
+};
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..764fe243128
--- /dev/null
+++ b/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizResults.tsx
@@ -0,0 +1,286 @@
+import type { ReactElement } from 'react';
+import React, { useEffect, 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 { ArrowIcon, BellIcon, UserShareIcon } from '../../../components/icons';
+import { IconSize } from '../../../components/Icon';
+import {
+ ProfilePicture,
+ ProfileImageSize,
+} from '../../../components/ProfilePicture';
+import { WeeklyQuizSharePopover } from './WeeklyQuizSharePopover';
+import { formatElapsed } from './WeeklyQuizTimer';
+import { useSubmitWeeklyQuiz } from '../hooks/useSubmitWeeklyQuiz';
+import { useWeeklyQuizLeaderboard } from '../hooks/useWeeklyQuizLeaderboard';
+import { 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;
+ // Return to the intro ("main"), which shows the full leaderboard.
+ onBackToMain: () => void;
+}
+
+const buildMessage = (correct: number, total: number): string => {
+ const ratio = total === 0 ? 0 : correct / total;
+ if (ratio === 1) {
+ return 'Flawless — you were paying attention this week!';
+ }
+ if (ratio >= 0.6) {
+ return 'Nicely done. You know your tech news.';
+ }
+ if (ratio >= 0.3) {
+ return 'Not bad — a few slipped past you.';
+ }
+ return 'Tough week? There is always next week.';
+};
+
+// Final screen: a back arrow to the main screen, then the player's own result
+// as the hero (placement + score + time) and the share/reminder actions. The
+// full leaderboard lives on the main screen, not here. Logged-in players'
+// results are submitted once on arrival (and again if an anonymous player signs
+// in from here).
+export const WeeklyQuizResults = ({
+ quizId,
+ result,
+ onBackToMain,
+}: 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 [isChallengeOpen, setIsChallengeOpen] = useState(false);
+ const submittedRef = useRef(false);
+ // 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;
+
+ // Renders the result as a shareable PNG and downloads it. Target may change
+ // later (native share sheet / upload); for now it saves locally.
+ const handleShareResult = (): void => {
+ generateWeeklyQuizResultImage({
+ name: user?.name || user?.username || 'You',
+ imageUrl: user?.image,
+ correctCount: result.correctCount,
+ totalQuestions: result.totalQuestions,
+ timeLabel: formatElapsed(result.timeMs),
+ rank,
+ logoUrl: '/logos/weekly-quiz-logo.png',
+ }).catch(() => undefined);
+ };
+
+ // 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 (
+
+ {/* Big icon-only back arrow to the main screen, top-left. */}
+
+
+
+ {/* Achievement first: the player's own placement is the hero, with their
+ avatar and a medal-style rank badge. Only when we know their rank. */}
+ {rank && user && (
+
+
+
+
+ #{rank}
+
+
+
+ You placed #{rank}!
+
+
+ )}
+ {/* Score and time carry equal weight — two matching stat circles. */}
+
+ )}
+ {!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..3742e0dd7e4
--- /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 (
+