diff --git a/packages/shared/src/components/Icon.tsx b/packages/shared/src/components/Icon.tsx index 37a1b445f5b..3e5bdd5186d 100644 --- a/packages/shared/src/components/Icon.tsx +++ b/packages/shared/src/components/Icon.tsx @@ -7,6 +7,7 @@ export enum IconSize { Size16 = 'size16', XSmall = 'xsmall', Small = 'small', + Size26 = 'size26', Medium = 'medium', Large = 'large', XLarge = 'xlarge', @@ -21,6 +22,7 @@ export const iconSizeToClassName: Record = { [IconSize.Size16]: 'size-4', [IconSize.XSmall]: 'w-5 h-5', [IconSize.Small]: 'w-6 h-6', + [IconSize.Size26]: 'size-[1.625rem]', [IconSize.Medium]: 'w-7 h-7', [IconSize.Large]: 'w-8 h-8', [IconSize.XLarge]: 'w-10 h-10', diff --git a/packages/shared/src/components/notifications/NotificationsBell.tsx b/packages/shared/src/components/notifications/NotificationsBell.tsx index 7b14d12bca2..8187f4d8fee 100644 --- a/packages/shared/src/components/notifications/NotificationsBell.tsx +++ b/packages/shared/src/components/notifications/NotificationsBell.tsx @@ -13,8 +13,18 @@ import { webappUrl } from '../../lib/constants'; import { useViewSize, ViewSize } from '../../hooks'; import { Tooltip } from '../tooltip/Tooltip'; import Link from '../utilities/Link'; -import { IconSize } from '../Icon'; -import { railTabClass, railTabLabelClass } from '../sidebar/common'; +import { + RAIL_ICON_SIZE, + railTabClass, + railTabLabelClass, +} from '../sidebar/common'; + +// The count itself: one step down from `Bubble`'s own size and bolder, so the +// number stays legible without overpowering the bell glyph it notches, and +// `tabular-nums` stops the badge reflowing as the count ticks. Shared by both +// bell variants so the header and the v2 rail stay identical. `Bubble` already +// supplies the 20px box and the 8px radius. +const notificationBubbleClass = '!font-bold !typo-footnote tabular-nums'; function NotificationsBell({ compact, @@ -70,6 +80,11 @@ function NotificationsBell({ // we only own the active text color). role="tab" aria-selected={atNotificationsPage} + // Every other rail tab is a diff --git a/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx b/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx index 04bb25655ff..c8b6285c9fd 100644 --- a/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx +++ b/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx @@ -11,12 +11,13 @@ import { useRouter } from 'next/router'; import * as HoverCardPrimitive from '@radix-ui/react-hover-card'; import { DndContext, + DragOverlay, closestCenter, PointerSensor, useSensor, useSensors, } from '@dnd-kit/core'; -import type { DragEndEvent } from '@dnd-kit/core'; +import type { DragOverEvent, DragStartEvent } from '@dnd-kit/core'; import { SortableContext, arrayMove, @@ -29,9 +30,13 @@ import { isSidebarItemActive, ListIcon, Nav, + RAIL_ICON_SIZE, + railGlyphBoxClass, railTabClass, railTabLabelClass, SidebarAside, + sidebarDragGhostClass, + sidebarDragSlotClass, SidebarScrollWrapper, } from './common'; import type { SidebarMenuItem } from './common'; @@ -152,7 +157,15 @@ const sidebarCategories: SidebarCategoryConfig[] = [ label: 'Explore', defaultPath: `${webappUrl}posts`, icon: (active) => ( - + ), }, { @@ -163,7 +176,7 @@ const sidebarCategories: SidebarCategoryConfig[] = [ // Surfaced as the panel title and the avatar tooltip/label. label: 'You', icon: (active) => ( - + ), }, { @@ -171,7 +184,15 @@ const sidebarCategories: SidebarCategoryConfig[] = [ label: 'Squads', defaultPath: `${webappUrl}squads/discover`, icon: (active) => ( - + ), }, { @@ -182,11 +203,19 @@ const sidebarCategories: SidebarCategoryConfig[] = [ label: 'Streak', defaultPath: `${webappUrl}game-center`, icon: (active) => ( - + ), }, ]; +// Fallback release for the post-drag click guard — see releaseRailDragClickGuard. +const RAIL_DRAG_CLICK_GUARD_FALLBACK_MS = 500; + +// New post is reorderable alongside the tabs, so it needs an id in the rail +// order. It matches the key the panel preview already uses for the create panel. +const RAIL_CREATE_ID = 'create'; +type RailItemId = SidebarCategoryId | typeof RAIL_CREATE_ID; + const railButtonClass = 'flex size-10 items-center justify-center rounded-12 text-text-tertiary transition-[background-color,color,transform] duration-150 ease-out hover:bg-surface-hover hover:text-text-primary active:scale-90 motion-reduce:transition-none focus-outline'; // Shared group so the rail's click popups (support, profile menu, streak) are @@ -214,9 +243,13 @@ const SAFE_ZONE_BUFFER = 26; const SortableRailTab = ({ id, children, + consumeClickGuard, }: { id: string; children: ReactNode; + // Armed while a rail drag is live — see `releaseRailDragClickGuard`. Returns + // whether the guard was armed and disarms it. + consumeClickGuard: () => boolean; }): ReactElement => { const { setNodeRef, listeners, transform, transition, isDragging } = useSortable({ id }); @@ -224,12 +257,42 @@ const SortableRailTab = ({
{ + if (!consumeClickGuard()) { + return; + } + event.preventDefault(); + event.stopPropagation(); + }} + // Translate only, never CSS.Transform — that also emits the sortable's + // scale, which dnd-kit sets to the size ratio between the dragged item + // and the one it displaces. The rail mixes tall tabs with the shorter + // New post button, so that ratio squashed/stretched them mid-drag. + // While dragging, the REAL element stays parked in the list as a slot + // skeleton and a pointer-events-none DragOverlay ghost follows the + // cursor (same architecture as the shortcuts dock). This is what makes + // the notifications tab safe to drag: its live anchor is never under + // the pointer at release, so the browser's post-drag click can't hit + // the link and natively navigate — no guard timing can promise that + // when the real anchor rides along with the cursor. + style={{ + transform: isDragging ? undefined : CSS.Translate.toString(transform), + transition, + }} className={classNames( // `relative z-1` keeps every tab painted above the sliding selected // pill (an absolute z-0 indicator behind them in the tablist). - 'relative z-1 w-full touch-none', - isDragging ? 'opacity-60 cursor-grabbing' : 'cursor-grab', + 'relative z-1 w-full touch-none rounded-12 transition-colors', + // Landing skeleton: the dock's shared slot treatment, with the item's + // own content faded out rather than removed so the slot keeps its + // exact height. + isDragging + ? classNames(sidebarDragSlotClass, '[&>*]:opacity-0') + : 'cursor-grab', )} > {children} @@ -319,7 +382,7 @@ const railGiftLink = (label: string, href: string): ReactElement => ( - + @@ -402,7 +465,7 @@ const SidebarSupportButton = (): ReactElement => { isOpen && 'bg-background-default !text-text-primary', )} > - + {isOpen && ( @@ -513,7 +576,7 @@ const SidebarSettingsButton = (): ReactElement => { isOpen && 'bg-background-default !text-text-primary', )} > - + {isOpen && ( @@ -585,7 +648,11 @@ const SidebarProfileButton = ({ isPreviewing && 'bg-surface-hover text-text-primary', )} > - + {/* Fixed 24px slot (the shared rail glyph box) with a 20px avatar + inside: a solid photo reads far heavier than the outline glyphs beside + it, so it needs to be optically smaller to look the same size. The + slot keeps this tab's height identical to the others. */} + [ isLoggedIn ? SidebarCategory.Profile : null, @@ -697,22 +764,55 @@ export const SidebarDesktopV2 = ({ isLoggedIn ? SidebarCategory.Notifications : null, // Drops out of the rail entirely when all gamification is opted out. showGameCenterTab ? SidebarCategory.GameCenter : null, - ].filter(Boolean) as SidebarCategoryId[], + isLoggedIn ? RAIL_CREATE_ID : null, + ].filter(Boolean) as RailItemId[], [isLoggedIn, showGameCenterTab], ); const [storedRailOrder, setStoredRailOrder] = usePersistentContext< - SidebarCategoryId[] - >('sidebar_rail_order', reorderableCategories); + RailItemId[] + >('sidebar_rail_order', reorderableRailItems); + // Local override applied synchronously in onDragEnd (same pattern as the + // shortcuts dock). The persisted value round-trips through a react-query + // mutation, so its re-render lands a beat AFTER dnd-kit clears the sortable + // transforms — one visible frame of the OLD order at the drop, i.e. the tab + // flashed back to its original slot before jumping to the new one. A plain + // setState here commits in the same React update as dnd-kit's drag-end + // state, so the drop renders the new order immediately. + const [railOrderOverride, setRailOrderOverride] = useState< + RailItemId[] | null + >(null); + useEffect(() => { + // Drop the override once the persisted value catches up, so later external + // changes to the stored order aren't shadowed. + if (!railOrderOverride) { + return; + } + if ((storedRailOrder ?? []).join('|') === railOrderOverride.join('|')) { + setRailOrderOverride(null); + } + }, [storedRailOrder, railOrderOverride]); // Reconcile the saved order against the valid set: drop unknown/stale ids and // surface any newly-added category that isn't in the stored order yet at the // front (e.g. the avatar tab for users who saved an order before it existed). + // New post is the exception — for users with a saved layout it joins at the + // end (its default slot, below the tabs) rather than jumping to the top. const railOrder = useMemo(() => { - const known = (storedRailOrder ?? []).filter((id) => - reorderableCategories.includes(id), + const known = (railOrderOverride ?? storedRailOrder ?? []).filter((id) => + reorderableRailItems.includes(id), ); - const missing = reorderableCategories.filter((id) => !known.includes(id)); - return [...missing, ...known]; - }, [reorderableCategories, storedRailOrder]); + const missing = reorderableRailItems.filter((id) => !known.includes(id)); + return [ + ...missing.filter((id) => id !== RAIL_CREATE_ID), + ...known, + ...missing.filter((id) => id === RAIL_CREATE_ID), + ]; + }, [reorderableRailItems, storedRailOrder, railOrderOverride]); + // Only the tabs fold into "More" — New post always stays on the rail. + const foldableTabIds = useMemo( + () => + railOrder.filter((id) => id !== RAIL_CREATE_ID) as SidebarCategoryId[], + [railOrder], + ); // Overflow, measured against the content-independent (flex-1) height of the // lower region that holds the tabs + dock — so folding never changes the @@ -742,13 +842,18 @@ export const SidebarDesktopV2 = ({ const shortcutCount = isLoggedIn ? shortcutItems.length : 0; const iconRowPx = 40 + 4; // shortcut dot row (height + gap) const tabRowPx = (isCompact ? 44 : 56) + 4; // tab / "More" row (height + gap) - const SEP_PX = 12; // framing separator + its vertical margins - const tabCount = railOrder.length; + const SEP_PX = 25; // framing separator (1px) + its my-3 margins + const tabCount = foldableTabIds.length; + // New post sits inside the measured region but never folds into "More" — + // reserve its row up front so the tabs/dock budget is only what's left, and + // folding still peels off one tab at a time. + const createRowPx = isLoggedIn ? 36 + 16 + 4 : 0; // button + my-2 + gap + const availableHeight = regionHeight - createRowPx; const minDockPx = shortcutCount > 0 ? SEP_PX + SHORTCUTS_MIN_INLINE * iconRowPx : 0; // Progressive overflow (a "priority+" rail). Stage 1: everything fits — all // tabs inline plus a usefully-sized, scrollable shortcuts dock; no "More". - const fitsAllInline = regionHeight >= tabCount * tabRowPx + minDockPx; + const fitsAllInline = availableHeight >= tabCount * tabRowPx + minDockPx; // Otherwise a "More" tab (same row height) collects the overflow. Keep as // many tabs inline as fit ABOVE that row and drop the lowest-priority tabs // (end of railOrder) into More ONE AT A TIME as the viewport shrinks — never @@ -756,13 +861,20 @@ export const SidebarDesktopV2 = ({ // here; its shortcuts move into the same More menu (one combined dropdown). const tabsThatFitWithMore = Math.max( 0, - Math.floor((regionHeight - tabRowPx) / tabRowPx), + Math.floor((availableHeight - tabRowPx) / tabRowPx), ); const visibleTabCount = fitsAllInline ? tabCount : Math.min(tabCount, tabsThatFitWithMore); - const visibleCategoryIds = railOrder.slice(0, visibleTabCount); - const overflowTabIds = fitsAllInline ? [] : railOrder.slice(visibleTabCount); + const visibleTabIds = foldableTabIds.slice(0, visibleTabCount); + const overflowTabIds = fitsAllInline + ? [] + : foldableTabIds.slice(visibleTabCount); + // What the rail actually renders, in the user's order: every tab that fits, + // plus New post — which is never dropped. + const visibleCategoryIds = railOrder.filter( + (id) => id === RAIL_CREATE_ID || visibleTabIds.includes(id), + ); // More is needed when any tab overflows, or when all tabs still fit inline // but the shortcuts can't get a usable inline dock (so they collapse in). const moreNeeded = @@ -793,25 +905,92 @@ export const SidebarDesktopV2 = ({ () => ({ isDragging: isAnyDragging, setDragging: setSidebarDragging }), [isAnyDragging, setSidebarDragging], ); - const handleRailDragStart = useCallback(() => { - setSidebarDragging(true); - }, [setSidebarDragging]); - const handleRailDragEnd = useCallback( - (event: DragEndEvent) => { - setSidebarDragging(false); - const { active, over } = event; - if (!over || active.id === over.id) { + // Ending a drag makes the browser dispatch a `click` on whatever sits under + // the cursor, and dnd-kit doesn't swallow it. Armed at drag start; CONSUMED + // by that stray click in SortableRailTab's capture handler. It cannot be + // released on a short timer: dnd-kit runs onDragEnd synchronously inside the + // pointerup listener, while the browser's click arrives tasks later + // (pointerup → mouseup → click) — a 0ms timer disarmed the guard in between + // and the click sailed through. Every other rail tab survives that click + // because `onSelectCategory` skips `router.push` for the current page — + // Notifications is the one item rendered as a plain link, so its click + // always navigated and reloaded /notifications. The timer below is only a + // fallback for drags that end with no click at all (Escape, pointer + // released outside the window), sized so a real post-drop click always + // consumes the guard first. + const railDragClickGuardRef = useRef(false); + const consumeRailDragClickGuard = useCallback(() => { + if (!railDragClickGuardRef.current) { + return false; + } + railDragClickGuardRef.current = false; + return true; + }, []); + const releaseRailDragClickGuard = useCallback(() => { + setTimeout(() => { + railDragClickGuardRef.current = false; + }, RAIL_DRAG_CLICK_GUARD_FALLBACK_MS); + }, []); + // Which rail item is being dragged — drives the DragOverlay ghost while the + // in-list original shows as a slot skeleton. + const [activeRailId, setActiveRailId] = useState(null); + // The live (in-progress) reorder, mirrored in a ref so onDragOver/onDragEnd + // read the latest order without a stale-closure risk (same as the dock). + const liveRailOrderRef = useRef(null); + const handleRailDragStart = useCallback( + (event: DragStartEvent) => { + railDragClickGuardRef.current = true; + setActiveRailId(event.active.id as RailItemId); + liveRailOrderRef.current = railOrder; + setSidebarDragging(true); + }, + [railOrder, setSidebarDragging], + ); + // Live reorder: as the dragged item passes over a slot, reorder the rendered + // list so its parked slot skeleton moves into that slot — the same live + // landing indicator the shortcuts dock shows. Persisted only on drop. + const handleRailDragOver = useCallback( + (event: DragOverEvent) => { + const id = event.active.id as RailItemId; + const over = (event.over?.id as RailItemId) ?? null; + if (!over || over === id) { return; } - const oldIndex = railOrder.indexOf(active.id as SidebarCategoryId); - const newIndex = railOrder.indexOf(over.id as SidebarCategoryId); - if (oldIndex === -1 || newIndex === -1) { + const base = liveRailOrderRef.current ?? railOrder; + const from = base.indexOf(id); + const to = base.indexOf(over); + if (from === -1 || to === -1 || from === to) { return; } - setStoredRailOrder(arrayMove(railOrder, oldIndex, newIndex)); + const next = arrayMove(base, from, to); + liveRailOrderRef.current = next; + setRailOrderOverride(next); }, - [railOrder, setStoredRailOrder, setSidebarDragging], + [railOrder], ); + const handleRailDragEnd = useCallback(() => { + setSidebarDragging(false); + setActiveRailId(null); + releaseRailDragClickGuard(); + // The rendered order was already updated live in onDragOver (so there's + // nothing to move here — at drop the item is over its own slot); just + // commit it. If nothing actually moved, drop the override. + const liveOrder = liveRailOrderRef.current; + liveRailOrderRef.current = null; + if (!liveOrder) { + return; + } + if (liveOrder.join('|') === (storedRailOrder ?? []).join('|')) { + setRailOrderOverride(null); + return; + } + setStoredRailOrder(liveOrder).catch(() => undefined); + }, [ + releaseRailDragClickGuard, + setStoredRailOrder, + setSidebarDragging, + storedRailOrder, + ]); const activePage = activePageProp || router.asPath || router.pathname || ''; const isFeedPage = activePage.includes('/feeds/'); // When the For You feed is the current page, the Home button reads as @@ -821,8 +1000,12 @@ export const SidebarDesktopV2 = ({ const resolvedBaseCategory = useMemo((): SidebarCategoryId => { // The home / For You feed is a logged-in user's personal hub, so it // defaults to the Profile panel rather than Explore. Anonymous users (no - // profile panel) fall back to Explore. - if (isLoggedIn && isHomeActive) { + // profile panel) fall back to Explore. `/daily` is a home-equivalent + // surface (it renders the same DailyHome the feed shows with + // daily-as-default), so switching feed ⇄ daily keeps the same sidebar + // instead of flipping the panel to Explore. + const isDailyPage = activePage.split('?')[0] === '/daily'; + if (isLoggedIn && (isHomeActive || isDailyPage)) { return SidebarCategory.Profile; } // The user's own profile page (`/` and its sub-pages) also keeps @@ -1359,7 +1542,7 @@ export const SidebarDesktopV2 = ({ iconNode = ( ); @@ -1490,10 +1673,58 @@ export const SidebarDesktopV2 = ({ const isNotificationsSelected = selectedCategory === SidebarCategory.Notifications; - // Resolve a rail tab by id. Notifications is the one tab that isn't a plain - // category (it renders the bell with its unread badge); everything else goes - // through renderCategoryTab. - const renderRailTab = (id: SidebarCategoryId): ReactElement => { + // New post is reorderable with the tabs but is an action, not a panel tab — + // it keeps `role="button"` inside the tablist and carries no `aria-selected`, + // so the sliding selected pill (which tracks `aria-selected`) ignores it and + // stays on the committed category. + const renderCreateButton = (): ReactElement => ( + // The sortable wrapper is a full-width block and the tabs centre their own + // content; this button is a fixed 36px, so it needs the centring itself. +
+ +
+ ); + + // Resolve a rail item by id. New post and Notifications are the two that + // aren't plain categories (an action button and the bell with its unread + // badge); everything else goes through renderCategoryTab. + const renderRailTab = (id: RailItemId): ReactElement => { + if (id === RAIL_CREATE_ID) { + return renderCreateButton(); + } if (id === SidebarCategory.Profile) { return ( {/* mt nudges the logo down so it lines up vertically with the - panel title row (which sits at pt-6); no mb so the gap to Home + panel title row (which sits at pt-6); no mb so the gap below matches the uniform gap-1 rhythm of the rest of the rail. */} - - - - - - - - - + - {isLoggedIn && ( - - + +
); } diff --git a/packages/shared/src/components/streak/popup/StreakMonthCalendar.tsx b/packages/shared/src/components/streak/popup/StreakMonthCalendar.tsx index 8d3a37124dc..2a513f1a46c 100644 --- a/packages/shared/src/components/streak/popup/StreakMonthCalendar.tsx +++ b/packages/shared/src/components/streak/popup/StreakMonthCalendar.tsx @@ -4,7 +4,8 @@ import classNames from 'classnames'; import { addDays, subDays } from 'date-fns'; import { ReadingStreakIcon } from '../../icons'; import { IconSize } from '../../Icon'; -import { Streak } from './DayStreak'; +import { Tooltip } from '../../tooltip/Tooltip'; +import { freezeTooltipCopy, Streak } from './DayStreak'; import type { UserStreak } from '../../../graphql/users'; import { getStreak, @@ -75,7 +76,7 @@ export const StreakMonthCalendar = ({ stateClass = 'bg-[repeating-linear-gradient(135deg,currentColor_0_1.5px,transparent_1.5px_4px)] text-border-subtlest-tertiary'; } - return ( + const cell = (
); + // Freeze days keep the explanatory tooltip the old streak popup had + // (weekend auto-freeze / consumed freeze). + const tooltipContent = freezeTooltipCopy[state]; + if (!tooltipContent) { + return cell; + } + return ( + + {cell} + + ); })}
); diff --git a/packages/storybook/stories/components/QuestStates.stories.tsx b/packages/storybook/stories/components/QuestStates.stories.tsx new file mode 100644 index 00000000000..f509eea0e4d --- /dev/null +++ b/packages/storybook/stories/components/QuestStates.stories.tsx @@ -0,0 +1,358 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { QuestCard } from '@dailydotdev/shared/src/components/quest/QuestCard'; +import { CompactQuestRow } from '@dailydotdev/shared/src/components/quest/CompactQuestList'; +import type { + QuestReward, + UserQuest, +} from '@dailydotdev/shared/src/graphql/quests'; +import { + QuestRewardType, + QuestStatus, + QuestType, +} from '@dailydotdev/shared/src/graphql/quests'; + +// Every quest state, on both surfaces that render one: +// +// • QuestCard — Game Center / quest dropdown +// • CompactQuestRow — the v2 sidebar's Streak panel list +// +// State is not a single field. It is derived from `status`, `claimable`, +// `locked` and `progress` (see getQuestStatusLabel in QuestCard), plus the +// transient claim flags. Each column below is one of those combinations. +// +// NOTE on the "active" colour: an in-progress quest and a completed one both +// draw the progress bar in `accent-cabbage-default`; only `locked` differs +// (`accent-cabbage-bolder`). So nothing in the CARD colour distinguishes +// "actively in progress" — only the status text and the bar's width. That is +// the gap to review here. + +const reward = (type: QuestRewardType, amount: number): QuestReward => ({ + type, + amount, +}); + +const XP = reward(QuestRewardType.Xp, 50); +const CORES = reward(QuestRewardType.Cores, 20); +const REP = reward(QuestRewardType.Reputation, 10); + +// `quest` is omitted from the base so it can be overridden field-by-field — +// intersecting instead would still demand a whole QuestDefinition. +type QuestOverrides = Omit, 'quest'> & { + quest?: Partial; +}; + +const makeQuest = (overrides: QuestOverrides = {}): UserQuest => { + const { quest: questOverrides, ...rest } = overrides; + + return { + userQuestId: 'uq-1', + rotationId: 'rot-1', + progress: 1, + status: QuestStatus.InProgress, + completedAt: null, + claimedAt: null, + locked: false, + claimable: false, + rewards: [XP, CORES], + quest: { + id: 'q-1', + name: 'Read 3 posts', + description: 'Read three posts from your feed today.', + type: QuestType.Daily, + eventType: 'read_post', + targetCount: 3, + ...questOverrides, + }, + ...rest, + }; +}; + +// The full state matrix, in the order a quest moves through it. +const STATES: { label: string; note: string; quest: UserQuest }[] = [ + { + label: 'Not started', + note: 'in_progress, 0 of 3', + quest: makeQuest({ progress: 0 }), + }, + { + label: 'In progress', + note: 'in_progress, 1 of 3', + quest: makeQuest({ progress: 1 }), + }, + { + label: 'Almost there', + note: 'in_progress, 2 of 3', + quest: makeQuest({ progress: 2 }), + }, + { + label: 'Completed', + note: 'completed, not claimable', + quest: makeQuest({ + progress: 3, + status: QuestStatus.Completed, + completedAt: null, + }), + }, + { + label: 'Ready to claim', + note: 'completed + claimable', + quest: makeQuest({ + progress: 3, + status: QuestStatus.Completed, + claimable: true, + }), + }, + { + label: 'Claimed', + note: 'claimed', + quest: makeQuest({ + progress: 3, + status: QuestStatus.Claimed, + claimable: false, + }), + }, + { + label: 'Plus required', + note: 'completed + locked', + quest: makeQuest({ + progress: 3, + status: QuestStatus.Completed, + locked: true, + }), + }, +]; + +const REWARD_VARIANTS: { label: string; rewards: QuestReward[] }[] = [ + { label: 'XP + Cores', rewards: [XP, CORES] }, + { label: 'XP only', rewards: [XP] }, + { label: 'Cores only', rewards: [CORES] }, + { label: 'Reputation', rewards: [REP] }, + { label: 'All three', rewards: [XP, CORES, REP] }, +]; + +const noop = () => undefined; + +const cardProps = { + onClaim: noop, + showLevelSystem: true, + isClaiming: false, + isClaimAnimating: false, + showClaimedStamp: false, + animateClaimedStamp: false, + suppressPersistedClaimedStamp: false, +}; + +const Cell = ({ + label, + note, + children, +}: { + label: string; + note?: string; + children: React.ReactNode; +}) => ( +
+
+ {label} + {note && ( + {note} + )} +
+ {children} +
+); + +const meta: Meta = { + title: 'Components/Quest/States', + decorators: [ + (Story) => ( +
+ +
+ ), + ], + parameters: { layout: 'fullscreen' }, +}; + +export default meta; + +type Story = StoryObj; + +// The card as Game Center renders it, in every state. +export const Card: Story = { + render: () => ( +
+ {STATES.map(({ label, note, quest }) => ( + + + + ))} +
+ ), +}; + +// Transient claim states — the flags QuestSection drives during a claim. +export const CardClaimFlow: Story = { + render: () => { + const claimable = makeQuest({ + progress: 3, + status: QuestStatus.Completed, + claimable: true, + }); + + return ( +
+ + + + + + + + + + + + + + + +
+ ); + }, +}; + +// Reward chips, plus the level-system toggle that hides XP. +export const CardRewards: Story = { + render: () => ( +
+
+ {REWARD_VARIANTS.map(({ label, rewards }) => ( + + + + ))} +
+
+ {REWARD_VARIANTS.map(({ label, rewards }) => ( + + + + ))} +
+
+ ), +}; + +// A card with a destination shows the arrow that jumps to where the quest is +// completed; without one it doesn't. Claimable/claimed cards hide it either way. +export const CardDestination: Story = { + render: () => ( +
+ + + + + + + + + + + WEEKLY + } + statusLabel="Resets in 3 days" + /> + +
+ ), +}; + +// The compact row used by the v2 sidebar's Streak panel. Rendered on the panel's +// own width so truncation and the hover pill read true to the app. +export const SidebarRow: Story = { + render: () => ( +
+ {[ + { label: 'Not started', quest: makeQuest({ progress: 0 }) }, + { label: 'In progress', quest: makeQuest({ progress: 1 }) }, + { + label: 'Ready to claim', + quest: makeQuest({ + progress: 3, + status: QuestStatus.Completed, + claimable: true, + }), + }, + { + label: 'Claiming', + quest: makeQuest({ + progress: 3, + status: QuestStatus.Completed, + claimable: true, + }), + isClaiming: true, + }, + { + label: 'Claimed', + quest: makeQuest({ progress: 3, status: QuestStatus.Claimed }), + }, + { + label: 'Long title + description', + quest: makeQuest({ + progress: 2, + quest: { + name: 'Upvote five posts and leave a comment on one of them', + description: + 'A deliberately long description to check wrapping inside the narrow sidebar panel.', + targetCount: 5, + }, + }), + }, + ].map(({ label, quest, isClaiming }) => ( + + {/* 320px ≈ the v2 context panel's inner width. */} +
    + +
+
+ ))} +
+ ), +}; diff --git a/packages/storybook/stories/components/RailIconSet.stories.tsx b/packages/storybook/stories/components/RailIconSet.stories.tsx new file mode 100644 index 00000000000..5d5fc53715c --- /dev/null +++ b/packages/storybook/stories/components/RailIconSet.stories.tsx @@ -0,0 +1,134 @@ +import React from 'react'; +import classNames from 'classnames'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { + BellIcon, + CompassIcon, + HotIcon, + NewPostIcon, + SourceIcon, +} from '@dailydotdev/shared/src/components/icons'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { + RAIL_ICON_SIZE, + railTabClass, + railTabLabelClass, +} from '@dailydotdev/shared/src/components/sidebar/common'; +import { StreakBadge } from '@dailydotdev/shared/src/components/sidebar/StreakBadge'; + +// Optical-size audit for the v2 rail. Every tab glyph sits in the same box +// (RAIL_ICON_SIZE, with railGlyphBoxClass for the shaped ones), +// but a box is not what the eye measures — it measures INK. A ring-shaped glyph +// (compass, streak) reads smaller than a solid one (bell, avatar) at identical +// box sizes, so the set needs per-glyph correction rather than one shared size. +// +// Use `Audit` to compare ink coverage: each cell draws the 24px box as a dashed +// outline so you can see how much of it the glyph actually fills. + +const AVATAR = + 'https://daily-now-res.cloudinary.com/image/upload/f_auto,q_auto/v1/placeholders/1'; + +type Tab = { + key: string; + label: string; + render: () => React.ReactNode; +}; + +const TABS: Tab[] = [ + { + key: 'you', + label: 'You', + render: () => ( + + + + ), + }, + { + key: 'explore', + label: 'Explore', + render: () => , + }, + { + key: 'squads', + label: 'Squads', + render: () => , + }, + { + key: 'activity', + label: 'Activity', + render: () => , + }, + { + key: 'streak', + label: '73', + render: () => , + }, + { + key: 'streak-read', + label: '73', + render: () => , + }, + { + key: 'plain-flame', + label: 'Flame', + render: () => , + }, + { + key: 'new-post', + label: 'New', + render: () => ( + + {/* Deliberately NOT RAIL_ICON_SIZE — the create chip is sized on its own. */} + + + ), + }, +]; + +const RailTab = ({ tab, audit }: { tab: Tab; audit: boolean }) => ( + + + {tab.render()} + + {tab.label} + +); + +const Grid = ({ audit }: { audit: boolean }) => ( +
+ {TABS.map((tab) => ( +
+ +
+ ))} +
+); + +const meta: Meta = { + title: 'Components/Sidebar/Rail icon set', + decorators: [ + (Story) => ( +
+ +
+ ), + ], + parameters: { layout: 'fullscreen' }, +}; + +export default meta; + +type Story = StoryObj; + +// The set as the rail renders it — check that no glyph reads noticeably +// larger or smaller than its neighbours. +export const Default: Story = { render: () => }; + +// Same set with each glyph's 24px box outlined, to see ink coverage per glyph. +export const Audit: Story = { render: () => }; diff --git a/packages/storybook/stories/components/StreakBadge.stories.tsx b/packages/storybook/stories/components/StreakBadge.stories.tsx index 71ed85d97c4..56933909d7d 100644 --- a/packages/storybook/stories/components/StreakBadge.stories.tsx +++ b/packages/storybook/stories/components/StreakBadge.stories.tsx @@ -5,8 +5,12 @@ import { railTabLabelClass } from '@dailydotdev/shared/src/components/sidebar/co import type { StreakRingState } from '@dailydotdev/shared/src/hooks/streaks/useStreakRingState'; // The v2 rail's reading-streak indicator (replaces the old avatar StreakRing). -// A small square badge: state-driven border + fill, with the flame filled once +// A small circular badge: state-driven ring + fill, with the flame filled once // you've read today. Presentational — driven entirely by `state` + `hasReadToday`. +// +// HOVER: every badge below sits in a wrapper carrying `group/streaktab` — the +// same hook the real rail tab provides — so you can hover any of them to see +// the live hover state (unread ring AND flame go white together). const STATES: StreakRingState[] = [ 'none', @@ -31,6 +35,28 @@ const STATE_LABEL: Record = { // Which states represent "already read today" (flame filled) in real usage. const READ_STATES = new Set(['safe', 'celebration', 'freeze']); +// One rail tab as the sidebar renders it: badge + day-count label, inside the +// `group/streaktab` wrapper the badge's hover styles hook into. Hovering it here +// behaves exactly like hovering the tab in the app. +const RailTabPreview = ({ + state, + selected = false, +}: { + state: StreakRingState; + selected?: boolean; +}) => ( + + + + {state === 'none' ? 'Streak' : '73'} + + +); + const meta: Meta = { title: 'Components/Sidebar/StreakBadge', component: StreakBadge, @@ -77,13 +103,7 @@ export const AllStates: Story = {
{STATES.map((state) => (
- {/* Mirror the rail tab: badge glyph + count label underneath. */} - - - - {state === 'none' ? 'Streak' : '73'} - - + {STATE_LABEL[state]} @@ -93,10 +113,8 @@ export const AllStates: Story = { ), }; -// The same matrix with `selected` on — the calm-state borders turn pink (the -// reading-streak brand colour). Note: the hover-white border can't show here -// because it depends on the tab's `group/streaktab`, which only exists in the -// real rail. +// The same matrix with `selected` on — the calm-state rings turn pink (the +// reading-streak brand colour). export const Selected: Story = { argTypes: { state: { table: { disable: true } }, @@ -107,16 +125,7 @@ export const Selected: Story = {
{STATES.map((state) => (
- - - - {state === 'none' ? 'Streak' : '73'} - - + {STATE_LABEL[state]}