diff --git a/packages/shared/src/components/MainFeedLayout.tsx b/packages/shared/src/components/MainFeedLayout.tsx index 6b863d8fc04..f1132426f89 100644 --- a/packages/shared/src/components/MainFeedLayout.tsx +++ b/packages/shared/src/components/MainFeedLayout.tsx @@ -17,7 +17,7 @@ import classNames from 'classnames'; import { useRouter } from 'next/router'; import type { FeedProps } from './Feed'; import Feed from './Feed'; -import { FeedPageLayoutMobile } from './utilities/common'; +import { FeedPageLayoutMobile, feedGutter } from './utilities/common'; import { ExploreChipsBar } from './feeds/ExploreChipsBar'; import { buildPersonalizedCategories } from './feeds/exploreCategories'; import { useFeeds } from '../hooks/feed/useFeeds'; @@ -671,7 +671,14 @@ export default function MainFeedLayout({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [sortingEnabled, selectedAlgo, loadedSettings, loadedAlgo]); - const disableTopPadding = isFinder || shouldUseListFeedLayout; + // Explore keeps the page's top padding in both layouts. It renders a + // breadcrumb and tab header above the feed, and zeroing the padding + // leaves that header jammed under the site header — while + // `shouldUseListFeedLayout` flips between first paint and mount + // (see `enableSsrSafeLayout`), so keying the spacing to it made the + // gap change size on navigation and settle differently on reload. + const disableTopPadding = + isFinder || (shouldUseListFeedLayout && !isAnyExplore); const onTabChange = useCallback( (clickedTab: ExploreTabs) => { if (clickedTab === ExploreTabs.BestOf && isExtension) { @@ -694,7 +701,13 @@ export default function MainFeedLayout({ ); } @@ -705,8 +718,10 @@ export default function MainFeedLayout({ setTab={onTabChange} showBreadcrumbs={false} className={{ - container: + container: classNames( 'sticky top-[4.5rem] z-header w-full border-b border-border-subtlest-tertiary bg-background-default', + feedGutter, + ), tabBarHeader: 'no-scrollbar overflow-x-auto', tabBarContainer: 'min-w-0 flex-1', }} @@ -835,9 +850,7 @@ export default function MainFeedLayout({ ) : undefined } - className={classNames( - shouldUseListFeedLayout && !isFinder && 'laptop:px-6', - )} + className={classNames(!isFinder && feedGutter)} /> ) )} diff --git a/packages/shared/src/components/sponsors/CodeRabbitLockup.tsx b/packages/shared/src/components/sponsors/CodeRabbitLockup.tsx new file mode 100644 index 00000000000..097509d960a --- /dev/null +++ b/packages/shared/src/components/sponsors/CodeRabbitLockup.tsx @@ -0,0 +1,65 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import type { LockupProps } from './SponsoredStrip'; + +/** + * CodeRabbit's lockup, inline so it can be drawn two ways. + * + * In colour the mark is orange with a white detail inside it and the + * wordmark takes `currentColor` — the shipped asset's wordmark is + * near-black (#171717) and disappears on the dark feed. + * + * As a silhouette the same two shapes become a single path under + * `evenodd`, so the detail is a hole rather than paint. That matters: + * the wall silhouettes through a CSS mask, which reads paint as + * opaque, so drawing the detail as a filled shape turned the rabbit + * into a flat blob. + * + * Drawing the rabbit alone and dropping the disc is the other option, + * and it is more legible at the 16-20px the wall gives a logo — but it + * is not their mark. Keep the disc; this is a paid slot. + * + * Paths are CodeRabbit's own artwork (viewBox 0 0 2152 314); only the + * fills and the knockout are ours. + */ +export const CodeRabbitLockup = ({ monochrome }: LockupProps): ReactElement => ( + + {monochrome ? ( + + ) : ( + <> + + + + )} + + + + + + + + + + + +); + +export default CodeRabbitLockup; diff --git a/packages/shared/src/components/sponsors/NvidiaLockup.tsx b/packages/shared/src/components/sponsors/NvidiaLockup.tsx new file mode 100644 index 00000000000..ba7037098b1 --- /dev/null +++ b/packages/shared/src/components/sponsors/NvidiaLockup.tsx @@ -0,0 +1,40 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import type { LockupProps } from './SponsoredStrip'; + +/** + * NVIDIA's lockup, inline so it can be two-tone. + * + * The brand sets the symbol in green and the wordmark in black or + * white to suit the background — as a flat file that is two assets, + * and whichever one ships is invisible on the other theme. Rendered + * inline the wordmark takes `currentColor`, so it is black on the + * light feed and white on the dark one from a single source, while + * the symbol keeps #76B900 on both. + * + * Paths are NVIDIA's own artwork (the 656x120 Wikimedia lockup, + * viewBox 0 0 164 30); only the fills are ours. Path roles were + * identified by bounding box: symbol x 0-45.3, wordmark x 52.7-157.7, + * (R) x 158.8-162.4. + */ +export const NvidiaLockup = ({ monochrome }: LockupProps): ReactElement => ( + + + + + +); + +export default NvidiaLockup; diff --git a/packages/shared/src/components/sponsors/SponsorDock.tsx b/packages/shared/src/components/sponsors/SponsorDock.tsx new file mode 100644 index 00000000000..6ba404c5652 --- /dev/null +++ b/packages/shared/src/components/sponsors/SponsorDock.tsx @@ -0,0 +1,52 @@ +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import type { SponsoredStripProps } from './SponsoredStrip'; +import { SponsorRailPinned } from './SponsoredStrip'; + +// ============================================================= +// The dock — the sponsor row with a value rail stacked beneath. +// +// This is the answer to the link-status bubble, and it is worth +// stating plainly because the fix does not look like a fix. The +// browser paints its URL preview over the bottom corners of the +// viewport, and a feed is nearly all links, so anything flush to +// the bottom edge is covered most of the time. Floating the bar +// clear of the edge works and reads badly: it detaches from the +// product. +// +// Stacking solves it without moving anything. The rail underneath +// takes the hit, and it is the right thing to sacrifice — ambient +// data that loses nothing by being briefly half-covered, unlike +// the row someone paid for. Each rail also opens with its label +// on the left, which is where the bubble lands first, so what it +// covers is the least worth reading on the row. +// +// It also earns the space. A permanent bar that only carries +// advertising is rent; one that carries something the reader came +// for is a feature that happens to be sponsored — which is what +// the broadcast format it borrows from has always been. +// ============================================================= + +export type SponsorDockProps = SponsoredStripProps & { + /** The rail stacked beneath the sponsor row. */ + children?: ReactNode; +}; + +export const SponsorDock = ({ + children, + className, + ...strip +}: SponsorDockProps): ReactElement => ( +
+ {/* The row keeps its own chrome but gives up its stickiness: + the dock is what pins now, so the two rows move together. */} + + {children} +
+); diff --git a/packages/shared/src/components/sponsors/SponsoredStrip.tsx b/packages/shared/src/components/sponsors/SponsoredStrip.tsx new file mode 100644 index 00000000000..32b757ad840 --- /dev/null +++ b/packages/shared/src/components/sponsors/SponsoredStrip.tsx @@ -0,0 +1,655 @@ +import type { CSSProperties, ReactElement, ReactNode } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import classNames from 'classnames'; +import { pagePaddings } from '../utilities/common'; + +// ============================================================= +// Sponsored strip — a TBPN-style "presented by" lockup plus a +// row of partner logos, sized for the extension new tab. +// +// The reference format (a live show's lower third) can afford a +// permanently pinned bar because there is no content to cover. +// A feed cannot: every pixel the strip holds is a pixel of post. +// So each concept below trades visibility against how much feed +// it displaces, and they are meant to be judged side by side in +// `SponsoredStrip.stories.tsx` rather than shipped all at once. +// ============================================================= + +/** + * What an inline lockup is told when it renders. `monochrome` is the + * wall treatment: draw in `currentColor` and punch any knockout out + * of the shape, because the wall masks by alpha and painted detail + * comes through solid. + */ +export type LockupProps = { monochrome?: boolean }; + +export type Sponsor = { + name: string; + /** + * Absolute URL of a horizontal SVG wordmark. Optional only for a + * sponsor supplied as inline `Artwork`; the silhouette treatment + * needs the file, so every partner must have one. + */ + logo?: string; + /** + * Intrinsic width / height. Logo files vary from square marks to + * 5:1 wordmarks, so a strip that fixes only the height needs the + * ratio to reserve the right width and keep cap heights optical. + */ + ratio: number; + /** + * Click-through destination. Only the lead sponsor gets one — the + * partner wall is a credit, not a row of links. + */ + href?: string; + /** + * Optional inline artwork, for a lockup that cannot be one flat + * file — typically a brand symbol that must hold its colour beside + * a wordmark that has to flip with the theme. Rendered in place of + * the `logo` image in both treatments: the lockup is told which one + * it is drawing, because a silhouette needs its knockouts punched + * out rather than painted. + */ + Artwork?: (props: LockupProps) => ReactElement; +}; + +export type SponsoredStripProps = { + /** The single paid-out slot, given the "Presented by" lockup. */ + primary: Sponsor; + /** Secondary logo wall, ~10 slots. */ + partners: Sponsor[]; + /** + * Render the *partner* logos as single-colour silhouettes that + * inherit the surrounding text colour. Full colour is available for + * comparison but fails the theme test — see the stories. The + * presenting sponsor always keeps its brand colour. + */ + monochrome?: boolean; + onSponsorClick?: (sponsor: Sponsor) => void; + className?: string; +}; + +// The lead mark reads a step above the wall, not a tier above it: at +// these caps it lands ~20% taller than the median partner wordmark +// (18px against 15px), which is enough to rank it without turning the +// rail into a billboard. +const PRIMARY_CAP = 23; +export const PARTNER_CAP = 16; + +/** + * Cap heights are sized to the 40px rail: at PARTNER_CAP the tallest + * optical result is ~22px, leaving 9px of air above and below, which + * is what sets the floor on the bar's height. Raising PARTNER_CAP + * past ~18 would crowd it. + * + * Logo files run from square marks (GitLab, 1:1) to long lockups + * (LaunchDarkly, 6.4:1). Sizing them all to one cap height makes the + * square ones illegible and the long ones dominate the row, so the + * height is normalised by area instead — every mark gets roughly the + * same ink — and clamped so nothing blows out the strip. + */ +const REFERENCE_RATIO = 3.5; + +const opticalHeight = (ratio: number, cap: number): number => + Math.round( + Math.min( + cap * 1.6, + Math.max(cap * 0.8, cap * Math.sqrt(REFERENCE_RATIO / ratio)), + ), + ); + +/** Fisher-Yates. Callers own when this runs; it is not pure. */ +const shuffle = (items: T[]): T[] => { + const out = [...items]; + + for (let i = out.length - 1; i > 0; i -= 1) { + const j = Math.floor(Math.random() * (i + 1)); + [out[i], out[j]] = [out[j], out[i]]; + } + + return out; +}; + +/** + * A fresh order of the partner wall per page load, so no advertiser + * is permanently first and — once the row starts trimming to fit — + * none is permanently the one that gets dropped. + * + * The shuffle deliberately waits for mount. Randomising during render + * would produce different markup on the server and the client, which + * React would flag as a hydration mismatch; this way the server order + * is what hydrates and the rotation lands immediately after. + */ +export const useShuffledSponsors = (partners: Sponsor[]): Sponsor[] => { + const [order, setOrder] = useState(partners); + + useEffect(() => { + setOrder(shuffle(partners)); + }, [partners]); + + return order; +}; + +/** Covers the layout's 300ms padding transition, plus a little. */ +const LAYOUT_SETTLE_MS = 400; + +/** + * Narrower than any single mark, so a row this size cannot have been + * laid out yet. Treated as "not measured" rather than "nothing fits": + * a transient narrow reading must not be able to strand the wall + * empty, because nothing else is guaranteed to come along and correct + * it — ResizeObserver is the only other corrector, and an environment + * that throttles it would leave the strip permanently blank. + */ +const MIN_MEASURABLE_WIDTH = 80; + +/** Rendered width of a mark at a given cap height. */ +const markWidth = (sponsor: Sponsor, cap: number): number => + opticalHeight(sponsor.ratio, cap) * sponsor.ratio; + +/** + * How many marks fit the measured row, in order, at `gap` apart. + * The marks' widths are known from their ratios, so this needs no + * DOM measurement beyond the row itself. + */ +const countThatFit = ( + partners: Sponsor[], + available: number, + cap: number, + gap: number, +): number => { + let used = 0; + + for (let i = 0; i < partners.length; i += 1) { + const next = used + (i > 0 ? gap : 0) + markWidth(partners[i], cap); + + if (next > available) { + return i; + } + + used = next; + } + + return partners.length; +}; + +/** + * Trims the wall to what the row can actually hold, rather than + * letting it overflow and clipping the remainder. Twelve marks at the + * widest, fewer as the window narrows — an advertiser is either shown + * whole or not at all, never as a half logo under a fade. + */ +const useFittedSponsors = ( + partners: Sponsor[], + cap: number, + gap: number, +): { ref: React.RefObject; fitted: Sponsor[] } => { + const ref = useRef(null); + const [available, setAvailable] = useState(null); + + useEffect(() => { + const el = ref.current; + + if (!el) { + return undefined; + } + + const measure = () => setAvailable(el.getBoundingClientRect().width); + + // Measure directly rather than waiting on ResizeObserver's first + // callback: the row has to be trimmed on the initial paint, and + // not every environment delivers that callback. + measure(); + + // The layout animates its padding when the sidebar opens or + // closes, so the first measurement can be of a width that is on + // its way somewhere else. Re-measure once the transition is over. + const settle = window.setTimeout(measure, LAYOUT_SETTLE_MS); + + if (typeof ResizeObserver === 'undefined') { + // Window resizes are the common case; without RO the row still + // reflows on those, it just misses element-only changes such as + // the sidebar expanding. + window.addEventListener('resize', measure); + + return () => { + window.clearTimeout(settle); + window.removeEventListener('resize', measure); + }; + } + + const observer = new ResizeObserver(([entry]) => + setAvailable(entry.contentRect.width), + ); + + observer.observe(el); + + return () => { + window.clearTimeout(settle); + observer.disconnect(); + }; + }, []); + + const fitted = useMemo(() => { + // Render the full wall until a real width arrives; the row clips, + // so a frame of overflow is invisible and the server markup stays + // complete. + if (available === null || available < MIN_MEASURABLE_WIDTH) { + return partners; + } + + // Never fewer than one: a clipped mark is a worse look than a + // tidy row, but an empty sponsor wall is a broken one. + return partners.slice( + 0, + Math.max(1, countThatFit(partners, available, cap, gap)), + ); + }, [partners, available, cap, gap]); + + return { ref, fitted }; +}; + +type SponsorLogoProps = { + sponsor: Sponsor; + /** Cap height in px; width follows from the intrinsic ratio. */ + height: number; + monochrome?: boolean; + className?: string; +}; + +/** + * Not lazy-loaded: eleven inline SVGs weigh nothing, and deferring + * them would let the paid slot be the last thing on the page to + * appear — the masked partner marks are CSS and never defer at all, + * so a lazy only buys an inconsistent strip. + * + * Painting a currentColor block through the logo as a mask, rather + * than filtering an , keeps a single + * implementation working in both themes: the mark simply takes the + * text colour of whatever it sits in. The cost is that knockouts + * (Notion's white "N", Postman's white glyph) fill in, so a real + * rollout wants monochrome assets from the advertiser. + */ +export const SponsorLogo = ({ + className, + height, + monochrome = true, + sponsor, +}: SponsorLogoProps): ReactElement => { + const optical = opticalHeight(sponsor.ratio, height); + const style: CSSProperties = { + height: optical, + width: optical * sponsor.ratio, + }; + + if (sponsor.Artwork) { + const { Artwork } = sponsor; + + return ( + + + + ); + } + + if (!monochrome) { + return ( + {sponsor.name} + ); + } + + return ( + + ); +}; + +type SponsorSlotProps = { + sponsor: Sponsor; + height: number; + monochrome?: boolean; + className?: string; +}; + +/** + * A partner mark: shown, not clickable. Ten inert logos beside one + * live link keep the click target unambiguous, and spare the wall a + * row of hover states competing with the posts around it. + */ +const SponsorSlot = ({ + className, + height, + monochrome, + sponsor, +}: SponsorSlotProps): ReactElement => ( + + + +); + +export const Label = ({ + children, + className, +}: { + children: ReactNode; + className?: string; +}): ReactElement => ( + + {children} + +); + +/** + * "Made possible by" + the primary mark. The paid slot is the one place + * that keeps its brand colour — it is what the advertiser is buying, + * and one coloured mark against a neutral wall is the hierarchy. It + * only works with a logo whose inks survive both themes: anything + * near-black or near-white disappears on one of them. See the + * LogoTreatment story for the check. + */ +export const PrimaryLockup = ({ + onSponsorClick, + primary, + vertical = false, +}: Pick & { + vertical?: boolean; +}): ReactElement => ( +
+ + {primary.href ? ( + onSponsorClick?.(primary)} + rel="noopener noreferrer" + target="_blank" + > + + + ) : ( + + + + )} +
+); + +/** + * Partner logos spread across the full run, the way the reference + * bar distributes its wall. `gap-4` is the floor and `justify-between` + * hands out whatever is left, so the row breathes on a wide new tab + * and tightens before it clips. The `pr-12` keeps the last mark clear + * of the fade when everything fits; only genuine overflow runs into + * it. Clipped, not scrolled or animated: a marquee in the periphery + * of a reading surface is exactly the distraction we are avoiding. + */ +const PARTNER_GAP = 16; + +export const PartnerRow = ({ + monochrome, + partners, +}: Pick): ReactElement => { + const rotated = useShuffledSponsors(partners); + const { ref, fitted } = useFittedSponsors(rotated, PARTNER_CAP, PARTNER_GAP); + + return ( +
+ {fitted.map((sponsor) => ( + + ))} +
+ ); +}; + +export const Divider = (): ReactElement => ( + +); + +// --------------------------------------------------------------- +// A. Pinned rail — the closest translation of the reference. +// Held near the bottom edge of the viewport for the whole session. +// +// The ground stays opaque — `surface-float` is a translucent token, +// and cards scrolling through the marks costs more legibility than +// it buys. +// +// It sits flush on the edge. An earlier version floated 28px clear to +// dodge the browser's link-status bubble, but a detached island reads +// as a widget bolted onto the product rather than part of it. The +// bubble is handled by what sits underneath instead — see +// SponsorDock, where a value rail takes the hit. +// +// `sticky`, not `fixed`. A fixed bar is positioned against the +// viewport, so it runs the full width of the window and slides under +// the left sidebar. Sticky keeps the bar in flow inside the layout's +// padded main, which is where the sidebar offset already lives — so +// the bar spans the feed and nothing else, and follows that offset +// across layout variants and sidebar states without having to know +// what either is. The horizontal inset is the app's own +// `pagePaddings`, so the strip lines up with every other page +// surface rather than inventing its own number. +// --------------------------------------------------------------- +export const SponsorRailPinned = ({ + className, + monochrome = true, + onSponsorClick, + partners, + primary, +}: SponsoredStripProps): ReactElement => ( +
+ + + +
+); + +// --------------------------------------------------------------- +// B. Inline rail — same bar, but in flow above the first card row. +// Costs one scroll of feed height and then leaves. +// --------------------------------------------------------------- +export const SponsorRailInline = ({ + className, + monochrome = true, + onSponsorClick, + partners, + primary, +}: SponsoredStripProps): ReactElement => ( +
+ + + +
+); + +// --------------------------------------------------------------- +// C. Feed band — a full-width row between card rows. Reads as part +// of the feed, wraps instead of clipping, scrolls away like a post. +// --------------------------------------------------------------- +export const SponsorFeedBand = ({ + className, + monochrome = true, + onSponsorClick, + partners, + primary, +}: SponsoredStripProps): ReactElement => { + const rotated = useShuffledSponsors(partners); + + return ( +
+ +
+ +
+ {/* + * A grid rather than a wrapped flex row: fixed column counts break + * cleanly into rows, where a wrapped `justify-between` flex would + * strand the last one. The columns are `auto`, not equal fractions + * — the marks differ in width by 2x, so equal cells make the wide + * ones overlap their neighbours — and the grid's own + * `justify-between` hands the leftover space to the gaps. + */} +
+ {rotated.map((sponsor) => ( + + ))} +
+
+ ); +}; + +// --------------------------------------------------------------- +// D. Card slot — takes one post's place in the grid. Maximum +// native feel, and the only concept whose cost is a whole card. +// --------------------------------------------------------------- +export const SponsorFeedCard = ({ + className, + monochrome = true, + onSponsorClick, + partners, + primary, +}: SponsoredStripProps): ReactElement => { + const rotated = useShuffledSponsors(partners); + + return ( +
+ + + +
+ {rotated.map((sponsor) => ( + + ))} +
+
+ ); +}; + +// --------------------------------------------------------------- +// E. Side rail — zero feed displacement, lowest attention. Only +// viable on laptop and up, where the rail exists at all. +// --------------------------------------------------------------- +export const SponsorSideRail = ({ + className, + monochrome = true, + onSponsorClick, + partners, + primary, +}: SponsoredStripProps): ReactElement => { + const rotated = useShuffledSponsors(partners); + + return ( + + ); +}; diff --git a/packages/shared/src/components/sponsors/ValueRailSwitcher.tsx b/packages/shared/src/components/sponsors/ValueRailSwitcher.tsx new file mode 100644 index 00000000000..3a853f461b1 --- /dev/null +++ b/packages/shared/src/components/sponsors/ValueRailSwitcher.tsx @@ -0,0 +1,85 @@ +import type { ReactElement } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuOptions, + DropdownMenuTrigger, +} from '../dropdown/DropdownMenu'; +import { ArrowIcon } from '../icons'; +import { IconSize } from '../Icon'; +import { VALUE_RAILS } from './ValueRails'; + +// ============================================================= +// The rail is a channel, not a fixture. +// +// Ten rails is ten opinions about what a reader wants under their +// feed, and the honest answer is that it differs per reader. So +// the rail's label — which was already the throwaway element, +// sitting where the browser's URL tooltip lands — becomes the +// control that swaps it. Nothing else on the row moves, and the +// dropdown costs no space it was not already spending. +// +// The choice is stored locally rather than on the account: it is +// a preference about one strip, it should survive a reload, and +// it is not worth a round trip or a migration. +// ============================================================= + +const STORAGE_KEY = 'sponsorRail'; +const DEFAULT_RAIL = 'hot'; + +const railById = (id: string) => + VALUE_RAILS.find((rail) => rail.id === id) ?? VALUE_RAILS[0]; + +export const ValueRailSwitcher = (): ReactElement => { + const [id, setId] = useState(DEFAULT_RAIL); + + // Read after mount, not during render: the server has no + // localStorage, and reading it in a useState initialiser would + // make the two disagree on the first paint. + useEffect(() => { + const stored = globalThis.localStorage?.getItem(STORAGE_KEY); + + if (stored && VALUE_RAILS.some((rail) => rail.id === stored)) { + setId(stored); + } + }, []); + + const select = useCallback((next: string) => { + setId(next); + globalThis.localStorage?.setItem(STORAGE_KEY, next); + }, []); + + const { Rail, name } = railById(id); + + const label = ( + + + + + + ({ + label: rail.name, + action: () => select(rail.id), + }))} + /> + + + ); + + return ; +}; + +export default ValueRailSwitcher; diff --git a/packages/shared/src/components/sponsors/ValueRails.tsx b/packages/shared/src/components/sponsors/ValueRails.tsx new file mode 100644 index 00000000000..e171461fc62 --- /dev/null +++ b/packages/shared/src/components/sponsors/ValueRails.tsx @@ -0,0 +1,532 @@ +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { pagePaddings } from '../utilities/common'; + +// ============================================================= +// Value rails — the second row of the dock. +// +// Two jobs at once. The obvious one: give the strip a reason to +// exist for the reader, so a permanent bottom bar is not pure +// rent. The less obvious one: browsers draw their link-status +// bubble in the bottom corners, over whatever the page renders, +// and on a feed that is nearly all links it is showing most of +// the time. Stacking a value rail beneath the sponsor row puts +// something expendable in the bubble's path — ambient, glanceable +// data that costs nothing when it is briefly covered — and lifts +// the paid row clear of it. The reference format does the same +// thing: the sponsor lockup sits above the tickers, not in them. +// +// Every rail therefore opens with a label on the left. That is +// the sacrificial zone: it is the first thing the bubble covers +// and the least worth reading. Data starts after it. +// +// Each variant below is mocked. The data each one would need +// already exists in the app — trendingTags, userStreak, the +// leaderboard queries, live rooms, quests, opportunities, polls — +// so these are proposals about surfacing, not about new plumbing. +// ============================================================= + +const RAIL_HEIGHT = 'h-8'; + +/** + * Every rail accepts a label override so the switcher can hand it a + * dropdown trigger in place of the static word, without any rail + * needing to know the switcher exists. + */ +export type RailProps = { label?: ReactNode }; + +type ValueRailProps = { + /** + * Sacrificial left zone: the tooltip covers this first, which is + * why the rail's least important element lives here. When the + * switcher is in use this is the dropdown trigger — still the + * least costly thing to lose, since it says what you are already + * looking at. + */ + label: ReactNode; + children: ReactNode; + className?: string; +}; + +export const ValueRail = ({ + children, + className, + label, +}: ValueRailProps): ReactElement => ( +
+ + {label} + + {/* + * The row carries more than fits on purpose: it should read as a + * ticker continuing past the edge, not a list that happens to + * end. The fade is what makes that read as intentional — a hard + * clip chops a word in half and looks like a bug. + */} +
+ {children} +
+
+); + +const Item = ({ + children, + className, +}: { + children: ReactNode; + className?: string; +}): ReactElement => ( + + {children} + +); + +/** Up/down delta in the platform's status colours. */ +const Delta = ({ value }: { value: number }): ReactElement => ( + = 0 ? 'text-status-success' : 'text-status-error', + )} + > + {value >= 0 ? '▲' : '▼'} + {Math.abs(value)}% + +); + +const Dot = ({ className }: { className?: string }): ReactElement => ( + +); + +// --- 1. Tag momentum ------------------------------------------ +// The closest analogue to the reference's stock ticker, and the +// one that most obviously belongs to daily.dev: which topics are +// moving on the feed right now. Source: trendingTags, plus a +// week-over-week occurrence delta. +export const TagMomentumRail = ({ label }: RailProps): ReactElement => ( + + {[ + { tag: 'rust', delta: 48 }, + { tag: 'llm-agents', delta: 31 }, + { tag: 'postgres', delta: 12 }, + { tag: 'kubernetes', delta: -6 }, + { tag: 'webassembly', delta: 22 }, + { tag: 'golang', delta: -11 }, + { tag: 'typescript', delta: 4 }, + { tag: 'zig', delta: 37 }, + { tag: 'observability', delta: 9 }, + { tag: 'react', delta: -3 }, + { tag: 'sqlite', delta: 26 }, + { tag: 'devex', delta: 14 }, + { tag: 'terraform', delta: -8 }, + { tag: 'edge-compute', delta: 19 }, + { tag: 'security', delta: 6 }, + { tag: 'python', delta: -2 }, + ].map(({ tag, delta }) => ( + + #{tag} + + + ))} + +); + +// --- 2. Model leaderboard ------------------------------------- +// What developers on daily.dev are actually discussing, ranked. +// Source: post volume on model tags over seven days. +const ModelRankRail = ({ label }: RailProps): ReactElement => ( + + {[ + { name: 'Claude Opus 5', move: 0 }, + { name: 'GPT-5.2', move: 1 }, + { name: 'Gemini 3 Pro', move: -1 }, + { name: 'Llama 4', move: 2 }, + { name: 'DeepSeek V4', move: 0 }, + { name: 'Qwen 3', move: 3 }, + { name: 'Mistral Large 3', move: -2 }, + { name: 'Grok 4', move: 1 }, + { name: 'Command R+', move: 0 }, + { name: 'Phi-5', move: -1 }, + ].map(({ name, move }, i) => ( + + {i + 1} + {name} + {move !== 0 && ( + 0 ? 'text-status-success' : 'text-status-error'} + > + {move > 0 ? '▲' : '▼'} + {Math.abs(move)} + + )} + + ))} + +); + +// --- 3. Breaking news ------------------------------------------ +// A headline ticker: the posts climbing fastest, with their +// upvote counts. Source: the feed's own trending ranking. +const HotPostsRail = ({ label }: RailProps): ReactElement => ( + + {[ + { title: 'Postgres 18 ships async I/O', votes: 412 }, + { title: 'The case against microservices, again', votes: 289 }, + { title: 'Rust in the Linux kernel: one year on', votes: 231 }, + { title: 'We deleted our CI cache and got faster', votes: 198 }, + { title: 'SQLite is all you need until it isn’t', votes: 176 }, + { title: 'Why your p99 is lying to you', votes: 154 }, + { title: 'A year of shipping without staging', votes: 131 }, + { title: 'The quiet death of the REST client', votes: 118 }, + ].map(({ title, votes }) => ( + + {title} + ▲{votes} + + ))} + +); + +// --- 4. Your streak ------------------------------------------- +// The most personal option, and the only one that changes if the +// reader does nothing. Source: userStreak. +export const StreakRail = ({ label }: RailProps): ReactElement => ( + + + 🔥 12 days + + + Longest 34 days + + + Weekend shield on + + + 5 reading days this week + + + 17 posts read · 4 bookmarked + + + Best day Tue · 9 posts + + + 3 of 5 posts read today + + + + + +); + +// --- 5. Weekly rank ------------------------------------------- +// Source: the leaderboard queries already in the app +// (MostReadingDays, HighestReputation, LongestStreak). +const RankRail = ({ label }: RailProps): ReactElement => ( + + + #42 in reading days + ▲6 + + + #118 in reputation + ▲12 + + + #9 in your squads + ▼2 + + + Top 4% this week + + + Next rank in 2 days of reading + + + Longest streak #63 + + + Level 14 · 320 to next + + +); + +// --- 6. Squad pulse ------------------------------------------- +const SquadPulseRail = ({ label }: RailProps): ReactElement => ( + + {[ + { name: 'Frontend Devs', note: '3 new posts' }, + { name: 'AI Builders', note: '1 discussion' }, + { name: 'Rustaceans', note: '5 new posts' }, + { name: 'Platform Eng', note: '2 new posts' }, + { name: 'Data Wranglers', note: '4 new posts' }, + { name: 'Go Gophers', note: '1 new post' }, + { name: 'Security Club', note: '6 new posts' }, + { name: 'Design Systems', note: '2 discussions' }, + ].map(({ name, note }) => ( + + {name} + {note} + + ))} + +); + +// --- 7. Live now ---------------------------------------------- +// Source: live rooms (topic, status, listener count). +export const LiveNowRail = ({ label }: RailProps): ReactElement => ( + + + + Rust in production + 214 listening + + + + Postgres office hours + 88 listening + + + + Next: Shipping agents safely · 16:00 + + + + + Then: Kernel patches explained · 18:30 + + + + + Tomorrow: Zig for C people · 11:00 + + + + + 3 rooms in your squads this week + + + +); + +// --- 8. Role pulse -------------------------------------------- +// Source: opportunities matched against the reader's tags. +const RolePulseRail = ({ label }: RailProps): ReactElement => ( + + + 12 matching Rust + + + 4 matching Postgres + + + 7 matching TypeScript + + + 3 matching Kubernetes + + + 9 remote · 5 senior · 2 staff + + + 6 new since Monday + + + 2 saved · 1 replied + + +); + +// --- 9. Release radar ----------------------------------------- +// Versions of the tools the reader already follows, which is the +// single most repeated "why do I open this tab" answer. +const ReleaseRadarRail = ({ label }: RailProps): ReactElement => ( + + {[ + { name: 'React', version: '19.2' }, + { name: 'Node', version: '24 LTS' }, + { name: 'TypeScript', version: '5.9' }, + { name: 'Postgres', version: '18.1' }, + { name: 'Bun', version: '1.3' }, + { name: 'Deno', version: '2.4' }, + { name: 'Vite', version: '7.1' }, + { name: 'Rust', version: '1.91' }, + { name: 'Go', version: '1.26' }, + { name: 'Python', version: '3.14' }, + { name: 'Kubernetes', version: '1.34' }, + { name: 'Redis', version: '8.2' }, + ].map(({ name, version }) => ( + + {name} + {version} + + ))} + +); + +// --- 10. Community poll --------------------------------------- +// The app already has poll posts; this surfaces the live split +// and takes one tap to answer. +const PollRail = ({ label }: RailProps): ReactElement => ( + + + + Do you review AI-written code line by line? + + + + Yes 62% + + + + No 38% + + + 1,204 votes + + + You voted Yes + + + + Yesterday: Do you write tests first? No 71% + + + + 3 polls in your squads + + +); + +// --- 11. Agent status ----------------------------------------- +// The most personal rail of the set: what the reader's own agents +// are doing right now. Ambient status rather than news — closer to +// a build indicator than a ticker. +const AgentStatusRail = ({ label }: RailProps): ReactElement => ( + + + + Digest + running · 4 sources + + + PR reviewer + idle · last run 2h ago + + + + Release watcher + 3 findings waiting + + + Tag curator + queued + + + + Changelog + failed · auth expired + + + 14 runs today · 2 need input + + +); + +export const VALUE_RAILS: { + id: string; + name: string; + note: string; + Rail: (props: RailProps) => ReactElement; +}[] = [ + { + id: 'tags', + name: 'Tag momentum', + note: 'the reference’s stock ticker, in daily.dev’s own currency — which topics are moving', + Rail: TagMomentumRail, + }, + { + id: 'models', + name: 'Models this week', + note: 'what developers here are actually discussing, ranked, with movement', + Rail: ModelRankRail, + }, + { + id: 'hot', + name: 'Breaking news', + note: 'headline ticker of the posts climbing fastest', + Rail: HotPostsRail, + }, + { + id: 'streak', + name: 'Your streak', + note: 'the only rail that changes if the reader does nothing — habit, not news', + Rail: StreakRail, + }, + { + id: 'rank', + name: 'Leaderboard', + note: 'weekly rank and movement, from the leaderboard queries already in the app', + Rail: RankRail, + }, + { + id: 'squads', + name: 'Squad pulse', + note: 'unread activity in the squads the reader already joined', + Rail: SquadPulseRail, + }, + { + id: 'live', + name: 'Live now', + note: 'the one rail with genuine urgency — a room happening right now', + Rail: LiveNowRail, + }, + { + id: 'roles', + name: 'Role pulse', + note: 'opportunities matched to the reader’s tags, the highest-intent surface here', + Rail: RolePulseRail, + }, + { + id: 'releases', + name: 'Release radar', + note: 'versions of the tools they follow — the most repeated reason to open a new tab', + Rail: ReleaseRadarRail, + }, + { + id: 'agents', + name: 'Your agents', + note: 'what the reader’s own agents are doing — a build indicator, not a ticker', + Rail: AgentStatusRail, + }, + { + id: 'poll', + name: 'Today’s poll', + note: 'live split on a one-tap question; the app already has poll posts', + Rail: PollRail, + }, +]; diff --git a/packages/shared/src/components/sponsors/mockSponsors.ts b/packages/shared/src/components/sponsors/mockSponsors.ts new file mode 100644 index 00000000000..02ecf366396 --- /dev/null +++ b/packages/shared/src/components/sponsors/mockSponsors.ts @@ -0,0 +1,60 @@ +import type { Sponsor } from './SponsoredStrip'; +import { CodeRabbitLockup } from './CodeRabbitLockup'; +import { NvidiaLockup } from './NvidiaLockup'; + +// =========================================================== +// PLACEHOLDER DATA — not a sponsorship, not an ad server. +// +// Hard-coded stand-ins so the strip can be reviewed on a real +// feed. Nothing here has been sold: the marks are advertiser +// logos already published on business.daily.dev, and NVIDIA is +// a mock lead sponsor. Real inventory would come from the ad +// service, behind a flag, before any of this ships. +// =========================================================== + +const LOGO_BASE = 'https://business.daily.dev/assets/company-logos'; + +const sponsor = (name: string, file: string, ratio: number): Sponsor => ({ + name, + logo: `${LOGO_BASE}/${file}.svg`, + ratio, +}); + +/** + * The paid slot: brand colour, larger, and the only link in the strip. + * + * NVIDIA sets its symbol in green and its wordmark in black or white + * to suit the background — two assets, and either one dies on the + * opposite theme. Rendered inline the symbol holds #76B900 while the + * wordmark takes `currentColor`, so one source covers both. + */ +export const MOCK_LEAD_SPONSOR: Sponsor = { + name: 'NVIDIA', + ratio: 164 / 30, + Artwork: NvidiaLockup, + href: 'https://www.nvidia.com', +}; + +/** The wall: silhouetted, even-weighted, inert. */ +export const MOCK_PARTNER_SPONSORS: Sponsor[] = [ + // Inline rather than the library file: CodeRabbit's mark carries a + // painted white detail, and the wall masks by alpha, so the flat + // asset silhouettes into a featureless blob. The lockup punches the + // detail out instead. + { + name: 'CodeRabbit', + ratio: 2152 / 314, + Artwork: CodeRabbitLockup, + }, + sponsor('Datadog', 'datadog', 800.5 / 203.19), + sponsor('PostHog', 'posthog', 512 / 90), + sponsor('ClickHouse', 'clickhouse', 584.9 / 103.1), + sponsor('Retool', 'retool', 87 / 17), + sponsor('Snyk', 'snyk', 65 / 35), + sponsor('Okta', 'okta', 512 / 169), + sponsor('Neo4j', 'neo4j', 512 / 170), + sponsor('Pulumi', 'pulumi', 512 / 128), + sponsor('LaunchDarkly', 'launchdarkly', 512 / 80), + sponsor('Amazon', 'amazon', 512 / 256), + sponsor('Sonar', 'sonar', 512 / 125), +]; diff --git a/packages/shared/src/components/utilities/common.tsx b/packages/shared/src/components/utilities/common.tsx index c10e822865d..ec5498eca9f 100644 --- a/packages/shared/src/components/utilities/common.tsx +++ b/packages/shared/src/components/utilities/common.tsx @@ -4,8 +4,6 @@ import classNames from 'classnames'; import classed from '../../lib/classed'; import styles from './utilities.module.css'; import { ArrowIcon } from '../icons'; -import { pageMainClassNames } from '../layout/PageWrapperLayout'; -import { useLayoutVariant } from '../../hooks/layout/useLayoutVariant'; import { SourceMemberRole } from '../../graphql/sources'; import type { OrganizationMemberRole } from '../../features/organizations/types'; @@ -31,7 +29,13 @@ export enum Justify { export const pageBorders = 'laptop:border-r laptop:border-l border-border-subtlest-tertiary'; -const pagePaddings = 'px-4 tablet:px-8'; +/** + * The horizontal padding every page container uses. Exported so + * surfaces that sit outside a page container — the sponsor dock, for + * one — can line up with the feed instead of inventing their own + * inset. + */ +export const pagePaddings = 'px-4 tablet:px-8'; const basePageClassNames = classNames( styles.pageContainer, 'relative z-1 flex w-full flex-col', @@ -99,23 +103,37 @@ export const BaseFeedPage = classed( styles.feedPage, ); -// v2 (dual-sidebar layout) ships the feed inside the floating-card chrome, -// which provides its own outer inset. The legacy `pageMainClassNames` -// (`laptop:p-10`) adds another 40px on top, which reads as way too much -// side spacing inside the card. Drop that padding under v2; control keeps -// the existing behavior unchanged. +/** + * The feed's horizontal inset — the single source of it. + * + * It lives on FeedContainer rather than on a page container because + * the feed renders through two different ones depending on layout and + * route: FeedPage, which carries `pageMainClassNames`, and + * FeedPageLayoutList, which forces `!px-0`. FeedContainer is the only + * element common to both, so it is the only place an inset applies + * everywhere and can never stack with another. + * + * Chrome outside the container — the breadcrumbs and the tab strip — + * uses the same constant to line up with the cards. + */ +export const feedGutter = 'px-4 tablet:px-6 laptop:px-10'; + +// Vertical padding only. The horizontal inset moved to FeedContainer +// (see `feedGutter`) because this component is not in the tree on +// every feed route — FeedPageLayoutList is used instead on some — and +// an inset here would both miss those routes and stack with the one +// that covers them. +const feedPageVerticalPadding = 'tablet:py-4 laptop:py-10'; + export const FeedPage = ({ className, ...props -}: HTMLAttributes): ReactElement => { - const { isV2 } = useLayoutVariant(); - return ( - - ); -}; +}: HTMLAttributes): ReactElement => ( + +); export const FeedPageLayoutList = classed( BasePageContainer, pageContainerClassNames, diff --git a/packages/storybook/stories/extension/BottomStripVariants.stories.tsx b/packages/storybook/stories/extension/BottomStripVariants.stories.tsx new file mode 100644 index 00000000000..129eee8e733 --- /dev/null +++ b/packages/storybook/stories/extension/BottomStripVariants.stories.tsx @@ -0,0 +1,161 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { BOTTOM_VARIANTS } from './_bottomStripVariants'; +import { + MOCK_LEAD_SPONSOR, + MOCK_PARTNER_SPONSORS, +} from '@dailydotdev/shared/src/components/sponsors/mockSponsors'; +import ExtensionProviders from './_providers'; +import { MockFeedGrid, MockFeedHeader } from './_mockPostFeed'; + +// ============================================================= +// Ten ways to hold the strip at the bottom — no second row, no +// float, always on. +// +// A permanent bar has to justify being permanent every second it +// is on screen, and there are only a few honest ways to do that: +// yield while the reader is reading, look like part of the tool +// rather than part of the page, do an actual job, or ask for so +// little that nobody minds. Each variant below picks one. +// +// Most of these are behavioural — they answer to scroll or to +// stillness — so **Overview** is only an index. Open the +// individual stories and scroll to judge them. +// ============================================================= + +const strip = { + primary: MOCK_LEAD_SPONSOR, + partners: MOCK_PARTNER_SPONSORS, +}; + +const meta: Meta = { + title: 'Extension/Bottom Strip Variants', + parameters: { layout: 'fullscreen' }, +}; + +export default meta; + +type Story = StoryObj; + +const Feed = ({ children }: { children: ReactElement }): ReactElement => ( + +
+
+
+ + +
+ {children} +
+
+
+); + +// --------------------------------------------------------------- +// Overview +// --------------------------------------------------------------- +export const Overview: Story = { + render: () => ( + +
+
+
+

