From d494300f0c774a35770170e13dc855ab7cea059c Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 30 Jul 2026 14:17:47 +0300 Subject: [PATCH 01/17] =?UTF-8?q?feat(layout-v2):=20sidebar=20rail=20itera?= =?UTF-8?q?tion=20=E2=80=94=20icon=20system,=20drag=20hardening,=20streak?= =?UTF-8?q?=20polish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rail - Fold the daily.dev logo and Home into one control: logo at rest, crossfading to the home glyph on hover/focus. The glyph is grey while inactive and white on direct hover (matching Search), filled only on the feed itself. - New post joins the tabs as a reorderable item persisted in sidebar_rail_order, but is excluded from the foldable set so it never collapses into "More". - One shared icon scale (RAIL_ICON_SIZE + railGlyphBoxClass) sizes every rail glyph — tabs, Home, Search, bottom utilities, "More" and the shortcuts dock — replacing ~30 hard-coded sizes. Hit areas are untouched; New post keeps its own size. Adds a 26px step to IconSize for it. - Optical normalisation, measured rather than eyeballed: the avatar is smaller than the outline glyphs (a solid photo carries more mass), the compass gets a 5% overshoot (a hollow circle reads small), and Squads is trimmed 5% (its four crossing ellipses put far more ink in the box than a single outline). Drag and drop (rail + shortcuts dock now share one architecture) - Rail tabs drag via DragOverlay: the real element parks as a slot skeleton while a pointer-events-none ghost follows the cursor. This is what makes the Notifications tab safe — its live anchor is never under the pointer at release, so the browser cannot natively follow the href. `draggable={false}` and a consume-based click guard back it up. - The slot skeleton reorders live (onDragOver) so it tracks the landing position, and the drop commits synchronously to avoid a frame of stale order. - Translate-only transforms: dnd-kit's scale distorted glyphs of differing heights. - Shared translucent glass ghost + slot treatment (sidebarDragGhostClass / SlotClass), identical in both systems. - Dock: dropping past the last icon now lands, driven by the same geometry as its indicator. Streak - Circular badge whose ring always takes the colour of the flame inside it — grey unread, pink read, blue on a frozen rest day — ring and flame going white together on hover. Ring stroke matches the icon set's measured weight. - Ring and fill share one box so the ring's border covers the fill's edge: insetting the fill separately left a fractional gap that rounded unevenly per device pixel and read as an off-centre crescent. Fill paints first so the ring draws over it. - Flame centred on even clearance inside the ring, verified by measuring the white flame against the filled disc. - Weekend auto-freeze tooltips restored on the 30-day calendar. - Freeze row follows the panel guidelines: title, subtitle, neutral button. Panels - Quest rows route to the surface where each quest can be completed, via a reusable destination mapping extracted from QuestButton. - "Hot Takes" no longer false-highlights on the feed (it is a modal launcher). - /daily resolves to the same panel as the feed, so switching does not reshuffle the sidebar. - Drop the duplicate "Get API Access" CTA, plus Jobs and DevCard (both reachable from settings); Feed settings takes the filter icon; concentric radius on the profile pill. - Notification count is a square badge growing rightward so wide values stay inside the rail. Storybook - Rail icon set, with an Audit variant that outlines each glyph box so ink coverage stays measurable. - StreakBadge stories supply `group/streaktab`, so hover states are reviewable. - Quest States: every derived quest state (not started, in progress, completed, ready to claim, claiming, claimed, Plus-locked) on both surfaces that render one, plus claim-animation flags, reward combinations and the destination arrow. Exports CompactQuestRow so the stories render the real row. Co-Authored-By: Claude Fable 5 --- packages/shared/src/components/Icon.tsx | 2 + .../notifications/NotificationsBell.tsx | 44 +- .../notifications/NotificationsRailPanel.tsx | 4 +- .../quest/CompactQuestList.spec.tsx | 83 +++ .../src/components/quest/CompactQuestList.tsx | 26 +- .../src/components/quest/QuestButton.tsx | 57 +- .../src/components/quest/questDestinations.ts | 70 +++ .../src/components/sidebar/RailMoreMenu.tsx | 5 +- .../components/sidebar/SidebarDesktopV2.tsx | 495 +++++++++++++----- .../components/sidebar/SidebarEntityIcon.tsx | 10 +- .../sidebar/SidebarShortcutsDock.tsx | 91 ++-- .../src/components/sidebar/StreakBadge.tsx | 93 +++- .../shared/src/components/sidebar/common.tsx | 20 + .../sidebar/sections/DiscoverSection.tsx | 4 + .../sidebar/sections/ProfilePanelSection.tsx | 35 +- .../src/components/streak/popup/DayStreak.tsx | 2 +- .../streak/popup/StreakFreezeRow.tsx | 52 +- .../streak/popup/StreakMonthCalendar.tsx | 21 +- .../components/QuestStates.stories.tsx | 358 +++++++++++++ .../components/RailIconSet.stories.tsx | 134 +++++ .../components/RailQuestIndicator.stories.tsx | 258 +++++++++ .../components/StreakBadge.stories.tsx | 53 +- 22 files changed, 1580 insertions(+), 337 deletions(-) create mode 100644 packages/shared/src/components/quest/CompactQuestList.spec.tsx create mode 100644 packages/shared/src/components/quest/questDestinations.ts create mode 100644 packages/storybook/stories/components/QuestStates.stories.tsx create mode 100644 packages/storybook/stories/components/RailIconSet.stories.tsx create mode 100644 packages/storybook/stories/components/RailQuestIndicator.stories.tsx 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..e49950f680d 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,17 @@ const SAFE_ZONE_BUFFER = 26; const SortableRailTab = ({ id, children, + consumeClickGuard, + bareDrag = false, }: { id: string; children: ReactNode; + // Armed while a rail drag is live — see `releaseRailDragClickGuard`. Returns + // whether the guard was armed and disarms it. + consumeClickGuard: () => boolean; + // New post is a filled chip, not a tab with a hover surface, so it drags as + // just its icon: no landing skeleton behind it and no chip around the ghost. + bareDrag?: boolean; }): ReactElement => { const { setNodeRef, listeners, transform, transition, isDragging } = useSortable({ id }); @@ -224,12 +261,41 @@ 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 && !bareDrag && sidebarDragSlotClass, + isDragging ? '[&>*]:opacity-0' : 'cursor-grab', )} > {children} @@ -319,7 +385,7 @@ const railGiftLink = (label: string, href: string): ReactElement => ( - + @@ -402,7 +468,7 @@ const SidebarSupportButton = (): ReactElement => { isOpen && 'bg-background-default !text-text-primary', )} > - + {isOpen && ( @@ -513,7 +579,7 @@ const SidebarSettingsButton = (): ReactElement => { isOpen && 'bg-background-default !text-text-primary', )} > - + {isOpen && ( @@ -585,7 +651,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 +767,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 +845,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 +864,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 +908,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 +1003,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 +1545,7 @@ export const SidebarDesktopV2 = ({ iconNode = ( ); @@ -1490,10 +1676,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/RailQuestIndicator.stories.tsx b/packages/storybook/stories/components/RailQuestIndicator.stories.tsx new file mode 100644 index 00000000000..f46d0b8bdd4 --- /dev/null +++ b/packages/storybook/stories/components/RailQuestIndicator.stories.tsx @@ -0,0 +1,258 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import ProgressCircle from '@dailydotdev/shared/src/components/ProgressCircle'; +import { StreakBadge } from '@dailydotdev/shared/src/components/sidebar/StreakBadge'; +import { + railTabClass, + railTabLabelClass, + RAIL_ICON_SIZE, +} from '@dailydotdev/shared/src/components/sidebar/common'; +import { Bubble } from '@dailydotdev/shared/src/components/tooltips/utils'; +import { JoystickIcon, VIcon } from '@dailydotdev/shared/src/components/icons'; +import type { StreakRingState } from '@dailydotdev/shared/src/hooks/streaks/useStreakRingState'; + +// PLAYGROUND — how should the rail tell you a quest is ready? +// +// Today the gamification tab is the reading-streak badge (flame + day count) and +// a claimable quest only adds a small count Bubble in the corner. The question +// is whether that is enough, or whether the tab should become a quest indicator +// while something is claimable — the ring-with-a-number we used to have. +// +// Each option below is drawn as a real rail tab (railTabClass + the shared label +// class) so proportions match the app. Compare them at the same claimable +// counts, and note the trade-off called out per option. +// +// Nothing here is wired into the rail yet — this is for choosing a direction. + +const STREAK_DAYS = 73; + +const Tab = ({ + label, + children, + selected = false, +}: { + label: string; + children: React.ReactNode; + selected?: boolean; +}) => ( + + {children} + {label} + +); + +const Option = ({ + title, + note, + children, +}: { + title: string; + note: string; + children: React.ReactNode; +}) => ( +
+
+ {title} + {note} +
+
+ {children} +
+
+); + +// ── Option A: today's behaviour ───────────────────────────────────────────── +const StreakWithBubble = ({ claimable }: { claimable: number }) => ( + + + {claimable > 0 && {claimable}} + +); + +// ── Option B: the old quest ring, replacing the flame while claimable ─────── +const QuestRing = ({ + progress, + count, +}: { + progress: number; + count: number; +}) => ( + + + + {/* The arc carries progress; the number is the claimable count, so it + stays one digit and can't overflow the 26px ring. */} + + {count} + + + +); + +// ── Option C: streak keeps the tab, quest progress rides the outside ──────── +const StreakWithProgressRing = ({ progress }: { progress: number }) => ( + + + + + + + + +); + +// ── Option D: swap the glyph for a "ready" mark while claimable ───────────── +const ReadyToClaim = ({ claimable }: { claimable: number }) => ( + + + + {claimable > 1 && ( + {claimable} + )} + + +); + +// ── Option E: quests as their own glyph (no streak) ───────────────────────── +const JoystickWithBubble = ({ claimable }: { claimable: number }) => ( + + + {claimable > 0 && ( + {claimable} + )} + +); + +const COUNTS = [0, 1, 3, 9]; + +const meta: Meta = { + title: 'Components/Sidebar/Rail quest indicator', + decorators: [ + (Story) => ( +
+ +
+ ), + ], + parameters: { layout: 'fullscreen' }, +}; + +export default meta; + +type Story = StoryObj; + +// All five candidates at a glance, each across 0/1/3/9 claimable quests. +export const Options: Story = { + render: () => ( +
+ + + + + + + + + +
+ ), +}; + +// Option A against every streak state, since whichever option wins has to work +// on top of all of them — including the danger states, where a purple bubble +// lands next to an amber or red ring. +export const BubbleOverStreakStates: Story = { + render: () => { + const states: { state: StreakRingState; label: string; read: boolean }[] = [ + { state: 'none', label: 'New', read: false }, + { state: 'pending', label: 'Pending', read: false }, + { state: 'safe', label: 'Read', read: true }, + { state: 'at_risk', label: 'At risk', read: false }, + { state: 'critical', label: 'Critical', read: false }, + { state: 'freeze', label: 'Rest', read: true }, + ]; + + return ( +
+ {states.map(({ state, label, read }) => ( +
+ + + 3 + + {label} +
+ ))} +
+ ); + }, +}; + +// The selected-tab treatment, since the rail's sliding pill sits behind whichever +// option wins. +export const Selected: 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]} From 016dc691dcba4152951ac2ad713c4de609bbd778 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 30 Jul 2026 15:25:24 +0300 Subject: [PATCH 02/17] fix(layout-v2): centre the streak flame and add a quest+streak rail exploration The flame was riding 0.8px high because of a translate added to "optically" centre its centre of mass. Measured both Hot SVGs: they are already symmetric in their 24x24 viewBox (ink margins 4.8/4.8 and 3.0/3.0, ink centre 12,12), so flex centring is exact and the nudge only created the offset it claimed to fix. Removing it takes clearance from 2.2/3.8 to 3.0/3.0 top/bottom. Adds a Storybook exploration of ten ways one rail tab could carry both the reading streak and quest state, each drawn as a real 68px tab with its mechanic and trade-off, plus today's behaviour as the control. Stories cover the ten at one signal, a playground driving all of them together, 3x glyphs, claimable counts 0-3, all six streak states, and each option in situ under the Activity bell so bubble collisions are visible. Nothing is wired into the rail. Co-Authored-By: Claude Fable 5 --- .../src/components/sidebar/StreakBadge.tsx | 15 +- .../RailQuestStreakExploration.stories.tsx | 1042 +++++++++++++++++ 2 files changed, 1049 insertions(+), 8 deletions(-) create mode 100644 packages/storybook/stories/components/RailQuestStreakExploration.stories.tsx diff --git a/packages/shared/src/components/sidebar/StreakBadge.tsx b/packages/shared/src/components/sidebar/StreakBadge.tsx index 86ffc8f73eb..2c57da4d8c4 100644 --- a/packages/shared/src/components/sidebar/StreakBadge.tsx +++ b/packages/shared/src/components/sidebar/StreakBadge.tsx @@ -144,14 +144,13 @@ export const StreakBadge = ({ secondary={hasReadToday || state === 'freeze'} size={IconSize.XSmall} className={classNames( - // Optically centred, which is NOT the same as centring its box: the - // flame's bounding box sat dead centre on the disc while its centre of - // MASS measured +0.35/+1.30 off it — a teardrop carries its weight low - // and right — so it read as sitting low and to the right. These - // offsets put the mass on the disc's centre instead. (An earlier - // attempt at this crowded the tip against the ring, but that was with - // a larger flame; at this size there is room.) - 'relative -translate-y-[0.8px] translate-x-[0.15px] transition-colors', + // No nudge offsets here on purpose. Both flame SVGs are already + // symmetric in their 24x24 viewBox (ink margins 4.8/4.8 and 3.0/3.0), + // so flex centring lands the ink exactly on the disc's centre. A + // previous attempt to "optically" centre the flame's centre of mass + // shifted it 0.8px up, which showed as a cramped tip (clearance 2.2 + // top vs 3.8 bottom) and read as misaligned. + 'relative transition-colors', flameByState[state], // Unread states brighten with the ring on hover, matching the other // rail tabs' icons. Read/danger states own their colour. diff --git a/packages/storybook/stories/components/RailQuestStreakExploration.stories.tsx b/packages/storybook/stories/components/RailQuestStreakExploration.stories.tsx new file mode 100644 index 00000000000..bf0d3a97c32 --- /dev/null +++ b/packages/storybook/stories/components/RailQuestStreakExploration.stories.tsx @@ -0,0 +1,1042 @@ +import type { ReactElement } from 'react'; +import React, { useEffect, useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import classNames from 'classnames'; +import { StreakBadge } from '@dailydotdev/shared/src/components/sidebar/StreakBadge'; +import { + railTabClass, + railTabLabelClass, + RAIL_ICON_SIZE, +} from '@dailydotdev/shared/src/components/sidebar/common'; +import { Bubble } from '@dailydotdev/shared/src/components/tooltips/utils'; +import { + BellIcon, + GiftIcon, + HomeIcon, + HotIcon, + JoystickIcon, + MagicIcon, +} from '@dailydotdev/shared/src/components/icons'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import type { StreakRingState } from '@dailydotdev/shared/src/hooks/streaks/useStreakRingState'; + +// EXPLORATION — how should ONE rail tab carry BOTH the reading streak and quests? +// +// The problem, stated plainly: the v2 rail gives gamification a single tab — +// a 26px glyph box and one short text label under it, in a 68px-wide column. +// That one tab has to say four things at once: +// +// 1. the reading streak day count (73) +// 2. the streak's urgency state (safe / at-risk / critical / rest) +// 3. how far through today's quests you are (2 of 3) +// 4. that a reward is sitting there unclaimed (the thing we most want clicked) +// +// Today only (1), (2) and a bare count for (4) are expressed. Ten directions +// below, each a different STRATEGY for splitting those four messages across the +// channels the tab actually owns: the ring, the glyph, the corner, the label, +// the space under the glyph, motion, and — last resort — a second tab. +// +// COLOUR SYSTEM (not invented here — read off the existing components, so every +// idea below stays inside the language the product already speaks): +// pink / accent-bacon → the reading streak (StreakBadge fill) +// green / accent-avocado → quest progress in flight (QuestLevelProgressCircle) +// purple / accent-cabbage → a reward ready to claim (Bubble) +// Progress is green while you work, purple the moment there is something to take. +// +// Nothing here is wired into the rail. Use the Playground story to drive all ten +// from one set of controls and compare them under the same signal. +// +// The five first-pass options live in "Rail quest indicator" — this page is the +// deeper exploration, and every story here also draws TODAY'S behaviour as the +// control, because an option is only worth shipping if it beats the baseline. + +// ─── the signal every idea renders from ────────────────────────────────────── + +interface RailSignal { + streakState: StreakRingState; + hasReadToday: boolean; + days: number; + // Quests finished out of today's set. + done: number; + total: number; + // Finished quests whose reward has not been collected yet. + claimable: number; +} + +const DEFAULT_SIGNAL: RailSignal = { + streakState: 'safe', + hasReadToday: true, + days: 73, + done: 2, + total: 3, + claimable: 1, +}; + +const progressOf = ({ done, total }: RailSignal): number => + total ? Math.min(100, (done / total) * 100) : 0; + +// Below the smallest typo step (caption2 is 12px), which is what a numeral has to +// be to sit inside a 26px glyph without pushing its own box around. +const microNumeral = 'text-[0.5625rem] font-bold leading-none tabular-nums'; + +// ─── shared drawing primitives ─────────────────────────────────────────────── + +const GLYPH = 26; + +// The rail's glyph box. Every idea must fit inside this, or say that it doesn't. +const GlyphBox = ({ + children, + size = GLYPH, +}: { + children: React.ReactNode; + size?: number; +}) => ( + + {children} + +); + +// One arc per quest, laid around the glyph — a segmented ring rather than a +// continuous one, so "2 of 3" is countable instead of estimated. +const SegmentRing = ({ + total, + done, + size = GLYPH, + stroke = 2, +}: { + total: number; + done: number; + size?: number; + stroke?: number; +}): ReactElement => { + const radius = (size - stroke) / 2; + const circumference = 2 * Math.PI * radius; + const step = circumference / Math.max(1, total); + // Round caps add stroke/2 at each end, so the gap has to absorb a full stroke + // before it reads as a gap at all — at stroke+2 two finished arcs merged into + // one and "2 of 3" became uncountable. + const gap = stroke + 5; + const segment = Math.max(0.5, step - gap); + + return ( + + {Array.from({ length: Math.max(1, total) }, (_, index) => ( + + ))} + + ); +}; + +// A continuous progress arc, for the ideas that show "how far" rather than "how many". +const ProgressArc = ({ + progress, + size = GLYPH, + stroke = 1.5, + showTrack = true, + className, +}: { + progress: number; + size?: number; + stroke?: number; + showTrack?: boolean; + className?: string; +}): ReactElement => { + const radius = (size - stroke) / 2; + const circumference = 2 * Math.PI * radius; + + return ( + + {showTrack && ( + + )} + + + ); +}; + +// The streak disc WITHOUT its flame — ring + fill only, mirroring StreakBadge's +// state colours. Ideas 1, 5 and 10 compose their own contents inside the disc, so +// they cannot nest the real badge (it would draw a second flame behind theirs). +const ringColorByState: Partial> = { + none: 'border-text-quaternary', + pending: 'border-text-tertiary', + safe: 'border-accent-bacon-default', + celebration: 'border-accent-bacon-default', + at_risk: 'border-status-warning', + critical: 'border-status-error', + freeze: 'border-accent-blueCheese-default', +}; + +const discFillByState: Partial> = { + safe: 'bg-accent-bacon-default', + celebration: 'bg-accent-bacon-default', + freeze: 'bg-accent-blueCheese-flat', +}; + +const StreakDisc = ({ + signal, + showRing = true, + showFill = true, + children, +}: { + signal: RailSignal; + showRing?: boolean; + showFill?: boolean; + children: React.ReactNode; +}) => ( + <> + {showFill && ( + + )} + {showRing && ( + + )} + {children} + +); + +// The flame on its own, in the streak's colour — for the ideas that rebuild the +// glyph instead of nesting the real StreakBadge inside something. +const flameColorByState: Partial> = { + none: 'text-text-quaternary', + pending: 'text-text-tertiary', + safe: 'text-accent-bacon-default', + celebration: 'text-accent-bacon-default', + at_risk: 'text-status-warning', + critical: 'text-status-error', + freeze: 'text-accent-blueCheese-default', +}; + +const Flame = ({ + signal, + size = IconSize.Size16, + // On a filled disc the flame has to invert, exactly as StreakBadge does. + onFill = false, + className, +}: { + signal: RailSignal; + size?: IconSize; + onFill?: boolean; + className?: string; +}) => ( + +); + +// ─── the ten ideas ─────────────────────────────────────────────────────────── + +// 1 — The ring becomes the quest tracker. The streak keeps the glyph (flame + +// its state colour); the circle around it splits into one arc per quest. +const IdeaSegmentedRing = (signal: RailSignal) => ( + + + + +); + +// 2 — A single gem set INTO the ring. Not a floating badge: it sits on the ring's +// own path, so the glyph's box never grows and it can't collide with the bell's +// bubble. Binary — "something is waiting" — with the count left to the label. +const IdeaRingGem = (signal: RailSignal) => ( + + + {signal.claimable > 0 && ( + + )} + +); + +// The untouched badge — shared by every idea that puts its quest signal somewhere +// other than the glyph (3 the label, 7 the strip below, 9 a second tab). +const IdeaPlainBadge = (signal: RailSignal) => ( + + + +); + +// 3 — Leave the glyph alone entirely and time-share the LABEL, the one channel +// already sized for words. The day count and the call to action alternate. +const TimeSharedLabelText = ({ signal }: { signal: RailSignal }) => { + const [showClaim, setShowClaim] = useState(false); + + useEffect(() => { + if (!signal.claimable) { + setShowClaim(false); + return undefined; + } + const id = setInterval(() => setShowClaim((prev) => !prev), 2200); + return () => clearInterval(id); + }, [signal.claimable]); + + return ( + + + {signal.days} + + + Claim + + + ); +}; + +// 4 — An app-icon-style chip, but INSET rather than overflowing: it tucks into +// the box's bottom-right corner, so the whole thing still measures 26px. +const IdeaInsetChip = (signal: RailSignal) => ( + + {/* inline-block: a transform has no effect on an inline element. */} + + + + {signal.claimable > 0 && ( + + {signal.claimable} + + )} + +); + +// 5 — The flame itself is the progress bar: it fills from the bottom as you +// finish today's quests. One glyph carrying two channels — ring colour is the +// streak, fill level is the quests. +// The filled flame's ink is 15px tall inside its 20px box (2.5px margin each +// side), so the fill window is measured against the INK, not the box — otherwise +// "66% done" leaves a full flame and the level stops meaning anything. +const FLAME_INK_HEIGHT = 15; +const FLAME_INK_MARGIN = 2.5; + +const IdeaLiquidFlame = (signal: RailSignal) => { + const progress = progressOf(signal); + const fillHeight = FLAME_INK_MARGIN + (FLAME_INK_HEIGHT * progress) / 100; + + return ( + + + + {/* The unfilled level is the SOLID flame in a track colour, not the + outlined one — mixing the two silhouettes read as a smudge rather + than a flame filling up. */} + + + {/* Re-anchored at full height so the clip reveals the icon bottom-up. */} + + 0 + ? 'text-accent-cabbage-default' + : 'text-accent-avocado-default' + } + /> + + + + + + ); +}; + +// 6 — Change nothing about the shape. A soft purple aura breathes behind the +// badge while a reward is waiting: attention through light instead of geometry, +// so no information is displaced and nothing is hidden. +const IdeaBreathingHalo = (signal: RailSignal) => ( + + {signal.claimable > 0 && ( + + )} + + +); + +// 7 — Borrow the strip between glyph and label. Pagination-style dots, one per +// quest — the glyph stays 100% the streak's, and the quest state is countable. +const MicroDotsRow = ({ signal }: { signal: RailSignal }) => ( + + {Array.from({ length: signal.total }, (_, index) => ( + + ))} + +); + +// 8 — A transient takeover instead of a permanent one. The glyph morphs to a +// gift for a beat when a reward lands, then hands the tab back to the streak and +// leaves the quiet chip behind. Answers the "the streak loses its home" problem. +const IdeaMorphAnnouncement = (signal: RailSignal) => { + const [showGift, setShowGift] = useState(false); + + useEffect(() => { + if (!signal.claimable) { + setShowGift(false); + return undefined; + } + const id = setInterval(() => setShowGift((prev) => !prev), 2600); + return () => clearInterval(id); + }, [signal.claimable]); + + return ( + + + + + + + + + ); +}; + +// 9 — Stop compressing. Two tabs, each unambiguous, each with its own label and +// its own badge. The honest baseline the nine clever options have to beat. +const QuestSiblingTab = ({ signal }: { signal: RailSignal }) => ( + + + + {signal.claimable > 0 && ( + {signal.claimable} + )} + + Quests + +); + +// 10 — Two concentric rings, both inside 26px: the streak keeps its outer ring +// untouched, and the quest arc is drawn INSIDE it. The flame shrinks to make +// room — the cost is paid by the glyph, not by the rail's rhythm. +const IdeaInnerArc = (signal: RailSignal) => ( + + + {/* No track: a grey ring over the streak's pink fill reads as mud, and the + outer ring already gives the arc a circle to be measured against. */} + + + + +); + +// ─── idea catalogue ────────────────────────────────────────────────────────── + +interface Idea { + n: number; + title: string; + mechanic: string; + pro: string; + con: string; + // Renders the glyph for a given signal. + glyph: (signal: RailSignal) => ReactElement; + // Overrides the tab's label (default: the day count). + label?: (signal: RailSignal) => ReactElement | string; + // Extra row between glyph and label. + underGlyph?: (signal: RailSignal) => ReactElement | null; + // Renders a second tab alongside. + sibling?: (signal: RailSignal) => ReactElement; +} + +const IDEAS: Idea[] = [ + { + n: 1, + title: 'Segmented quest ring', + mechanic: + 'The circle around the flame splits into one arc per quest; arcs fill green as you finish them.', + pro: 'Reuses a ring that already exists, so it costs zero extra pixels. "2 of 3" is countable, not estimated.', + con: 'The streak loses the ring as its state channel — urgency has to live in the flame colour alone.', + glyph: IdeaSegmentedRing, + }, + { + n: 2, + title: 'Gem set into the ring', + mechanic: + 'A 7px purple gem, centred on the streak ring at 45° so it straddles the stroke rather than floating off the corner.', + pro: 'Stays inside the glyph box, so it cannot collide with the notification bubble above it. Reads as one object.', + con: 'Binary only — it says "something is waiting", never how much. The count has to move to the label.', + glyph: IdeaRingGem, + }, + { + n: 3, + title: 'Time-shared label', + mechanic: + 'The glyph never changes. The label alternates between the day count and "Claim" every 2.2s.', + pro: 'Uses the one channel already built for words, so the message is literal instead of decoded. Cheapest to ship.', + con: 'Half the time the day count is not on screen, and moving text in a fixed rail can read as noise.', + glyph: IdeaPlainBadge, + label: (signal) => , + }, + { + n: 4, + title: 'Inset corner chip', + mechanic: + 'App-icon badge, but tucked inside: the badge scales to 82% and the count chip fills the freed corner.', + pro: 'Carries an exact count and still measures 26px. Instantly familiar from every mobile home screen.', + con: 'Shrinking the streak badge breaks the icon-size consistency pass the rail just went through.', + glyph: IdeaInsetChip, + }, + { + n: 5, + title: 'Liquid-fill flame', + mechanic: + 'The flame fills bottom-up with quest progress — green while working, purple once claimable.', + pro: 'One glyph, two channels, no added marks. The most game-like and the most distinctive option here.', + con: 'Worst on the state matrix: the fill colour owns the flame, so all six streak states look nearly identical and "read today" stops reading at all.', + glyph: IdeaLiquidFlame, + }, + { + n: 6, + title: 'Breathing halo', + mechanic: + 'Geometry untouched. A soft purple aura pulses behind the badge while a reward waits.', + pro: 'Displaces nothing and hides nothing — the streak keeps every channel it has. Reads in peripheral vision.', + con: 'Motion in a persistent rail gets tuned out fast, and it carries no count at all.', + glyph: IdeaBreathingHalo, + }, + { + n: 7, + title: 'Micro dots under the glyph', + mechanic: + 'A row of dots between glyph and label, one per quest, filled as each completes.', + pro: 'The glyph stays 100% the streak’s. Progress is countable and the pattern is already understood.', + con: 'Costs ~7px of tab height, so every rail tab grows or this one stops matching its neighbours.', + glyph: IdeaPlainBadge, + underGlyph: (signal) => , + }, + { + n: 8, + title: 'Morph announcement', + mechanic: + 'The glyph springs from flame to gift when a reward lands, holds a beat, then springs back.', + pro: 'Gets the loudest possible moment without permanently evicting the streak. Best of A and B.', + con: 'If you look away during the beat you miss it — it needs a quiet resting badge as a fallback.', + glyph: IdeaMorphAnnouncement, + }, + { + n: 9, + title: 'Two tabs', + mechanic: + 'Give up on one tab. Streak and Quests become siblings, each with its own glyph, label and badge.', + pro: 'Zero ambiguity and zero compromise — both features get a real name and a real target.', + con: 'Costs a full row of rail height, and the rail already folds tabs into the More menu when short.', + glyph: IdeaPlainBadge, + sibling: (signal) => , + }, + { + n: 10, + title: 'Inner concentric arc', + mechanic: + 'The quest arc is drawn INSIDE the streak ring; the flame shrinks to 12px to make room.', + pro: 'Both rings live in 26px, so the rail rhythm is untouched and the streak keeps its full state ring.', + con: 'At this size the arc and the shrunken flame are near the limit of legibility — verify on a real display.', + glyph: IdeaInnerArc, + }, +]; + +// ─── presentation ──────────────────────────────────────────────────────────── + +// One idea drawn as the real rail tab (68px column, railTabClass, label slot). +const IdeaTab = ({ + idea, + signal, +}: { + idea: Idea; + signal: RailSignal; +}): ReactElement => { + const label = idea.label?.(signal) ?? `${signal.days}`; + const under = idea.underGlyph?.(signal); + + // The rail is a column, so a second tab stacks BELOW rather than beside. + return ( + + + + {idea.glyph(signal)} + + {under} + {label} + + {idea.sibling?.(signal)} + + ); +}; + +const IdeaCard = ({ + idea, + signal, +}: { + idea: Idea; + signal: RailSignal; +}): ReactElement => ( +
+
+ + {String(idea.n).padStart(2, '0')} + + + {idea.title} + +
+
+ + + +

