diff --git a/packages/storybook/stories/gta-vi/1-Award.stories.tsx b/packages/storybook/stories/gta-vi/1-Award.stories.tsx
new file mode 100644
index 0000000000..3fb9c35087
--- /dev/null
+++ b/packages/storybook/stories/gta-vi/1-Award.stories.tsx
@@ -0,0 +1,73 @@
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import React from 'react';
+import { Case, Page } from '../hero-cover/parts';
+import { GiveAwardFlow } from './production/GiveAward';
+import { CommentWithAward, ProfileAwards } from './production/AwardPreviews';
+import { productionAwards } from './data';
+
+const meta: Meta = {
+ title: 'GTA VI/1. Vice City award',
+ parameters: { layout: 'fullscreen' },
+ globals: { theme: 'dark' },
+};
+
+export default meta;
+
+export const ViceCityAward: StoryObj = {
+ name: '1. Vice City award',
+ render: () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ),
+};
diff --git a/packages/storybook/stories/gta-vi/2-Achievements.stories.tsx b/packages/storybook/stories/gta-vi/2-Achievements.stories.tsx
new file mode 100644
index 0000000000..1f02ea0968
--- /dev/null
+++ b/packages/storybook/stories/gta-vi/2-Achievements.stories.tsx
@@ -0,0 +1,115 @@
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import type { ReactElement } from 'react';
+import React from 'react';
+import type { UserAchievement } from '@dailydotdev/shared/src/graphql/user/achievements';
+import { Case, Page } from '../hero-cover/parts';
+import {
+ AchievementDetail,
+ AchievementShelfCard,
+} from './production/AchievementShelfCard';
+import type { GtaViMoment } from './data';
+import {
+ getShelf,
+ GTA_VI_RELEASE_AT,
+ readOnGtaDayAchievement,
+ viceCityAchievement,
+} from './data';
+
+const meta: Meta = {
+ title: 'GTA VI/2. Event achievements',
+ parameters: { layout: 'fullscreen' },
+ globals: { theme: 'dark' },
+};
+
+export default meta;
+
+/** Game Center's Achievement shelf: its header and grid, as production. */
+const Shelf = ({ moment }: { moment: GtaViMoment }): ReactElement => (
+
+
+ Achievement shelf
+
+
+ {getShelf(moment).map((item) => (
+
+ ))}
+
+
+);
+
+const unlocked = (
+ achievement: UserAchievement['achievement'],
+): UserAchievement => ({
+ achievement,
+ progress: 1,
+ unlockedAt: GTA_VI_RELEASE_AT,
+ createdAt: null,
+ updatedAt: null,
+});
+
+const DetailFrame = ({
+ userAchievement,
+}: {
+ userAchievement: UserAchievement;
+}): ReactElement => (
+
+);
+
+const moments: { moment: GtaViMoment; title: string; note: string }[] = [
+ {
+ moment: 'releaseRead',
+ title: 'Nov 19, after a read',
+ note: 'Both event achievements lead the shelf. Made It to Vice City is everyone’s, so its chip says Free; the read is the one to earn, so its chip says Rare. Rarity percentages don’t exist on day one, so the chips name the kind instead. Click a card for its detail.',
+ },
+ {
+ moment: 'releaseNotRead',
+ title: 'Nov 19, no read yet',
+ note: 'Opening daily.dev on release day unlocks Made It to Vice City. Still Read waits, grayed like any locked achievement, and says what to do instead of a 0 / 1 count.',
+ },
+ {
+ moment: 'afterMissed',
+ title: 'Nov 20 on, the day missed',
+ note: 'Release day is gone, so Still Read can’t be earned and leaves the shelf. Made It to Vice City stays.',
+ },
+ {
+ moment: 'before',
+ title: 'Before Nov 19',
+ note: 'Nothing on the shelf: the achievements are a surprise on the day. The streak calendar’s VI day is the teaser.',
+ },
+];
+
+export const EventAchievements: StoryObj = {
+ name: '2. Event achievements',
+ render: () => (
+
+
+
+
+
+
+
+
+
+ {moments.map(({ moment, title, note }) => (
+
+
+
+ ))}
+
+
+ ),
+};
diff --git a/packages/storybook/stories/gta-vi/3-Theme.stories.tsx b/packages/storybook/stories/gta-vi/3-Theme.stories.tsx
new file mode 100644
index 0000000000..02a1ce6dcf
--- /dev/null
+++ b/packages/storybook/stories/gta-vi/3-Theme.stories.tsx
@@ -0,0 +1,69 @@
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import React, { useState } from 'react';
+import type { ReactElement } from 'react';
+import { Case, Page } from '../hero-cover/parts';
+import type { ThemeChoice } from './theme';
+import { SettingsPopover, ThemedHome } from './theme';
+
+const meta: Meta = {
+ title: 'GTA VI/3. Vice City theme',
+ parameters: { layout: 'fullscreen' },
+ globals: { theme: 'dark' },
+};
+
+export default meta;
+
+const Popover = ({ initial }: { initial: ThemeChoice }): ReactElement => {
+ const [theme, setTheme] = useState(initial);
+ return ;
+};
+
+export const ViceCityTheme: StoryObj = {
+ name: '3. Vice City theme',
+ render: () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ),
+};
diff --git a/packages/storybook/stories/gta-vi/4-AllStates.stories.tsx b/packages/storybook/stories/gta-vi/4-AllStates.stories.tsx
new file mode 100644
index 0000000000..773e01fc54
--- /dev/null
+++ b/packages/storybook/stories/gta-vi/4-AllStates.stories.tsx
@@ -0,0 +1,499 @@
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import React from 'react';
+import { Case, Page } from '../hero-cover/parts';
+import { GiveAwardFlow } from './production/GiveAward';
+import { CommentWithAward, ProfileAwards } from './production/AwardPreviews';
+import { AchievementDetail } from './production/AchievementShelfCard';
+import { Shelf } from './production/Shelf';
+import { SettingsPopover, ThemedHome, ThemeRow } from './theme';
+import { DeviceRow, State, StateGrid } from './parts';
+import {
+ GTA_VI_RELEASE_AT,
+ productionAwards,
+ readOnGtaDayAchievement,
+ viceCityAchievement,
+} from './data';
+
+/*
+ * Every state of the three GTA VI pieces, for a careful review: each case is
+ * tagged Production (unchanged behavior), GTA VI (the change) or Proposed
+ * (a gap production doesn't cover yet). Devices rows load the Devices stories
+ * in real iframes at each width.
+ */
+
+const meta: Meta = {
+ title: 'GTA VI/4. All states',
+ parameters: { layout: 'fullscreen' },
+ globals: { theme: 'dark' },
+};
+
+export default meta;
+
+const longName = 'Maximiliano Fernández-Castellanos de la Vega y Montoya';
+const maxNote =
+ `${'Patch notes, changelogs and a 4GB day-one download. '.repeat(8)}`.slice(
+ 0,
+ 400,
+ );
+
+/* -------------------------------------------------------------- award */
+
+export const Award: StoryObj = {
+ name: '1. Award',
+ render: () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ),
+};
+
+/* ------------------------------------------------------- achievements */
+
+const unlocked = (
+ achievement: typeof viceCityAchievement,
+): Parameters[0]['userAchievement'] => ({
+ achievement,
+ progress: 1,
+ unlockedAt: GTA_VI_RELEASE_AT,
+ createdAt: null,
+ updatedAt: null,
+});
+
+const DetailFrame = ({
+ achievement,
+ isLocked = false,
+}: {
+ achievement: typeof viceCityAchievement;
+ isLocked?: boolean;
+}) => (
+
+);
+
+export const Achievements: StoryObj = {
+ name: '2. Achievements',
+ render: () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {(
+ [
+ ['releaseRead', 'Nov 19, after a read', 'GTA VI'],
+ ['releaseNotRead', 'Nov 19, no read yet', 'GTA VI'],
+ ['afterRead', 'Nov 20 on, read that day', 'GTA VI'],
+ ['afterMissed', 'Nov 20 on, the day missed', 'GTA VI'],
+ ['before', 'Before Nov 19', 'Production'],
+ ['loading', 'Loading', 'Production'],
+ ['empty', 'No achievements', 'Production'],
+ ['longText', 'Long names and descriptions', 'GTA VI'],
+ ['brokenImage', 'Images fail to load', 'Production'],
+ ] as const
+ ).map(([state, title, tag]) => (
+
+
+
+
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+ ),
+};
+
+/* -------------------------------------------------------------- theme */
+
+export const Theme: StoryObj = {
+ name: '3. Theme',
+ render: () => (
+
+
+
+ {(['dark', 'light', 'auto', 'viceCity'] as const).map((theme) => (
+
+ undefined} />
+
+ ))}
+
+
+
+ Appearance
+
+ undefined} />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ),
+};
diff --git a/packages/storybook/stories/gta-vi/5-BuildNotes.stories.tsx b/packages/storybook/stories/gta-vi/5-BuildNotes.stories.tsx
new file mode 100644
index 0000000000..b42e107de2
--- /dev/null
+++ b/packages/storybook/stories/gta-vi/5-BuildNotes.stories.tsx
@@ -0,0 +1,646 @@
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import type { ReactElement, ReactNode } from 'react';
+import React, { useEffect, useRef, useState } from 'react';
+import classNames from 'classnames';
+import { Case, Page } from '../hero-cover/parts';
+
+/*
+ * What a builder needs beyond the pictures: the production components and
+ * tokens each piece is made of, how every edge case is handled, the
+ * accessibility checks (contrast measured live from the tokens, so it
+ * follows the toolbar's theme), and the gaps to decide before building.
+ */
+
+const meta: Meta = {
+ title: 'GTA VI/5. Build notes',
+ // Not pinned to dark, so the toolbar's theme switch re-measures contrast.
+ parameters: { layout: 'fullscreen' },
+};
+
+export default meta;
+
+const Table = ({
+ head,
+ rows,
+}: {
+ head: string[];
+ rows: ReactNode[][];
+}): ReactElement => (
+
+
+
+
+ {head.map((cell) => (
+
+ {cell}
+
+ ))}
+
+
+
+ {rows.map((row, index) => (
+
+ {row.map((cell, cellIndex) => (
+
+ {cell}
+
+ ))}
+
+ ))}
+
+
+
+);
+
+const Code = ({ children }: { children: ReactNode }): ReactElement => (
+
+ {children}
+
+);
+
+/* ------------------------------------------------------------ contrast */
+
+type Rgba = [number, number, number, number];
+
+const parseColor = (value: string): Rgba | null => {
+ const rgb = value.match(/rgba?\(([^)]+)\)/);
+ if (rgb) {
+ const [r, g, b, a = '1'] = rgb[1].split(/[\s,/]+/).filter(Boolean);
+ return [Number(r), Number(g), Number(b), Number(a)];
+ }
+ const srgb = value.match(/color\(srgb ([^)]+)\)/);
+ if (srgb) {
+ const [r, g, b, a = '1'] = srgb[1].split(/[\s/]+/).filter(Boolean);
+ return [Number(r) * 255, Number(g) * 255, Number(b) * 255, Number(a)];
+ }
+ return null;
+};
+
+const over = (top: Rgba, bottom: Rgba): Rgba => {
+ const a = top[3];
+ return [
+ top[0] * a + bottom[0] * (1 - a),
+ top[1] * a + bottom[1] * (1 - a),
+ top[2] * a + bottom[2] * (1 - a),
+ 1,
+ ];
+};
+
+const luminance = ([r, g, b]: Rgba): number => {
+ const channel = (value: number) => {
+ const c = value / 255;
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
+ };
+ return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
+};
+
+const ratio = (a: Rgba, b: Rgba): number => {
+ const [light, dark] = [luminance(a), luminance(b)].sort((x, y) => y - x);
+ return (light + 0.05) / (dark + 0.05);
+};
+
+type Kind = 'text' | 'large' | 'ui';
+
+const thresholds: Record = {
+ text: { aa: 4.5, aaa: 7 },
+ large: { aa: 3, aaa: 4.5 },
+ ui: { aa: 3, aaa: null },
+};
+
+const Badge = ({ pass, label }: { pass: boolean; label: string }) => (
+
+ {label} {pass ? 'pass' : 'fail'}
+
+);
+
+/**
+ * Measures one pairing from the live tokens: the sample's text color over
+ * its background, over the page (or a worst-case art color).
+ */
+const Contrast = ({
+ label,
+ where,
+ fg,
+ bg,
+ base,
+ kind,
+}: {
+ label: string;
+ where: string;
+ fg: string;
+ bg: string;
+ /** A color behind the background, e.g. the art's lightest tone. */
+ base?: string;
+ kind: Kind;
+}): ReactElement => {
+ const sampleRef = useRef(null);
+ const [value, setValue] = useState(null);
+ const [theme, setTheme] = useState(document.documentElement.className);
+
+ useEffect(() => {
+ const observer = new MutationObserver(() =>
+ setTheme(document.documentElement.className),
+ );
+ observer.observe(document.documentElement, { attributes: true });
+ return () => observer.disconnect();
+ }, []);
+
+ useEffect(() => {
+ const sample = sampleRef.current;
+ if (!sample) {
+ return;
+ }
+ const page = parseColor(
+ getComputedStyle(document.body).getPropertyValue('background-color'),
+ );
+ const pageColor =
+ page && page[3] > 0
+ ? page
+ : (parseColor(
+ getComputedStyle(sample.closest('.bg-background-default') ?? sample)
+ .backgroundColor,
+ ) as Rgba);
+ const baseColor = base
+ ? (parseColor(base) as Rgba)
+ : (pageColor ?? [14, 18, 23, 1]);
+ const background = over(
+ parseColor(
+ getComputedStyle(sample.parentElement as Element).backgroundColor,
+ ) ?? [0, 0, 0, 0],
+ baseColor,
+ );
+ const foreground = over(
+ parseColor(getComputedStyle(sample).color) ?? [0, 0, 0, 1],
+ background,
+ );
+ setValue(ratio(foreground, background));
+ }, [base, bg, fg, theme]);
+
+ const { aa, aaa } = thresholds[kind];
+ return (
+
+ {label}
+ {where}
+
+
+
+ Aa
+
+
+
+
+ {value ? `${value.toFixed(2)}:1` : '…'}
+
+
+ {value && (
+
+ = aa} label="AA" />
+ {aaa && = aaa} label="AAA" />}
+
+ )}
+
+
+ {
+ { text: 'Text', large: 'Large or bold text', ui: 'Icon / non-text' }[
+ kind
+ ]
+ }
+
+
+ );
+};
+
+/* -------------------------------------------------------------- page */
+
+export const BuildNotes: StoryObj = {
+ name: '5. Build notes',
+ render: () => (
+
+
+
+ GiveAwardModal › AwardItem{' '}
+ (components/modals/award)
+ >,
+ 'None to the component. A product with value 0 already reads Free; the backend lists it first during launch week.',
+ <>
+ ButtonVariant.Float, bg-surface-float,{' '}
+ rounded-14, image size-20,{' '}
+ TypographyType.Footnote /{' '}
+ TypographyColor.Secondary
+ >,
+ ],
+ [
+ 'Free footer',
+ <>
+ GiveAwardModal › CommentScreen
+ >,
+ <>
+ For an event product, a Footnote line replaces{' '}
+ AwardFeesNote.
+ >,
+ <>
+ TypographyType.Footnote,{' '}
+ TypographyColor.Tertiary; expired:{' '}
+ TypographyColor.StatusError
+ >,
+ ],
+ [
+ 'Award received',
+ <>
+ CommentAwardActions, PostAwardAction,
+ profile BadgesAndAwards › Award
+ >,
+ 'None. The image renders at size-6 (comment), size-8 (profile).',
+ 'The art itself: light frame, white-outlined VI, 74% of its canvas like the other awards.',
+ ],
+ [
+ 'Event chips',
+ <>
+ AchievementShelfCard{' '}
+ (features/profile/components/achievements)
+ >,
+ <>
+ An event field on Achievement swaps
+ the <1% rare chip for Free or Rare, with{' '}
+ MedalBadgeIcon.
+ >,
+ <>
+ Free: bg-accent-cabbage-default with{' '}
+ text-background-default, which flips with the theme
+ (any fixed ink fails AA at 14px in one of them). Rare: the
+ card's existing rarityPillClasses.{' '}
+ rounded-max.
+ >,
+ ],
+ [
+ 'Unlocked date',
+ AchievementShelfCard,
+ 'Event cards say "Unlocked Nov 19, 2026" (always the year) and the detail adds CalendarIcon.',
+ <>
+ TypographyType.Subhead,{' '}
+ TypographyColor.Tertiary,{' '}
+ IconSize.Size16
+ >,
+ ],
+ [
+ 'Theme option',
+ <>
+ ThemeSection (ProfileMenu/sections), shown by{' '}
+ SidebarDesktopV2's settings popover,{' '}
+ ProfileMenu and Settings › Appearance
+ >,
+ <>
+ A fourth themes entry. Picking it sets dark mode
+ plus the cover.
+ >,
+ <>
+ ButtonGroup, ButtonSize.XSmall,{' '}
+ ButtonVariant.Float when active; glyph gradient
+ from --theme-accent-cabbage/bacon/bun-default
+ >,
+ ],
+ [
+ 'Home cover',
+ 'The home page header (Hero Cover rail header)',
+ 'Art behind the shortcuts and greeting, fading into the page.',
+ <>
+ Fade to background-default; shortcut labels on{' '}
+ bg-overlay-primary-pepper rounded-6;
+ no raw colors
+ >,
+ ],
+ ]}
+ />
+
+
+
+
+
+
+
+
+
+
+
+ {['Pair', 'Where', 'Sample', 'Ratio', 'WCAG', 'Kind'].map(
+ (cell) => (
+
+ {cell}
+
+ ),
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ A normal award product with value: 0 and{' '}
+ flags.availableUntil; the products query hides it
+ after that date, and the award mutation rejects it after that
+ date. No client logic beyond the expired message.
+ >,
+ ],
+ [
+ 'Spam: a free award costs nothing',
+ 'Reuse the existing one-award-per-entity rule (userState.awarded). No new limit.',
+ ],
+ [
+ 'Free awards and revenue share',
+ 'Value 0 means no transaction and no revenue share; AwardFeesNote is replaced for it.',
+ ],
+ [
+ 'Achievement type has no event field',
+ <>
+ Add event?: 'free' | 'rare' to{' '}
+ Achievement in graphql/user/achievements.ts (types
+ are hand-written) and to the query fragment.
+ >,
+ ],
+ [
+ 'Which day counts as Nov 19',
+ 'The reader’s timezone (user.timezone), as the streak does. Granting: Vice City on any boot that day; Still Read on the first read that day, from the same event that counts the streak.',
+ ],
+ [
+ 'Rarity on event achievements',
+ 'Exclude them from rarity: the chip names the kind, and they shouldn’t push others out of the <1% band.',
+ ],
+ [
+ 'Still Read after the day',
+ 'The backend stops returning it to readers who don’t have it once Nov 20 starts in their timezone.',
+ ],
+ [
+ 'Storing the theme',
+ <>
+ Add viceCity to the settings theme enum on the
+ backend. Clients that don’t know it fall back to dark, so old
+ extension versions don’t break.
+ >,
+ ],
+ [
+ 'Light mode and the theme',
+ 'VI is dark-only: picking it sets dark. Picking Light or Auto turns VI off.',
+ ],
+ [
+ 'After the event',
+ 'Keep VI in the Theme row; people who like it keep it. Revisit at the next event.',
+ ],
+ [
+ 'Feature flags',
+ <>
+ gta_vi_award, gta_vi_achievements,{' '}
+ gta_vi_theme in featureManagement.ts, all default
+ false.
+ >,
+ ],
+ [
+ 'Analytics',
+ <>
+ Theme: the existing LogEvent.ChangeSettings with{' '}
+ target_id: viceCity. Award: existing award events
+ carry the product id.
+ >,
+ ],
+ [
+ 'Found in production: the card description never clamps',
+ <>
+ AchievementShelfCard sets{' '}
+ line-clamp-1 hidden tablet:block, and{' '}
+ tablet:block overrides the clamp's display.
+ Fix separately with tablet:line-clamp-1 if product
+ wants one line; it changes every card, so it's not part of
+ this work.
+ >,
+ ],
+ [
+ 'Found in production: the achievement image fallback 404s',
+ <>
+ AchievementShelfCard's{' '}
+ fallbackImage is{' '}
+ https://daily.dev/default-achievement.png, which
+ returns 404. Point it at an image on media.daily.dev.
+ >,
+ ],
+ [
+ 'Assets',
+ 'Move the art to webapp/public/app/assets and load with fromCDN; give the cover explicit height so it never shifts the layout.',
+ ],
+ ]}
+ />
+
+
+ ),
+};
diff --git a/packages/storybook/stories/gta-vi/Devices.stories.tsx b/packages/storybook/stories/gta-vi/Devices.stories.tsx
new file mode 100644
index 0000000000..f15ac76b2f
--- /dev/null
+++ b/packages/storybook/stories/gta-vi/Devices.stories.tsx
@@ -0,0 +1,119 @@
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import type { ReactElement, ReactNode } from 'react';
+import React from 'react';
+import { GiveAwardFlow } from './production/GiveAward';
+import { CommentWithAward, ProfileAwards } from './production/AwardPreviews';
+import { Shelf } from './production/Shelf';
+import { FeedHeroProviders } from '../features/feed/feedHero.mocks';
+import { ThemedHome } from './theme';
+
+/*
+ * One case per story, filling the window at its own size. The All states
+ * pages load these in iframes at each device's width, so media queries
+ * (tablet:, laptop:) and the modal's phone sheet behave as in the app.
+ */
+
+const meta: Meta = {
+ title: 'GTA VI/Devices',
+ parameters: { layout: 'fullscreen' },
+ globals: { theme: 'dark' },
+};
+
+export default meta;
+
+/** Production's modal overlay, the sheet at the bottom on phones. */
+const ModalBackdrop = ({ children }: { children: ReactNode }): ReactElement => (
+ // #__next is a stretched column in production's CSS, so the layout sits
+ // on a child.
+
+);
+
+const Page = ({ children }: { children: ReactNode }): ReactElement => (
+
+ {children}
+
+);
+
+export const AwardPicker: StoryObj = {
+ name: 'Award picker',
+ render: () => (
+
+
+
+ ),
+};
+
+export const AwardNote: StoryObj = {
+ name: 'Award note',
+ render: () => (
+
+
+
+ ),
+};
+
+export const AwardReceived: StoryObj = {
+ name: 'Award received',
+ render: () => (
+
+
+
+ ),
+};
+
+export const AchievementShelf: StoryObj = {
+ name: 'Achievement shelf',
+ render: () => (
+
+
+
+ ),
+};
+
+export const AchievementDetail: StoryObj = {
+ name: 'Achievement detail',
+ render: () => (
+
+
+
+ ),
+};
+
+export const ThemeOn: StoryObj = {
+ name: 'Theme on',
+ render: () => (
+
+
+
+ ),
+};
+
+export const ThemePicking: StoryObj = {
+ name: 'Theme picking',
+ render: () => (
+
+
+
+ ),
+};
+
+export const ThemeOff: StoryObj = {
+ name: 'Theme off',
+ render: () => (
+
+
+
+ ),
+};
diff --git a/packages/storybook/stories/gta-vi/assets/achievement-read-on-gta-day.webp b/packages/storybook/stories/gta-vi/assets/achievement-read-on-gta-day.webp
new file mode 100644
index 0000000000..c6a7163e03
Binary files /dev/null and b/packages/storybook/stories/gta-vi/assets/achievement-read-on-gta-day.webp differ
diff --git a/packages/storybook/stories/gta-vi/assets/achievement-vice-city.webp b/packages/storybook/stories/gta-vi/assets/achievement-vice-city.webp
new file mode 100644
index 0000000000..35f9d373f6
Binary files /dev/null and b/packages/storybook/stories/gta-vi/assets/achievement-vice-city.webp differ
diff --git a/packages/storybook/stories/gta-vi/assets/award-gta-vi.webp b/packages/storybook/stories/gta-vi/assets/award-gta-vi.webp
new file mode 100644
index 0000000000..f614d36932
Binary files /dev/null and b/packages/storybook/stories/gta-vi/assets/award-gta-vi.webp differ
diff --git a/packages/storybook/stories/gta-vi/assets/theme-vice-city-cover.webp b/packages/storybook/stories/gta-vi/assets/theme-vice-city-cover.webp
new file mode 100644
index 0000000000..386af76497
Binary files /dev/null and b/packages/storybook/stories/gta-vi/assets/theme-vice-city-cover.webp differ
diff --git a/packages/storybook/stories/gta-vi/data.ts b/packages/storybook/stories/gta-vi/data.ts
new file mode 100644
index 0000000000..a08650e25d
--- /dev/null
+++ b/packages/storybook/stories/gta-vi/data.ts
@@ -0,0 +1,377 @@
+import type { Product } from '@dailydotdev/shared/src/graphql/njord';
+import type {
+ Achievement,
+ UserAchievement,
+} from '@dailydotdev/shared/src/graphql/user/achievements';
+import { AchievementType } from '@dailydotdev/shared/src/graphql/user/achievements';
+import awardArt from './assets/award-gta-vi.webp';
+import viceCityArt from './assets/achievement-vice-city.webp';
+import readOnGtaDayArt from './assets/achievement-read-on-gta-day.webp';
+
+/*
+ * The GTA VI launch beyond the streak (see stories/streak/gtaViParts.tsx):
+ * a free award, two event achievements and an opt-in home theme. Everything
+ * here is data; the award catalog and the shelf's other achievements are
+ * copied from production (the `products` and `achievements` queries,
+ * 2026-11-10).
+ */
+
+export const GTA_VI_RELEASE_DAY = '2026-11-19';
+export const GTA_VI_RELEASE_AT = `${GTA_VI_RELEASE_DAY}T12:00:00.000Z`;
+
+/* ---------------------------------------------------------------- award */
+
+const media = (path: string): string =>
+ `https://media.daily.dev/image/upload/${path}`;
+
+const award = (
+ id: string,
+ name: string,
+ image: string,
+ value: number,
+): Product => ({ id, type: 'award', name, image, value });
+
+export const gtaViAward: Product = {
+ id: 'gta-vi-award',
+ type: 'award',
+ name: 'Vice City',
+ image: awardArt,
+ value: 0,
+ flags: { description: 'Vice City', imageGlow: awardArt },
+};
+
+export const productionAwards: Product[] = [
+ award(
+ 'a39d7368',
+ 'Smiley Face',
+ media('s--Ft4p9rjO--/f_auto/v1743664345/public/happy'),
+ 10,
+ ),
+ award(
+ 'ef2e2633',
+ 'Laughing Face',
+ media('s--THFmDVKR--/f_auto/v1743664345/public/Lol'),
+ 25,
+ ),
+ award(
+ '8aeb45ce',
+ 'Heart',
+ media('s--IPWhKcis--/f_auto/v1743664345/public/Love'),
+ 50,
+ ),
+ award(
+ '352f1e07',
+ 'Coffee Mug',
+ media('s--aenMzSPZ--/f_auto/v1743664347/public/Coffee'),
+ 75,
+ ),
+ award(
+ '1b536ed1',
+ 'Star',
+ media('s--PdFJ22B8--/f_auto/v1743664345/public/Star'),
+ 100,
+ ),
+ award(
+ '17432ba4',
+ 'Hot Dog',
+ media('s--zOtftlFL--/f_auto/v1743664345/public/hotDog'),
+ 125,
+ ),
+ award(
+ '1ab1bc67',
+ 'Pizza',
+ media('s--n35mhpLB--/f_auto/v1743664345/public/Pizza'),
+ 150,
+ ),
+ award(
+ 'c0d239b9',
+ 'Stash of Cash',
+ media('s--7SPVvVPK--/f_auto/v1743664345/public/Cash'),
+ 250,
+ ),
+ award(
+ '8c0257d7',
+ 'Balloon Brackets',
+ media('s--eJMxq8lw--/f_auto/v1747300201/public/Curly%20Brackets'),
+ 300,
+ ),
+ award(
+ '0951d2c8',
+ 'Terminal',
+ media('s--KBzEqo01--/f_auto/v1743664345/public/Terminal'),
+ 400,
+ ),
+ award(
+ '7bd171f2',
+ 'Rubber Duck',
+ media('s--Q65Srvfv--/f_auto/v1743664345/public/Duck'),
+ 600,
+ ),
+ award(
+ '5efc4400',
+ 'Robotic Bug',
+ media('s--wJzQYBN0--/f_auto/v1747300201/public/Bug'),
+ 750,
+ ),
+ award(
+ '57c82004',
+ 'Gaming Controller',
+ media('s--IMOkH2qD--/f_auto/v1747300201/public/Controller'),
+ 900,
+ ),
+ award(
+ 'e9dab194',
+ 'Keyboard',
+ media('s--ezX7VAyg--/f_auto/v1743664345/public/Keyboard'),
+ 1200,
+ ),
+ award(
+ '9d2131e8',
+ 'Shady Hacker',
+ media('s--UvWxsXT5--/f_auto/v1747300201/public/Hacker'),
+ 1500,
+ ),
+];
+
+export const receiver = { name: 'nerdalytics', username: 'nerdalytics' };
+
+/* --------------------------------------------------------- achievements */
+
+export type GtaViAchievementKind = 'free' | 'rare';
+
+export interface GtaViAchievement extends Achievement {
+ /** Event achievements carry their own chip instead of the rarity one. */
+ event: GtaViAchievementKind;
+}
+
+export const viceCityAchievement: GtaViAchievement = {
+ id: 'gta-vi-made-it',
+ name: 'Made It to Vice City',
+ description: 'You survived the wait. Now go make some memories.',
+ image: viceCityArt,
+ type: AchievementType.Instant,
+ criteria: { targetCount: 1 },
+ xp: 10,
+ rarity: null,
+ unit: null,
+ event: 'free',
+};
+
+export const readOnGtaDayAchievement: GtaViAchievement = {
+ id: 'gta-vi-still-read',
+ name: 'Still Read on GTA VI Day',
+ description:
+ 'While the world was in Vice City, you still made time to read. Respect.',
+ image: readOnGtaDayArt,
+ type: AchievementType.Instant,
+ criteria: { targetCount: 1 },
+ xp: 25,
+ rarity: null,
+ unit: null,
+ event: 'rare',
+};
+
+const achievement = (
+ name: string,
+ description: string,
+ image: string,
+ targetCount: number,
+ rarity: number,
+): Achievement => ({
+ id: name,
+ name,
+ description,
+ image,
+ type: AchievementType.Milestone,
+ criteria: { targetCount },
+ xp: 10,
+ rarity,
+ unit: null,
+});
+
+const owned = (
+ item: Achievement,
+ progress: number,
+ unlockedAt: string | null = null,
+): UserAchievement => ({
+ achievement: item,
+ progress,
+ unlockedAt,
+ createdAt: null,
+ updatedAt: null,
+});
+
+/** The reader's shelf as production orders it: in progress, then rarest. */
+export const productionShelf: UserAchievement[] = [
+ owned(
+ achievement(
+ 'On the path',
+ 'Complete 50 quests',
+ media('s--iwqvFWLT--/q_auto/v1773743172/achievements/on_the_path'),
+ 50,
+ 0.21,
+ ),
+ 46,
+ ),
+ owned(
+ achievement(
+ 'Committed',
+ 'Reach a 50-day reading streak',
+ media('v1770222887/achievements/Comitted.png'),
+ 50,
+ 1.69,
+ ),
+ 38,
+ ),
+ owned(
+ achievement(
+ 'Hero',
+ 'Complete 100 quests',
+ media('s--5WqXv9y7--/q_auto/v1773743176/achievements/heros_quest'),
+ 100,
+ 0.12,
+ ),
+ 46,
+ ),
+ owned(
+ achievement(
+ 'I took "daily dev" literally',
+ 'Reach a 365-day reading streak',
+ media('v1770224984/achievements/I_took_dailydev_literally.png'),
+ 365,
+ 0.25,
+ ),
+ 38,
+ ),
+ owned(
+ achievement(
+ "Can't spend it all",
+ 'Spend 10000 Cores',
+ media('s--_MjhSTze--/q_auto/v1773608417/achievements/cant_spend_it_all'),
+ 10000,
+ 0.01,
+ ),
+ 10000,
+ '2026-06-23T10:00:00.000Z',
+ ),
+ owned(
+ achievement(
+ 'In the big league',
+ 'Reach 10,000 reputation',
+ media('v1770222928/achievements/In_the_big_league.png'),
+ 10000,
+ 0.05,
+ ),
+ 10000,
+ '2026-02-11T10:00:00.000Z',
+ ),
+ owned(
+ achievement(
+ 'Organized',
+ 'Create a bookmark folder',
+ media('v1770222923/achievements/Organized.png'),
+ 1,
+ 0.08,
+ ),
+ 1,
+ '2026-02-11T10:00:00.000Z',
+ ),
+ owned(
+ achievement(
+ 'True chad',
+ 'Receive 1000 upvotes',
+ media('v1770222934/achievements/True_chad.png'),
+ 1000,
+ 0.1,
+ ),
+ 1000,
+ '2026-04-28T10:00:00.000Z',
+ ),
+ owned(
+ achievement(
+ 'Coraholic',
+ 'Spend 1000 Cores',
+ media('s--SNnLKKWe--/q_auto/v1773608419/achievements/coraholic'),
+ 1000,
+ 0.12,
+ ),
+ 1000,
+ '2026-03-16T10:00:00.000Z',
+ ),
+ owned(
+ achievement(
+ 'Poll it',
+ 'Create a poll',
+ media('s--J20Dc3CL--/q_auto/v1773608418/achievements/poll_it'),
+ 1,
+ 0.13,
+ ),
+ 1,
+ '2026-05-02T10:00:00.000Z',
+ ),
+ owned(
+ achievement(
+ 'Altruistic',
+ 'Give your first award',
+ media('s--bwQRPWPt--/q_auto/v1770802226/achievements/altruistic.png'),
+ 1,
+ 0.41,
+ ),
+ 1,
+ '2026-03-04T10:00:00.000Z',
+ ),
+ owned(
+ achievement(
+ 'Prophet',
+ 'Reach 100 followers',
+ media('v1770222920/achievements/Prophet.png'),
+ 100,
+ 0.21,
+ ),
+ 100,
+ '2026-07-19T10:00:00.000Z',
+ ),
+ owned(
+ achievement(
+ 'Curator',
+ 'Have 10 different shared links clicked',
+ media('v1770222887/achievements/Curator.png'),
+ 10,
+ 0.44,
+ ),
+ 10,
+ '2026-08-07T10:00:00.000Z',
+ ),
+];
+
+/**
+ * Where the reader stands against release day: before it, on it before and
+ * after their first read, and after it with or without that read.
+ */
+export type GtaViMoment =
+ | 'before'
+ | 'releaseNotRead'
+ | 'releaseRead'
+ | 'afterRead'
+ | 'afterMissed';
+
+/** The event achievements first, then production's shelf, per moment. */
+export const getShelf = (moment: GtaViMoment): UserAchievement[] => {
+ const unlockedViceCity = owned(viceCityAchievement, 1, GTA_VI_RELEASE_AT);
+ const event: Record = {
+ // Nothing before release: the surprise is on the day.
+ before: [],
+ releaseNotRead: [unlockedViceCity, owned(readOnGtaDayAchievement, 0)],
+ releaseRead: [
+ unlockedViceCity,
+ owned(readOnGtaDayAchievement, 1, GTA_VI_RELEASE_AT),
+ ],
+ afterRead: [
+ unlockedViceCity,
+ owned(readOnGtaDayAchievement, 1, GTA_VI_RELEASE_AT),
+ ],
+ // The day is gone, so the read can't be earned any more: it leaves.
+ afterMissed: [unlockedViceCity],
+ };
+ return [...event[moment], ...productionShelf].slice(0, 15);
+};
diff --git a/packages/storybook/stories/gta-vi/parts.tsx b/packages/storybook/stories/gta-vi/parts.tsx
new file mode 100644
index 0000000000..738952f74e
--- /dev/null
+++ b/packages/storybook/stories/gta-vi/parts.tsx
@@ -0,0 +1,97 @@
+import type { ReactElement, ReactNode } from 'react';
+import React from 'react';
+import { Frame } from '../startups-onboarding/frame';
+
+/*
+ * Review furniture for the All states pages: a state grid with a label and
+ * a tag per case, and a row of real device frames (iframes at each width,
+ * from Devices.stories.tsx).
+ */
+
+export type Tag = 'Production' | 'GTA VI' | 'Proposed';
+
+const tagClass: Record = {
+ Production: 'bg-surface-float text-text-tertiary',
+ 'GTA VI': 'bg-accent-cabbage-default text-background-default',
+ Proposed: 'bg-accent-bun-default text-background-default',
+};
+
+export const State = ({
+ title,
+ note,
+ tag,
+ children,
+}: {
+ title: string;
+ note?: string;
+ tag: Tag;
+ children: ReactNode;
+}): ReactElement => (
+
+
+
+
+ {title}
+
+
+ {tag}
+
+
+ {note && {note} }
+
+ {children}
+
+);
+
+export const StateGrid = ({
+ children,
+}: {
+ children: ReactNode;
+}): ReactElement => (
+ {children}
+);
+
+export const devices = [
+ { label: 'Phone · 375', width: 375, height: 812, shown: 200 },
+ { label: 'Tablet · 768', width: 768, height: 1024, shown: 300 },
+ { label: 'Laptop · 1280', width: 1280, height: 800, shown: 420 },
+ { label: 'Laptop L · 1440', width: 1440, height: 900, shown: 460 },
+ { label: 'Desktop · 1920', width: 1920, height: 1080, shown: 520 },
+];
+
+/** One story in a real iframe at every device width; click opens it live. */
+export const DeviceRow = ({
+ storyId,
+ title,
+}: {
+ storyId: string;
+ title: string;
+}): ReactElement => (
+
+ {devices.map((device) => (
+
+ ))}
+
+);
diff --git a/packages/storybook/stories/gta-vi/production/AchievementShelfCard.tsx b/packages/storybook/stories/gta-vi/production/AchievementShelfCard.tsx
new file mode 100644
index 0000000000..518c1c7ec9
--- /dev/null
+++ b/packages/storybook/stories/gta-vi/production/AchievementShelfCard.tsx
@@ -0,0 +1,337 @@
+import type { ReactElement } from 'react';
+import React, { useState } from 'react';
+import classNames from 'classnames';
+import type { UserAchievement } from '@dailydotdev/shared/src/graphql/user/achievements';
+import { getTargetCount } from '@dailydotdev/shared/src/graphql/user/achievements';
+import {
+ Typography,
+ TypographyColor,
+ TypographyTag,
+ TypographyType,
+} from '@dailydotdev/shared/src/components/typography/Typography';
+import {
+ ButtonSize,
+ ButtonVariant,
+} from '@dailydotdev/shared/src/components/buttons/Button';
+import { LazyImage } from '@dailydotdev/shared/src/components/LazyImage';
+import { ProgressBar } from '@dailydotdev/shared/src/components/fields/ProgressBar';
+import CloseButton from '@dailydotdev/shared/src/components/CloseButton';
+import { Modal } from '@dailydotdev/shared/src/components/modals/common/Modal';
+import {
+ ModalKind,
+ ModalSize,
+} from '@dailydotdev/shared/src/components/modals/common/types';
+import { MedalBadgeIcon } from '@dailydotdev/shared/src/components/icons/MedalBadge';
+import { CalendarIcon } from '@dailydotdev/shared/src/components/icons/Calendar';
+import { IconSize } from '@dailydotdev/shared/src/components/Icon';
+import {
+ AchievementRarityTier,
+ getAchievementRarityTier,
+} from '@dailydotdev/shared/src/features/profile/components/achievements/achievementRarity';
+import type { GtaViAchievementKind } from '../data';
+
+/*
+ * Production's AchievementShelfCard (features/profile/components/achievements)
+ * with the GTA VI branch: an event achievement swaps the "<1% rare" chip for
+ * its own (Free for everyone's, Rare for the read), says "Unlocked" with the
+ * year, and its detail view keeps the date beside a calendar. Everything else
+ * is production's, line for line, so the diff is the production change.
+ */
+
+const fallbackImage = 'https://daily.dev/default-achievement.png';
+
+const formatUnlockedAt = (value: string): string => {
+ const date = new Date(value);
+ const now = new Date();
+ return date.toLocaleString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ ...(date.getFullYear() === now.getFullYear() ? {} : { year: 'numeric' }),
+ });
+};
+
+// An event happened on one day, so its date reads in full.
+const formatEventDate = (value: string): string =>
+ new Date(value).toLocaleString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ });
+
+const rarityPillClasses = 'bg-[#efab27] text-[#08110c]';
+
+const eventPillClasses: Record = {
+ // Ink that flips with the theme: cabbage is light in dark mode and dark in
+ // light mode, and a fixed ink falls under 4.5:1 at 14px in one of them.
+ free: 'bg-accent-cabbage-default text-background-default',
+ rare: rarityPillClasses,
+};
+
+const eventPillLabel: Record = {
+ free: 'Free',
+ rare: 'Rare',
+};
+
+const EventPill = ({
+ event,
+ isLarge,
+}: {
+ event: GtaViAchievementKind;
+ isLarge?: boolean;
+}): ReactElement => (
+
+
+ {eventPillLabel[event]}
+
+);
+
+const getEvent = (
+ userAchievement: UserAchievement,
+): GtaViAchievementKind | undefined =>
+ (userAchievement.achievement as { event?: GtaViAchievementKind }).event;
+
+/** The detail view the card opens, also drawn on its own for review. */
+export const AchievementDetail = ({
+ userAchievement,
+ onClose,
+}: {
+ userAchievement: UserAchievement;
+ onClose?: () => void;
+}): ReactElement => {
+ const { achievement, progress, unlockedAt } = userAchievement;
+ const event = getEvent(userAchievement);
+ const targetCount = getTargetCount(achievement);
+ const isUnlocked = unlockedAt !== null;
+ const progressPercentage = Math.min((progress / targetCount) * 100, 100);
+ const isRarest =
+ isUnlocked &&
+ getAchievementRarityTier(achievement.rarity) ===
+ AchievementRarityTier.Emerald;
+ const progressLabel = `${progress.toLocaleString()} / ${targetCount.toLocaleString()}`;
+
+ return (
+
+
+
+ {event &&
}
+ {!event && isRarest && (
+
+ <1% rare
+
+ )}
+
+
+
+
+ {achievement.name}
+
+
+ {achievement.description}
+
+
+ {isUnlocked && event && (
+
+ Unlocked {formatEventDate(unlockedAt)}
+
+
+ )}
+ {isUnlocked && !event && (
+
+ Completed {formatUnlockedAt(unlockedAt)}
+
+ )}
+ {!isUnlocked && event && (
+
+ Read a post before midnight to unlock
+
+ )}
+ {!isUnlocked && !event && (
+ <>
+
+ {progressLabel}
+
+
+ >
+ )}
+
+
+ );
+};
+
+export function AchievementShelfCard({
+ userAchievement,
+ initialExpanded = false,
+}: {
+ userAchievement: UserAchievement;
+ /** Opens the detail on load, for review pages. */
+ initialExpanded?: boolean;
+}): ReactElement {
+ const [isExpanded, setIsExpanded] = useState(initialExpanded);
+ const { achievement, progress, unlockedAt } = userAchievement;
+ const event = getEvent(userAchievement);
+ const targetCount = getTargetCount(achievement);
+ const isUnlocked = unlockedAt !== null;
+ const progressPercentage = Math.min((progress / targetCount) * 100, 100);
+ const isRarest =
+ isUnlocked &&
+ getAchievementRarityTier(achievement.rarity) ===
+ AchievementRarityTier.Emerald;
+ const progressLabel = `${progress.toLocaleString()} / ${targetCount.toLocaleString()}`;
+
+ return (
+ <>
+
+
+
+
+
+ setIsExpanded(true)}
+ />
+
+ {event && }
+ {!event && isRarest && (
+
+ <1% rare
+
+ )}
+
+ {!isUnlocked && !event && (
+
+ {progressLabel}
+
+ )}
+
+
+
+ {achievement.name}
+
+
+ {achievement.description}
+
+
+ {isUnlocked && (
+
+ {event
+ ? `Unlocked ${formatEventDate(unlockedAt)}`
+ : `Completed ${formatUnlockedAt(unlockedAt)}`}
+
+ )}
+ {!isUnlocked && event && (
+ // One day to earn it, so it says what to do instead of a count.
+
+ Read a post today to unlock
+
+ )}
+ {!isUnlocked && !event && (
+ <>
+
+ {progressLabel}
+
+
+ >
+ )}
+
+
+
+ {isExpanded && (
+ setIsExpanded(false)}
+ kind={ModalKind.FlexibleCenter}
+ size={ModalSize.XSmall}
+ className="overflow-hidden"
+ >
+ setIsExpanded(false)}
+ />
+
+ )}
+ >
+ );
+}
diff --git a/packages/storybook/stories/gta-vi/production/AwardPreviews.tsx b/packages/storybook/stories/gta-vi/production/AwardPreviews.tsx
new file mode 100644
index 0000000000..bc7ddb19f5
--- /dev/null
+++ b/packages/storybook/stories/gta-vi/production/AwardPreviews.tsx
@@ -0,0 +1,195 @@
+import type { ReactElement } from 'react';
+import React from 'react';
+import classNames from 'classnames';
+import {
+ Typography,
+ TypographyColor,
+ TypographyTag,
+ TypographyType,
+} from '@dailydotdev/shared/src/components/typography/Typography';
+import { ActivityContainer } from '@dailydotdev/shared/src/components/profile/ActivitySection';
+import {
+ Award,
+ BadgesAndAwardsSkeleton,
+ SummaryCard,
+} from '@dailydotdev/shared/src/features/profile/components/ProfileWidgets/BadgesAndAwardsComponents';
+import {
+ DiscussIcon,
+ DownvoteIcon,
+ ShareIcon,
+ UpvoteIcon,
+} from '@dailydotdev/shared/src/components/icons';
+import { IconSize } from '@dailydotdev/shared/src/components/Icon';
+import { fallbackImages } from '@dailydotdev/shared/src/lib/config';
+import { largeNumberFormat } from '@dailydotdev/shared/src/lib/numberFormat';
+import type { Product } from '@dailydotdev/shared/src/graphql/njord';
+import { gtaViAward, productionAwards } from '../data';
+
+/*
+ * Where a received Vice City award shows, as production draws any award:
+ * the comment's award count (CommentAwardActions) with the featured award at
+ * size-6, and the profile's Badges & Awards widget at size-8. Nothing here
+ * changes for the event award; it's the same image at the same sizes.
+ */
+
+const ActionIcon = ({
+ icon,
+ label,
+ count,
+}: {
+ icon: ReactElement;
+ label: string;
+ count?: number;
+}): ReactElement => (
+
+ {icon}
+ {count !== undefined && (
+ {count}
+ )}
+
+);
+
+/** A comment that received awards, with one of them featured. */
+export const CommentWithAward = ({
+ numAwards = 3,
+ featured = gtaViAward,
+ text = "Day-one patch notes are already 4GB. I'll read the changelog here while it downloads.",
+ className,
+}: {
+ numAwards?: number;
+ featured?: Product;
+ text?: string;
+ className?: string;
+}): ReactElement => (
+
+
+
+ {text}
+
+
+
} label="Upvote" count={42} />
+
} label="Downvote" />
+
} label="Reply" />
+
+
+
+ {largeNumberFormat(numAwards)}
+ {/* Production hides the word below tablet. */}
+
+ Award{numAwards > 1 ? 's' : ''}
+
+
+
+
} label="Share" />
+
+
+);
+
+export type ProfileAwardsState =
+ | 'loading'
+ | 'empty'
+ | 'single'
+ | 'some'
+ | 'many';
+
+const counts = [6, 3, 12, 1, 2, 5, 1, 2, 9, 4, 1, 3, 2, 1, 7];
+
+const awardsFor = (
+ state: ProfileAwardsState,
+): { id: string; image: string; count: number }[] => {
+ const viceCity = { id: gtaViAward.id, image: gtaViAward.image, count: 4 };
+ const others = productionAwards.map((award, index) => ({
+ id: award.id,
+ image: award.image,
+ count: counts[index],
+ }));
+ if (state === 'empty' || state === 'loading') {
+ return [];
+ }
+ if (state === 'single') {
+ return [{ ...viceCity, count: 1 }];
+ }
+ if (state === 'some') {
+ return [viceCity, ...others.slice(0, 8)];
+ }
+ return [{ ...viceCity, count: 1284 }, ...others];
+};
+
+/** The profile's Badges & Awards widget, the Vice City award first. */
+export const ProfileAwards = ({
+ state = 'some',
+}: {
+ state?: ProfileAwardsState;
+}): ReactElement => {
+ if (state === 'loading') {
+ return ;
+ }
+ const awards = awardsFor(state);
+ const total = awards.reduce((sum, award) => sum + award.count, 0);
+ return (
+
+
+ Badges & Awards
+
+ Learn more
+
+
+
+
+ {awards.length > 0 && (
+
+ {awards.map((award) => (
+
+ ))}
+
+ )}
+
+ );
+};
diff --git a/packages/storybook/stories/gta-vi/production/GiveAward.tsx b/packages/storybook/stories/gta-vi/production/GiveAward.tsx
new file mode 100644
index 0000000000..2c5b675456
--- /dev/null
+++ b/packages/storybook/stories/gta-vi/production/GiveAward.tsx
@@ -0,0 +1,569 @@
+import type { ReactElement, ReactNode } from 'react';
+import React, { useState } from 'react';
+import classNames from 'classnames';
+import {
+ Typography,
+ TypographyColor,
+ TypographyTag,
+ TypographyType,
+} from '@dailydotdev/shared/src/components/typography/Typography';
+import {
+ Button,
+ ButtonSize,
+ ButtonVariant,
+} from '@dailydotdev/shared/src/components/buttons/Button';
+import CloseButton from '@dailydotdev/shared/src/components/CloseButton';
+import {
+ ArrowIcon,
+ CoreIcon,
+ PlusIcon,
+} from '@dailydotdev/shared/src/components/icons';
+import { IconSize } from '@dailydotdev/shared/src/components/Icon';
+import { AwardFeesNote } from '@dailydotdev/shared/src/components/cores/AwardFeesNote';
+import { ElementPlaceholder } from '@dailydotdev/shared/src/components/ElementPlaceholder';
+import { maxNoteLength } from '@dailydotdev/shared/src/contexts/GiveAwardModalContext';
+import { fallbackImages } from '@dailydotdev/shared/src/lib/config';
+import { featuredAwardImage } from '@dailydotdev/shared/src/lib/image';
+import { labels } from '@dailydotdev/shared/src/lib/labels';
+import { formatCoresCurrency } from '@dailydotdev/shared/src/lib/utils';
+import { largeNumberFormat } from '@dailydotdev/shared/src/lib/numberFormat';
+import type { Product } from '@dailydotdev/shared/src/graphql/njord';
+import { gtaViAward, productionAwards, receiver } from '../data';
+
+/*
+ * GiveAwardModal's three screens (components/modals/award) drawn inline, so
+ * they sit side by side for review. The markup is production's; the GTA VI
+ * branch is the event award first in the grid, drawn like every other award,
+ * and a free event award's footer, which says until when it's free. Every
+ * state the modal can be in is a prop, so a review page can pin each one.
+ */
+
+export type AwardStep = 'INTRO' | 'COMMENT' | 'SUCCESS';
+
+/** Where the send stands: production's mutation, plus the event's window. */
+export type SendState = 'idle' | 'sending' | 'error' | 'expired';
+
+/**
+ * Phone is production's bottom sheet (ModalKind.FlexibleTop below 656px),
+ * the rest the centered XSmall modal; auto picks by the window, for device
+ * frames.
+ */
+export type ModalLayout = 'phone' | 'desktop' | 'auto';
+
+const PHONE_BELOW = 656;
+
+const isPhoneLayout = (layout: ModalLayout): boolean =>
+ layout === 'phone' ||
+ (layout === 'auto' &&
+ typeof window !== 'undefined' &&
+ window.innerWidth < PHONE_BELOW);
+
+export interface AwardScenario {
+ /** The viewer's Core balance. */
+ balance?: number;
+ /** The products query hasn't answered yet. */
+ isLoading?: boolean;
+ /** Launch week: Nov 19 through Nov 25. */
+ isEventLive?: boolean;
+ receiverName?: string;
+ receiverUsername?: string;
+ /** Awards the post or comment already has. */
+ numAwards?: number;
+ sendState?: SendState;
+ note?: string;
+}
+
+const ModalFrame = ({
+ isPhone,
+ isFluid,
+ header,
+ footer,
+ children,
+}: {
+ isPhone: boolean;
+ isFluid: boolean;
+ header: ReactNode;
+ footer?: ReactNode;
+ children: ReactNode;
+}): ReactElement => (
+
+
+
+ {children}
+
+ {footer && (
+
+ )}
+
+);
+
+/** BuyCreditsButton: the balance and the + to buy more. */
+const Balance = ({ amount }: { amount: number }): ReactElement => (
+
+
+
+ Balance:
+ {largeNumberFormat(amount)}
+ Cores
+
+
+
+
+
+
+);
+
+const priceLabel = (product: Product): string =>
+ product.value === 0 ? 'Free' : formatCoresCurrency(product.value);
+
+const AwardItem = ({
+ item,
+ onClick,
+}: {
+ item: Product;
+ onClick: () => void;
+}): ReactElement => (
+
+
+
+
+
+ {priceLabel(item)}
+
+
+
+);
+
+/** Proposed: production paints an empty grid until the products load. */
+const AwardItemPlaceholder = (): ReactElement => (
+
+
+
+
+);
+
+const ReceiverName = ({
+ scenario,
+}: {
+ scenario: AwardScenario;
+}): ReactElement => (
+
+ {scenario.receiverName || `@${scenario.receiverUsername}`}
+
+);
+
+const IntroScreen = ({
+ isPhone,
+ isFluid,
+ scenario,
+ onPick,
+}: {
+ isPhone: boolean;
+ isFluid: boolean;
+ scenario: Required;
+ onPick: (product: Product) => void;
+}): ReactElement => {
+ const [buyFor, setBuyFor] = useState(null);
+ const hasAwards = scenario.numAwards > 0;
+ const awards = scenario.isEventLive
+ ? [gtaViAward, ...productionAwards]
+ : productionAwards;
+
+ return (
+
+
+ {isPhone ? (
+
+ Close
+
+ ) : (
+
+ )}
+
+ }
+ footer={
+ buyFor && (
+ <>
+
+ Buy Cores {priceLabel(buyFor)}
+
+
+ >
+ )
+ }
+ >
+
+
+
+ {hasAwards
+ ? `${largeNumberFormat(scenario.numAwards)} Award${
+ scenario.numAwards === 1 ? '' : 's'
+ } given`
+ : 'Give an Award'}
+
+
+ {hasAwards && (
+
+ Show all →
+
+ )}
+
+ Show your appreciation! Pick an Award to send to{' '}
+ !
+
+
+ {scenario.isLoading
+ ? Array.from({ length: 12 }, (_, index) => (
+ // eslint-disable-next-line react/no-array-index-key
+
+ ))
+ : awards.map((item) => (
+
{
+ // Production: an Award the balance can't cover stays on
+ // this screen and offers Cores in the footer.
+ if (item.value > scenario.balance) {
+ setBuyFor(item);
+ return;
+ }
+ setBuyFor(null);
+ onPick(item);
+ }}
+ />
+ ))}
+
+
+ );
+};
+
+/** Production's error toast (useTransactionError), pinned to the modal. */
+const Toast = ({ children }: { children: ReactNode }): ReactElement => (
+
+ {children}
+
+);
+
+const CommentScreen = ({
+ isPhone,
+ isFluid,
+ scenario,
+ product,
+ onBack,
+ onSend,
+}: {
+ isPhone: boolean;
+ isFluid: boolean;
+ scenario: Required;
+ product: Product;
+ onBack: () => void;
+ onSend: () => void;
+}): ReactElement => {
+ const isEvent = product.id === gtaViAward.id;
+ const isExpired = isEvent && scenario.sendState === 'expired';
+ const hasAwards = scenario.numAwards > 0;
+
+ return (
+ <>
+
+ }
+ aria-label="Back to Awards"
+ />
+
+ Give an Award
+
+ {!isPhone && }
+ >
+ }
+ footer={
+ <>
+
+ Send Award for {priceLabel(product)}
+
+ {isExpired && (
+ // Proposed: the free window closed while the modal was open.
+
+ The Vice City award stopped being free on Nov 25. Go back to
+ pick another Award.
+
+ )}
+ {isEvent && !isExpired && (
+
+ Free to send through Nov 25, as many times as you like.
+
+ )}
+ {!isEvent && }
+ >
+ }
+ >
+
+
+
+ Awesome choice! We're sure that{' '}
+ will appreciate this award!
+
+
+
+ Note to the receiver (optional)
+
+
+
+ {scenario.sendState === 'error' && {labels.error.generic} }
+ >
+ );
+};
+
+const SuccessScreen = ({
+ isPhone,
+ isFluid,
+ scenario,
+ product,
+ onClose,
+}: {
+ isPhone: boolean;
+ isFluid: boolean;
+ scenario: Required;
+ product: Product;
+ onClose: () => void;
+}): ReactElement => (
+
+ {!isPhone && }
+
+ }
+ >
+
+
+
+
+
+
+ {priceLabel(product)}
+
+
+
+
+
+ Congrats! Your Award has been sent to{' '}
+ {scenario.receiverName || `@${scenario.receiverUsername}`}
+
+
+ Thanks for supporting the community!
+
+
+ {!isPhone && (
+
+ Close
+
+ )}
+
+
+);
+
+const defaultScenario: Required = {
+ balance: 8100,
+ isLoading: false,
+ isEventLive: true,
+ receiverName: receiver.name,
+ receiverUsername: receiver.username,
+ numAwards: 0,
+ sendState: 'idle',
+ note: '',
+};
+
+/** The whole flow, clickable: pick, write a note, send. */
+export const GiveAwardFlow = ({
+ initialStep = 'INTRO',
+ initialProduct = gtaViAward,
+ isPhone,
+ layout,
+ isEventLive,
+ ...scenario
+}: AwardScenario & {
+ initialStep?: AwardStep;
+ initialProduct?: Product;
+ /** Shorthand for layout="phone". */
+ isPhone?: boolean;
+ layout?: ModalLayout;
+}): ReactElement => {
+ const [step, setStep] = useState(initialStep);
+ const [product, setProduct] = useState(initialProduct);
+ const resolvedLayout = layout ?? (isPhone ? 'phone' : 'desktop');
+ const phone = isPhoneLayout(resolvedLayout);
+ const isFluid = resolvedLayout === 'auto';
+ const merged: Required = {
+ ...defaultScenario,
+ ...(isEventLive === undefined ? {} : { isEventLive }),
+ ...Object.fromEntries(
+ Object.entries(scenario).filter(([, value]) => value !== undefined),
+ ),
+ };
+
+ if (step === 'COMMENT') {
+ return (
+ setStep('INTRO')}
+ onSend={() => setStep('SUCCESS')}
+ />
+ );
+ }
+ if (step === 'SUCCESS') {
+ return (
+ setStep('INTRO')}
+ />
+ );
+ }
+ return (
+ {
+ setProduct(picked);
+ setStep('COMMENT');
+ }}
+ />
+ );
+};
diff --git a/packages/storybook/stories/gta-vi/production/Shelf.tsx b/packages/storybook/stories/gta-vi/production/Shelf.tsx
new file mode 100644
index 0000000000..db7ce8b8c6
--- /dev/null
+++ b/packages/storybook/stories/gta-vi/production/Shelf.tsx
@@ -0,0 +1,149 @@
+import type { ReactElement } from 'react';
+import React from 'react';
+import {
+ Typography,
+ TypographyColor,
+ TypographyType,
+} from '@dailydotdev/shared/src/components/typography/Typography';
+import { ElementPlaceholder } from '@dailydotdev/shared/src/components/ElementPlaceholder';
+import type { UserAchievement } from '@dailydotdev/shared/src/graphql/user/achievements';
+import { AchievementShelfCard } from './AchievementShelfCard';
+import type { GtaViMoment } from '../data';
+import {
+ getShelf,
+ GTA_VI_RELEASE_AT,
+ readOnGtaDayAchievement,
+ viceCityAchievement,
+} from '../data';
+
+/*
+ * Game Center's Achievement shelf (webapp/pages/game-center), its three
+ * branches as production has them: placeholders while pending, the cards,
+ * or EmptyStateCard. Extra states stress the GTA VI cards: long copy and an
+ * image that fails to load (LazyImage falls back to the default art).
+ */
+
+export type ShelfState =
+ | GtaViMoment
+ | 'loading'
+ | 'empty'
+ | 'longText'
+ | 'brokenImage';
+
+const unlocked = (
+ achievement: UserAchievement['achievement'],
+): UserAchievement => ({
+ achievement,
+ progress: 1,
+ unlockedAt: GTA_VI_RELEASE_AT,
+ createdAt: null,
+ updatedAt: null,
+});
+
+const stressShelf = (state: 'longText' | 'brokenImage'): UserAchievement[] => {
+ const rest = getShelf('releaseRead').slice(2);
+ if (state === 'brokenImage') {
+ return [
+ unlocked({ ...viceCityAchievement, image: '/missing/vice-city.webp' }),
+ unlocked({
+ ...readOnGtaDayAchievement,
+ image: '/missing/still-read.webp',
+ }),
+ ...rest,
+ ];
+ }
+ return [
+ unlocked({
+ ...viceCityAchievement,
+ name: 'Made It to Vice City Before Everyone Else in the Group Chat',
+ description:
+ 'You survived the wait, the delays, the trailers and every rumor. Now go make some memories.',
+ }),
+ unlocked({
+ ...readOnGtaDayAchievement,
+ name: 'Still Read on GTA VI Day While the Download Crawled Along',
+ }),
+ ...rest,
+ ];
+};
+
+/** The empty card, copied from the game-center page. */
+const EmptyStateCard = ({
+ title,
+ description,
+}: {
+ title: string;
+ description: string;
+}): ReactElement => (
+
+
+ {title}
+
+
+ {description}
+
+
+);
+
+export const Shelf = ({
+ state,
+ openFirst = false,
+}: {
+ state: ShelfState;
+ /** Opens Still Read's detail, for device frames. */
+ openFirst?: boolean;
+}): ReactElement => {
+ let content: ReactElement;
+
+ if (state === 'loading') {
+ content = (
+
+ {[0, 1, 2, 3, 4].map((placeholder) => (
+
+ ))}
+
+ );
+ } else if (state === 'empty') {
+ content = (
+
+ );
+ } else {
+ const items =
+ state === 'longText' || state === 'brokenImage'
+ ? stressShelf(state)
+ : getShelf(state);
+ content = (
+
+ {items.map((item, index) => (
+
+ ))}
+
+ );
+ }
+
+ return (
+
+
+ Achievement shelf
+
+ {content}
+
+ );
+};
diff --git a/packages/storybook/stories/gta-vi/theme.tsx b/packages/storybook/stories/gta-vi/theme.tsx
new file mode 100644
index 0000000000..aaf6adb53a
--- /dev/null
+++ b/packages/storybook/stories/gta-vi/theme.tsx
@@ -0,0 +1,498 @@
+import type { ReactElement } from 'react';
+import React, { useState } from 'react';
+import classNames from 'classnames';
+import {
+ Button,
+ ButtonGroup,
+ ButtonSize,
+ ButtonVariant,
+} from '@dailydotdev/shared/src/components/buttons/Button';
+import { HorizontalSeparator } from '@dailydotdev/shared/src/components/utilities/common';
+import { IconSize } from '@dailydotdev/shared/src/components/Icon';
+import {
+ ArrowIcon,
+ BellIcon,
+ BookmarkIcon,
+ CreditCardIcon,
+ ExitIcon,
+ EyeIcon,
+ FilterIcon,
+ HomeIcon,
+ MegaphoneIcon,
+ MoonIcon,
+ PlusIcon,
+ SearchIcon,
+ SettingsIcon,
+ SquadIcon,
+ SunIcon,
+ TrendingIcon,
+} from '@dailydotdev/shared/src/components/icons';
+import { ReadingStreakIcon } from '@dailydotdev/shared/src/components/icons/ReadingStreak';
+import { ThemeAutoIcon } from '@dailydotdev/shared/src/components/icons/ThemeAuto';
+import { ArticleGrid } from '@dailydotdev/shared/src/components/cards/article/ArticleGrid';
+import { cardHandlers } from '../features/feed/feedHero.mocks';
+import { realPosts } from '../hero-cover/realData';
+import { rw, WidgetRail } from '../hero-cover/widgets/rail';
+import { myShortcuts } from '../hero-cover/widgets/shortcuts';
+import coverArt from './assets/theme-vice-city-cover.webp';
+
+/*
+ * The Vice City theme: dark mode with the release art behind the home page's
+ * shortcuts and greeting. It's never applied for the reader: the Theme row in
+ * the settings popover (ThemeSection, also on Settings › Appearance) gets a
+ * fourth option, VI, beside dark, light and auto, and picking another theme
+ * turns it off. The page around it is the Hero Cover home
+ * (stories/hero-cover), drawn here at two widths.
+ */
+
+export type ThemeChoice = 'dark' | 'light' | 'auto' | 'viceCity';
+
+/** The VI option's glyph: the title's letters in the sunset gradient. */
+const ViGlyph = (): ReactElement => (
+
+
+
+
+
+
+
+
+
+ VI
+
+
+);
+
+const themeOptions: {
+ value: ThemeChoice;
+ label: string;
+ icon: (isActive: boolean) => ReactElement;
+}[] = [
+ { value: 'dark', label: 'Dark', icon: (on) => },
+ { value: 'light', label: 'Light', icon: (on) => },
+ {
+ value: 'auto',
+ label: 'Auto',
+ icon: (on) => ,
+ },
+ { value: 'viceCity', label: 'Vice City', icon: () => },
+];
+
+/** Production's ThemeSection with the VI option added last. */
+export const ThemeRow = ({
+ theme,
+ onTheme,
+}: {
+ theme: ThemeChoice;
+ onTheme: (theme: ThemeChoice) => void;
+}): ReactElement => (
+
+ Theme
+
+ {themeOptions.map((option) => {
+ const isActive = option.value === theme;
+ return (
+ onTheme(option.value)}
+ className={isActive ? 'text-text-primary' : undefined}
+ />
+ );
+ })}
+
+
+);
+
+const menuGroups = [
+ [
+ { title: 'Settings', icon: SettingsIcon },
+ { title: 'Appearance', icon: EyeIcon },
+ ],
+ [
+ { title: 'Payment & Subscription', icon: CreditCardIcon },
+ { title: 'Ads dashboard', icon: TrendingIcon },
+ { title: 'Advertise', icon: MegaphoneIcon },
+ ],
+ [{ title: 'Log out', icon: ExitIcon }],
+];
+
+/** SidebarDesktopV2's settings popover, from the rail's bottom gear. */
+export const SettingsPopover = ({
+ theme,
+ onTheme,
+ className,
+}: {
+ theme: ThemeChoice;
+ onTheme: (theme: ThemeChoice) => void;
+ className?: string;
+}): ReactElement => (
+
+
+ {menuGroups.map((items) => (
+
+
+
+ {items.map(({ title, icon: Icon }) => (
+
+
+ {title}
+
+ ))}
+
+
+ ))}
+
+);
+
+const sideIcons = [
+ HomeIcon,
+ SearchIcon,
+ ReadingStreakIcon,
+ BellIcon,
+ SquadIcon,
+ BookmarkIcon,
+];
+
+const SideRail = ({
+ isSettingsOpen,
+ onSettings,
+}: {
+ isSettingsOpen: boolean;
+ onSettings: () => void;
+}): ReactElement => (
+
+
+ d
+
+ {sideIcons.map((Icon, index) => (
+
+
+
+ ))}
+
+
+
+
+);
+
+/** The shortcuts row on top of the home page, over the art when themed. */
+const Shortcuts = ({
+ isThemed,
+ count,
+}: {
+ isThemed: boolean;
+ count: number;
+}): ReactElement => (
+
+ {myShortcuts.slice(0, count).map((shortcut) => (
+
+
+
+
+
+ {shortcut.name}
+
+
+ ))}
+
+);
+
+/** The art, fading into the page so the greeting under it stays legible. */
+const CoverArt = ({
+ isPhone,
+ src = coverArt,
+}: {
+ isPhone?: boolean;
+ src?: string;
+}): ReactElement => {
+ // A cover that fails to load leaves the default home, not a broken image.
+ const [isBroken, setIsBroken] = useState(false);
+ if (isBroken) {
+ return null;
+ }
+ return (
+
+
setIsBroken(true)}
+ className={classNames(
+ 'w-full object-cover',
+ isPhone ? 'h-56 object-[70%_60%]' : 'h-64 object-[center_65%]',
+ )}
+ />
+
+
+ );
+};
+
+const tabs = ['For you', 'AI', 'Career', 'Architecture', 'My new feed'];
+
+/**
+ * The home page at one width. Interactive: the rail's gear opens the
+ * settings popover, and the Theme row's VI turns the theme on or off.
+ */
+export const ThemedHome = ({
+ initialTheme = 'dark',
+ isSettingsOpenInitially = false,
+ isPhone: isPhoneProp = false,
+ width: widthProp = 1440,
+ height: heightProp = 900,
+ columns: columnsProp = 4,
+ isFluid = false,
+ coverSrc,
+ userName = 'TheCoverLiker',
+ shortcutCount = 7,
+}: {
+ initialTheme?: ThemeChoice;
+ isSettingsOpenInitially?: boolean;
+ isPhone?: boolean;
+ width?: number;
+ height?: number;
+ columns?: number;
+ /** Fills the window at its own size, for device frames. */
+ isFluid?: boolean;
+ /** Another cover URL, to show a cover that fails to load. */
+ coverSrc?: string;
+ userName?: string;
+ shortcutCount?: number;
+}): ReactElement => {
+ const [theme, setTheme] = useState(initialTheme);
+ const [isSettingsOpen, setIsSettingsOpen] = useState(isSettingsOpenInitially);
+ const width = isFluid ? window.innerWidth : widthProp;
+ const height = isFluid ? window.innerHeight : heightProp;
+ // styles/media.ts: phone < 656, tablet, laptop 1020, laptopL 1360,
+ // laptopXL 1668; the feed's columns follow.
+ const isPhone = isFluid ? width < 656 : isPhoneProp;
+ const columns = isFluid
+ ? (width < 656 && 1) ||
+ (width < 1020 && 2) ||
+ (width < 1360 && 3) ||
+ (width < 1668 && 4) ||
+ 5
+ : columnsProp;
+ const isThemed = theme === 'viceCity';
+ const scale = isFluid ? 1 : Math.min(1, 1380 / width);
+ const pad = isPhone ? 'px-4' : 'px-6';
+
+ return (
+
+
+ {!isPhone && (
+
setIsSettingsOpen((value) => !value)}
+ />
+ )}
+ {isSettingsOpen && !isPhone && (
+
+ )}
+
+ {isThemed &&
}
+
+
+ {!isPhone && (
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+ {tabs.map((tab, index) => (
+
+ {tab}
+
+ ))}
+
+ {!isPhone && (
+ }
+ >
+ Feed settings
+
+ )}
+
+
+ {realPosts.slice(10, 10 + columns * 2).map((post) => (
+
+ ))}
+
+
+
+
+ );
+};