+ Ten bottom strips +

+

+ All of them are one row, always on, and flush to the bottom. What + differs is how each one earns the right to stay there. Most are + behavioural — they respond to scrolling or to stillness — so this + page is an index, not a preview. Open a story and scroll it. +

+
+ {[ + 'Get out of the way', + 'Become chrome', + 'Do a job', + 'Earn it by restraint', + ].map((family) => ( +
+

+ {family} +

+
    + {BOTTOM_VARIANTS.filter((v) => v.family === family).map((v) => ( +
  • + {v.name} + — {v.note} +
  • + ))} +
+
+ ))} +
+
+
+ ), +}; + +const [ + retract, + condense, + hairline, + status, + shortcuts, + progress, + credits, + colophon, + idle, + seam, +] = BOTTOM_VARIANTS; + +export const RetractOnRead: Story = { + name: `1 · ${retract.name}`, + render: () => {}, +}; + +export const CondenseOnRead: Story = { + name: `2 · ${condense.name}`, + render: () => {}, +}; + +export const HairlinePeek: Story = { + name: `3 · ${hairline.name}`, + render: () => {}, +}; + +export const StatusBar: Story = { + name: `4 · ${status.name}`, + render: () => {}, +}; + +export const ShortcutBar: Story = { + name: `5 · ${shortcuts.name}`, + render: () => {}, +}; + +export const ProgressRail: Story = { + name: `6 · ${progress.name}`, + render: () => {}, +}; + +export const BroadcastCredits: Story = { + name: `7 · ${credits.name}`, + render: () => {}, +}; + +export const Colophon: Story = { + name: `8 · ${colophon.name}`, + render: () => {}, +}; + +export const IdleReveal: Story = { + name: `9 · ${idle.name}`, + render: () => {}, +}; + +export const BrowserSeam: Story = { + name: `10 · ${seam.name}`, + render: () => {}, +}; diff --git a/packages/storybook/stories/extension/BubbleSafeStrips.stories.tsx b/packages/storybook/stories/extension/BubbleSafeStrips.stories.tsx new file mode 100644 index 00000000000..4f1c3139221 --- /dev/null +++ b/packages/storybook/stories/extension/BubbleSafeStrips.stories.tsx @@ -0,0 +1,317 @@ +import type { ReactElement } from 'react'; +import React, { useEffect, useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { + BUBBLE_SAFE_VARIANTS, + TOOLTIP_HEIGHT, + TOOLTIP_MAX_FRACTION, +} from './_bubbleSafeStrips'; +import { + MOCK_LEAD_SPONSOR, + MOCK_PARTNER_SPONSORS, +} from '@dailydotdev/shared/src/components/sponsors/mockSponsors'; +import { + Divider, + PartnerRow, + PrimaryLockup, +} from '@dailydotdev/shared/src/components/sponsors/SponsoredStrip'; +import ExtensionProviders from './_providers'; +import { MockFeedGrid, MockFeedHeader } from './_mockPostFeed'; + +// ============================================================= +// Ten technical fixes for one problem: the browser paints its +// link tooltip over the bottom-left corner, which is where the +// strip's paid mark sits. +// +// Every variant keeps the bar the reader already liked — flush, +// full width, sticky, one row, no float. What changes is +// geometry, ordering, or what the page hands the browser. +// +// HOW TO USE THIS PAGE: hover a card. The black box that appears +// bottom-left is a stand-in for the browser's real tooltip, at +// roughly its real size, showing the real href of whatever you +// are hovering. It is a simulation — the real one is browser +// chrome and cannot be rendered by a page — but it is anchored +// and sized like the real thing, so if a variant survives this +// it will survive Chrome. +// ============================================================= + +const strip = { + primary: MOCK_LEAD_SPONSOR, + partners: MOCK_PARTNER_SPONSORS, +}; + +const meta: Meta = { + title: 'Extension/Bubble-Safe Strips', + parameters: { layout: 'fullscreen' }, +}; + +export default meta; + +type Story = StoryObj; + +/** + * Stand-in for the browser's link tooltip: bottom-left, real + * href, roughly the real size. Chrome jumps it to the opposite + * corner when the pointer comes near, which this reproduces. + */ +const TooltipSim = (): ReactElement | null => { + const [href, setHref] = useState(null); + const [flip, setFlip] = useState(false); + + useEffect(() => { + const onOver = (event: PointerEvent) => { + const anchor = (event.target as HTMLElement)?.closest?.('a[href]'); + + setHref(anchor ? (anchor as HTMLAnchorElement).href : null); + }; + const onMove = (event: PointerEvent) => { + // Chrome relocates the bubble when the cursor nears it. + setFlip(event.clientY > window.innerHeight - 80 && event.clientX < 400); + }; + + document.addEventListener('pointerover', onOver, { passive: true }); + document.addEventListener('pointermove', onMove, { passive: true }); + + return () => { + document.removeEventListener('pointerover', onOver); + document.removeEventListener('pointermove', onMove); + }; + }, []); + + if (!href) { + return null; + } + + return ( + + {href} + + ); +}; + +const Note = ({ children }: { children: React.ReactNode }): ReactElement => ( +

+ {children} +

+); + +const Bench = ({ + children, + linkStyle, +}: { + children: ReactElement; + linkStyle?: 'long' | 'short' | 'tracking'; +}): ReactElement => ( + +
+
+
+ + +
+ {children} +
+ +
+
+); + +// --------------------------------------------------------------- +// The problem, and the index +// --------------------------------------------------------------- +export const Overview: Story = { + render: () => ( + +
+
+
+