+ {idea.mechanic} +

+
+
+

+ + {idea.pro} +

+

+ {idea.con} +

+
+
+); + +// TODAY — the control. The streak badge with a floating count bubble in the +// corner, which is what the rail ships right now. +const TodayBaseline = ({ signal }: { signal: RailSignal }): ReactElement => ( + + + + {signal.claimable > 0 && ( + {signal.claimable} + )} + + {signal.days} + +); + +const BaselineStrip = ({ signal }: { signal: RailSignal }): ReactElement => ( +
+ + + +
+ + Today — the control + + + Streak badge plus a floating count bubble. The bubble overflows the glyph + box, so it lands directly under the Activity bell's own purple + bubble — and it says nothing about quest progress, only that a number + exists. + +
+
+); + +const Legend = (): ReactElement => ( +
+ {[ + { color: 'bg-accent-bacon-default', text: 'reading streak' }, + { color: 'bg-accent-avocado-default', text: 'quest progress' }, + { color: 'bg-accent-cabbage-default', text: 'reward ready to claim' }, + ].map(({ color, text }) => ( + + + {text} + + ))} +
+); + +const STATES: StreakRingState[] = [ + 'none', + 'pending', + 'safe', + 'at_risk', + 'critical', + 'freeze', +]; + +const meta: Meta = { + title: 'Components/Sidebar/Rail quest + streak exploration', + decorators: [ + (Story) => ( +
+ +
+ ), + ], + parameters: { layout: 'fullscreen', controls: { expanded: true } }, +}; + +export default meta; + +type Story = StoryObj; + +// All ten directions with their mechanic and their trade-off, at one shared +// signal (73-day streak, read today, 2 of 3 quests done, 1 reward waiting). +export const TenIdeas: Story = { + render: () => ( +
+
+