+ Ten ways to keep the tooltip off the logos +

+ + The bar stays exactly as it is in all ten — flush, full width, + sticky, no float. Only the geometry, the ordering, or what the + page hands the browser changes. + + + + What the URLs actually measure. + {' '} + Counted on the live feed, 28 links on one screen, at the 12px UI + font the tooltip uses: nav and tag links run 33 characters and + 189px; post slugs run 68 characters and 393px, a quarter of a + 1440px screen; and promoted cards link through a signed token — + 742 characters — which Chrome clips at half the viewport. The + tooltip is exactly as wide as the URL inside it, so those numbers + are the fixes' pass mark. + + + + That rules the horizontal fixes out as guarantees. + {' '} + A gutter has to be 400px to survive an ordinary post and half the + bar to survive a promoted one, at which point there is no bar + left. A vertical clearance is 26px no matter what the URL says. + The holds column below is the honest verdict: “any URL” + means the fix does not care how long the link is. + + + Open any story and hover a card — a stand-in tooltip appears + bottom-left with the real href, at roughly the real size. + +
+ {[ + 'Give it nothing to cover', + 'Make it smaller', + 'Move only when it matters', + ].map((family) => ( +
+

+ {family} +

+ + + {BUBBLE_SAFE_VARIANTS.filter((v) => v.family === family).map( + (v) => ( + + + + + + + ), + )} + +
+ {v.name} + + {v.how} + + {v.cost} + + + { + { + width: 'holds · any URL', + slug: 'holds · post links only', + short: 'needs shorter URLs', + }[v.holds] + } + +
+
+ ))} +
+
+
+ ), +}; + +const [ + gutter, + right, + band, + shorthref, + narrow, + lift, + slide, + adaptive, + swap, + centred, +] = BUBBLE_SAFE_VARIANTS; + +export const Unfixed: Story = { + name: '0 · Unfixed (the problem)', + render: () => ( + +
+ + + +
+
+ ), +}; + +export const WorstCase: Story = { + name: '0b · Worst case (promoted card)', + render: () => ( + +
+ + + +
+
+ ), +}; + +export const LeftGutter: Story = { + name: `1 · ${gutter.name}`, + render: () => {}, +}; + +export const RightAnchored: Story = { + name: `2 · ${right.name}`, + render: () => ( + {} + ), +}; + +export const SacrificialBand: Story = { + name: `3 · ${band.name}`, + render: () => {}, +}; + +export const ShortHref: Story = { + name: `4 · ${shorthref.name}`, + render: () => ( + {} + ), +}; + +export const NarrowAnchor: Story = { + name: `5 · ${narrow.name}`, + render: () => {}, +}; + +export const LiftOnHover: Story = { + name: `6 · ${lift.name}`, + render: () => {}, +}; + +export const SlideOnHover: Story = { + name: `7 · ${slide.name}`, + render: () => {}, +}; + +export const Adaptive: Story = { + name: `8 · ${adaptive.name}`, + render: () => {}, +}; + +export const SwapEnds: Story = { + name: `9 · ${swap.name}`, + render: () => {}, +}; + +export const Centred: Story = { + name: `10 · ${centred.name}`, + render: () => {}, +}; diff --git a/packages/storybook/stories/extension/SponsorDock.stories.tsx b/packages/storybook/stories/extension/SponsorDock.stories.tsx new file mode 100644 index 00000000000..fb735956ad8 --- /dev/null +++ b/packages/storybook/stories/extension/SponsorDock.stories.tsx @@ -0,0 +1,246 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { SponsorDock } from '@dailydotdev/shared/src/components/sponsors/SponsorDock'; +import { + VALUE_RAILS, + LiveNowRail, + StreakRail, + TagMomentumRail, +} from '@dailydotdev/shared/src/components/sponsors/ValueRails'; +import { + MOCK_LEAD_SPONSOR, + MOCK_PARTNER_SPONSORS, +} from '@dailydotdev/shared/src/components/sponsors/mockSponsors'; +import { ValueRailSwitcher } from '@dailydotdev/shared/src/components/sponsors/ValueRailSwitcher'; +import ExtensionProviders from './_providers'; +import { MockFeedGrid, MockFeedHeader } from './_mockPostFeed'; + +// ============================================================= +// The dock — the sponsor row with a value rail stacked under it. +// +// Two problems, one shape. The browser's link-status bubble sits +// over the bottom corners and covers a flush bar most of the time +// on a feed full of links; and a permanent bar carrying only +// advertising is rent the reader never agreed to. Stacking a +// value rail underneath answers both: it takes the bubble instead +// of the paid row, and it gives the strip a reason to be there. +// +// Start with **Bubble Problem** for the argument, then **Rails** +// for the ten options, then **On The Feed** to see one in place. +// ============================================================= + +const strip = { + primary: MOCK_LEAD_SPONSOR, + partners: MOCK_PARTNER_SPONSORS, +}; + +const meta: Meta = { + title: 'Extension/Sponsor Dock', + parameters: { layout: 'fullscreen' }, +}; + +export default meta; + +type Story = StoryObj; + +const Page = ({ children }: { children: React.ReactNode }): ReactElement => ( +
{children}
+); + +const Note = ({ children }: { children: React.ReactNode }): ReactElement => ( +

+ {children} +

+); + +// --------------------------------------------------------------- +// Why the dock exists +// --------------------------------------------------------------- +const BubbleGhost = ({ label }: { label: string }): ReactElement => ( + + {label} + +); + +const LONG_URL = + 'https://app.daily.dev/posts/github-copilot-app-is-actually-good-just-not-with-copilot-nrl7lxzbn'; + +export const BubbleProblem: Story = { + name: 'Bubble problem', + render: () => ( + + +
+
+

+ Why the dock has two rows +

+ + Browsers draw a URL preview in the bottom corner whenever a link + is hovered. It is browser chrome: it paints over the page, cannot + be styled or detected, and moves to the opposite corner if the + cursor comes near it — so there is no safe side, only a safe + height. A feed is almost entirely links, so this is the normal + state, not an edge case. The black box below is a stand-in for it + at roughly its real size. + +
+ +
+
+ One row, flush — the paid slot is what gets covered +
+
+ + +
+
+ +
+
+ Two rows — the value rail takes it, the sponsor row is clear +
+
+ + + + +
+ + The rail opens with its label on the left, which is exactly where + the bubble lands. What it covers is the word “Trending”, not the + data — and the row underneath is ambient information that loses + nothing by being briefly half-covered, unlike the row someone paid + for. + +
+
+
+
+ ), +}; + +// --------------------------------------------------------------- +// The ten rails +// --------------------------------------------------------------- +export const Rails: Story = { + render: () => ( + + +
+
+

+ Ten value rails +

+ + Each one is mocked, but none of them needs new plumbing: the data + is already in the app — trendingTags, userStreak, the leaderboard + queries, live rooms, opportunities, poll posts. They are ordered + roughly from ambient to personal. The ambient ones are safer + (nothing to be wrong about) and the personal ones are stickier + (they change when the reader does). + +
+ {VALUE_RAILS.map(({ id, name, note, Rail }) => ( +
+
+ + {name} + + {note} +
+
+ +
+
+ ))} +
+
+
+ ), +}; + +// --------------------------------------------------------------- +// Each rail, docked under the sponsor row +// --------------------------------------------------------------- +export const Docked: Story = { + render: () => ( + + +
+ + The same ten, each stacked under the sponsor row as it would ship. + The dock is 72px total — a 40px sponsor row over a 32px rail. + + {VALUE_RAILS.map(({ id, name, Rail }) => ( +
+
+ {name} +
+
+ + + +
+
+ ))} +
+
+
+ ), +}; + +// --------------------------------------------------------------- +// In place on a feed +// --------------------------------------------------------------- +const OnFeed = ({ children }: { children: ReactElement }): ReactElement => ( + + +
+
+ + +
+ {children} +
+
+
+); + +export const OnTheFeed: Story = { + name: 'On the feed · tag momentum', + render: () => {}, +}; + +export const OnTheFeedStreak: Story = { + name: 'On the feed · your streak', + render: () => {}, +}; + +export const OnTheFeedLive: Story = { + name: 'On the feed · live now', + render: () => {}, +}; + +// --------------------------------------------------------------- +// The rail as a channel the reader picks +// --------------------------------------------------------------- +export const Switcher: Story = { + name: 'On the feed · switchable rail', + render: () => ( + + +
+
+ + +
+ + + +
+
+
+ ), +}; diff --git a/packages/storybook/stories/extension/SponsoredStrip.stories.tsx b/packages/storybook/stories/extension/SponsoredStrip.stories.tsx new file mode 100644 index 00000000000..5d748b334e6 --- /dev/null +++ b/packages/storybook/stories/extension/SponsoredStrip.stories.tsx @@ -0,0 +1,663 @@ +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { + SponsorFeedBand, + SponsorFeedCard, + SponsorRailInline, + SponsorRailPinned, + SponsorLogo, + SponsorSideRail, + type Sponsor, + type SponsoredStripProps, +} from '@dailydotdev/shared/src/components/sponsors/SponsoredStrip'; +import ExtensionProviders from './_providers'; +import { MockFeedGrid, MockFeedHeader } from './_mockPostFeed'; +import { CodeRabbitLockup } from '@dailydotdev/shared/src/components/sponsors/CodeRabbitLockup'; +import { + MOCK_LEAD_SPONSOR, + MOCK_PARTNER_SPONSORS, +} from '@dailydotdev/shared/src/components/sponsors/mockSponsors'; + +// ============================================================= +// Sponsored strip — five ways to put a "presented by" slot and a +// twelve-logo partner wall on the new tab, evaluated over a real feed. +// +// Reference: TBPN's lower third — a pinned "Presented by " +// card plus a "Made possible by" logo ticker. That show can hold a +// bar on screen forever because the bar covers nothing. Our new tab +// is a reading surface, so the ticker is dropped (motion in the +// periphery is the disruption we are avoiding) and each concept is +// scored on how much feed it costs. Start with **Evaluation**. +// +// Logos are real daily.dev advertiser marks pulled from +// business.daily.dev — placeholders for layout only, not a claim +// that any of these companies has bought this slot. +// ============================================================= + +const LOGO_BASE = 'https://business.daily.dev/assets/company-logos'; + +const sponsor = (name: string, file: string, ratio: number): Sponsor => ({ + name, + logo: `${LOGO_BASE}/${file}.svg`, + ratio, +}); + +// The lead and partner fixtures are imported, not redeclared: the feed +// wiring renders these exact objects, so what is reviewed here is what +// ships to the preview. Provenance and the two-tone rationale live in +// mockSponsors.ts and NvidiaLockup.tsx. +const PRIMARY = MOCK_LEAD_SPONSOR; +const PARTNERS = MOCK_PARTNER_SPONSORS; + +// Marks whose artwork defeats the silhouette treatment, kept for the +// LogoTreatment story so the constraint is shown rather than asserted. +// (A backplate inside a is harmless — Redis has one +// and masks fine — so the check is what gets painted, not what exists.) +const ASSET_PROBLEMS: (Sponsor & { reason: string })[] = [ + { + ...sponsor('Postman', 'postman', 512 / 156), + reason: 'white knockouts fill in', + }, + { + ...sponsor('Notion', 'notion', 512 / 178), + reason: 'white knockouts fill in', + }, + { + ...sponsor('GitLab', 'gitlab', 1), + reason: 'raster WebP in an .svg wrapper, no alpha', + }, +]; + +// Candidates for the coloured lead slot, with the check that +// matters: does every ink survive both grounds? Rendered side by side +// in LogoTreatment against a normal and an `.invert` ground. +const PRIMARY_CANDIDATES: (Sponsor & { verdict: string })[] = [ + { + ...PRIMARY, + verdict: 'two-tone: green symbol, wordmark on currentColor', + }, + { + name: 'CodeRabbit', + ratio: 2152 / 314, + Artwork: CodeRabbitLockup, + verdict: 'two-tone as well — now a regular mark in the wall', + }, + { + ...sponsor('Google', 'google', 75 / 24), + verdict: 'four inks, 0.40–0.74 luminance — the alternative', + }, + { + ...sponsor('Appwrite', 'appwrite', 512 / 91), + verdict: 'a single #f02e65 — holds on both', + }, + { + ...sponsor('Sentry', 'sentry', 512 / 113), + verdict: '#362D59 at 0.20 luminance — sinks into the dark ground', + }, + { + ...sponsor('Notion', 'notion', 512 / 178), + verdict: 'black wordmark — gone on the dark ground', + }, +]; + +const stripProps: Pick = { + primary: PRIMARY, + partners: PARTNERS, +}; + +const meta: Meta = { + title: 'Extension/Sponsored Strip', + parameters: { layout: 'fullscreen' }, + argTypes: { + monochrome: { + control: 'boolean', + description: + 'Silhouette logos in the surrounding text colour. Off shows the brand-colour originals.', + }, + }, + args: { monochrome: true, ...stripProps }, +}; + +export default meta; + +type Story = StoryObj; + +const Page = ({ children }: { children: ReactNode }): ReactElement => ( +
{children}
+); + +const Note = ({ children }: { children: ReactNode }): ReactElement => ( +

+ {children} +

+); + +// --------------------------------------------------------------- +// A. Pinned rail +// --------------------------------------------------------------- +export const PinnedRail: Story = { + name: 'A · Pinned rail', + render: (args) => ( + + +
+
+ + +
+ +
+
+
+ ), +}; + +// --------------------------------------------------------------- +// B. Inline rail +// --------------------------------------------------------------- +export const InlineRail: Story = { + name: 'B · Inline rail', + render: (args) => ( + + +
+ +
+ +
+ +
+
+
+ ), +}; + +// --------------------------------------------------------------- +// C. Feed band +// --------------------------------------------------------------- +export const FeedBand: Story = { + name: 'C · Feed band', + render: (args) => ( + + +
+ + } + insertAfter={3} + /> +
+
+
+ ), +}; + +// --------------------------------------------------------------- +// D. Card slot +// --------------------------------------------------------------- +export const CardSlot: Story = { + name: 'D · Card slot', + render: (args) => ( + + +
+ + } + insertAfter={4} + /> +
+
+
+ ), +}; + +// --------------------------------------------------------------- +// E. Side rail +// --------------------------------------------------------------- +export const SideRail: Story = { + name: 'E · Side rail', + render: (args) => ( + + +
+
+ + +
+ +
+
+
+ ), +}; + +// --------------------------------------------------------------- +// Gallery — all five, same data, no feed +// --------------------------------------------------------------- +const CONCEPTS = [ + { + id: 'A', + name: 'Pinned rail', + note: 'the literal translation of the reference: bottom edge, whole session', + render: (args: SponsoredStripProps) => ( + + ), + }, + { + id: 'B', + name: 'Inline rail', + note: 'same bar, in flow above the first card row — scrolls away', + render: (args: SponsoredStripProps) => , + }, + { + id: 'C', + name: 'Feed band', + note: 'full-width row between card rows; wraps instead of clipping', + render: (args: SponsoredStripProps) => , + }, + { + id: 'D', + name: 'Card slot', + note: 'takes one post card’s place in the grid', + render: (args: SponsoredStripProps) => ( +
+ +
+ ), + }, + { + id: 'E', + name: 'Side rail', + note: 'no feed displaced at all, lowest attention, laptop and up only', + render: (args: SponsoredStripProps) => , + }, +]; + +export const Gallery: Story = { + render: (args) => ( + + +
+ {CONCEPTS.map((concept) => ( +
+
+ + {concept.id} · {concept.name} + + + {concept.note} + +
+ {concept.render(args)} +
+ ))} +
+
+
+ ), +}; + +// --------------------------------------------------------------- +// Logo treatment — why the silhouette default exists +// --------------------------------------------------------------- +const Ground = ({ + children, + invert, + label, +}: { + children: ReactNode; + invert?: boolean; + label: string; +}): ReactElement => ( +
+ + {label} + + {children} +
+); + +export const LogoTreatment: Story = { + render: (args) => ( + + +
+ + The strip splits its logo treatment. The lead slot keeps its brand + colour and sits ~20% larger — it is the thing being paid for, and + one coloured mark ranked above a neutral wall is the whole + hierarchy. The partner wall goes to silhouettes that inherit the + strip's text colour, so twelve marks stay even-weighted and + none of them out-shouts a post. + + +
+
+ The lead slot has to clear both grounds +
+ + Keeping brand colour means a flat asset gets no help from the + theme, so any ink near black or near white dies on one side. + NVIDIA is the exception that proves the rule: its lockup is + rendered inline rather than as a file, so the symbol holds #76B900 + while the wordmark rides `currentColor` and flips with the + background — which is what the brand's own black and white + wordmarks do, from one source instead of two. Both columns are the + same asset on both grounds at once — the right one uses the + app's own .invert class, so it is the real + theme, not a mock-up of it, and the pair swaps when you flip the + toolbar. Of the library's vector wordmarks only a handful + pass; the bottom two are why Sentry was dropped as the primary. + +
+ {PRIMARY_CANDIDATES.map((candidate) => ( +
+ + {candidate.name} + + + {candidate.verdict} + +
+ + + + + + +
+
+ ))} +
+
+ +
+
+ The wall, silhouetted (default) +
+ +
+
+
+ The wall, in original brand colour +
+ + Ten marks all fighting for their own colour, several of them + near-black. This is what the silhouette default is avoiding. + + +
+ +
+
+ Where the silhouette breaks +
+ + An alpha mask keeps the artwork's outline, so anything opaque + comes through solid. Two failure modes show up in the existing + advertiser library: white knockouts fill in, and 31 of its 66 + wordmark files are raster images wrapped in an .svg, which mask to + a plain block. The fix is not code — a single-colour vector asset + has to be part of the slot spec. + +
+ {ASSET_PROBLEMS.map((logo) => ( +
+ + {logo.name} + + + {logo.reason} + + + + + +
+ ))} +
+
+
+
+
+ ), +}; + +// --------------------------------------------------------------- +// Evaluation +// --------------------------------------------------------------- +type Row = { + id: string; + concept: string; + cost: string; + exposure: string; + legibility: string; + mobile: string; +}; + +const ROWS: Row[] = [ + { + id: 'A', + concept: 'Pinned rail', + cost: '40px of the feed column for the whole session; 72px once a value rail is docked under it', + exposure: 'Every session, continuously', + legibility: + 'All twelve from ~1250px, then the wall trims to fit: 11 at 1200, 9 at 1000, 7 at 800, 1 at 375 — always whole marks', + mobile: + 'Thin — the 196px lockup leaves room for one partner at 375px, and it lands on the browser’s own bottom chrome', + }, + { + id: 'B', + concept: 'Inline rail', + cost: '44px once, above the fold', + exposure: 'Until the first scroll', + legibility: 'Same trimming as A, without the feed-column padding', + mobile: 'Thin — one or two partners at 375px', + }, + { + id: 'C', + concept: 'Feed band', + cost: '89px at 1440px, 175px at 375px', + exposure: 'On scroll past, then gone', + legibility: + 'All twelve at every width — six columns wide, four then three as it narrows', + mobile: 'Good — wraps under the lockup', + }, + { + id: 'D', + concept: 'Card slot', + cost: 'One post card — 406px at 1440px', + exposure: 'On scroll past, then gone', + legibility: 'All twelve — two-column grid, no clipping', + mobile: 'Good — full-width card', + }, + { + id: 'E', + concept: 'Side rail', + cost: 'None', + exposure: 'Whole session, in the periphery', + legibility: 'All twelve — two-column grid', + mobile: 'Absent — no rail below laptop', + }, +]; + +const Cell = ({ children }: { children: ReactNode }): ReactElement => ( + + {children} + +); + +const Head = ({ children }: { children: ReactNode }): ReactElement => ( + + {children} + +); + +export const Evaluation: Story = { + render: () => ( + + +
+
+