+ Ten ways one rail tab can carry both the streak and quests +

+

+ Each option is drawn as a real 68px rail tab. They differ in WHICH + channel carries the quest signal — the ring, the glyph, a corner, the + label, the strip under the glyph, motion, or a second tab. +

+
+ + +
+ {IDEAS.map((idea) => ( + + ))} +
+
+ ), +}; + +// Drive all ten from one control set. This is the story to sit in while deciding. +export const Playground: Story = { + args: DEFAULT_SIGNAL, + argTypes: { + streakState: { control: 'select', options: STATES }, + hasReadToday: { control: 'boolean' }, + days: { control: { type: 'number', min: 0, max: 999 } }, + done: { control: { type: 'range', min: 0, max: 5, step: 1 } }, + total: { control: { type: 'range', min: 1, max: 5, step: 1 } }, + claimable: { control: { type: 'range', min: 0, max: 9, step: 1 } }, + }, + render: (args) => { + const signal = args as RailSignal; + + return ( +
+
+

+ Playground — every idea under the same signal +

+

+ {signal.done}/{signal.total} quests done · {signal.claimable}{' '} + claimable · {signal.days}-day streak · {signal.streakState} +

+
+ + +
+ {IDEAS.map((idea) => ( +
+ + + {String(idea.n).padStart(2, '0')} {idea.title} + +
+ ))} +
+
+ ); + }, +}; + +// Every glyph at 3x, because most of these live or die on detail that is +// invisible at 26px — stroke weights, gem placement, arc gaps, inner clearance. +export const Zoomed: Story = { + render: () => ( +
+

+ Glyphs at 3x — 2 of 3 quests done, 1 reward waiting +

+
+ {IDEAS.map((idea) => ( +
+ + + {idea.glyph(DEFAULT_SIGNAL)} + + + + {String(idea.n).padStart(2, '0')} {idea.title} + +
+ ))} +
+
+ ), +}; + +// The signal that actually matters: does the idea still read as you go from +// "nothing waiting" to "several waiting"? +export const AcrossClaimableCounts: Story = { + render: () => ( +
+

+ 0 → 1 → 2 → 3 rewards waiting +

+
+ {IDEAS.map((idea) => ( +
+ + {String(idea.n).padStart(2, '0')} {idea.title} + + + {[0, 1, 2, 3].map((claimable) => ( + + + + ))} + +
+ ))} +
+
+ ), +}; + +// Whichever idea wins has to survive all six streak states — including the amber +// and red danger rings, where a purple quest mark competes for the same glyph. +export const AcrossStreakStates: Story = { + render: () => ( +
+

+ Every idea against every streak state +

+

+ The danger states are the stress test: at-risk (amber) and critical (red) + need to stay the loudest thing on the tab, or a claimable reward will pull + attention away from a streak that is about to break. +