+ Sponsored strip — how it lands on the feed +

+

+ One lead sponsor plus twelve partner logos, in five placements. + The columns below are geometry, measured off these very stories at + 375px and 1440px. They are not performance data — nothing here has + been A/B tested. +

+
+ + + + + Concept + Feed it costs + Exposure + 12 logos legible? + Mobile / narrow + + + + {ROWS.map((row) => ( + + + + {row.id} · {row.concept} + + + {row.cost} + {row.exposure} + {row.legibility} + {row.mobile} + + ))} + +
+ +
+

+ What the reference does that a feed cannot +

+

+ A broadcast lower third is free real estate: the video is already + letterboxed, and a viewer who is watching is not reading. A pinned + rail on the new tab spends feed on every session instead, and a + moving ticker spends attention on every session — which is why + every concept here is static, and why only A and E persist. +

+

+ How many marks the wall shows +

+

+ The rails no longer clip. The row measures itself and renders only + the marks that fit whole, so an advertiser is either shown or not + — never a half logo fading out at the edge. Twelve marks at a 16px + cap measure an 889px run, and the “Made possible by” lockup takes + another 196px, so all twelve need about 1250px. Below that the + wall steps down: 11 at 1200px, 9 at 1000px, 7 at 800px, 1 at + 375px, measured off these stories. +

+

+ Those are story widths. On the feed the sidebar and the column + padding take their cut first, so the same viewport yields a + narrower row and one fewer mark — 8 rather than 9 at 1000px, + measured on the preview. Read the numbers as the shape of the + curve, not as a rate card. +

+

+ That step-down is the real inventory question, and it is a + commercial one rather than a layout one: twelve slots are only + twelve slots on a large desktop, and a laptop buyer is sharing a + shorter wall. The dials are slot count, the 16px cap (13px would + buy back about 120px, at marks small enough to be decoration), the + lead mark's size, and the label. The wrapping concepts (C, D, E) + hold all twelve at every width instead, which is the argument for + pairing the rail with one of them rather than relying on it alone. +

+

+ The bar is flush, and something else takes the tooltip +

+

+ Browsers draw their link-status bubble over the bottom corners, so + anything flush to the edge of a feed — which is nearly all links — + is covered most of the time. Floating the bar clear of the edge + fixes it and reads badly: it detaches from the product. The answer + is to stack instead, putting a value rail underneath to take the + hit. See the Sponsor Dock story; concept A on its own still has + the problem. +

+

+ The wall rotates every load +

+

+ Partner order is reshuffled on every page load, so no advertiser + is permanently first and — since the row trims from the end — none + is permanently the one that gets dropped on a narrow window. Over + a run of sessions the exposure evens out on its own. The lead slot + never moves: it is the one that was paid for by position. The + shuffle waits for mount rather than running during render, so the + server and client agree on the first paint. +

+

+ Only the lead mark is interactive +

+

+ The lead sponsor's mark is a link to their site and the only + thing in the strip that answers a cursor — it lifts 5% on hover. + The partner wall is inert: no links, no hover, no focus stops. + That keeps the one paid click target unambiguous, and it keeps a + row of twelve hover states from competing with the posts they sit + between. It also means the wall costs nothing in keyboard + navigation, which matters most for the pinned rail, where it would + otherwise sit in the tab order of every session. +

+

+ The asset spec matters more than the layout +

+

+ Every concept renders logos as silhouettes so one asset works in + both themes. That only holds if the file is a clean vector: in the + current advertiser library 31 of 66 wordmarks are raster images + wrapped in an .svg and mask to a solid block, and several more + carry knockouts that fill in. See the LogoTreatment story. Whoever + sells this slot needs a single-colour vector in the spec. +

+

+ Suggested pairing +

+

+ C or D for the partner wall, since they hold all twelve and give + the space back on scroll, with the lead sponsor also carried in E + where the rail exists. A is the only option that guarantees a + session-long impression, and the only one that never returns the + pixels — worth testing against the header-ad experiment rather + than shipping alongside it. +