+
+ {IDEAS.map((idea) => ( +
+ + {String(idea.n).padStart(2, '0')} {idea.title} + + + {STATES.map((streakState) => ( + + + + {streakState} + + + ))} + +
+ ))} +
+
+ ), +}; + +// In situ. A rail tab never appears alone — the notification bell sits right +// above it with its own purple bubble, which is the collision every option that +// overflows the glyph box has to survive. +const MiniRail = ({ + idea, + signal, +}: { + idea: Idea; + signal: RailSignal; +}): ReactElement => ( +
+ + + + + + + 3 + + Activity + + + + + + + You + +
+); + +export const InTheRail: Story = { + render: () => ( +
+

+ In situ — under the Activity bell and its purple bubble +

+

+ Two purple marks stacked in an 80px column is the real risk. Ideas 2, 5, 6 + and 10 keep everything inside the glyph box and avoid it; 4 sits in a + corner the bell does not use; 3, 7 and 9 move the signal out of the glyph + entirely. +

+
+ {IDEAS.map((idea) => ( +
+ + + {String(idea.n).padStart(2, '0')} {idea.title} + +
+ ))} +
+
+ ), +}; From e63f8310407242ad0eff54df7b9da0fd8640741a Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 30 Jul 2026 16:38:55 +0300 Subject: [PATCH 03/17] =?UTF-8?q?feat(layout-v2):=20round-2=20rail=20quest?= =?UTF-8?q?=20mark=20=E2=80=94=20gem,=20chip=20and=20arrival=20refinements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 1's ring/arc/fill options were rejected: any second progress indicator on the badge fuses with the streak's own ring and reads as a broken control. This round keeps the streak's ring untouched and gives quests either a discrete token or a one-shot moment. A - gem: cut a real hole for it instead of laying it over the badge. A first attempt opened a gap in the ring's stroke, which was invisible on the `safe` state (filled pink disc, same-pink ring) - the state most users see. Masking through fill and ring together reads on every state. Variants for clearance, position, size and one-gem-per-reward. B - chip: drop round 1's 82% badge shrink, which broke the rail's icon-size consistency. The badge stays full size and is bitten instead, so the chip needs no border - removing the separator colour that mismatches the moment the tab is hovered. Squircle chips get a shape-matched SVG mask, since a radial-gradient bite cannot match a rounded square. Includes the rest/hover/selected comparison that shows the bordered variant failing. C - moment: fires once on arrival and settles back to the resting mark, rather than round 1's permanent loop. Storybook only, nothing wired into the rail. Shared scaffolding extracted to railQuestMark.mocks.tsx. Co-Authored-By: Claude Fable 5 --- .../components/RailQuestMark.stories.tsx | 896 ++++++++++++++++++ .../components/railQuestMark.mocks.tsx | 333 +++++++ 2 files changed, 1229 insertions(+) create mode 100644 packages/storybook/stories/components/RailQuestMark.stories.tsx create mode 100644 packages/storybook/stories/components/railQuestMark.mocks.tsx diff --git a/packages/storybook/stories/components/RailQuestMark.stories.tsx b/packages/storybook/stories/components/RailQuestMark.stories.tsx new file mode 100644 index 00000000000..5b3e7b82cb5 --- /dev/null +++ b/packages/storybook/stories/components/RailQuestMark.stories.tsx @@ -0,0 +1,896 @@ +import type { CSSProperties, ReactElement, ReactNode } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import classNames from 'classnames'; +import { StreakBadge } from '@dailydotdev/shared/src/components/sidebar/StreakBadge'; +import { GiftIcon } from '@dailydotdev/shared/src/components/icons'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { + DEFAULT_SIGNAL, + GLYPH, + GlyphBox, + isReadState, + Legend, + microNumeral, + MiniRail, + OnRail, + RailTab, + RING_PATH_RADIUS, + SectionHeading, + STREAK_STATES, + TodayBaseline, + Variant, +} from './railQuestMark.mocks'; +import type { RailSignal } from './railQuestMark.mocks'; + +// ROUND 2 — refining the three directions that survived review. +// +// The rejected round-1 options (segmented ring, liquid fill, inner concentric +// arc, two tabs) all failed the same way: any second PROGRESS indicator on the +// badge visually fuses with the streak's own ring and reads as a broken control +// rather than two facts. So the rule for this round: +// +// The streak owns the ring. Quests get a DISCRETE TOKEN or a MOMENT IN TIME — +// never an arc, never a fill, never a second progress track. +// +// That leaves exactly three mechanics, developed below: +// A — the gem : a token set into the ring (round 1, option 02) +// B — the chip : a counted token in the corner (round 1, option 04) +// C — the moment: a one-shot reveal on arrival (round 1, option 08) +// +// A and B are resting states; C is an event. They are not rivals — the endgame +// is one of A/B as the resting mark plus C as the arrival. See "Recommended". + +const CABBAGE = 'bg-accent-cabbage-default'; + +// ─── A · the gem ───────────────────────────────────────────────────────────── + +// A token placed ON the ring's stroke centre line at a given angle (0 = top, +// measured clockwise), so it belongs to the ring instead of floating near it. +const RingGem = ({ + angle, + size = 7, + square = false, + separator = true, + index = 0, +}: { + angle: number; + size?: number; + square?: boolean; + separator?: boolean; + index?: number; +}): ReactElement => { + const radians = (angle * Math.PI) / 180; + const cx = GLYPH / 2 + RING_PATH_RADIUS * Math.sin(radians); + const cy = GLYPH / 2 - RING_PATH_RADIUS * Math.cos(radians); + + return ( + + ); +}; + +// Carves circular holes out of whatever it is applied to, so a mark nests INTO +// the badge instead of sitting on top of it. This is the one mechanic shared by +// the gem and the chip. +// +// It replaced an earlier attempt that opened a gap in the ring's stroke: on the +// `safe` state the disc is filled pink and the ring is the same pink, so a gap +// in the stroke was completely invisible — and `safe` is the state most users +// see. Masking the whole badge cuts through fill and ring together, so it reads +// on every state. +const biteMask = ( + holes: { x: number; y: number; r: number }[], +): CSSProperties => { + const gradients = holes + .map( + ({ x, y, r }) => + `radial-gradient(circle ${r}px at ${x}px ${y}px, transparent 98%, #000 100%)`, + ) + .join(','); + + return { + WebkitMaskImage: gradients, + maskImage: gradients, + WebkitMaskComposite: 'source-in', + maskComposite: 'intersect', + }; +}; + +// Where a gem's centre lands for a given angle on the ring. +const gemCentre = (angle: number) => { + const radians = (angle * Math.PI) / 180; + + return { + x: GLYPH / 2 + RING_PATH_RADIUS * Math.sin(radians), + y: GLYPH / 2 - RING_PATH_RADIUS * Math.cos(radians), + }; +}; + +// A1 — round 1 as shipped: gem laid over an unbroken ring. +const GemOverRing = (signal: RailSignal, angle = 45): ReactElement => ( + + + {signal.claimable > 0 && } + +); + +// A2 — the badge opens to receive the gem. +const GemInNotch = ( + signal: RailSignal, + { angle = 45, gemSize = 7, clearance = 1.5 } = {}, +): ReactElement => { + const holes = [{ ...gemCentre(angle), r: gemSize / 2 + clearance }]; + + return ( + + 0 ? biteMask(holes) : undefined} + > + + + {signal.claimable > 0 && ( + + )} + + ); +}; + +// A6 — one gem per waiting reward. Discrete tokens, so it stays a count rather +// than becoming an arc — but see the note: at three it starts to drift. +const GemCluster = (signal: RailSignal): ReactElement => { + const count = Math.min(3, signal.claimable); + const gemSize = 6; + // Centres must be at least a gem apart plus a gap, or the beads merge into the + // dashed arc this whole round exists to avoid. + const circumference = 2 * Math.PI * RING_PATH_RADIUS; + const spacing = ((gemSize + 2.5) / circumference) * 360; + const start = 45 - ((count - 1) * spacing) / 2; + const angles = Array.from({ length: count }, (_, i) => start + i * spacing); + const holes = angles.map((angle) => ({ + ...gemCentre(angle), + r: gemSize / 2 + 1.25, + })); + + return ( + + 0 ? biteMask(holes) : undefined} + > + + + {angles.map((angle) => ( + + ))} + + ); +}; + +// ─── B · the chip ──────────────────────────────────────────────────────────── + +const CHIP = 13; +// Where the chip's centre lands when it is pinned to the box's bottom-right. +const CHIP_CENTRE = GLYPH - CHIP / 2; + +// A rounded-rect hole, for the squircle chip. A radial-gradient bite cannot +// match a square: on the axes it clears 2px past the chip's edge while the +// chip's own corners poke out along the diagonal, which is exactly the mismatch +// the first squircle attempt showed. This punches the real shape instead. +// +// CSS `mask-image` on an HTML element masks by ALPHA, so the kept area is opaque +// and the hole must be fully transparent — hence one path, filled opaque, with +// the hole as a second subpath under `evenodd`. +const squircleBiteMask = ( + x: number, + y: number, + size: number, + radius: number, +): CSSProperties => { + const r = Math.min(radius, size / 2); + const hole = + `M${x + r},${y} h${size - 2 * r} a${r},${r} 0 0 1 ${r},${r}` + + ` v${size - 2 * r} a${r},${r} 0 0 1 ${-r},${r}` + + ` h${-(size - 2 * r)} a${r},${r} 0 0 1 ${-r},${-r}` + + ` v${-(size - 2 * r)} a${r},${r} 0 0 1 ${r},${-r} z`; + const svg = + `` + + `` + + ``; + const url = `url("data:image/svg+xml;utf8,${encodeURIComponent(svg)}")`; + + return { WebkitMaskImage: url, maskImage: url }; +}; + +const Chip = ({ + count, + square = false, + separator = true, +}: { + count: number; + square?: boolean; + separator?: boolean; +}): ReactElement => ( + + {count > 9 ? '9+' : count} + +); + +// B1 — round 1 as shipped: badge shrunk to 82% to make room. +const ChipWithShrunkBadge = (signal: RailSignal): ReactElement => ( + + + + + {signal.claimable > 0 && } + +); + +// B2 — badge at full size; the chip simply overlaps its corner. +const ChipOverBadge = (signal: RailSignal, square = false): ReactElement => ( + + + {signal.claimable > 0 && } + +); + +// B3/B4 — the badge has a bite taken out of it and the chip nests in the hole. +// No border on the chip, so there is no colour to mismatch when the tab is +// hovered. The hole is shape-matched to the chip. +const ChipInBite = (signal: RailSignal, square = false): ReactElement => { + const clearance = 1.5; + const mask = square + ? squircleBiteMask( + CHIP_CENTRE - CHIP / 2 - clearance, + CHIP_CENTRE - CHIP / 2 - clearance, + CHIP + clearance * 2, + 4 + clearance, + ) + : biteMask([{ x: CHIP_CENTRE, y: CHIP_CENTRE, r: CHIP / 2 + clearance }]); + + return ( + + 0 ? mask : undefined} + > + + + {signal.claimable > 0 && ( + + )} + + ); +}; + +// ─── C · the moment ────────────────────────────────────────────────────────── + +type Arrival = 'pop' | 'flip' | 'drop'; + +const REVEAL_MS = 1500; + +// A one-shot reveal, NOT a loop: the reward lands, the glyph announces it once, +// then hands the tab back to the streak and leaves the resting mark behind. +// Looping was round 1's real flaw — persistent motion in a rail gets tuned out. +const ArrivalMoment = ({ + signal, + arrival, + rest, + autoPlay = true, +}: { + signal: RailSignal; + arrival: Arrival; + rest: (signal: RailSignal) => ReactElement; + autoPlay?: boolean; +}): ReactElement => { + const [revealing, setRevealing] = useState(false); + const timer = useRef>(); + + const play = useCallback(() => { + clearTimeout(timer.current); + setRevealing(true); + timer.current = setTimeout(() => setRevealing(false), REVEAL_MS); + }, []); + + useEffect(() => { + if (autoPlay) { + play(); + } + return () => clearTimeout(timer.current); + }, [autoPlay, play]); + + const springy = 'duration-500 ease-[cubic-bezier(0.34,1.56,0.64,1)]'; + const hiddenByArrival: Record = { + pop: 'scale-50 opacity-0', + flip: 'opacity-0 [transform:rotateY(90deg)]', + drop: '-translate-y-3 opacity-0', + }; + const restHiddenByArrival: Record = { + pop: 'scale-75 opacity-0', + flip: 'opacity-0 [transform:rotateY(-90deg)]', + drop: 'scale-90 opacity-0', + }; + + return ( + + + + + {rest(signal)} + + + + + + } + /> + + + + ); +}; + +// ─── the two finalists, as reusable marks ──────────────────────────────────── + +const RestGem = (signal: RailSignal): ReactElement => GemInNotch(signal); +const RestChip = (signal: RailSignal): ReactElement => ChipInBite(signal, true); + +const withClaimable = (signal: RailSignal, claimable: number): RailSignal => ({ + ...signal, + claimable, +}); + +const meta: Meta = { + title: 'Components/Sidebar/Rail quest mark', + decorators: [ + (Story) => ( +
+ +
+ ), + ], + parameters: { layout: 'fullscreen', controls: { expanded: true } }, +}; + +export default meta; + +type Story = StoryObj; + +// ─── stories ───────────────────────────────────────────────────────────────── + +export const GemRefinements: Story = { + render: () => ( +
+ + The round-1 gem sat on top of the badge, which is why it still read as a + dot parked at the corner. Cutting a real hole for it is what turns two + shapes into one object — and it removes the separator ring, a colour that + has to match the tab background and stops matching the moment you hover. +
+
+ First attempt opened a gap in the ring's stroke instead. That failed: + on the safe state the disc is filled pink and the ring is the same + pink, so a gap in the stroke was invisible — and safe is the state most + users see most days. Cutting through fill and ring together is the only + version that reads on every state. +
+ +
+ + + + + + + + + + + + + + {[0.5, 1.5, 2.5].map((clearance) => ( + + + + ))} + + + + {[45, 90, 135].map((angle) => ( + + + + ))} + + + + {[6, 7, 8].map((gemSize) => ( + + + + ))} + + + + {[1, 2, 3].map((claimable) => ( + + + + ))} + +
+
+ ), +}; + +export const ChipRefinements: Story = { + render: () => ( +
+ + The chip is the only direction that carries an exact number. Round 1 paid + for it by shrinking the badge to 82%, which broke the icon-size + consistency the rail just went through. It does not need to: the badge can + stay full size and have a bite taken out of it instead. + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + {[1, 3, 9, 12].map((claimable) => ( + + 9 ? '9+' : claimable} + glyph={ChipInBite(withClaimable(DEFAULT_SIGNAL, claimable), true)} + /> + + ))} + +
+ + + A chip or gem separated by a hard `background-default` edge only looks + right while the tab is at rest. Hovering paints `surface-hover` behind it + and the separator becomes a visible halo. The bitten variants have no + separator colour, so they are identical in all three states. + +
+ {[ + { label: 'rest', bg: 'bg-background-default' }, + { label: 'hover', bg: 'bg-surface-hover' }, + { label: 'selected', bg: 'bg-background-default' }, + ].map(({ label, bg }) => ( + + + + + + + + + ))} +
+
+ ), +}; + +export const ArrivalMoments: Story = { + render: () => ( +
+ + Round 1 looped this forever, which is why it read as noise. It should fire + ONCE when a reward actually lands, hold about a second, then settle to the + resting mark. That is the answer to “the streak loses its + home”: the streak only ever gives up the glyph for a moment. Each + plays on load — hit Replay to see it again. + + +
+ {( + [ + ['C1', 'Pop → gem', 'pop', RestGem], + ['C2', 'Flip → chip', 'flip', RestChip], + ['C3', 'Drop → gem', 'drop', RestGem], + ] as [string, string, Arrival, (s: RailSignal) => ReactElement][] + ).map(([code, title, arrival, rest]) => ( + + + + ))} +
+
+ ), +}; + +export const Recommended: Story = { + render: () => ( +
+ + The chip is the only finalist that carries a count, and once it nests in a + bite it costs the badge nothing — full size, no separator colour, no + second progress track. Pair it with the one-shot pop so arrival still has + a moment. The gem stays the fallback if we decide the exact number is not + worth the corner. + + + +
+ + + + + + + + + + + + + + + +
+ + + The resting tab must be completely unchanged when there is nothing to + claim, or the mark stops meaning anything. + +
+ {[0, 1, 2, 5].map((claimable) => ( + + + + + + + + + ))} +
+ + + The danger states are the test: at-risk and critical must stay the loudest + thing on the tab even with a reward waiting. + +
+ {STREAK_STATES.map((streakState) => { + const signal: RailSignal = { + ...DEFAULT_SIGNAL, + streakState, + hasReadToday: isReadState(streakState), + }; + + return ( + + + + + + + + + ); + })} +
+ + + Under the Activity bell, which owns the other purple mark in this column. + +
+ {( + [ + ['Recommended · chip', RestChip], + ['Fallback · gem', RestGem], + ['Today (control)', null], + ] as [string, ((s: RailSignal) => ReactElement) | null][] + ).map(([title, mark]) => ( +
+ + {mark ? ( + + ) : ( + + )} + + {title} +
+ ))} +
+
+ ), +}; + +// Every finalist at 3x, because these live or die on detail that is invisible at +// 26px — the notch gap, the bite clearance, the chip's corner radius. +export const Zoomed: Story = { + render: () => ( +
+ +
+ {( + [ + ['A1', 'Gem over ring', GemOverRing(DEFAULT_SIGNAL)], + ['A2', 'Gem in notch', GemInNotch(DEFAULT_SIGNAL)], + ['A6', 'Gem cluster ×3', GemCluster(withClaimable(DEFAULT_SIGNAL, 3))], + ['B1', 'Chip, shrunk badge', ChipWithShrunkBadge(DEFAULT_SIGNAL)], + ['B2', 'Chip, bordered', ChipOverBadge(DEFAULT_SIGNAL)], + ['B3', 'Chip in bite', ChipInBite(DEFAULT_SIGNAL)], + ['B4', 'Squircle in bite', ChipInBite(DEFAULT_SIGNAL, true)], + ['B5', 'Squircle, 9+', ChipInBite(withClaimable(DEFAULT_SIGNAL, 12), true)], + ] as [string, string, ReactNode][] + ).map(([code, title, glyph]) => ( +
+ + {glyph} + + + {code} {title} + +
+ ))} +
+
+ ), +}; + +export const Playground: Story = { + args: { ...DEFAULT_SIGNAL, claimable: 2 }, + argTypes: { + streakState: { control: 'select', options: STREAK_STATES }, + hasReadToday: { control: 'boolean' }, + days: { control: { type: 'number', min: 0, max: 999 } }, + claimable: { control: { type: 'range', min: 0, max: 12, step: 1 } }, + done: { table: { disable: true } }, + total: { table: { disable: true } }, + }, + render: (args) => { + const signal = args as RailSignal; + + return ( +
+ + Change `claimable` and `streakState` in the controls panel. Watch the + zero state especially — the tab has to be indistinguishable from today + when nothing is waiting. + + +
+ {( + [ + ['—', 'Today (control)', null], + ['A2', 'Gem in notch', RestGem], + ['A6', 'Gem cluster', GemCluster], + ['B4', 'Squircle in bite', RestChip], + ['B2', 'Chip, bordered', (s: RailSignal) => ChipOverBadge(s, true)], + ] as [string, string, ((s: RailSignal) => ReactElement) | null][] + ).map(([code, title, mark]) => ( + + + {mark ? ( + + ) : ( + + )} + + + ))} +
+
+ {( + [ + ['C1', 'Pop → gem', 'pop', RestGem], + ['C2', 'Flip → chip', 'flip', RestChip], + ] as [string, string, Arrival, (s: RailSignal) => ReactElement][] + ).map(([code, title, arrival, rest]) => ( + + + + ))} +
+
+ ); + }, +}; diff --git a/packages/storybook/stories/components/railQuestMark.mocks.tsx b/packages/storybook/stories/components/railQuestMark.mocks.tsx new file mode 100644 index 00000000000..328a7ecdb8a --- /dev/null +++ b/packages/storybook/stories/components/railQuestMark.mocks.tsx @@ -0,0 +1,333 @@ +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { + railTabClass, + railTabLabelClass, + RAIL_ICON_SIZE, +} from '@dailydotdev/shared/src/components/sidebar/common'; +import { StreakBadge } from '@dailydotdev/shared/src/components/sidebar/StreakBadge'; +import { Bubble } from '@dailydotdev/shared/src/components/tooltips/utils'; +import { + BellIcon, + HomeIcon, + HotIcon, + MagicIcon, +} from '@dailydotdev/shared/src/components/icons'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import type { StreakRingState } from '@dailydotdev/shared/src/hooks/streaks/useStreakRingState'; + +// Shared scaffolding for the rail gamification-tab explorations, so the two +// story pages draw the same geometry instead of each re-deriving it. + +// ─── the signal every variant renders from ─────────────────────────────────── + +export interface RailSignal { + streakState: StreakRingState; + hasReadToday: boolean; + days: number; + // Quests finished out of today's set. + done: number; + total: number; + // Finished quests whose reward has not been collected yet. + claimable: number; +} + +export const DEFAULT_SIGNAL: RailSignal = { + streakState: 'safe', + hasReadToday: true, + days: 73, + done: 2, + total: 3, + claimable: 1, +}; + +export const progressOf = ({ done, total }: RailSignal): number => + total ? Math.min(100, (done / total) * 100) : 0; + +export const STREAK_STATES: StreakRingState[] = [ + 'none', + 'pending', + 'safe', + 'at_risk', + 'critical', + 'freeze', +]; + +// Which states mean "already read today", so `hasReadToday` stays consistent +// with the state being previewed. +export const isReadState = (state: StreakRingState): boolean => + state === 'safe' || state === 'freeze' || state === 'celebration'; + +// ─── geometry ──────────────────────────────────────────────────────────────── + +// The rail's glyph box. Every variant must fit inside this, or say that it doesn't. +export const GLYPH = 26; +// The streak disc inside that box (StreakBadge insets its ring by 1.5px). +export const DISC = 23; +export const DISC_RADIUS = DISC / 2; +// The radius of the ring's STROKE CENTRE LINE — 1.5px inset, then half of the +// 1.5px border. Anything meant to sit *on* the ring is placed at this radius, +// not at the disc edge, or it reads as floating just outside it. +export const RING_PATH_RADIUS = DISC_RADIUS - 0.75; + +// Below the smallest typo step (caption2 is 11px) — what a numeral has to be to +// sit inside a 26px glyph without pushing its own box around. +export const microNumeral = 'text-[0.5625rem] font-bold leading-none tabular-nums'; + +export const GlyphBox = ({ + children, + size = GLYPH, +}: { + children: ReactNode; + size?: number; +}): ReactElement => ( + + {children} + +); + +// ─── streak colours, mirroring StreakBadge ─────────────────────────────────── + +export const ringColorByState: Partial> = { + none: 'border-text-quaternary', + pending: 'border-text-tertiary', + safe: 'border-accent-bacon-default', + celebration: 'border-accent-bacon-default', + at_risk: 'border-status-warning', + critical: 'border-status-error', + freeze: 'border-accent-blueCheese-default', +}; + +export const discFillByState: Partial> = { + safe: 'bg-accent-bacon-default', + celebration: 'bg-accent-bacon-default', + freeze: 'bg-accent-blueCheese-flat', +}; + +export const flameColorByState: Partial> = { + none: 'text-text-quaternary', + pending: 'text-text-tertiary', + safe: 'text-accent-bacon-default', + celebration: 'text-accent-bacon-default', + at_risk: 'text-status-warning', + critical: 'text-status-error', + freeze: 'text-accent-blueCheese-default', +}; + +// The streak disc WITHOUT its flame — ring + fill only. For variants that +// compose their own contents inside the disc and so cannot nest the real badge +// (it would draw a second flame behind theirs). +export const StreakDisc = ({ + signal, + showRing = true, + showFill = true, + children, +}: { + signal: RailSignal; + showRing?: boolean; + showFill?: boolean; + children?: ReactNode; +}): ReactElement => ( + <> + {showFill && ( + + )} + {showRing && ( + + )} + {children} + +); + +export const Flame = ({ + signal, + size = IconSize.Size16, + // On a filled disc the flame has to invert, exactly as StreakBadge does. + onFill = false, + className, +}: { + signal: RailSignal; + size?: IconSize; + onFill?: boolean; + className?: string; +}): ReactElement => ( + +); + +// ─── tab chrome ────────────────────────────────────────────────────────────── + +// One rail tab at the real geometry: 68px column, railTabClass, glyph + label. +// `group/streaktab` is the hook StreakBadge's hover styles rely on, so hovering +// these behaves exactly like hovering the tab in the app. +export const RailTab = ({ + glyph, + label, + under, + selected = false, +}: { + glyph: ReactNode; + label: ReactNode; + under?: ReactNode; + selected?: boolean; +}): ReactElement => ( + + {glyph} + {under} + {label} + +); + +// A swatch of dark rail behind a tab, so hover/selected surfaces read correctly. +export const OnRail = ({ children }: { children: ReactNode }): ReactElement => ( + + {children} + +); + +// TODAY — the control: streak badge with a floating count bubble in the corner. +export const TodayBaseline = ({ + signal, +}: { + signal: RailSignal; +}): ReactElement => ( + + + {signal.claimable > 0 && ( + {signal.claimable} + )} + + } + /> +); + +// In situ. A rail tab never appears alone — the Activity bell sits right above it +// with its own purple bubble, which is the collision any mark that overflows the +// glyph box has to survive. +export const MiniRail = ({ + children, +}: { + children: ReactNode; +}): ReactElement => ( +
+ + + + + + 3 + + } + /> + {children} + } + /> +
+); + +// ─── page furniture ────────────────────────────────────────────────────────── + +export const Legend = (): ReactElement => ( +
+ {[ + { color: 'bg-accent-bacon-default', text: 'reading streak' }, + { color: 'bg-accent-cabbage-default', text: 'reward ready to claim' }, + ].map(({ color, text }) => ( + + + {text} + + ))} +
+); + +export const SectionHeading = ({ + eyebrow, + title, + children, +}: { + eyebrow?: string; + title: string; + children?: ReactNode; +}): ReactElement => ( +
+ {eyebrow && ( + + {eyebrow} + + )} +

{title}

+ {children && ( +

{children}

+ )} +
+); + +// A single labelled variant cell. +export const Variant = ({ + code, + title, + note, + children, +}: { + code: string; + title: string; + note?: string; + children: ReactNode; +}): ReactElement => ( +
+
+ + {code} + + {title} +
+
{children}
+ {note && ( +

{note}

+ )} +
+); From 2fe3d87d5fdc3202ce1de0a02402653fb852128f Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 30 Jul 2026 16:48:21 +0300 Subject: [PATCH 04/17] feat(layout-v2): ten rail quest-mark variations for review Adds the Ten Variations page (plus 3x and in-rail views) to the Rail quest mark folder, so all directions sit on one reviewable page. All ten obey the round-2 rule: the streak keeps its ring, and quests get a discrete token or a moment - never an arc, a fill or a second progress track. V1-V3 gem, V4-V6 chip (squircle / circle / gift icon), V7 adaptive, V8 folded corner, V9 label dot, V10 arrival pop. Unifies the nesting mechanic on a single alpha mask (holeMask) with circle, rounded-rect and corner hole shapes, replacing the split radial-gradient and per-shape SVG mask helpers. Each variation is shown at 1 reward, 4 rewards and 0 rewards, since the zero state has to be indistinguishable from today's tab. Co-Authored-By: Claude Fable 5 --- .../components/RailQuestMark.stories.tsx | 443 +++++++++++++++--- 1 file changed, 371 insertions(+), 72 deletions(-) diff --git a/packages/storybook/stories/components/RailQuestMark.stories.tsx b/packages/storybook/stories/components/RailQuestMark.stories.tsx index 5b3e7b82cb5..c2944b50357 100644 --- a/packages/storybook/stories/components/RailQuestMark.stories.tsx +++ b/packages/storybook/stories/components/RailQuestMark.stories.tsx @@ -86,33 +86,53 @@ const RingGem = ({ ); }; -// Carves circular holes out of whatever it is applied to, so a mark nests INTO -// the badge instead of sitting on top of it. This is the one mechanic shared by -// the gem and the chip. +// Carves holes out of whatever it is applied to, so a mark nests INTO the badge +// instead of sitting on top of it. This is the one mechanic every variation +// below shares — only the hole's SHAPE changes. // // It replaced an earlier attempt that opened a gap in the ring's stroke: on the // `safe` state the disc is filled pink and the ring is the same pink, so a gap // in the stroke was completely invisible — and `safe` is the state most users // see. Masking the whole badge cuts through fill and ring together, so it reads // on every state. -const biteMask = ( - holes: { x: number; y: number; r: number }[], -): CSSProperties => { - const gradients = holes - .map( - ({ x, y, r }) => - `radial-gradient(circle ${r}px at ${x}px ${y}px, transparent 98%, #000 100%)`, - ) - .join(','); +// +// CSS `mask-image` on an HTML element masks by ALPHA, so the kept area must be +// opaque and the hole fully transparent — hence one opaque path whose holes are +// extra subpaths under `evenodd`. +const holeMask = (...holes: string[]): CSSProperties => { + const svg = + `` + + `` + + ``; + const url = `url("data:image/svg+xml;utf8,${encodeURIComponent(svg)}")`; - return { - WebkitMaskImage: gradients, - maskImage: gradients, - WebkitMaskComposite: 'source-in', - maskComposite: 'intersect', - }; + return { WebkitMaskImage: url, maskImage: url }; }; +const circleHole = (cx: number, cy: number, r: number): string => + `M${cx - r},${cy} a${r},${r} 0 1 0 ${r * 2},0 a${r},${r} 0 1 0 ${-r * 2},0 z`; + +const roundedRectHole = ( + x: number, + y: number, + w: number, + h: number, + radius: number, +): string => { + const r = Math.min(radius, w / 2, h / 2); + + return ( + `M${x + r},${y} h${w - 2 * r} a${r},${r} 0 0 1 ${r},${r}` + + ` v${h - 2 * r} a${r},${r} 0 0 1 ${-r},${r}` + + ` h${-(w - 2 * r)} a${r},${r} 0 0 1 ${-r},${-r}` + + ` v${-(h - 2 * r)} a${r},${r} 0 0 1 ${r},${-r} z` + ); +}; + +// The diagonal corner cut used by the folded-corner variation. +const cornerHole = (side: number): string => + `M${GLYPH - side},${GLYPH} L${GLYPH},${GLYPH - side} L${GLYPH},${GLYPH} z`; + // Where a gem's centre lands for a given angle on the ring. const gemCentre = (angle: number) => { const radians = (angle * Math.PI) / 180; @@ -136,13 +156,14 @@ const GemInNotch = ( signal: RailSignal, { angle = 45, gemSize = 7, clearance = 1.5 } = {}, ): ReactElement => { - const holes = [{ ...gemCentre(angle), r: gemSize / 2 + clearance }]; + const { x, y } = gemCentre(angle); + const hole = circleHole(x, y, gemSize / 2 + clearance); return ( 0 ? biteMask(holes) : undefined} + style={signal.claimable > 0 ? holeMask(hole) : undefined} > { const spacing = ((gemSize + 2.5) / circumference) * 360; const start = 45 - ((count - 1) * spacing) / 2; const angles = Array.from({ length: count }, (_, i) => start + i * spacing); - const holes = angles.map((angle) => ({ - ...gemCentre(angle), - r: gemSize / 2 + 1.25, - })); + const holes = angles.map((angle) => { + const { x, y } = gemCentre(angle); + + return circleHole(x, y, gemSize / 2 + 1.25); + }); return ( 0 ? biteMask(holes) : undefined} + style={count > 0 ? holeMask(...holes) : undefined} > { // ─── B · the chip ──────────────────────────────────────────────────────────── const CHIP = 13; -// Where the chip's centre lands when it is pinned to the box's bottom-right. -const CHIP_CENTRE = GLYPH - CHIP / 2; - -// A rounded-rect hole, for the squircle chip. A radial-gradient bite cannot -// match a square: on the axes it clears 2px past the chip's edge while the -// chip's own corners poke out along the diagonal, which is exactly the mismatch -// the first squircle attempt showed. This punches the real shape instead. -// -// CSS `mask-image` on an HTML element masks by ALPHA, so the kept area is opaque -// and the hole must be fully transparent — hence one path, filled opaque, with -// the hole as a second subpath under `evenodd`. -const squircleBiteMask = ( - x: number, - y: number, - size: number, - radius: number, -): CSSProperties => { - const r = Math.min(radius, size / 2); - const hole = - `M${x + r},${y} h${size - 2 * r} a${r},${r} 0 0 1 ${r},${r}` + - ` v${size - 2 * r} a${r},${r} 0 0 1 ${-r},${r}` + - ` h${-(size - 2 * r)} a${r},${r} 0 0 1 ${-r},${-r}` + - ` v${-(size - 2 * r)} a${r},${r} 0 0 1 ${r},${-r} z`; - const svg = - `` + - `` + - ``; - const url = `url("data:image/svg+xml;utf8,${encodeURIComponent(svg)}")`; - - return { WebkitMaskImage: url, maskImage: url }; -}; const Chip = ({ - count, + children, square = false, separator = true, + size = CHIP, }: { - count: number; + children: ReactNode; square?: boolean; separator?: boolean; + size?: number; }): ReactElement => ( - {count > 9 ? '9+' : count} + {children} ); +// Counts above 9 would widen the chip past the glyph box. +const chipCount = (count: number): string => (count > 9 ? '9+' : `${count}`); + +// The hole a chip nests into, shape-matched to it. +const chipHole = (square: boolean, size = CHIP, clearance = 1.5): string => { + const centre = GLYPH - size / 2; + + return square + ? roundedRectHole( + centre - size / 2 - clearance, + centre - size / 2 - clearance, + size + clearance * 2, + size + clearance * 2, + 4 + clearance, + ) + : circleHole(centre, centre, size / 2 + clearance); +}; + // B1 — round 1 as shipped: badge shrunk to 82% to make room. const ChipWithShrunkBadge = (signal: RailSignal): ReactElement => ( @@ -259,7 +270,7 @@ const ChipWithShrunkBadge = (signal: RailSignal): ReactElement => ( hasReadToday={signal.hasReadToday} /> - {signal.claimable > 0 && } + {signal.claimable > 0 && {chipCount(signal.claimable)}} ); @@ -267,7 +278,9 @@ const ChipWithShrunkBadge = (signal: RailSignal): ReactElement => ( const ChipOverBadge = (signal: RailSignal, square = false): ReactElement => ( - {signal.claimable > 0 && } + {signal.claimable > 0 && ( + {chipCount(signal.claimable)} + )} ); @@ -275,15 +288,7 @@ const ChipOverBadge = (signal: RailSignal, square = false): ReactElement => ( // No border on the chip, so there is no colour to mismatch when the tab is // hovered. The hole is shape-matched to the chip. const ChipInBite = (signal: RailSignal, square = false): ReactElement => { - const clearance = 1.5; - const mask = square - ? squircleBiteMask( - CHIP_CENTRE - CHIP / 2 - clearance, - CHIP_CENTRE - CHIP / 2 - clearance, - CHIP + clearance * 2, - 4 + clearance, - ) - : biteMask([{ x: CHIP_CENTRE, y: CHIP_CENTRE, r: CHIP / 2 + clearance }]); + const mask = holeMask(chipHole(square)); return ( @@ -297,12 +302,98 @@ const ChipInBite = (signal: RailSignal, square = false): ReactElement => { /> {signal.claimable > 0 && ( - + + {chipCount(signal.claimable)} + )} ); }; +// V6 — a token that says "claim", not "how many". Drops the numeral entirely, +// which is the only way to stay legible when the count is irrelevant. +const IconChip = (signal: RailSignal): ReactElement => { + const size = 14; + + return ( + + 0 + ? holeMask(chipHole(false, size)) + : undefined + } + > + + + {signal.claimable > 0 && ( + + + + )} + + ); +}; + +// V7 — adaptive: one reward is a clean gem, several earn the counted chip. The +// common case stays quiet and only the rarer case pays for the numeral. +const AdaptiveMark = (signal: RailSignal): ReactElement => + signal.claimable > 1 ? ChipInBite(signal, true) : GemInNotch(signal); + +// V8 — no added object at all: the badge's corner is folded away and the reward +// colour shows underneath, like a turned page. +const FOLD = 12; + +const FoldedCorner = (signal: RailSignal): ReactElement => ( + + 0 ? holeMask(cornerHole(FOLD + 2)) : undefined} + > + + + {signal.claimable > 0 && ( + + )} + +); + +// V9 — leave the glyph completely alone and mark the LABEL instead. The streak +// keeps every pixel it has; the reward becomes a word-level cue. +const LabelToken = (signal: RailSignal): ReactElement => ( + + + +); + +const LabelWithToken = ({ signal }: { signal: RailSignal }): ReactElement => ( + + {signal.days} + {signal.claimable > 0 && ( + + )} + +); + // ─── C · the moment ────────────────────────────────────────────────────────── type Arrival = 'pop' | 'flip' | 'drop'; @@ -419,6 +510,214 @@ export default meta; type Story = StoryObj; +// ─── the ten variations ────────────────────────────────────────────────────── + +interface Variation { + code: string; + title: string; + note: string; + glyph: (signal: RailSignal) => ReactElement; + label?: (signal: RailSignal) => ReactNode; +} + +const VARIATIONS: Variation[] = [ + { + code: 'V1', + title: 'Gem · top-right', + note: 'A bead nested into a hole cut through the badge at 45°. The quietest mark that still reads.', + glyph: (signal) => GemInNotch(signal), + }, + { + code: 'V2', + title: 'Gem · bottom-right', + note: 'Same bead, moved to 135° — the furthest point from the Activity bubble sitting above it.', + glyph: (signal) => GemInNotch(signal, { angle: 135 }), + }, + { + code: 'V3', + title: 'Gem cluster', + note: 'One bead per reward, capped at 3. Countable without a numeral — but at 3 it starts drifting toward an arc.', + glyph: GemCluster, + }, + { + code: 'V4', + title: 'Chip · squircle', + note: 'Exact count in a rounded square, matching the Activity bell’s badge language.', + glyph: (signal) => ChipInBite(signal, true), + }, + { + code: 'V5', + title: 'Chip · circle', + note: 'Same count, round — matching the streak badge it sits in rather than the bell.', + glyph: (signal) => ChipInBite(signal), + }, + { + code: 'V6', + title: 'Chip · gift icon', + note: 'Says “claim”, not “how many”. Drops the numeral, so it never has to cope with 9+.', + glyph: IconChip, + }, + { + code: 'V7', + title: 'Adaptive', + note: 'A gem for one reward, the counted chip for several. The common case stays quiet.', + glyph: AdaptiveMark, + }, + { + code: 'V8', + title: 'Folded corner', + note: 'No added object — the badge’s corner folds away and the reward colour shows underneath.', + glyph: FoldedCorner, + }, + { + code: 'V9', + title: 'Label dot', + note: 'Glyph untouched. The mark moves to the label, so the streak keeps every pixel it owns.', + glyph: LabelToken, + label: (signal) => , + }, + { + code: 'V10', + title: 'Arrival pop', + note: 'The moment, not a resting state: fires once when a reward lands, then settles to V4.', + glyph: (signal) => ChipInBite(signal, true), + }, +]; + +const VariationCell = ({ + variation, + signal, +}: { + variation: Variation; + signal: RailSignal; +}): ReactElement => ( + + {variation.code === 'V10' ? ( + + ) : ( + + + + )} + +); + +// THE PAGE TO REVIEW. Ten variations, all obeying the round-2 rule: the streak +// keeps its ring, and quests get a discrete token or a moment — never an arc. +export const TenVariations: Story = { + render: () => ( +
+ + Every option here leaves the streak's ring completely alone. None of + them adds a second progress track, an arc or a fill — that was the thing + that made round 1 read as a broken control. What differs is the SHAPE of + the token, where it sits, and whether it carries a number at all. +
+
+ All ten are cut INTO the badge rather than laid on top of it, so none of + them needs a separator colour — which matters because a separator only + matches the tab background at rest and breaks the moment you hover. +
+ +
+ {VARIATIONS.map((variation) => ( + + ))} +
+ + + Where the counted options earn their keep — and where the countless ones + stop distinguishing 2 from 7. + +
+ {VARIATIONS.map((variation) => ( + + ))} +
+ + + Every variation must be indistinguishable from today's tab when + there is nothing to claim. + +
+ {VARIATIONS.filter((v) => v.code !== 'V10').map((variation) => ( + + ))} +
+
+ ), +}; + +// The ten at 3x, where the nesting detail actually becomes judgeable. +export const TenVariationsZoomed: Story = { + render: () => ( +
+ +
+ {VARIATIONS.map((variation) => ( +
+ + + {variation.glyph(DEFAULT_SIGNAL)} + + + + {variation.code} {variation.title} + +
+ ))} +
+
+ ), +}; + +// The ten in the actual rail column, under the Activity bell that owns the other +// purple mark. This is where a badly-placed token gives itself away. +export const TenVariationsInRail: Story = { + render: () => ( +
+ + Two purple marks stacked in an 80px column is the real risk. V2 and V8 + put the most distance between them; V9 removes the second mark from the + glyph entirely. + +
+ {VARIATIONS.map((variation) => ( +
+ + + + + {variation.code} {variation.title} + +
+ ))} +
+
+ ), +}; + // ─── stories ───────────────────────────────────────────────────────────────── export const GemRefinements: Story = { From 4276c68e39dc30c81b40ffdc0e7fc2da6d0c37fb Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 30 Jul 2026 17:17:55 +0300 Subject: [PATCH 05/17] =?UTF-8?q?fix(layout-v2):=20address=20PR=20review?= =?UTF-8?q?=20=E2=80=94=20extension=20logo=20click,=20hover=20scope,=20dra?= =?UTF-8?q?g=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blocking: merging the logo into Home ran two conflicting feed writes on the extension new tab. The extension's onLogoClick defaults the event and switches the feed in place (My Feed), then onHomeClick immediately overwrote it with the default feed, so the brand mark stopped landing on My Feed. Home now yields when the event was already defaulted, leaving one owner for the destination. On the webapp onLogoClick is undefined, so Home still owns the click. Branding: the logo/home crossfade used an unnamed `group`, which Tailwind also matches against the sidebar-wide group on SidebarAside — the brand mark vanished whenever the pointer was anywhere in the rail. Scoped to `group/home`, matching how the hover colour already behaved. Rail order: a rejected persist left railOrderOverride stranded for the session (the clearing effect only fires when stored matches the override, which after a rejection it never does), so the user saw a saved-looking order that was gone on reload. The rejection path now drops the override and logs. Drag click guard: only a click landing on a rail tab consumed it, so drops over the shortcuts dock, outside the rail or on Escape left it armed for the full fallback window and ate the next real click. It now also disarms on the next pointerdown. Fold budget: the row-height arithmetic mirroring class strings is now named constants co-located in one block, each naming the class it mirrors. a11y: the DragOverlay ghost duplicated `#sidebar-*` ids and a second role="tab"/aria-selected outside the tablist for the length of a drag; it is now aria-hidden with its ids stripped. (New post's role="button" inside the tablist needs an ARIA pattern change and is tracked separately.) SidebarEntityIcon: dock avatars were 24px next to 26px fallback glyphs on the same row; both now use the rail glyph size. ProfilePanelSection: removing UpgradeToPlus also removed the only UpgradeSubscription/profile_dropdown attribution on this surface. The surviving "Get API Access" row now carries that log event and the anon showLogin prompt. Storybook: adds V11, the Activity bell's badge reused verbatim on the streak tab (same Bubble, bold footnote numeral, left-edge anchor, 20+ cap). Co-Authored-By: Claude Fable 5 --- .../components/sidebar/SidebarDesktopV2.tsx | 99 ++++++++++++++++--- .../components/sidebar/SidebarEntityIcon.tsx | 9 +- .../sidebar/sections/ProfilePanelSection.tsx | 22 ++++- .../components/RailQuestMark.stories.tsx | 46 ++++++++- 4 files changed, 154 insertions(+), 22 deletions(-) diff --git a/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx b/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx index e49950f680d..e71067a3481 100644 --- a/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx +++ b/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx @@ -210,6 +210,16 @@ const sidebarCategories: SidebarCategoryConfig[] = [ // Fallback release for the post-drag click guard — see releaseRailDragClickGuard. const RAIL_DRAG_CLICK_GUARD_FALLBACK_MS = 500; +// The overflow budget below has to know how tall each rail row is, which means +// restating class strings as numbers. Keep every one of them here, each naming +// the exact class it mirrors, so editing a class has one obvious place to +// follow — a silent desync only shows up as one tab too many or too few folding +// into "More" at a particular viewport height, which nothing in CI can catch. +const RAIL_ROW_GAP_PX = 4; // `gap-1` on the rail column +const SHORTCUT_ROW_PX = 40; // shortcut dot row height +const CREATE_BUTTON_PX = 36; // New post `!size-9` +const CREATE_MARGIN_Y_PX = 16; // New post `my-2` +const SEP_PX = 1 + 24; // framing separator `h-px` + its `my-3` // 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. @@ -843,14 +853,15 @@ export const SidebarDesktopV2 = ({ const { resolved: shortcutItems } = useSidebarShortcutItems(); 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 = 25; // framing separator (1px) + its my-3 margins + const iconRowPx = SHORTCUT_ROW_PX + RAIL_ROW_GAP_PX; + const tabRowPx = (isCompact ? 44 : 56) + RAIL_ROW_GAP_PX; 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 createRowPx = isLoggedIn + ? CREATE_BUTTON_PX + CREATE_MARGIN_Y_PX + RAIL_ROW_GAP_PX + : 0; const availableHeight = regionHeight - createRowPx; const minDockPx = shortcutCount > 0 ? SEP_PX + SHORTCUTS_MIN_INLINE * iconRowPx : 0; @@ -929,11 +940,36 @@ export const SidebarDesktopV2 = ({ railDragClickGuardRef.current = false; return true; }, []); + const railDragClickGuardCleanupRef = useRef<() => void>(); const releaseRailDragClickGuard = useCallback(() => { - setTimeout(() => { + railDragClickGuardCleanupRef.current?.(); + const disarm = () => { railDragClickGuardRef.current = false; - }, RAIL_DRAG_CLICK_GUARD_FALLBACK_MS); + }; + // The stray post-drop click always arrives before the user can press again, + // so the next pointerdown means the guard has either done its job or was + // never going to — drops that end over the shortcuts dock, outside the rail + // or on Escape produce no click at all, and without this the guard stayed + // armed for the full fallback window and ate the user's next real click. + let timer: ReturnType; + const controller = new AbortController(); + const cleanup = () => { + clearTimeout(timer); + controller.abort(); + railDragClickGuardCleanupRef.current = undefined; + }; + const finish = () => { + cleanup(); + disarm(); + }; + globalThis.window?.addEventListener('pointerdown', finish, { + signal: controller.signal, + once: true, + }); + timer = setTimeout(finish, RAIL_DRAG_CLICK_GUARD_FALLBACK_MS); + railDragClickGuardCleanupRef.current = cleanup; }, []); + useEffect(() => () => railDragClickGuardCleanupRef.current?.(), []); // 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); @@ -987,7 +1023,16 @@ export const SidebarDesktopV2 = ({ setRailOrderOverride(null); return; } - setStoredRailOrder(liveOrder).catch(() => undefined); + // If the write fails, drop the override so the rail falls back to what is + // actually stored. Keeping it would strand the user on an order that only + // exists in memory: the clearing effect fires when the stored order matches + // the override, which after a rejection it never will, so they would see a + // saved-looking order that is gone on reload with no signal anywhere. + setStoredRailOrder(liveOrder).catch((error) => { + setRailOrderOverride(null); + // eslint-disable-next-line no-console + console.error('Failed to persist sidebar rail order', error); + }); }, [ releaseRailDragClickGuard, setStoredRailOrder, @@ -1724,6 +1769,12 @@ export const SidebarDesktopV2 = ({ // 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. + // Runs once per ghost mount (the overlay only re-mounts when the dragged item + // changes; dnd-kit moves it by transforming the wrapper, not by re-rendering). + const stripRailGhostIds = useCallback((node: HTMLDivElement | null) => { + node?.querySelectorAll('[id]').forEach((el) => el.removeAttribute('id')); + }, []); + const renderRailTab = (id: RailItemId): ReactElement => { if (id === RAIL_CREATE_ID) { return renderCreateButton(); @@ -1945,15 +1996,26 @@ export const SidebarDesktopV2 = ({ // The brand mark doubles as the Home button: the daily.dev // logo at rest, crossfading into the home glyph on // hover/focus so the destination is obvious pre-click. - // `group` (unnamed) drives the logo→home crossfade, which - // the sidebar-wide group on SidebarAside also triggers, so - // the glyph reveals on rail hover. `group/home` is scoped to - // this button alone, for the hover colour below. - className="focus-outline group/home group flex size-10 items-center justify-center rounded-12 text-text-primary transition-[background-color,transform] duration-150 ease-out hover:bg-surface-hover active:scale-90 motion-reduce:transition-none" + // Everything below is scoped to `group/home` — this button + // alone. An unnamed `group` here would also match the + // sidebar-wide group on SidebarAside, so the logo would + // vanish whenever the pointer was anywhere in the rail + // (reordering tabs, using the dock, opening settings). + className="focus-outline group/home flex size-10 items-center justify-center rounded-12 text-text-primary transition-[background-color,transform] duration-150 ease-out hover:bg-surface-hover active:scale-90 motion-reduce:transition-none" onClick={(event) => { // Keep the removed logo link's click contract — the // extension resets its feed/search state there. onLogoClick?.(event); + // ONE owner for the destination. The extension's + // `onLogoClick` defaults the event and switches the feed + // in place (to My Feed on the new tab); running Home's + // handler afterwards would immediately overwrite that + // with the default feed, so the brand mark would stop + // landing on My Feed. On the webapp `onLogoClick` is + // undefined, so Home still owns the click. + if (event.defaultPrevented) { + return; + } onHomeClick(); }} > @@ -1961,13 +2023,13 @@ export const SidebarDesktopV2 = ({ ); } @@ -52,7 +56,8 @@ export const SidebarEntityIcon = ({ type={ImageType.Squad} alt="" aria-hidden - className="size-6 rounded-8 object-cover" + // Same rail glyph size as the fallbacks below. + className="size-[1.625rem] rounded-8 object-cover" /> ) : ( diff --git a/packages/shared/src/components/sidebar/sections/ProfilePanelSection.tsx b/packages/shared/src/components/sidebar/sections/ProfilePanelSection.tsx index cf6556d1c44..0fa7fd1da0a 100644 --- a/packages/shared/src/components/sidebar/sections/ProfilePanelSection.tsx +++ b/packages/shared/src/components/sidebar/sections/ProfilePanelSection.tsx @@ -17,6 +17,8 @@ import { import type { SidebarSectionProps } from './common'; import { OtherFeedPage } from '../../../lib/query'; import { plusUrl, settingsUrl, webappUrl } from '../../../lib/constants'; +import { LogEvent, TargetId } from '../../../lib/log'; +import { AuthTriggers } from '../../../lib/auth'; import { useAuthContext } from '../../../contexts/AuthContext'; import { usePlusSubscription } from '../../../hooks'; import Link from '../../utilities/Link'; @@ -37,8 +39,8 @@ export const ProfilePanelSection = ({ onNavTabClick, ...defaultRenderSectionProps }: SidebarSectionProps): ReactElement | null => { - const { user } = useAuthContext(); - const { isPlus } = usePlusSubscription(); + const { user, isLoggedIn, showLogin } = useAuthContext(); + const { isPlus, logSubscriptionEvent } = usePlusSubscription(); const router = useRouter(); // The header links to your profile, so highlight it as the active row (same @@ -94,6 +96,20 @@ export const ProfilePanelSection = ({ path: plusUrl, isForcedLink: true, requiresLogin: true, + // This row is the only upgrade entry point left on this panel, so it + // has to carry what the UpgradeToPlus button used to: the + // `profile_dropdown` attribution, and the anon auth prompt instead of + // dropping a logged-out user on the pricing page. + action: () => { + if (!isLoggedIn) { + showLogin({ trigger: AuthTriggers.Plus }); + return; + } + logSubscriptionEvent({ + event_name: LogEvent.UpgradeSubscription, + target_id: TargetId.ProfileDropdown, + }); + }, color: 'text-action-plus-default', itemClassName: 'bg-action-plus-float/50 hover:bg-action-plus-float', disableDefaultBackground: true, @@ -102,7 +118,7 @@ export const ProfilePanelSection = ({ ), }, ].filter(Boolean) as SidebarMenuItem[], - [onNavTabClick, isPlus], + [onNavTabClick, isPlus, isLoggedIn, showLogin, logSubscriptionEvent], ); if (!user) { diff --git a/packages/storybook/stories/components/RailQuestMark.stories.tsx b/packages/storybook/stories/components/RailQuestMark.stories.tsx index c2944b50357..32fe74a6a4d 100644 --- a/packages/storybook/stories/components/RailQuestMark.stories.tsx +++ b/packages/storybook/stories/components/RailQuestMark.stories.tsx @@ -5,6 +5,7 @@ import classNames from 'classnames'; import { StreakBadge } from '@dailydotdev/shared/src/components/sidebar/StreakBadge'; import { GiftIcon } from '@dailydotdev/shared/src/components/icons'; import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { Bubble } from '@dailydotdev/shared/src/components/tooltips/utils'; import { DEFAULT_SIGNAL, GLYPH, @@ -510,6 +511,39 @@ export default meta; type Story = StoryObj; +// V11 — the Activity bell's badge, reused verbatim on the streak tab. +// +// This is the shared `Bubble` (20px box, 8px radius, cabbage fill, white text) +// with the bell's two additions: `!font-bold !typo-footnote tabular-nums` for a +// legible-but-not-shouting numeral that doesn't reflow as the count ticks, and +// the LEFT-edge anchor at `-top-2 left-2.5` so multi-digit counts grow rightward +// and stay inside the rail instead of creeping across the glyph. Counts cap at +// "20+", matching `getUnreadText`. +// +// Note what it costs: unlike every other variation here it sits OUTSIDE the +// 26px glyph box, so it is the one option that can collide with the bell's own +// bubble in the column above it. See "Ten Variations In Rail". +const NOTIFICATION_BUBBLE_CLASS = '!font-bold !typo-footnote tabular-nums'; +const MAX_QUEST_BUBBLE = 20; + +const NotificationStyleBubble = (signal: RailSignal): ReactElement => ( + + + {signal.claimable > 0 && ( + + {signal.claimable > MAX_QUEST_BUBBLE + ? `${MAX_QUEST_BUBBLE}+` + : signal.claimable} + + )} + +); + // ─── the ten variations ────────────────────────────────────────────────────── interface Variation { @@ -582,6 +616,12 @@ const VARIATIONS: Variation[] = [ note: 'The moment, not a resting state: fires once when a reward lands, then settles to V4.', glyph: (signal) => ChipInBite(signal, true), }, + { + code: 'V11', + title: 'Notification bubble', + note: 'The Activity bell’s badge verbatim — same Bubble, same bold footnote numeral, same left-edge anchor, same 20+ cap. The only option that sits outside the glyph box.', + glyph: NotificationStyleBubble, + }, ]; const VariationCell = ({ @@ -612,7 +652,7 @@ export const TenVariations: Story = {
Every option here leaves the streak's ring completely alone. None of them adds a second progress track, an arc or a fill — that was the thing @@ -670,7 +710,7 @@ export const TenVariations: Story = { export const TenVariationsZoomed: Story = { render: () => (
- +
{VARIATIONS.map((variation) => (
@@ -694,7 +734,7 @@ export const TenVariationsZoomed: Story = { export const TenVariationsInRail: Story = { render: () => (
- + Two purple marks stacked in an 80px column is the real risk. V2 and V8 put the most distance between them; V9 removes the second mark from the glyph entirely. From 909acae0de698db3a0a119a80cafb830cd48cb3a Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 30 Jul 2026 18:10:50 +0300 Subject: [PATCH 06/17] fix(layout-v2): align rail count badges, trim shortcut catalog, fix Cores icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rail badges: the Activity bell and the gamification tab disagreed on all three of containing block, anchor edge and numeral size — the bell's badge lived in the 26px glyph box anchored `-top-2 left-2.5`, while the quest count was a child of the whole 68px button anchored `right-1 top-1` at Bubble's default type, so it resolved against the tab's full height (label included) and sat higher and further right. Both now use one shared `railCountBubbleClass` inside the glyph box, measured identical at dx +12 / dy -8, 20x20. Nudged 2px right (left-2.5 -> left-3); "20+" still clears the rail's width. Shortcut catalog: dropped Explore (already a permanent rail tab) and Jobs. Already-pinned copies resolve to null and simply drop out of the dock. Cores icon: it is the only raster in the icon set, so it inherits preflight's `img { max-width: 100% }`. In the customize dropdown's 24px icon slot that capped its width to 24px while the size class held the height at 26px, and the default `object-fit: fill` stretched the art — measured 24x26. SVG glyphs merely overflowed, which is why only Cores looked wrong. The dropdown's two icon slots now match RAIL_ICON_SIZE, and CoreIcon carries `max-w-none object-contain` so it cannot distort anywhere else either. Cores shortcut also inverts `secondary`: its secondary art is the greyed core and its primary is full colour, which is right for a balance or a price but backwards for a rail glyph, which should be muted at rest and lit when active. Co-Authored-By: Claude Fable 5 --- .../src/components/icons/Core/index.tsx | 32 +++++++++++++---- .../notifications/NotificationsBell.tsx | 26 ++++---------- .../components/sidebar/SidebarDesktopV2.tsx | 18 ++++++---- .../sidebar/SidebarShortcutsDock.tsx | 35 +++++++++---------- .../shared/src/components/sidebar/common.tsx | 17 +++++++++ .../components/RailQuestMark.stories.tsx | 20 ++++------- .../components/railQuestMark.mocks.tsx | 3 +- 7 files changed, 86 insertions(+), 65 deletions(-) diff --git a/packages/shared/src/components/icons/Core/index.tsx b/packages/shared/src/components/icons/Core/index.tsx index edb30de3d3f..47e1b3063f5 100644 --- a/packages/shared/src/components/icons/Core/index.tsx +++ b/packages/shared/src/components/icons/Core/index.tsx @@ -1,17 +1,37 @@ import type { ReactElement } from 'react'; import React from 'react'; +import classNames from 'classnames'; import type { IconItemType, IconProps } from '../../Icon'; import Icon from '../../Icon'; import { Image } from '../../image/Image'; import { coreImage, disabledCoreImage } from '../../../lib/image'; -const IconPrimary: IconItemType = (props) => { - return Core; -}; +// Unlike every other icon in the set this one is a raster , so it inherits +// preflight's `img { max-width: 100% }`. In any box narrower than the requested +// icon size that caps the width while the size class keeps the height, and the +// default `object-fit: fill` stretches the art to match. `max-w-none` opts out +// of the cap and `object-contain` guarantees the aspect ratio regardless. +const coreImageClass = 'max-w-none object-contain'; -const IconSecondary: IconItemType = (props) => { - return Core; -}; +const IconPrimary: IconItemType = ({ className, ...props }) => ( + Core +); + +const IconSecondary: IconItemType = ({ className, ...props }) => ( + Core +); export const CoreIcon = (props: IconProps): ReactElement => ( diff --git a/packages/shared/src/components/notifications/NotificationsBell.tsx b/packages/shared/src/components/notifications/NotificationsBell.tsx index 8187f4d8fee..7d0868b9223 100644 --- a/packages/shared/src/components/notifications/NotificationsBell.tsx +++ b/packages/shared/src/components/notifications/NotificationsBell.tsx @@ -15,15 +15,14 @@ import { Tooltip } from '../tooltip/Tooltip'; import Link from '../utilities/Link'; import { RAIL_ICON_SIZE, + railCountBubbleClass, 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. +// The header bell's numeral. The rail variant uses the shared +// `railCountBubbleClass` instead, so it stays identical to the gamification +// tab's badge. `Bubble` already supplies the 20px box and the 8px radius. const notificationBubbleClass = '!font-bold !typo-footnote tabular-nums'; function NotificationsBell({ @@ -99,20 +98,9 @@ function NotificationsBell({ className="pointer-events-none" /> {hasNotification && ( - // The shared square Bubble is the design-system badge (see the - // Bell Storybook "Count bubble" reference), offset so it notches - // the bell's corner without blanketing the glyph. - + // Shared with the gamification tab's count so every rail badge + // sits in exactly the same place (see railCountBubbleClass). + {getUnreadText(unreadCount)} )} diff --git a/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx b/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx index e71067a3481..1b0d85946c6 100644 --- a/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx +++ b/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx @@ -31,6 +31,7 @@ import { ListIcon, Nav, RAIL_ICON_SIZE, + railCountBubbleClass, railGlyphBoxClass, railTabClass, railTabLabelClass, @@ -1643,15 +1644,18 @@ export const SidebarDesktopV2 = ({ > {iconNode} + {category.id === SidebarCategory.GameCenter && showQuestBadge && ( + // Inside the glyph box, not the button, and on the shared recipe — + // so this lands in exactly the same spot as the Activity bell's + // count. Anchored to the button it resolved against the tab's full + // height (label included) and sat visibly higher and further right + // than the bell's, at a different numeral size. + + {claimableQuestCount} + + )} {!isCompact && {labelText}} - {category.id === SidebarCategory.GameCenter && showQuestBadge && ( - // Pin the badge to the button's top-right corner (not the icon's) - // so the quest level ring + number stay fully visible. - - {claimableQuestCount} - - )} ); diff --git a/packages/shared/src/components/sidebar/SidebarShortcutsDock.tsx b/packages/shared/src/components/sidebar/SidebarShortcutsDock.tsx index 233ab5dfa43..5640108cd28 100644 --- a/packages/shared/src/components/sidebar/SidebarShortcutsDock.tsx +++ b/packages/shared/src/components/sidebar/SidebarShortcutsDock.tsx @@ -39,13 +39,11 @@ import { AnalyticsIcon, BookmarkIcon, BriefIcon, - CompassIcon, CoreIcon, DiscussIcon, EarthIcon, EyeIcon, HashtagIcon, - JobIcon, MegaphoneIcon, MenuIcon, SquadIcon, @@ -91,14 +89,6 @@ interface ShortcutDef { // The catalog of pages a user can pin from the tray. Dragging an arbitrary // panel row in also resolves to one of these (by path) when it matches. export const SHORTCUT_CATALOG: ShortcutDef[] = [ - { - id: 'explore', - label: 'Explore', - path: `${webappUrl}posts`, - icon: (a) => ( - - ), - }, { id: 'tags', label: 'Tags', @@ -163,12 +153,6 @@ export const SHORTCUT_CATALOG: ShortcutDef[] = [ ), }, - { - id: 'jobs', - label: 'Jobs', - path: `${webappUrl}jobs`, - icon: (a) => , - }, { id: 'briefing', label: 'Briefing', @@ -178,8 +162,14 @@ export const SHORTCUT_CATALOG: ShortcutDef[] = [ { id: 'cores', label: 'Cores', + // The core wallet. path: walletUrl, - icon: (a) => , + // `secondary` is INVERTED here on purpose. CoreIcon's secondary art is the + // greyed-out core and its primary art is the full-colour one, which is the + // right default everywhere else it appears (a balance, a price) — but as a + // rail shortcut it has to behave like every neighbouring glyph: muted at + // rest, lit when it is the page you are on. + icon: (a) => , }, ]; @@ -349,7 +339,7 @@ const TrayItem = ({ isDragging && 'opacity-40', )} > - + {def.icon(added)} { onClick={() => setTrayOpen(false)} className="flex min-w-0 flex-1 items-center gap-2 rounded-8 px-1 py-1.5 text-text-secondary" > - + {/* 26px, matching RAIL_ICON_SIZE. At size-6 the + box was 2px smaller than the glyph, and + preflight's `img { max-width: 100% }` then + capped the Cores to 24px wide while its + height stayed 26px — a visibly stretched icon. + SVG glyphs just overflowed, so only Cores + showed it. */} + {shortcut.icon(false)} ( {signal.claimable > 0 && ( - + // The real shared recipe, imported rather than copied, so this preview + // cannot drift from what the rail actually renders. + {signal.claimable > MAX_QUEST_BUBBLE ? `${MAX_QUEST_BUBBLE}+` : signal.claimable} diff --git a/packages/storybook/stories/components/railQuestMark.mocks.tsx b/packages/storybook/stories/components/railQuestMark.mocks.tsx index 328a7ecdb8a..08cbe79db0e 100644 --- a/packages/storybook/stories/components/railQuestMark.mocks.tsx +++ b/packages/storybook/stories/components/railQuestMark.mocks.tsx @@ -2,6 +2,7 @@ import type { ReactElement, ReactNode } from 'react'; import React from 'react'; import classNames from 'classnames'; import { + railCountBubbleClass, railTabClass, railTabLabelClass, RAIL_ICON_SIZE, @@ -256,7 +257,7 @@ export const MiniRail = ({ glyph={ <> - 3 + 3 } /> From 6d546f127260434513a00af1c8ff900ce60440a2 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 30 Jul 2026 18:45:51 +0300 Subject: [PATCH 07/17] fix(layout-v2): nudge rail count badge right and size it down +4px to the right (left-3 -> left-4, measured dx 12 -> 16 from the glyph box), an 18px box instead of 20px, and typo-caption1 instead of footnote (12px from 13px). Radius stays Bubble's own rounded-8, which on the smaller box reads slightly rounder. Both the Activity bell and the gamification tab read from this one constant, so they move together. Worst case still clears the rail: "20+" measures 28.5px wide with 8.45px to spare against the rail's right edge. Co-Authored-By: Claude Fable 5 --- .../shared/src/components/sidebar/common.tsx | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/packages/shared/src/components/sidebar/common.tsx b/packages/shared/src/components/sidebar/common.tsx index 80292e133db..1eb1f65a421 100644 --- a/packages/shared/src/components/sidebar/common.tsx +++ b/packages/shared/src/components/sidebar/common.tsx @@ -111,14 +111,21 @@ export const railGlyphBoxClass = // below resolve against the tab's full height (label included) and the badge // lands somewhere else entirely. // -// Anchored by its LEFT edge rather than its right: multi-digit counts then grow +// Anchored by its LEFT edge rather than its right, so multi-digit counts grow // rightward into the tab's own padding instead of creeping left across the -// glyph, and even "20+" stays inside the rail's width. `!typo-footnote` is one -// step down from Bubble's own size, so the number stays legible without -// overpowering the 26px glyph it notches, and `tabular-nums` stops it reflowing -// as the count ticks. +// glyph — "20+" still clears the rail's width at this offset. +// +// Sized DOWN from Bubble's defaults: an 18px box (from 20px) and typo-caption1 +// (from subhead, via footnote), so the count notches the 26px glyph rather than +// competing with it. `tabular-nums` stops it reflowing as the number ticks. +// Radius stays Bubble's own `rounded-8`, which on the smaller box reads slightly +// rounder. +// +// The `!` overrides are load-bearing: Bubble sets its own min box and type, and +// same-specificity utilities resolve by stylesheet order, not by the order +// written here. export const railCountBubbleClass = - 'pointer-events-none -top-2 left-3 px-1 !font-bold !typo-footnote tabular-nums'; + 'pointer-events-none -top-2 left-4 px-1 !min-h-[1.125rem] !min-w-[1.125rem] !font-bold !typo-caption1 tabular-nums'; // Shared drag visuals for the v2 sidebar's two drag systems — the rail tabs and // the shortcuts dock — so a lifted item looks and feels identical in both. // A translucent glass chip: blurred surface, subtle border, elevated. Callers From 46f43903a3effb7a50ae39a5b33ca5f854db8aa6 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 30 Jul 2026 19:48:25 +0300 Subject: [PATCH 08/17] fix(layout-v2): nudge rail count badge 4px right and 4px down left-4 -> left-5 and -top-2 -> -top-1; measured dx 16 -> 20, dy -8 -> -4 from the glyph box. Both rail badges read from this constant so they move together. Worst case is getting tight: "20+" is 28.5px wide and now clears the rail's right edge by 4.45px (was 8.45px). Co-Authored-By: Claude Fable 5 --- packages/shared/src/components/sidebar/common.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/src/components/sidebar/common.tsx b/packages/shared/src/components/sidebar/common.tsx index 1eb1f65a421..2a317882e2b 100644 --- a/packages/shared/src/components/sidebar/common.tsx +++ b/packages/shared/src/components/sidebar/common.tsx @@ -125,7 +125,7 @@ export const railGlyphBoxClass = // same-specificity utilities resolve by stylesheet order, not by the order // written here. export const railCountBubbleClass = - 'pointer-events-none -top-2 left-4 px-1 !min-h-[1.125rem] !min-w-[1.125rem] !font-bold !typo-caption1 tabular-nums'; + 'pointer-events-none -top-1 left-5 px-1 !min-h-[1.125rem] !min-w-[1.125rem] !font-bold !typo-caption1 tabular-nums'; // Shared drag visuals for the v2 sidebar's two drag systems — the rail tabs and // the shortcuts dock — so a lifted item looks and feels identical in both. // A translucent glass chip: blurred surface, subtle border, elevated. Callers From ec9f9a7d45f86f73e410467d8b7bc044e4e32101 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 30 Jul 2026 19:54:13 +0300 Subject: [PATCH 09/17] fix(layout-v2): drop indicator for New post, stronger drag glass, readable streak calendar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New post now leaves a landing skeleton like every other rail item — it was the one thing telling you where the drop would land, and suppressing it meant dragging New post gave no target feedback. It still LIFTS bare (no chip around the ghost), which was the original ask; that is the ghost, not the slot. The `bareDrag` prop is gone, since the ghost already keys off RAIL_CREATE_ID. Drag glass: ghost fill drops 60% -> 30% so the backdrop blur actually reads and you can see the rail through the chip, while its border steps up to `-secondary` (2x the tertiary token) and the shadow deepens — so the chip's edge around the dragged glyph is MORE defined even as the fill gets more see-through. Blur goes xl -> 2xl. The landing slot moves from `surface-float` (8%) to `surface-active` (16%), which is what makes the drop position legible. Streak calendar: untouched and weekend days were drawn in `border-subtlest-tertiary` — 20% of an already-subtle border — and were close to invisible. They are data, not chrome, so they now use `text-quaternary`, the same family the quest list uses for secondary text: same hue, 20% -> 64% alpha, and correctly inverted per theme (verified light and dark) because the token is redefined per theme rather than hardcoded. Co-Authored-By: Claude Fable 5 --- .../components/sidebar/SidebarDesktopV2.tsx | 13 +++++-------- .../shared/src/components/sidebar/common.tsx | 18 ++++++++++++------ .../streak/popup/StreakMonthCalendar.tsx | 9 +++++++-- 3 files changed, 24 insertions(+), 16 deletions(-) diff --git a/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx b/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx index 1b0d85946c6..23935c959e7 100644 --- a/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx +++ b/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx @@ -255,16 +255,12 @@ const SortableRailTab = ({ id, children, consumeClickGuard, - bareDrag = false, }: { id: string; children: ReactNode; // Armed while a rail drag is live — see `releaseRailDragClickGuard`. Returns // whether the guard was armed and disarms it. consumeClickGuard: () => boolean; - // New post is a filled chip, not a tab with a hover surface, so it drags as - // just its icon: no landing skeleton behind it and no chip around the ghost. - bareDrag?: boolean; }): ReactElement => { const { setNodeRef, listeners, transform, transition, isDragging } = useSortable({ id }); @@ -303,9 +299,11 @@ const SortableRailTab = ({ // pill (an absolute z-0 indicator behind them in the tablist). '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 && !bareDrag && sidebarDragSlotClass, + // own content faded out rather than removed so the slot keeps its exact + // height. EVERY item gets one, New post included — it is the only thing + // telling you where the drop will land. (New post still lifts bare, but + // that is about the ghost, not the slot it leaves behind.) + isDragging && sidebarDragSlotClass, isDragging ? '[&>*]:opacity-0' : 'cursor-grab', )} > @@ -2155,7 +2153,6 @@ export const SidebarDesktopV2 = ({ key={id} id={id} consumeClickGuard={consumeRailDragClickGuard} - bareDrag={id === RAIL_CREATE_ID} > {renderRailTab(id)} diff --git a/packages/shared/src/components/sidebar/common.tsx b/packages/shared/src/components/sidebar/common.tsx index 2a317882e2b..d578f10d9db 100644 --- a/packages/shared/src/components/sidebar/common.tsx +++ b/packages/shared/src/components/sidebar/common.tsx @@ -128,14 +128,20 @@ export const railCountBubbleClass = 'pointer-events-none -top-1 left-5 px-1 !min-h-[1.125rem] !min-w-[1.125rem] !font-bold !typo-caption1 tabular-nums'; // Shared drag visuals for the v2 sidebar's two drag systems — the rail tabs and // the shortcuts dock — so a lifted item looks and feels identical in both. -// A translucent glass chip: blurred surface, subtle border, elevated. Callers -// add the lift (`scale-110`) and their own transition. +// +// A real glass chip: the fill is deliberately light (30%) so the blur behind it +// does the work and you can see the rail through the chip, while the border +// steps up to `-secondary` (2x the tertiary token) and the shadow deepens, so +// the chip's edge round the dragged glyph is MORE defined even as its fill gets +// more transparent. Callers add the lift (`scale-110`) and their own transition. export const sidebarDragGhostClass = - 'bg-background-default/60 rounded-12 border border-border-subtlest-tertiary shadow-3 backdrop-blur-xl'; -// The parked slot a dragged item will land in (its content is faded out, so the -// slot keeps the item's exact height). + 'bg-background-default/30 rounded-12 border border-border-subtlest-secondary shadow-4 backdrop-blur-2xl'; +// The parked slot a dragged item will land in — the "it will go here" marker. +// Its content is faded out, so the slot keeps the item's exact height. +// `surface-active` (16%) rather than `surface-float` (8%): at 8% the landing +// position was barely readable against the rail. export const sidebarDragSlotClass = - 'rounded-12 bg-surface-float backdrop-blur-md'; + 'rounded-12 bg-surface-active backdrop-blur-md'; export const SidebarAside = classed( 'aside', 'flex flex-col z-sidebarOverlay laptop:z-sidebar laptop:-translate-x-0 left-0 bg-background-default border-r border-border-subtlest-tertiary transition-[width,transform] duration-300 ease-in-out group fixed top-0 h-full', diff --git a/packages/shared/src/components/streak/popup/StreakMonthCalendar.tsx b/packages/shared/src/components/streak/popup/StreakMonthCalendar.tsx index 2a513f1a46c..c8c2207442f 100644 --- a/packages/shared/src/components/streak/popup/StreakMonthCalendar.tsx +++ b/packages/shared/src/components/streak/popup/StreakMonthCalendar.tsx @@ -67,14 +67,19 @@ export const StreakMonthCalendar = ({ // default XSmall (20px) overflows the 16px cell and the read dot reads // visibly bigger than the others. Today gets a ring, weekends the // dashed pattern. - let stateClass = 'border-border-subtlest-tertiary'; + // `text-quaternary` (64% of salt, defined per theme) rather than + // `border-subtlest-tertiary` (20% of an already-subtle border): the + // untouched and weekend days are DATA, not chrome, and at the old value + // they were nearly invisible in both themes. Same colour family the + // quest list uses for its secondary text. + let stateClass = 'border-text-quaternary'; if (isToday || isRead) { // Today's ring is a separate overlay (below), so today drops its own // border too. stateClass = 'border-transparent'; } else if (isFreeze) { stateClass = - 'bg-[repeating-linear-gradient(135deg,currentColor_0_1.5px,transparent_1.5px_4px)] text-border-subtlest-tertiary'; + 'bg-[repeating-linear-gradient(135deg,currentColor_0_1.5px,transparent_1.5px_4px)] text-text-quaternary'; } const cell = (
Date: Thu, 30 Jul 2026 20:24:44 +0300 Subject: [PATCH 10/17] fix(layout-v2): one border treatment across the sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The colours were already meant to match, with one exception that turned out to be the one being asked about: the rail/panel divider used `border-subtlest-quaternary` (8%) while every other line in the layout — the aside's own edge, the rail's separators, the panel's HorizontalSeparators, the freeze row — uses `-tertiary` (20%). At 2.5x fainter it all but vanished in light mode. Now `-tertiary`, so every border in the layout is one colour. Inset: the freeze row's own top border ran full-bleed while the separator below it is inset 12px, so the row was framed by two visibly different rules. The v2 panel now draws matching `mx-3` separators above and below it, and the row takes a `hideTopBorder` prop to opt out of its own. The prop defaults to false, so ReadingStreakPopup — the other consumer — keeps its original full-bleed border. Co-Authored-By: Claude Fable 5 --- .../components/sidebar/SidebarDesktopV2.tsx | 7 ++++++- .../shared/src/components/sidebar/common.tsx | 20 ++++++++++++------- .../sidebar/sections/StreakQuestsSection.tsx | 7 ++++++- .../streak/popup/StreakFreezeRow.tsx | 20 +++++++++++++++++-- 4 files changed, 43 insertions(+), 11 deletions(-) diff --git a/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx b/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx index 23935c959e7..b85d8cabbf7 100644 --- a/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx +++ b/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx @@ -1966,7 +1966,12 @@ export const SidebarDesktopV2 = ({