+
+
+
+
+ ), +}; diff --git a/packages/storybook/stories/extension/_bottomStripVariants.tsx b/packages/storybook/stories/extension/_bottomStripVariants.tsx new file mode 100644 index 00000000000..fd0f5e338d8 --- /dev/null +++ b/packages/storybook/stories/extension/_bottomStripVariants.tsx @@ -0,0 +1,481 @@ +import type { ReactElement } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; +import classNames from 'classnames'; +import type { SponsoredStripProps } from '@dailydotdev/shared/src/components/sponsors/SponsoredStrip'; +import { + Divider, + Label, + PARTNER_CAP, + PartnerRow, + PrimaryLockup, + SponsorLogo, + useShuffledSponsors, +} from '@dailydotdev/shared/src/components/sponsors/SponsoredStrip'; + +// ============================================================= +// Ten ways to hold a sponsor strip at the bottom of the feed. +// +// The brief: always there, never reading as an ad bar. A bottom +// strip has one problem the other placements do not — it is +// permanent, so it has to justify its permanence every second it +// is on screen. Broadly there are three ways to earn it: +// +// Get out of the way — be present but yield while reading +// (1 Retract, 2 Condense, 3 Hairline, +// 9 Idle reveal) +// Become chrome — look like part of the tool rather than +// part of the page (4 Status bar, +// 5 Shortcut bar, 10 Browser seam) +// Do a job — carry something functional, so the row +// is not only inventory (6 Progress) +// +// And one that earns it by restraint rather than utility: +// 7 Broadcast credits shows a single partner at a time, and +// 8 Colophon drops the panel entirely. +// +// The behavioural ones need scroll or idle state, so they are +// best judged over a real feed, not in a static frame. +// ============================================================= + +type VariantProps = Pick; + +// --- shared behaviour ----------------------------------------- + +/** Which way the reader is going: `down` means they are reading. */ +const useScrollDirection = (): 'up' | 'down' => { + const [direction, setDirection] = useState<'up' | 'down'>('up'); + + useEffect(() => { + let last = window.scrollY; + + const onScroll = () => { + const y = window.scrollY; + + // A threshold keeps sub-pixel jitter from flapping the bar. + if (Math.abs(y - last) > 8) { + setDirection(y > last ? 'down' : 'up'); + last = y; + } + }; + + window.addEventListener('scroll', onScroll, { passive: true }); + + return () => window.removeEventListener('scroll', onScroll); + }, []); + + return direction; +}; + +/** True once the reader has stopped moving for `delay`. */ +const useIdle = (delay = 1200): boolean => { + const [idle, setIdle] = useState(true); + const timer = useRef(); + + useEffect(() => { + const bump = () => { + setIdle(false); + window.clearTimeout(timer.current); + timer.current = window.setTimeout(() => setIdle(true), delay); + }; + + window.addEventListener('scroll', bump, { passive: true }); + window.addEventListener('pointermove', bump, { passive: true }); + bump(); + + return () => { + window.clearTimeout(timer.current); + window.removeEventListener('scroll', bump); + window.removeEventListener('pointermove', bump); + }; + }, [delay]); + + return idle; +}; + +/** How far down the page the reader is, 0–1. */ +const useScrollProgress = (): number => { + const [progress, setProgress] = useState(0); + + useEffect(() => { + const onScroll = () => { + const el = document.documentElement; + const max = el.scrollHeight - el.clientHeight; + + setProgress(max > 0 ? Math.min(1, window.scrollY / max) : 0); + }; + + onScroll(); + window.addEventListener('scroll', onScroll, { passive: true }); + + return () => window.removeEventListener('scroll', onScroll); + }, []); + + return progress; +}; + +/** Cycles an index every `ms`, for the one-at-a-time variants. */ +const useRotatingIndex = (length: number, ms: number): number => { + const [index, setIndex] = useState(0); + + useEffect(() => { + if (length < 2) { + return undefined; + } + + const id = window.setInterval(() => setIndex((i) => (i + 1) % length), ms); + + return () => window.clearInterval(id); + }, [length, ms]); + + return index; +}; + +const dockBase = + 'sticky bottom-0 z-3 flex w-full items-center gap-5 border-t border-border-subtlest-tertiary bg-background-default px-4 laptop:px-10'; + +// --- 1. Retract on read --------------------------------------- +// Scrolling down means reading, so the bar leaves. Scrolling up +// means looking for something, so it comes back. The pattern +// mobile browsers use for their own chrome, which is why it reads +// as the product behaving rather than an ad hiding. +export const RetractStrip = ({ + partners, + primary, +}: VariantProps): ReactElement => { + const direction = useScrollDirection(); + + return ( +
+ + + +
+ ); +}; + +// --- 2. Condense on read -------------------------------------- +// Never gone, just smaller: the wall collapses to a count while +// the reader moves, and unfolds when they stop. The paid mark is +// on screen the whole time, which is the part that was sold. +export const CondenseStrip = ({ + partners, + primary, +}: VariantProps): ReactElement => { + const direction = useScrollDirection(); + const condensed = direction === 'down'; + + return ( +
+ + + {condensed ? ( + + with {partners.length} partners + + ) : ( + + )} +
+ ); +}; + +// --- 3. Hairline peek ----------------------------------------- +// At rest it is a 6px seam with nothing in it. Approach the +// bottom of the window and it opens. Costs almost no feed and +// asks for no attention, at the price of most impressions. +export const HairlineStrip = ({ + partners, + primary, +}: VariantProps): ReactElement => { + const [open, setOpen] = useState(false); + + return ( +
setOpen(true)} + onMouseLeave={() => setOpen(false)} + > + {open && ( + <> + + + + + )} +
+ ); +}; + +// --- 4. Status bar -------------------------------------------- +// Dressed as an editor status bar: shorter, monospaced, muted, +// no border radius anywhere. Developers read this shape as part +// of the tool, not as a placement in it. +export const StatusBarStrip = ({ + partners, + primary, +}: VariantProps): ReactElement => ( +
+ + sponsored + + + + +
+); + +// --- 5. Shortcut bar ------------------------------------------ +// The row carries the keyboard hints the app already supports and +// puts the sponsor at the end of it. Permanence stops needing an +// argument: the bar is useful, and a sponsor sits on it. +export const ShortcutBarStrip = ({ + partners, + primary, +}: VariantProps): ReactElement => ( +
+ {[ + { keys: '⌘K', label: 'Search' }, + { keys: 'J / K', label: 'Next · previous' }, + { keys: 'B', label: 'Bookmark' }, + ].map(({ keys, label }) => ( + + + {keys} + + {label} + + ))} + + + + + + + +
+); + +// --- 6. Progress rail ----------------------------------------- +// The strip's top edge is the feed's scroll progress. It does a +// job the page needs done, so it is chrome that happens to be +// sponsored rather than a banner that happens to be pinned. +export const ProgressStrip = ({ + partners, + primary, +}: VariantProps): ReactElement => { + const progress = useScrollProgress(); + + return ( +
+ + + + + + +
+ ); +}; + +// --- 7. Broadcast credits ------------------------------------- +// One partner at a time, crossfading — the way closing credits or +// a lower third actually behave. Twelve logos at once is a wall; +// one logo at a time is a mention, and reads far more premium. +export const CreditsStrip = ({ + partners, + primary, +}: VariantProps): ReactElement => { + const rotated = useShuffledSponsors(partners); + const index = useRotatingIndex(rotated.length, 4000); + const current = rotated[index]; + + return ( +
+ + + + + {current && ( + + )} + + + {index + 1} / {rotated.length} + +
+ ); +}; + +// --- 8. Colophon ---------------------------------------------- +// No panel, no border, no ground: the marks sit straight on the +// page at low contrast, the way a colophon or a print credit +// does. The least ad-like option available, and the easiest to +// miss entirely. +export const ColophonStrip = ({ + partners, + primary, +}: VariantProps): ReactElement => ( +
+ + + +
+); + +// --- 9. Idle reveal ------------------------------------------- +// The inverse of retracting: minimal while anything is happening, +// full when the reader stops. Sponsors get the pause, the reader +// gets the motion. +export const IdleRevealStrip = ({ + partners, + primary, +}: VariantProps): ReactElement => { + const idle = useIdle(1200); + + return ( +
+ + {idle && ( + <> + + + + )} +
+ ); +}; + +// --- 10. Browser seam ----------------------------------------- +// Styled as a continuation of the browser's own bottom edge +// rather than the page's: darker than the feed, inset shadow, no +// top border. On a new tab the effect is that the window is +// slightly taller than the page, and the strip is part of the +// frame. +export const SeamStrip = ({ + partners, + primary, +}: VariantProps): ReactElement => ( +
+ + + +
+); + +export const BOTTOM_VARIANTS: { + id: string; + name: string; + family: string; + note: string; + Strip: (props: VariantProps) => ReactElement; +}[] = [ + { + id: 'retract', + name: 'Retract on read', + family: 'Get out of the way', + note: 'leaves on scroll down, returns on scroll up — the pattern browsers use for their own chrome', + Strip: RetractStrip, + }, + { + id: 'condense', + name: 'Condense on read', + family: 'Get out of the way', + note: 'the wall collapses to a count while moving; the paid mark never leaves', + Strip: CondenseStrip, + }, + { + id: 'hairline', + name: 'Hairline peek', + family: 'Get out of the way', + note: '6px seam at rest, opens on approach — cheapest in feed, dearest in impressions', + Strip: HairlineStrip, + }, + { + id: 'status', + name: 'Status bar', + family: 'Become chrome', + note: 'dressed as an editor status bar; developers read this shape as tooling', + Strip: StatusBarStrip, + }, + { + id: 'shortcuts', + name: 'Shortcut bar', + family: 'Become chrome', + note: 'carries the app’s keyboard hints, so the row is useful before it is sold', + Strip: ShortcutBarStrip, + }, + { + id: 'progress', + name: 'Progress rail', + family: 'Do a job', + note: 'its top edge is the feed’s scroll progress — chrome that happens to be sponsored', + Strip: ProgressStrip, + }, + { + id: 'credits', + name: 'Broadcast credits', + family: 'Earn it by restraint', + note: 'one partner at a time, crossfading — a mention rather than a wall', + Strip: CreditsStrip, + }, + { + id: 'colophon', + name: 'Colophon', + family: 'Earn it by restraint', + note: 'no panel at all: low-contrast marks on the page, brightening on hover', + Strip: ColophonStrip, + }, + { + id: 'idle', + name: 'Idle reveal', + family: 'Get out of the way', + note: 'minimal while anything moves, full when the reader stops', + Strip: IdleRevealStrip, + }, + { + id: 'seam', + name: 'Browser seam', + family: 'Become chrome', + note: 'reads as the window’s bottom edge rather than the page’s', + Strip: SeamStrip, + }, +]; diff --git a/packages/storybook/stories/extension/_bubbleSafeStrips.tsx b/packages/storybook/stories/extension/_bubbleSafeStrips.tsx new file mode 100644 index 00000000000..f4cdcf5ae49 --- /dev/null +++ b/packages/storybook/stories/extension/_bubbleSafeStrips.tsx @@ -0,0 +1,433 @@ +import type { ReactElement } from 'react'; +import React, { useEffect, useState } from 'react'; +import classNames from 'classnames'; +import type { SponsoredStripProps } from '@dailydotdev/shared/src/components/sponsors/SponsoredStrip'; +import { + Divider, + PartnerRow, + PrimaryLockup, +} from '@dailydotdev/shared/src/components/sponsors/SponsoredStrip'; + +// ============================================================= +// Ten technical answers to one problem: the browser's link +// tooltip covers the bottom-left of the viewport, and that is +// where the strip's paid mark sits. +// +// Constraint for all ten: the bar looks like the original. Flush +// to the bottom, full feed width, sticky, one row, opaque, no +// float. What changes is never the look — it is the geometry, +// the ordering, or what the page tells the browser to display. +// +// What is actually known about the tooltip, because the fixes +// depend on it: +// - it is browser chrome, painted over the page; it cannot be +// read, styled or suppressed from JS +// - it is anchored to the bottom-left corner of the viewport, +// and jumps to the bottom-right if the pointer comes near it +// - its height is roughly 20-25px at 100% zoom and scales with +// page zoom +// - its WIDTH is the width of the URL being shown, truncated +// around half the viewport +// +// That last point is the one most people miss, and it is the +// cheapest lever available: a shorter href is a smaller tooltip. +// +// Three families: +// Give it nothing to cover — 1, 2, 3, 10 +// Make it smaller — 4, 5 +// Move only when it matters — 6, 7, 8, 9 +// ============================================================= + +type VariantProps = Pick; + +/** The original bar, unchanged, for every variant to build on. */ +const BAR = + 'sticky bottom-0 z-3 flex w-full items-center gap-5 border-t border-border-subtlest-tertiary bg-background-default px-4 laptop:px-10'; + +/** + * Height is fixed and small. Width is neither — and that + * asymmetry decides which of the fixes below actually work. + * + * Measured on the live feed, 28 links on one screen, at the 12px + * UI font the tooltip uses: + * + * nav and tag links 33 chars 189px 13% of a 1440 screen + * post slugs 68 chars 393px 27% + * ad click-throughs 742 chars clipped 50% (the cap) + * + * So a horizontal clearance has to be ~400px to survive an + * ordinary post and half the viewport to survive a promoted card. + * At that point the bar has no room left. A vertical clearance is + * 26px whatever the URL says, which is why the width-independent + * fixes are the ones that hold. + */ +export const TOOLTIP_HEIGHT = 26; + +/** Clears nav links. Does NOT clear a post slug — see above. */ +export const TOOLTIP_SAFE_WIDTH = 320; + +/** What a post slug actually needs. */ +export const TOOLTIP_SLUG_WIDTH = 400; + +/** Chrome truncates around here, so this is the true worst case. */ +export const TOOLTIP_MAX_FRACTION = 0.5; + +// --- shared behaviour ----------------------------------------- + +/** + * The href currently under the pointer, or null. This is as close + * to detecting the tooltip as the platform allows: we cannot see + * it, but we know exactly when the browser is about to draw one, + * and for which URL. + */ +export const useHoveredHref = (): string | null => { + const [href, setHref] = useState(null); + + useEffect(() => { + const onOver = (event: PointerEvent) => { + const anchor = (event.target as HTMLElement)?.closest?.('a[href]'); + + setHref(anchor ? (anchor as HTMLAnchorElement).href : null); + }; + + document.addEventListener('pointerover', onOver, { passive: true }); + + return () => document.removeEventListener('pointerover', onOver); + }, []); + + return href; +}; + +// --- 1. Left gutter ------------------------------------------- +// Keep the bar identical and start its content past the tooltip. +// Sized to clear a post slug (400px), which covers the common +// case — but NOT a promoted card's click-through, which is +// clipped at half the viewport and would need a gutter wider than +// the content it protects. Good for ordinary links, not a +// guarantee. +export const GutterStrip = ({ + partners, + primary, +}: VariantProps): ReactElement => ( +
+ + + + +
+); + +// --- 2. Right-anchored ---------------------------------------- +// Same bar, reversed reading order: the paid mark sits at the +// right end and the wall fills back towards the left, so what the +// tooltip covers is the tail of the wall. Paired with the wall's +// per-load shuffle, the mark it buries is a different one every +// session rather than the same one for ever. +export const RightAnchoredStrip = ({ + partners, + primary, +}: VariantProps): ReactElement => ( +
+ + + +
+); + +// --- 3. Sacrificial band -------------------------------------- +// A taller bar whose content is top-aligned. The bottom 26px are +// padding — the tooltip lands in dead space inside the bar rather +// than on the marks. Reads as a slightly roomier strip, and the +// occlusion problem disappears without anything moving. +export const BandStrip = ({ + partners, + primary, +}: VariantProps): ReactElement => ( +
+ + + +
+); + +// --- 4. Short href -------------------------------------------- +// Nothing changes in the strip at all. The tooltip is as wide as +// the URL inside it, so serving cards a short canonical link +// (/p/, 301ing to the slug) shrinks it from most of the bar +// to a stub that a modest gutter already clears. The only fix +// here that costs no layout whatsoever — it is a routing change. +export const ShortHrefStrip = ({ + partners, + primary, +}: VariantProps): ReactElement => ( +
+ + + + +
+); + +// --- 5. Narrow anchor ----------------------------------------- +// The tooltip appears when an anchor is hovered, and a feed card +// is usually one big anchor. Shrinking the link to the title +// alone means most of the pointer's time over a card produces no +// tooltip at all. Fewer appearances rather than a safer strip — +// best combined with one of the others. +export const NarrowAnchorStrip = ShortHrefStrip; + +// --- 6. Lift on hover ----------------------------------------- +// Flush and identical until the moment a link is hovered, then it +// rises by the tooltip's height and settles back. Nothing floats: +// at rest it is exactly the original bar. The motion is tied to +// an intent the reader just expressed, so it reads as the product +// responding rather than as a widget hiding. +export const LiftOnHoverStrip = ({ + partners, + primary, +}: VariantProps): ReactElement => { + const href = useHoveredHref(); + + return ( +
+ + + +
+ ); +}; + +// --- 7. Slide on hover ---------------------------------------- +// The same trigger with no vertical movement at all: the bar's +// contents shift right past the tooltip's reach and slide back. +// The bar itself never moves, so the layout is completely still — +// only what is inside it travels. +export const SlideOnHoverStrip = ({ + partners, + primary, +}: VariantProps): ReactElement => { + const href = useHoveredHref(); + + return ( +
+
+ + + +
+
+ ); +}; + +// --- 8. Adaptive ---------------------------------------------- +// Reacts only when it needs to. We cannot see the tooltip, but we +// know the href the browser is about to draw, so we can estimate +// its width and move only for the long ones. Short links leave +// the bar completely still — most hovers cost nothing. +export const AdaptiveStrip = ({ + partners, + primary, +}: VariantProps): ReactElement => { + const href = useHoveredHref(); + // ~5.8px per character at the tooltip's 12px UI font, checked + // against real hrefs. Worth keeping even though today's links + // trip it almost every time: if the URLs get shorter, this stops + // moving on its own rather than needing to be removed. + const estimated = href ? href.length * 5.8 : 0; + const shift = estimated > TOOLTIP_SAFE_WIDTH; + + return ( +
+ + + +
+ ); +}; + +// --- 9. Swap ends --------------------------------------------- +// No movement and no lost space: when a link is hovered the lead +// mark and the wall trade places, so the paid mark leaves the +// corner the tooltip is about to occupy and the wall's tail takes +// it instead. The bar's geometry is untouched. +export const SwapEndsStrip = ({ + partners, + primary, +}: VariantProps): ReactElement => { + const href = useHoveredHref(); + + return ( +
+ + + +
+ ); +}; + +// --- 10. Centred -------------------------------------------- +// Both corners left empty and the content centred between them. +// The tooltip owns the left corner, and — since it jumps corners +// when the pointer nears it — the right one is spoken for too. +// This is the only static layout that is safe from both. +export const CentredStrip = ({ + partners, + primary, +}: VariantProps): ReactElement => ( +
+ + + +
+ +
+ +
+); + +export const BUBBLE_SAFE_VARIANTS: { + id: string; + name: string; + family: string; + how: string; + cost: string; + /** + * Does it hold when the URL is long? `width` means the fix is a + * vertical or structural one and does not care; `slug` means it + * survives an ordinary post link but not a promoted card's; + * `short` means it only works if the URLs are shortened first. + */ + holds: 'width' | 'slug' | 'short'; + Strip: (props: VariantProps) => ReactElement; + linkStyle?: 'long' | 'short'; + narrowAnchor?: boolean; +}[] = [ + { + id: 'gutter', + holds: 'slug', + name: 'Left gutter', + family: 'Give it nothing to cover', + how: 'content starts 320px in, so the corner the tooltip owns is empty', + cost: '320px of horizontal room; nothing else', + Strip: GutterStrip, + }, + { + id: 'right', + holds: 'width', + name: 'Right-anchored', + family: 'Give it nothing to cover', + how: 'lead mark at the right end, wall filling leftward into the danger zone', + cost: 'reversed reading order; the tooltip jumps right if the pointer follows it', + Strip: RightAnchoredStrip, + }, + { + id: 'band', + holds: 'width', + name: 'Sacrificial band', + family: 'Give it nothing to cover', + how: 'taller bar, content top-aligned, bottom 26px left as padding', + cost: '26px more feed, permanently', + Strip: BandStrip, + }, + { + id: 'shorthref', + holds: 'short', + name: 'Short href', + family: 'Make it smaller', + how: 'cards link to /p/; the tooltip is as wide as the URL in it', + cost: 'a routing change, not a layout one — needs a 301 to the slug', + Strip: ShortHrefStrip, + linkStyle: 'short', + }, + { + id: 'narrow', + holds: 'short', + name: 'Narrow anchor', + family: 'Make it smaller', + how: 'only the title is a link, so most of the pointer’s time over a card draws nothing', + cost: 'a smaller click target; reduces frequency rather than risk', + Strip: NarrowAnchorStrip, + narrowAnchor: true, + }, + { + id: 'lift', + holds: 'width', + name: 'Lift on hover', + family: 'Move only when it matters', + how: 'flush at rest; rises by the tooltip’s height while a link is hovered', + cost: 'vertical motion, tied to an intent the reader just expressed', + Strip: LiftOnHoverStrip, + }, + { + id: 'slide', + holds: 'slug', + name: 'Slide on hover', + family: 'Move only when it matters', + how: 'the bar holds still; its contents shift right past the tooltip', + cost: 'horizontal motion; the wall’s tail clips while shifted', + Strip: SlideOnHoverStrip, + }, + { + id: 'adaptive', + holds: 'width', + name: 'Adaptive', + family: 'Move only when it matters', + how: 'estimates the tooltip’s width from the hovered href and moves only for long ones', + cost: 'an estimate, not a measurement; short links cost nothing at all', + Strip: AdaptiveStrip, + }, + { + id: 'swap', + holds: 'width', + name: 'Swap ends', + family: 'Move only when it matters', + how: 'lead mark and wall trade places on hover; geometry untouched', + cost: 'the lead mark changes position, which is jarring if it happens often', + Strip: SwapEndsStrip, + }, + { + id: 'centred', + holds: 'slug', + name: 'Centred', + family: 'Give it nothing to cover', + how: 'both corners empty, content centred — safe from the tooltip in either corner', + cost: 'the widest layout cost, and it stops looking like a full-width rail', + Strip: CentredStrip, + }, +]; diff --git a/packages/storybook/stories/extension/_mockPostFeed.tsx b/packages/storybook/stories/extension/_mockPostFeed.tsx new file mode 100644 index 00000000000..80a552fab7b --- /dev/null +++ b/packages/storybook/stories/extension/_mockPostFeed.tsx @@ -0,0 +1,160 @@ +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import { fn } from 'storybook/test'; +import type { Post } from '@dailydotdev/shared/src/graphql/posts'; +import { PostType, UserVote } from '@dailydotdev/shared/src/graphql/posts'; +import { ArticleGrid } from '@dailydotdev/shared/src/components/cards/article/ArticleGrid'; +import type { Source } from '@dailydotdev/shared/src/graphql/sources'; +import { SourceType } from '@dailydotdev/shared/src/graphql/sources'; + +// Real feed cards, not grey boxes: judging a sponsor strip means +// judging it against the visual weight of actual post covers. + +const mockSource: Source = { + id: 'tds', + handle: 'tds', + name: 'Towards Data Science', + permalink: 'https://app.daily.dev/sources/tds', + image: 'https://media.daily.dev/image/upload/t_logo,f_auto/v1/logos/tds', + type: SourceType.Machine, + public: true, +}; + +const TITLES = [ + 'Understanding React Server Components: a deep dive into the future of the web', + 'Why your CI is slow, and the three fixes that actually matter', + 'Postgres indexing mistakes almost everyone makes', + 'A tour of the new TypeScript compiler internals', + 'How we cut our bundle size by 60% without dropping a feature', + 'LLM eval pipelines that do not lie to you', + 'The hidden cost of microservices, five years on', + 'Your retry logic is probably wrong', +]; + +// Spread through a Partial the way the other card stories do: +// the fixtures only carry the fields a grid card reads. +const basePost: Partial = { + source: mockSource, + tags: ['javascript', 'react', 'typescript'], + type: PostType.Article, + bookmarked: false, + read: false, + upvoted: false, + commented: false, + userState: { vote: UserVote.None, flags: { feedbackDismiss: false } }, +}; + +export const MOCK_POSTS: Post[] = TITLES.map( + (title, index) => + ({ + ...basePost, + id: `post-${index}`, + title, + summary: 'A short standfirst that sits under the title in some layouts.', + permalink: `https://api.daily.dev/r/post-${index}`, + commentsPermalink: `https://daily.dev/posts/post-${index}`, + createdAt: '2026-01-15T10:30:00.000Z', + readTime: 4 + (index % 7), + numUpvotes: 18 + index * 13, + numComments: 3 + index * 2, + image: `https://media.daily.dev/image/upload/f_auto/v1/placeholders/${ + (index % 8) + 1 + }`, + } as Post), +); + +const actionHandlers = { + onPostClick: fn(), + onPostAuxClick: fn(), + onUpvoteClick: fn(), + onDownvoteClick: fn(), + onCommentClick: fn(), + onBookmarkClick: fn(), + onCopyLinkClick: fn(), + onShare: fn(), + onReadArticleClick: fn(), +}; + +/** Mirrors the app's FeedContainer grid variables. */ +const gridStyle = { + '--num-cards': 3, + '--feed-gap': '2rem', + maxWidth: + 'calc(20rem * var(--num-cards) + var(--feed-gap) * (var(--num-cards) - 1))', +} as React.CSSProperties; + +export function MockFeedGrid({ + /** Node spliced into the grid after `insertAfter` cards. */ + insert, + insertAfter = 3, + count = MOCK_POSTS.length, + /** + * The browser's link tooltip is exactly as wide as the URL it + * shows, so this is the single biggest lever on how much of the + * strip it covers. Measured on the live feed: + * short id-only route ~25 chars ~150px + * long post slug (today) ~68 chars ~393px + * tracking ad click-through ~742 chars clipped at 50vw + */ + linkStyle = 'long', +}: { + insert?: ReactNode; + insertAfter?: number; + count?: number; + linkStyle?: 'long' | 'short' | 'tracking'; +}): ReactElement { + // The card builds its href as `${webappUrl}posts/${slug ?? id}` + // (see CardOverlay), so the slug is what decides how wide the + // browser's tooltip gets. These three lengths are taken from the + // live feed. + const slugFor = (index: number) => { + if (linkStyle === 'short') { + return `${1000 + index}`; + } + + if (linkStyle === 'tracking') { + // Stands in for a promoted card's signed click-through: the + // real ones carry a JWT and run past 700 characters. + return `c?id=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.${'eyJPYmplY3RiOnsiaSI6'.repeat( + 30, + )}${index}`; + } + + return `almost-nobody-pays-attention-to-web-standards-anymore-uqacluqm${index}`; + }; + + const posts = MOCK_POSTS.slice(0, count).map( + (post, index) => ({ ...post, slug: slugFor(index) } as Post), + ); + + return ( +
+ {posts.map((post, index) => ( + + {insert && index === insertAfter ? insert : null} + + + ))} +
+ ); +} + +/** The feed's own chrome, so the strip is judged against real neighbours. */ +export function MockFeedHeader(): ReactElement { + return ( +
+

For you

+ + +
+ ); +} diff --git a/packages/storybook/tailwind.config.ts b/packages/storybook/tailwind.config.ts index cdc29928af3..8a4ea57bc8a 100644 --- a/packages/storybook/tailwind.config.ts +++ b/packages/storybook/tailwind.config.ts @@ -7,6 +7,11 @@ export default { './src/**/*.{ts,tsx}', './stories/**/*.{ts,tsx}', './node_modules/@dailydotdev/shared/src/**/*.{ts,tsx}', + // Stories import extension components directly (see stories/extension). + // Without this, utilities used only there — anything with a breakpoint + // variant especially — are silently never generated, and the story + // renders a layout the app would not. + '../extension/src/**/*.{ts,tsx}', ], safelist: [ { diff --git a/packages/webapp/components/layouts/MainFeedPage.tsx b/packages/webapp/components/layouts/MainFeedPage.tsx index 1b374c4add6..480b12d571f 100644 --- a/packages/webapp/components/layouts/MainFeedPage.tsx +++ b/packages/webapp/components/layouts/MainFeedPage.tsx @@ -9,6 +9,12 @@ import type { GetDefaultFeedProps } from '@dailydotdev/shared/src/lib/feed'; import { getFeedName } from '@dailydotdev/shared/src/lib/feed'; import { OtherFeedPage } from '@dailydotdev/shared/src/lib/query'; import dynamic from 'next/dynamic'; +import { SponsorDock } from '@dailydotdev/shared/src/components/sponsors/SponsorDock'; +import { ValueRailSwitcher } from '@dailydotdev/shared/src/components/sponsors/ValueRailSwitcher'; +import { + MOCK_LEAD_SPONSOR, + MOCK_PARTNER_SPONSORS, +} from '@dailydotdev/shared/src/components/sponsors/mockSponsors'; import { getLayout } from './FeedLayout'; const MainFeedLayout = dynamic( @@ -108,16 +114,32 @@ export default function MainFeedPage({ } return ( - -

{getFeedHeading(feedName)}

- {children} -
+ <> + +

{getFeedHeading(feedName)}

+ {children} +
+ {/* + * MOCK-UP — do not merge. Concept A of the sponsored strip + * with a value rail docked under it (see Storybook: + * Extension/Sponsor Dock), wired onto the feed unconditionally + * so it can be reviewed on a preview deployment. Both the + * sponsors and the rail's data are fixtures; real inventory + * needs an ad-service source and a flag, and the rail needs + * wiring to trendingTags, before this is anything but a + * picture. The rail defaults to Breaking news and its label is + * a dropdown, so a reviewer can switch channels in place. + */} + + + + ); }