From 91fb7181213d21450ea20c104f9ba4da98338258 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Mon, 24 Aug 2026 12:52:12 +0300 Subject: [PATCH 01/20] feat(sponsors): sponsored strip primitives and pinned rail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bottom rail carrying one lead sponsor and a wall of partner marks. The lead mark keeps brand colour, sits ~20% larger than the wall, and is the only link and the only hover in the strip. NVIDIA's lockup renders inline rather than as a flat file so its wordmark can take currentColor — black on the light feed, white on the dark one — while the symbol holds #76B900. A single flat asset cannot do that. The wall is silhouetted through a CSS mask so one asset works in both themes, sized by area rather than cap height so a 2:1 mark and a 6:1 lockup carry the same weight, reshuffled per page load so no advertiser is permanently first, and trimmed to the marks that fit whole rather than clipped behind a fade. Sponsors are fixtures. Real inventory needs an ad-service source. --- .../src/components/sponsors/NvidiaLockup.tsx | 39 ++ .../components/sponsors/SponsoredStrip.tsx | 629 ++++++++++++++++++ .../src/components/sponsors/mockSponsors.ts | 44 ++ 3 files changed, 712 insertions(+) create mode 100644 packages/shared/src/components/sponsors/NvidiaLockup.tsx create mode 100644 packages/shared/src/components/sponsors/SponsoredStrip.tsx create mode 100644 packages/shared/src/components/sponsors/mockSponsors.ts diff --git a/packages/shared/src/components/sponsors/NvidiaLockup.tsx b/packages/shared/src/components/sponsors/NvidiaLockup.tsx new file mode 100644 index 0000000000..a21011bfb1 --- /dev/null +++ b/packages/shared/src/components/sponsors/NvidiaLockup.tsx @@ -0,0 +1,39 @@ +import type { ReactElement } from 'react'; +import React from 'react'; + +/** + * 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 = (): ReactElement => ( + + + + + +); + +export default NvidiaLockup; diff --git a/packages/shared/src/components/sponsors/SponsoredStrip.tsx b/packages/shared/src/components/sponsors/SponsoredStrip.tsx new file mode 100644 index 0000000000..d6d0e8a1c6 --- /dev/null +++ b/packages/shared/src/components/sponsors/SponsoredStrip.tsx @@ -0,0 +1,629 @@ +import type { CSSProperties, ReactElement, ReactNode } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import classNames from 'classnames'; + +// ============================================================= +// 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. +// ============================================================= + +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; `logo` is still used for the silhouette. + */ + Artwork?: () => 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; + +/** 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(() => { + // A zero width is "not laid out yet", not "nothing fits" — a row + // measured before layout settles would otherwise render an empty + // wall and, if no resize follows, stay empty. 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 <= 0) { + return partners; + } + + return partners.slice(0, 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 (!monochrome && 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. `laptop:px-10` matches the feed column's padding, +// so the marks line up with the card grid, not the gutter. +// --------------------------------------------------------------- +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/mockSponsors.ts b/packages/shared/src/components/sponsors/mockSponsors.ts new file mode 100644 index 0000000000..e94444c96c --- /dev/null +++ b/packages/shared/src/components/sponsors/mockSponsors.ts @@ -0,0 +1,44 @@ +import type { Sponsor } from './SponsoredStrip'; +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. */ +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[] = [ + 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('dbt', 'dbt', 512 / 196), + sponsor('Sonar', 'sonar', 512 / 125), +]; From cbe54ba76c1657d5172c76c318943793998171a7 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Mon, 24 Aug 2026 12:52:25 +0300 Subject: [PATCH 02/20] feat(sponsors): dock a switchable value rail under the strip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dock stacks a value rail beneath the sponsor row, which does two jobs at once. It gives the strip a reason to exist for the reader: a permanent bar carrying only advertising is rent, one carrying something the reader came for is a feature that happens to be sponsored. It also answers the browser's link tooltip. That tooltip paints over the bottom-left corner, cannot be read or suppressed from a page, and is as wide as the URL inside it — measured on the live feed, post slugs run 393px and promoted cards' signed click-throughs run to Chrome's 50%-of-viewport cap. Horizontal clearance therefore cannot be a guarantee; vertical clearance is 26px whatever the URL says. The rail gives the paid row 32px of it, and each rail opens with its label on the left, where the tooltip lands first, so what it covers is a word rather than a number. Eleven channels, all backed by data already in the app — trendingTags, userStreak, the leaderboard queries, live rooms, opportunities, poll posts. The label doubles as the switcher, so the control costs no space the row was not already spending. The choice lives in localStorage and is read after mount, so server and client agree on the first paint. --- .../src/components/sponsors/SponsorDock.tsx | 52 ++ .../components/sponsors/ValueRailSwitcher.tsx | 82 +++ .../src/components/sponsors/ValueRails.tsx | 530 ++++++++++++++++++ 3 files changed, 664 insertions(+) create mode 100644 packages/shared/src/components/sponsors/SponsorDock.tsx create mode 100644 packages/shared/src/components/sponsors/ValueRailSwitcher.tsx create mode 100644 packages/shared/src/components/sponsors/ValueRails.tsx diff --git a/packages/shared/src/components/sponsors/SponsorDock.tsx b/packages/shared/src/components/sponsors/SponsorDock.tsx new file mode 100644 index 0000000000..6ba404c565 --- /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/ValueRailSwitcher.tsx b/packages/shared/src/components/sponsors/ValueRailSwitcher.tsx new file mode 100644 index 0000000000..b855e65b77 --- /dev/null +++ b/packages/shared/src/components/sponsors/ValueRailSwitcher.tsx @@ -0,0 +1,82 @@ +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 0000000000..3759459bbe --- /dev/null +++ b/packages/shared/src/components/sponsors/ValueRails.tsx @@ -0,0 +1,530 @@ +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import classNames from 'classnames'; + +// ============================================================= +// 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. Hot right now ----------------------------------------- +// 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: 'Hot right now', + 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, + }, +]; From 6f480a3e29fb00e152b63633b6fa1765beb459b0 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Mon, 24 Aug 2026 12:52:32 +0300 Subject: [PATCH 03/20] feat(feed): render the sponsor dock on the main feed [mock-up] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MOCK-UP — not for merge. Wired unconditionally so the design can be reviewed on a preview deployment rather than only in Storybook. The dock is sticky rather than fixed: fixed positions against the viewport, so it spans the whole window and slides under the left sidebar. Sticky keeps it in flow inside the layout's padded main, which is where the sidebar offset already lives, so it spans the feed and follows that offset across both layout variants without knowing what either is. Missing before this could ship: an ad-service source instead of the fixture, a feature flag, impression logging, and the rail wired to its real queries. --- .../components/layouts/MainFeedPage.tsx | 42 ++++++++++++++----- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/packages/webapp/components/layouts/MainFeedPage.tsx b/packages/webapp/components/layouts/MainFeedPage.tsx index 1b374c4add..9f857d0331 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 Hot right now and its label is + * a dropdown, so a reviewer can switch channels in place. + */} + + + + ); } From 0e65c49575f2caa1612aba6cb34dcefde5d9b667 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Mon, 24 Aug 2026 12:52:43 +0300 Subject: [PATCH 04/20] docs(storybook): sponsored strip placements, dock and tooltip benches Stories for the shipped design and for the exploration behind it: five placements evaluated over a real feed, the dock and its eleven rails, ten ways to hold a bar at the bottom, and ten technical answers to the link tooltip. The rejected explorations live in the storybook package rather than in shared. They are decision-making artifacts, not product code, and nothing outside their own stories imports them. The tooltip bench is a testbed rather than a gallery: hovering a card draws a stand-in at the real size and corner, and the feed's link length is switchable, because the card href comes from the post slug and benching against a short one hides the problem entirely. Storybook's tailwind config now scans packages/extension. Utilities used only there generated no CSS, so ShortcutLinks and any story importing an extension component rendered a layout the app would not. Mockup-to-eng-pass: 1 --- .../extension/BottomStripVariants.stories.tsx | 161 +++++ .../extension/BubbleSafeStrips.stories.tsx | 317 +++++++++ .../stories/extension/SponsorDock.stories.tsx | 246 +++++++ .../extension/SponsoredStrip.stories.tsx | 657 ++++++++++++++++++ .../extension/_bottomStripVariants.tsx | 481 +++++++++++++ .../stories/extension/_bubbleSafeStrips.tsx | 433 ++++++++++++ .../stories/extension/_mockPostFeed.tsx | 160 +++++ packages/storybook/tailwind.config.ts | 5 + 8 files changed, 2460 insertions(+) create mode 100644 packages/storybook/stories/extension/BottomStripVariants.stories.tsx create mode 100644 packages/storybook/stories/extension/BubbleSafeStrips.stories.tsx create mode 100644 packages/storybook/stories/extension/SponsorDock.stories.tsx create mode 100644 packages/storybook/stories/extension/SponsoredStrip.stories.tsx create mode 100644 packages/storybook/stories/extension/_bottomStripVariants.tsx create mode 100644 packages/storybook/stories/extension/_bubbleSafeStrips.tsx create mode 100644 packages/storybook/stories/extension/_mockPostFeed.tsx diff --git a/packages/storybook/stories/extension/BottomStripVariants.stories.tsx b/packages/storybook/stories/extension/BottomStripVariants.stories.tsx new file mode 100644 index 0000000000..129eee8e73 --- /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 0000000000..4f1c313922 --- /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 0000000000..fb735956ad --- /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 0000000000..8790126706 --- /dev/null +++ b/packages/storybook/stories/extension/SponsoredStrip.stories.tsx @@ -0,0 +1,657 @@ +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 { NvidiaLockup } from '@dailydotdev/shared/src/components/sponsors/NvidiaLockup'; +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', + }, + { + ...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 0000000000..fd0f5e338d --- /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 0000000000..f4cdcf5ae4 --- /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 0000000000..80a552fab7 --- /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 cdc29928af..8a4ea57bc8 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: [ { From 68571f1d5ec4ec4802b89bfa49c04070c2cc7812 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Mon, 24 Aug 2026 13:11:45 +0300 Subject: [PATCH 05/20] fix(sponsors): never let a transient measurement empty the wall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On the deployed feed the partner wall rendered empty: the row was measured mid-layout, came back narrow but non-zero, and a narrow reading means "nothing fits". The previous guard only caught exactly zero, so anything between 1px and one mark's width stranded the wall blank — and nothing was guaranteed to correct it, since ResizeObserver was the only other corrector and an environment that throttles it leaves the strip permanently empty. Anything narrower than a single mark is now read as "not measured yet" and renders the full wall, which the row clips harmlessly. The fitted count also floors at one: a clipped mark is a worse look than a tidy row, but an empty sponsor wall is a broken one. Verified at 320px (falls back to the full wall) and 1440px (trims to twelve, no overflow). Mockup-to-eng-pass: 1 --- .../components/sponsors/SponsoredStrip.tsx | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/packages/shared/src/components/sponsors/SponsoredStrip.tsx b/packages/shared/src/components/sponsors/SponsoredStrip.tsx index d6d0e8a1c6..483318362a 100644 --- a/packages/shared/src/components/sponsors/SponsoredStrip.tsx +++ b/packages/shared/src/components/sponsors/SponsoredStrip.tsx @@ -122,6 +122,16 @@ export const useShuffledSponsors = (partners: Sponsor[]): Sponsor[] => { /** 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; @@ -210,16 +220,19 @@ const useFittedSponsors = ( }, []); const fitted = useMemo(() => { - // A zero width is "not laid out yet", not "nothing fits" — a row - // measured before layout settles would otherwise render an empty - // wall and, if no resize follows, stay empty. 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 <= 0) { + // 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; } - return partners.slice(0, countThatFit(partners, available, cap, gap)); + // 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 }; From 6081cf7845dda0ef3a878db20220a76479bd7443 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Mon, 24 Aug 2026 13:40:17 +0300 Subject: [PATCH 06/20] fix(sponsors): inset the dock with the app's own page padding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The strip carried an invented inset — px-4 with laptop:px-10 — which matched nothing. Below the laptop breakpoint that is 16px where every other page surface uses 32px, so on a tablet-width window the strip sat tighter to the frame than the feed beside it and its marks and ticker ran into the edge. Both rows now use `pagePaddings`, the same `px-4 tablet:px-8` every page container in the app uses, exported from utilities so there is one source of truth rather than a number copied into a component. Mockup-to-eng-pass: 1 --- .../shared/src/components/sponsors/SponsoredStrip.tsx | 9 ++++++--- packages/shared/src/components/sponsors/ValueRails.tsx | 4 +++- packages/shared/src/components/utilities/common.tsx | 8 +++++++- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/packages/shared/src/components/sponsors/SponsoredStrip.tsx b/packages/shared/src/components/sponsors/SponsoredStrip.tsx index 483318362a..4ef6c669e1 100644 --- a/packages/shared/src/components/sponsors/SponsoredStrip.tsx +++ b/packages/shared/src/components/sponsors/SponsoredStrip.tsx @@ -1,6 +1,7 @@ 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 @@ -463,8 +464,9 @@ export const Divider = (): ReactElement => ( // 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. `laptop:px-10` matches the feed column's padding, -// so the marks line up with the card grid, not the gutter. +// 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, @@ -475,7 +477,8 @@ export const SponsorRailPinned = ({ }: SponsoredStripProps): ReactElement => (
diff --git a/packages/shared/src/components/sponsors/ValueRails.tsx b/packages/shared/src/components/sponsors/ValueRails.tsx index 3759459bbe..a34c6b6342 100644 --- a/packages/shared/src/components/sponsors/ValueRails.tsx +++ b/packages/shared/src/components/sponsors/ValueRails.tsx @@ -1,6 +1,7 @@ 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. @@ -55,7 +56,8 @@ export const ValueRail = ({ }: ValueRailProps): ReactElement => (
Date: Mon, 24 Aug 2026 13:57:41 +0300 Subject: [PATCH 07/20] fix(feed): give every feed layout a horizontal gutter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feed cards ran flush into the sidebar on one side and the window edge on the other: no gap at all between the grid and the frame. The page container the feed sits in forces `!px-0` (FeedPageLayoutList), so the className passed to FeedContainer is the only horizontal inset the feed gets — and it was scoped to `laptop:` and to the list layout. Every other case (any width below laptop, and the grid layout at any width) therefore had none. Every non-finder feed now gets the same `px-4 tablet:px-6`. Laptop keeps the 24px it already had, so the only change is adding a gutter where there was none. Search results stay flush, as before. Cherry-picked from #6516, which carries this fix on its own against main. Duplicated here so the demo is not blocked on that merging; the change is byte-identical, so the merge is a no-op once #6516 lands. Mockup-to-eng-pass: 1 --- packages/shared/src/components/MainFeedLayout.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/components/MainFeedLayout.tsx b/packages/shared/src/components/MainFeedLayout.tsx index 6b863d8fc0..aa7e64633c 100644 --- a/packages/shared/src/components/MainFeedLayout.tsx +++ b/packages/shared/src/components/MainFeedLayout.tsx @@ -835,9 +835,14 @@ export default function MainFeedLayout({
) : undefined } - className={classNames( - shouldUseListFeedLayout && !isFinder && 'laptop:px-6', - )} + // The page container this sits in forces `!px-0` + // (FeedPageLayoutList), so this is the only horizontal + // inset the feed gets. Scoping it to `laptop:` and to the + // list layout left every other case flush against the + // frame — cards touched the sidebar on one side and the + // window edge on the other. Give every feed the same + // gutter; laptop keeps the 24px it already had. + className={classNames(!isFinder && 'px-4 tablet:px-6')} /> ) )} From 24e27d7be9b49f047e43a253bbb39d4c7ba769f1 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Mon, 24 Aug 2026 14:08:29 +0300 Subject: [PATCH 08/20] fix(feed): align the breadcrumbs and tab strip with the cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gutter added to the feed left the chrome above it behind: cards sat 24px in while the Explore breadcrumbs and the sort tabs stayed flush against the sidebar, so the column had two different left edges. Both come from FeedExploreHeader, whose `className.container` wraps the breadcrumbs and the tab strip together, so one prop settles both — on the laptop header and on the mobile sticky one. The gutter is now a single `feedGutter` constant used by all three call sites, because three copies of the same padding string is how they drift apart again. Cherry-picked from #6516. Byte-identical, so that PR merging is a no-op here. Mockup-to-eng-pass: 1 --- .../shared/src/components/MainFeedLayout.tsx | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/packages/shared/src/components/MainFeedLayout.tsx b/packages/shared/src/components/MainFeedLayout.tsx index aa7e64633c..a98d87e34b 100644 --- a/packages/shared/src/components/MainFeedLayout.tsx +++ b/packages/shared/src/components/MainFeedLayout.tsx @@ -223,6 +223,14 @@ const feedWithDateRange = [ ExploreTabs.BestDiscussions, ]; +/** + * The feed's horizontal gutter. The page container forces `!px-0` + * (FeedPageLayoutList), so this is the only inset the feed surface + * gets — and the breadcrumbs, the tab strip and the cards all have to + * use the same one or they line up on different left edges. + */ +const feedGutter = 'px-4 tablet:px-6'; + export default function MainFeedLayout({ feedName: feedNameProp, searchQuery, @@ -694,7 +702,7 @@ export default function MainFeedLayout({ ); } @@ -705,8 +713,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,14 +845,7 @@ export default function MainFeedLayout({
) : undefined } - // The page container this sits in forces `!px-0` - // (FeedPageLayoutList), so this is the only horizontal - // inset the feed gets. Scoping it to `laptop:` and to the - // list layout left every other case flush against the - // frame — cards touched the sidebar on one side and the - // window edge on the other. Give every feed the same - // gutter; laptop keeps the 24px it already had. - className={classNames(!isFinder && 'px-4 tablet:px-6')} + className={classNames(!isFinder && feedGutter)} /> ) )} From 15e9c095279614589c734d1f4deb68fc336fea4c Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Mon, 24 Aug 2026 14:15:18 +0300 Subject: [PATCH 09/20] fix(feed): give the feed header room above and tighten it below MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The breadcrumbs started flush against the site header — 0px above them — and then sat 16px clear of the tab strip, so the two read as one loose block instead of a heading with its tabs under it. 16px above the breadcrumbs, 8px between them and the tabs. The 16px from the tabs down to the cards is unchanged. Cherry-picked from #6516. Byte-identical, so that PR merging is a no-op here. Mockup-to-eng-pass: 1 --- packages/shared/src/components/MainFeedLayout.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/components/MainFeedLayout.tsx b/packages/shared/src/components/MainFeedLayout.tsx index a98d87e34b..b5712c6666 100644 --- a/packages/shared/src/components/MainFeedLayout.tsx +++ b/packages/shared/src/components/MainFeedLayout.tsx @@ -702,7 +702,16 @@ export default function MainFeedLayout({ ); } From d7f7c44d518bad5f2f7561b97d856e28f592944f Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Mon, 24 Aug 2026 14:21:13 +0300 Subject: [PATCH 10/20] fix(feed): one source for the feed's horizontal inset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commits added a gutter on FeedContainer while FeedPage was already applying `laptop:p-10` in the legacy layout. Both were in the same ancestry, so they stacked — and because `isV2` resolves after mount, which of them applied changed as the flag settled. The feed visibly jumped between a 64px inset and a 24px one depending on whether you loaded the page, refreshed it, or navigated to it. FeedPage now sets the inset for both variants and is the only place that does: `feedGutterV2` under v2, the existing `pageMainClassNames` otherwise. v2 previously set nothing at all, which is what left the cards flush against the frame. The breadcrumbs and tab strip sit outside FeedPage, so they take the same value through `useFeedGutter` rather than a hardcoded copy, and it is in the memo's deps so it cannot go stale. Cherry-picked from #6516. Byte-identical, so that PR merging is a no-op here. Mockup-to-eng-pass: 1 --- .../shared/src/components/MainFeedLayout.tsx | 20 ++++----- .../src/components/utilities/common.tsx | 42 ++++++++++++++++--- 2 files changed, 45 insertions(+), 17 deletions(-) diff --git a/packages/shared/src/components/MainFeedLayout.tsx b/packages/shared/src/components/MainFeedLayout.tsx index b5712c6666..44874703c4 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, useFeedGutter } from './utilities/common'; import { ExploreChipsBar } from './feeds/ExploreChipsBar'; import { buildPersonalizedCategories } from './feeds/exploreCategories'; import { useFeeds } from '../hooks/feed/useFeeds'; @@ -223,14 +223,6 @@ const feedWithDateRange = [ ExploreTabs.BestDiscussions, ]; -/** - * The feed's horizontal gutter. The page container forces `!px-0` - * (FeedPageLayoutList), so this is the only inset the feed surface - * gets — and the breadcrumbs, the tab strip and the cards all have to - * use the same one or they line up on different left edges. - */ -const feedGutter = 'px-4 tablet:px-6'; - export default function MainFeedLayout({ feedName: feedNameProp, searchQuery, @@ -696,6 +688,10 @@ export default function MainFeedLayout({ [isExtension, onNavTabClick], ); + // Matches whatever inset FeedPage is applying to the cards, so the + // breadcrumbs and tabs never sit on a different left edge. + const feedGutter = useFeedGutter(); + const FeedExploreComponent = useCallback(() => { if (isLaptop) { return ( @@ -731,7 +727,7 @@ export default function MainFeedLayout({ }} /> ); - }, [isLaptop, onTabChange, tab]); + }, [feedGutter, isLaptop, onTabChange, tab]); // v2 reaches the Explore hub sections (Explore, Tags, Sources, Leaderboard, // Discussions) from the sidebar's Explore panel, so the page header no longer @@ -854,7 +850,9 @@ export default function MainFeedLayout({ ) : undefined } - className={classNames(!isFinder && feedGutter)} + className={classNames( + shouldUseListFeedLayout && !isFinder && 'laptop:px-6', + )} /> ) )} diff --git a/packages/shared/src/components/utilities/common.tsx b/packages/shared/src/components/utilities/common.tsx index 82158e3129..5b0ff4d010 100644 --- a/packages/shared/src/components/utilities/common.tsx +++ b/packages/shared/src/components/utilities/common.tsx @@ -105,11 +105,38 @@ 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, under the v2 (dual-sidebar) layout. + * Smaller than the legacy `laptop:p-10`, which reads as far too much + * side spacing inside v2's floating-card chrome. + */ +export const feedGutterV2 = 'px-4 tablet:px-6'; + +/** The legacy inset, horizontal only — `pageMainClassNames` without its vertical padding. */ +export const feedGutterLegacy = 'tablet:px-4 laptop:px-10'; + +/** + * The feed's current horizontal inset. Chrome that sits outside + * `FeedPage` — the breadcrumbs and tab strip — has to line up with the + * cards, and the value depends on a flag that resolves after mount, so + * it cannot be hardcoded at the call site. + */ +export const useFeedGutter = (): string => { + const { isV2 } = useLayoutVariant(); + + return isV2 ? feedGutterV2 : feedGutterLegacy; +}; + +// v2 used to drop `pageMainClassNames` and put nothing in its place, +// on the assumption that the card chrome always supplies the inset. +// On the feed surfaces it does not, so the cards ran flush into the +// sidebar on one side and the window edge on the other. +// +// Both variants now set an inset here, and only here. That matters +// beyond the missing gutter: `isV2` resolves asynchronously, so any +// second source of horizontal padding elsewhere in the tree would +// stack on top of this one for whichever state the flag settles into, +// and the feed would visibly change width after load. export const FeedPage = ({ className, ...props @@ -118,7 +145,10 @@ export const FeedPage = ({ return ( ); }; From 582210829119d277fd054b908e76901cd6e47135 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Mon, 24 Aug 2026 14:29:37 +0300 Subject: [PATCH 11/20] fix(feed): stop the feed inset depending on the layout experiment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `isV2` is not stable across client-side navigation: it reads true on load and false after a route change. Any inset keyed to it therefore changed as you moved around the app — the feed sat 24px in on arrival and 40px in after clicking a tab, and refreshing "fixed" it by landing on the other state. The feed now keeps one inset, `pageMainClassNames`, whatever the layout experiment is doing. v2's special case is gone: it set nothing at all, which is what left the cards flush against the frame. The breadcrumbs and tab strip take the horizontal half of the same value through the `feedGutter` constant. Cherry-picked from #6516. Byte-identical, so that PR merging is a no-op here. Mockup-to-eng-pass: 1 --- .../shared/src/components/MainFeedLayout.tsx | 8 +-- .../src/components/utilities/common.tsx | 61 ++++++------------- 2 files changed, 22 insertions(+), 47 deletions(-) diff --git a/packages/shared/src/components/MainFeedLayout.tsx b/packages/shared/src/components/MainFeedLayout.tsx index 44874703c4..82920c6f96 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, useFeedGutter } 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'; @@ -688,10 +688,6 @@ export default function MainFeedLayout({ [isExtension, onNavTabClick], ); - // Matches whatever inset FeedPage is applying to the cards, so the - // breadcrumbs and tabs never sit on a different left edge. - const feedGutter = useFeedGutter(); - const FeedExploreComponent = useCallback(() => { if (isLaptop) { return ( @@ -727,7 +723,7 @@ export default function MainFeedLayout({ }} /> ); - }, [feedGutter, isLaptop, onTabChange, tab]); + }, [isLaptop, onTabChange, tab]); // v2 reaches the Explore hub sections (Explore, Tags, Sources, Leaderboard, // Discussions) from the sidebar's Explore panel, so the page header no longer diff --git a/packages/shared/src/components/utilities/common.tsx b/packages/shared/src/components/utilities/common.tsx index 5b0ff4d010..461554dc59 100644 --- a/packages/shared/src/components/utilities/common.tsx +++ b/packages/shared/src/components/utilities/common.tsx @@ -5,7 +5,6 @@ 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'; @@ -106,52 +105,32 @@ export const BaseFeedPage = classed( ); /** - * The feed's horizontal inset, under the v2 (dual-sidebar) layout. - * Smaller than the legacy `laptop:p-10`, which reads as far too much - * side spacing inside v2's floating-card chrome. + * The feed's horizontal inset, as a horizontal-only echo of + * `pageMainClassNames`. Chrome that sits outside `FeedPage` — the + * breadcrumbs and the tab strip — uses this to line up with the cards. */ -export const feedGutterV2 = 'px-4 tablet:px-6'; +export const feedGutter = 'tablet:px-4 laptop:px-10'; -/** The legacy inset, horizontal only — `pageMainClassNames` without its vertical padding. */ -export const feedGutterLegacy = 'tablet:px-4 laptop:px-10'; - -/** - * The feed's current horizontal inset. Chrome that sits outside - * `FeedPage` — the breadcrumbs and tab strip — has to line up with the - * cards, and the value depends on a flag that resolves after mount, so - * it cannot be hardcoded at the call site. - */ -export const useFeedGutter = (): string => { - const { isV2 } = useLayoutVariant(); - - return isV2 ? feedGutterV2 : feedGutterLegacy; -}; - -// v2 used to drop `pageMainClassNames` and put nothing in its place, -// on the assumption that the card chrome always supplies the inset. -// On the feed surfaces it does not, so the cards ran flush into the -// sidebar on one side and the window edge on the other. +// v2 used to drop `pageMainClassNames` here and put nothing in its +// place, on the assumption that its floating-card chrome always +// supplies the inset. On the feed surfaces it does not, so the cards +// ran flush into the sidebar on one side and the window edge on the +// other. // -// Both variants now set an inset here, and only here. That matters -// beyond the missing gutter: `isV2` resolves asynchronously, so any -// second source of horizontal padding elsewhere in the tree would -// stack on top of this one for whichever state the flag settles into, -// and the feed would visibly change width after load. +// The inset is no longer conditional at all. `isV2` is not stable +// across client-side navigation — it reads true on load and false +// after a route change — so anything keyed to it made the feed +// visibly change width as you moved around the app. The feed keeps +// one inset whatever the layout experiment is doing. export const FeedPage = ({ className, ...props -}: HTMLAttributes): ReactElement => { - const { isV2 } = useLayoutVariant(); - return ( - - ); -}; +}: HTMLAttributes): ReactElement => ( + +); export const FeedPageLayoutList = classed( BasePageContainer, pageContainerClassNames, From ed4e088a6c99ed9608539df5f5df953812c9c229 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Mon, 24 Aug 2026 14:35:41 +0300 Subject: [PATCH 12/20] fix(feed): put the horizontal inset on the one container both routes share MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The feed renders through two different page containers depending on layout and route: FeedPage, which carries `pageMainClassNames`, and FeedPageLayoutList, which forces `!px-0`. Which one you get flips with `isV2` — itself unstable across client-side navigation — so an inset placed on either covered some routes and not others, and stacked with the other where both applied. That is the whole bug: 64px on one arrival, 24px on another, 0px after a route change. FeedContainer is the only element common to both, so the inset lives there now and nowhere else. FeedPage keeps its vertical padding and gives up its horizontal, which it could not apply everywhere anyway. One value, `px-4 tablet:px-6 laptop:px-10`, for every feed route and both layout variants — 40px at laptop, matching what the legacy path already gave. The breadcrumbs and tab strip share the constant. Co-Authored-By: Claude Opus 5 (cherry picked from commit a366dd8052d93630f26e7e3d272278d37d820d56) Cherry-picked from #6516. Byte-identical, so that PR merging is a no-op here. Mockup-to-eng-pass: 1 --- .../shared/src/components/MainFeedLayout.tsx | 4 +- .../src/components/utilities/common.tsx | 39 ++++++++++--------- 2 files changed, 22 insertions(+), 21 deletions(-) diff --git a/packages/shared/src/components/MainFeedLayout.tsx b/packages/shared/src/components/MainFeedLayout.tsx index 82920c6f96..dff944637f 100644 --- a/packages/shared/src/components/MainFeedLayout.tsx +++ b/packages/shared/src/components/MainFeedLayout.tsx @@ -846,9 +846,7 @@ export default function MainFeedLayout({ ) : undefined } - className={classNames( - shouldUseListFeedLayout && !isFinder && 'laptop:px-6', - )} + className={classNames(!isFinder && feedGutter)} /> ) )} diff --git a/packages/shared/src/components/utilities/common.tsx b/packages/shared/src/components/utilities/common.tsx index 461554dc59..ec5498eca9 100644 --- a/packages/shared/src/components/utilities/common.tsx +++ b/packages/shared/src/components/utilities/common.tsx @@ -4,7 +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 { SourceMemberRole } from '../../graphql/sources'; import type { OrganizationMemberRole } from '../../features/organizations/types'; @@ -105,30 +104,34 @@ export const BaseFeedPage = classed( ); /** - * The feed's horizontal inset, as a horizontal-only echo of - * `pageMainClassNames`. Chrome that sits outside `FeedPage` — the - * breadcrumbs and the tab strip — uses this to line up with the cards. + * 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 = 'tablet:px-4 laptop:px-10'; - -// v2 used to drop `pageMainClassNames` here and put nothing in its -// place, on the assumption that its floating-card chrome always -// supplies the inset. On the feed surfaces it does not, so the cards -// ran flush into the sidebar on one side and the window edge on the -// other. -// -// The inset is no longer conditional at all. `isV2` is not stable -// across client-side navigation — it reads true on load and false -// after a route change — so anything keyed to it made the feed -// visibly change width as you moved around the app. The feed keeps -// one inset whatever the layout experiment is doing. +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 => ( ); export const FeedPageLayoutList = classed( From 8daa50292a0f39bde34fae04faabb0f6683a8277 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Mon, 24 Aug 2026 16:23:45 +0300 Subject: [PATCH 13/20] fix(feed): keep Explore's top spacing stable across navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Explore showed a large gap above the breadcrumbs on arrival that a reload "fixed" — the same shape of bug as the horizontal inset, in the other axis. `disableTopPadding` was keyed to `shouldUseListFeedLayout`, and that value is not stable: `enableSsrSafeLayout` forces the list layout before mount and hands over to the real one after, so the page's 40px top padding was present in one state and zeroed in the other. The header sat 104px down or 64px down depending on how you arrived. Explore now keeps that padding in both layouts. It is the one feed that renders a breadcrumb and tab header above the cards, and with the padding zeroed that header is jammed against the site header. The `pt-4` added earlier to the Explore header goes away with it. It was compensating for this missing padding, which is why the gap read as too large in the state where both applied. Production has no such compensation, and Explore now matches it: 104px to the breadcrumbs, 64px of header offset plus the page's 40px. Co-Authored-By: Claude Opus 5 Cherry-picked from #6516. Byte-identical, so that PR merging is a no-op here. Mockup-to-eng-pass: 1 --- packages/shared/src/components/MainFeedLayout.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/shared/src/components/MainFeedLayout.tsx b/packages/shared/src/components/MainFeedLayout.tsx index dff944637f..f1132426f8 100644 --- a/packages/shared/src/components/MainFeedLayout.tsx +++ b/packages/shared/src/components/MainFeedLayout.tsx @@ -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) { @@ -700,10 +707,7 @@ export default function MainFeedLayout({ // heading and its tabs. Give the group room above and pull // the tabs up under the breadcrumbs they belong to; the // 16px down to the cards is unchanged. - className={{ - container: classNames(feedGutter, 'pt-4'), - tabWrapper: 'mb-4 mt-2', - }} + className={{ container: feedGutter, tabWrapper: 'mb-4 mt-2' }} /> ); } From a870e40dc243318c73669401c31446194b411380 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 25 Aug 2026 18:26:12 +0300 Subject: [PATCH 14/20] feat(sponsors): lead with CodeRabbit, move NVIDIA into the wall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit takes the paid slot and NVIDIA becomes a regular partner mark, silhouetted with the rest of the wall. CodeRabbit has the same problem NVIDIA did: an orange mark with a near-black (#171717) wordmark, which is the only version the advertiser library ships and which disappears on the dark feed. So it renders inline as well — the mark keeps #FF570A, the wordmark takes `currentColor` and flips black to white with the theme. The split is by fill colour rather than by eye: ten #171717 paths are the wordmark, #FF570A is the mark, #FEFEFE is the detail inside it. NVIDIA is not in the advertiser library, so its silhouette source is a data URI rather than a hosted file — the app and Storybook would otherwise each need their own copy, and only one of them had it last time. Verified in both themes: wordmark computes to white on dark and #0f1218 on light, mark holds #FF570A in both. Mockup-to-eng-pass: 1 --- .../components/sponsors/CodeRabbitLockup.tsx | 78 +++++++++++++++++++ .../src/components/sponsors/mockSponsors.ts | 32 ++++++-- .../extension/SponsoredStrip.stories.tsx | 10 ++- 3 files changed, 110 insertions(+), 10 deletions(-) create mode 100644 packages/shared/src/components/sponsors/CodeRabbitLockup.tsx diff --git a/packages/shared/src/components/sponsors/CodeRabbitLockup.tsx b/packages/shared/src/components/sponsors/CodeRabbitLockup.tsx new file mode 100644 index 0000000000..5bb670e00f --- /dev/null +++ b/packages/shared/src/components/sponsors/CodeRabbitLockup.tsx @@ -0,0 +1,78 @@ +import type { ReactElement } from 'react'; +import React from 'react'; + +/** + * CodeRabbit's lockup, inline so it can be two-tone. + * + * The brand mark is orange and the wordmark is near-black (#171717), + * which disappears on the dark feed. Rendered inline the wordmark + * takes `currentColor` — black on the light feed, white on the dark + * one — while the mark keeps #FF570A on both. A flat file cannot do + * that, and the near-black version is the only one the advertiser + * library ships. + * + * Paths are CodeRabbit's own artwork (viewBox 0 0 2152 314); only the + * wordmark's fill is ours. The split is by fill colour, not by + * guesswork: ten #171717 paths are the wordmark, the #FF570A path is + * the mark and the #FEFEFE path is the detail inside it. + */ +export const CodeRabbitLockup = (): ReactElement => ( + + + + + + + + + + + + + + +); + +export default CodeRabbitLockup; diff --git a/packages/shared/src/components/sponsors/mockSponsors.ts b/packages/shared/src/components/sponsors/mockSponsors.ts index e94444c96c..e4cd514e28 100644 --- a/packages/shared/src/components/sponsors/mockSponsors.ts +++ b/packages/shared/src/components/sponsors/mockSponsors.ts @@ -1,13 +1,13 @@ import type { Sponsor } from './SponsoredStrip'; -import { NvidiaLockup } from './NvidiaLockup'; +import { CodeRabbitLockup } from './CodeRabbitLockup'; // =========================================================== // 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 +// logos already published on business.daily.dev, and CodeRabbit +// is a mock lead sponsor. Real inventory would come from the ad // service, behind a flag, before any of this ships. // =========================================================== @@ -19,16 +19,32 @@ const sponsor = (name: string, file: string, ratio: number): Sponsor => ({ ratio, }); -/** The paid slot: brand colour, larger, and the only link in the strip. */ +/** + * The paid slot: brand colour, larger, and the only link in the strip. + * + * CodeRabbit ships an orange mark with a near-black (#171717) + * wordmark, which disappears on the dark feed, so it renders inline — + * the mark holds its colour, the wordmark takes `currentColor`. A flat + * file cannot do that, and near-black is the only version the + * advertiser library has. + */ export const MOCK_LEAD_SPONSOR: Sponsor = { - name: 'NVIDIA', - ratio: 164 / 30, - Artwork: NvidiaLockup, - href: 'https://www.nvidia.com', + name: 'CodeRabbit', + ratio: 2152 / 314, + Artwork: CodeRabbitLockup, + href: 'https://coderabbit.ai', }; +/** + * NVIDIA is not in the advertiser library, so its silhouette source is + * inlined rather than served: a data URI keeps it working in the app + * and in Storybook without either having to host a file. + */ +const NVIDIA_MARK = `data:image/svg+xml,%3Csvg%20width%3D%22656%22%20height%3D%22120%22%20viewBox%3D%220%200%20164%2030%22%20version%3D%221.1%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20xmlns%3Axlink%3D%22http%3A%2F%2Fwww.w3.org%2F1999%2Fxlink%22%20xml%3Aspace%3D%22preserve%22%20xmlns%3Aserif%3D%22http%3A%2F%2Fwww.serif.com%2F%22%20style%3D%22fill-rule%3Aevenodd%3Bclip-rule%3Aevenodd%3Bstroke-linejoin%3Around%3Bstroke-miterlimit%3A2%3B%20fill%3A%2376B900%3B%22%3E%20%3Cg%3E%20%3Cpath%20d%3D%22M160.352%2C24.069L160.352%2C23.62L160.64%2C23.62C160.797%2C23.62%20161.011%2C23.632%20161.011%2C23.824C161.011%2C24.032%20160.901%2C24.069%20160.715%2C24.069L160.352%2C24.069M160.352%2C24.384L160.544%2C24.384L160.991%2C25.168L161.481%2C25.168L160.987%2C24.352C161.242%2C24.333%20161.452%2C24.212%20161.452%2C23.868C161.452%2C23.441%20161.157%2C23.303%20160.659%2C23.303L159.938%2C23.303L159.938%2C25.168L160.352%2C25.168L160.352%2C24.384M162.45%2C24.238C162.45%2C23.143%20161.599%2C22.508%20160.65%2C22.508C159.695%2C22.508%20158.845%2C23.143%20158.845%2C24.238C158.845%2C25.333%20159.695%2C25.971%20160.65%2C25.971C161.598%2C25.971%20162.45%2C25.333%20162.45%2C24.238M161.93%2C24.238C161.93%2C25.036%20161.343%2C25.572%20160.65%2C25.572L160.65%2C25.566C159.937%2C25.572%20159.361%2C25.036%20159.361%2C24.238C159.361%2C23.441%20159.938%2C22.907%20160.65%2C22.907C161.344%2C22.907%20161.93%2C23.441%20161.93%2C24.238%22%20style%3D%22fill%3Ablack%3B%22%2F%3E%20%3Cpath%20d%3D%22M96.374%2C5.707L96.376%2C25.367L101.928%2C25.367L101.928%2C5.707L96.374%2C5.707ZM52.697%2C5.681L52.697%2C25.367L58.3%2C25.367L58.3%2C10.086L62.67%2C10.1C64.107%2C10.1%2065.1%2C10.445%2065.793%2C11.184C66.672%2C12.12%2067.03%2C13.628%2067.03%2C16.389L67.03%2C25.367L72.457%2C25.367L72.457%2C14.49C72.457%2C6.727%2067.509%2C5.68%2062.668%2C5.68L52.698%2C5.68L52.697%2C5.681ZM105.314%2C5.708L105.314%2C25.367L114.32%2C25.367C119.118%2C25.367%20120.684%2C24.569%20122.377%2C22.78C123.575%2C21.524%20124.348%2C18.766%20124.348%2C15.753C124.348%2C12.99%20123.693%2C10.525%20122.551%2C8.99C120.494%2C6.245%20117.531%2C5.708%20113.106%2C5.708L105.314%2C5.708ZM110.822%2C9.988L113.209%2C9.988C116.672%2C9.988%20118.912%2C11.544%20118.912%2C15.579C118.912%2C19.616%20116.672%2C21.171%20113.209%2C21.171L110.822%2C21.171L110.822%2C9.988ZM88.369%2C5.708L83.735%2C21.288L79.295%2C5.709L73.302%2C5.708L79.642%2C25.367L87.645%2C25.367L94.036%2C5.708L88.369%2C5.708ZM126.932%2C25.367L132.485%2C25.367L132.485%2C5.709L126.93%2C5.708L126.932%2C25.367ZM142.496%2C5.715L134.743%2C25.36L140.218%2C25.36L141.445%2C21.888L150.62%2C21.888L151.781%2C25.36L157.725%2C25.36L149.913%2C5.714L142.496%2C5.715ZM146.1%2C9.3L149.464%2C18.504L142.631%2C18.504L146.101%2C9.3L146.1%2C9.3Z%22%20style%3D%22fill%3Ablack%3B%22%2F%3E%20%3Cpath%20d%3D%22M16.889%2C8.985L16.889%2C6.28C17.151%2C6.26%2017.417%2C6.247%2017.687%2C6.238C25.087%2C6.006%2029.942%2C12.597%2029.942%2C12.597C29.942%2C12.597%2024.698%2C19.879%2019.076%2C19.879C18.333%2C19.882%2017.594%2C19.764%2016.889%2C19.529L16.889%2C11.325C19.769%2C11.673%2020.349%2C12.945%2022.081%2C15.833L25.933%2C12.585C25.933%2C12.585%2023.121%2C8.897%2018.381%2C8.897C17.866%2C8.897%2017.373%2C8.933%2016.889%2C8.985ZM16.889%2C0.047L16.889%2C4.09C17.154%2C4.069%2017.42%2C4.052%2017.687%2C4.042C27.977%2C3.696%2034.682%2C12.482%2034.682%2C12.482C34.682%2C12.482%2026.982%2C21.846%2018.959%2C21.846C18.224%2C21.846%2017.535%2C21.778%2016.889%2C21.663L16.889%2C24.161C17.442%2C24.231%2018.015%2C24.273%2018.613%2C24.273C26.078%2C24.273%2031.477%2C20.461%2036.705%2C15.948C37.572%2C16.642%2041.121%2C18.331%2041.85%2C19.071C36.879%2C23.231%2025.295%2C26.586%2018.727%2C26.586C18.113%2C26.584%2017.5%2C26.552%2016.889%2C26.49L16.889%2C30L45.264%2C30L45.264%2C0.047L16.889%2C0.047ZM16.889%2C19.529L16.889%2C21.662C9.984%2C20.432%208.067%2C13.254%208.067%2C13.254C8.067%2C13.254%2011.383%2C9.58%2016.889%2C8.985L16.889%2C11.325L16.878%2C11.324C13.988%2C10.977%2011.731%2C13.677%2011.731%2C13.677C11.731%2C13.677%2012.996%2C18.221%2016.889%2C19.529ZM4.625%2C12.943C4.625%2C12.943%208.717%2C6.903%2016.889%2C6.28L16.889%2C4.088C7.838%2C4.815%200%2C12.48%200%2C12.48C0%2C12.48%204.439%2C25.313%2016.889%2C26.488L16.889%2C24.16C7.753%2C23.011%204.625%2C12.943%204.625%2C12.943Z%22%20style%3D%22fill%3Argb%28118%2C185%2C0%29%3B%22%2F%3E%20%3C%2Fg%3E%20%3C%2Fsvg%3E`; + /** The wall: silhouetted, even-weighted, inert. */ export const MOCK_PARTNER_SPONSORS: Sponsor[] = [ + { name: 'NVIDIA', logo: NVIDIA_MARK, ratio: 164 / 30 }, sponsor('Datadog', 'datadog', 800.5 / 203.19), sponsor('PostHog', 'posthog', 512 / 90), sponsor('ClickHouse', 'clickhouse', 584.9 / 103.1), diff --git a/packages/storybook/stories/extension/SponsoredStrip.stories.tsx b/packages/storybook/stories/extension/SponsoredStrip.stories.tsx index 8790126706..1d3d1b813e 100644 --- a/packages/storybook/stories/extension/SponsoredStrip.stories.tsx +++ b/packages/storybook/stories/extension/SponsoredStrip.stories.tsx @@ -47,7 +47,7 @@ const sponsor = (name: string, file: string, ratio: number): Sponsor => ({ // 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. +// mockSponsors.ts and CodeRabbitLockup.tsx. const PRIMARY = MOCK_LEAD_SPONSOR; const PARTNERS = MOCK_PARTNER_SPONSORS; @@ -76,7 +76,13 @@ const ASSET_PROBLEMS: (Sponsor & { reason: string })[] = [ const PRIMARY_CANDIDATES: (Sponsor & { verdict: string })[] = [ { ...PRIMARY, - verdict: 'two-tone: green symbol, wordmark on currentColor', + verdict: 'two-tone: orange mark, wordmark on currentColor', + }, + { + name: 'NVIDIA', + ratio: 164 / 30, + Artwork: NvidiaLockup, + verdict: 'two-tone as well — now a regular mark in the wall', }, { ...sponsor('Google', 'google', 75 / 24), From f64e7e5273aef7893aa00aba9158c29c6ea19cb8 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 25 Aug 2026 18:51:30 +0300 Subject: [PATCH 15/20] chore(sponsors): drop dbt from the partner wall Leaves twelve marks: eleven from the advertiser library plus NVIDIA. Mockup-to-eng-pass: 1 --- packages/shared/src/components/sponsors/mockSponsors.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/shared/src/components/sponsors/mockSponsors.ts b/packages/shared/src/components/sponsors/mockSponsors.ts index e4cd514e28..d4ac52b143 100644 --- a/packages/shared/src/components/sponsors/mockSponsors.ts +++ b/packages/shared/src/components/sponsors/mockSponsors.ts @@ -55,6 +55,5 @@ export const MOCK_PARTNER_SPONSORS: Sponsor[] = [ sponsor('Pulumi', 'pulumi', 512 / 128), sponsor('LaunchDarkly', 'launchdarkly', 512 / 80), sponsor('Amazon', 'amazon', 512 / 256), - sponsor('dbt', 'dbt', 512 / 196), sponsor('Sonar', 'sonar', 512 / 125), ]; From 51591db67236d5c563344b56dc840ddcd967bedd Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 25 Aug 2026 18:55:47 +0300 Subject: [PATCH 16/20] feat(sponsors): rename the rail to Breaking news and align its label "Hot right now" becomes "Breaking news", in the rail, the switcher menu and the feed wiring's default. The label also sat 4px right of "Made possible by" above it: the dropdown trigger carries `px-1` so its hover chip has something to sit on, and that padding pushed the text in. `-ml-1` cancels it, so the two rows start on the same left edge and the chip keeps its padding. Mockup-to-eng-pass: 1 --- .../shared/src/components/sponsors/ValueRailSwitcher.tsx | 5 ++++- packages/shared/src/components/sponsors/ValueRails.tsx | 6 +++--- packages/webapp/components/layouts/MainFeedPage.tsx | 2 +- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/shared/src/components/sponsors/ValueRailSwitcher.tsx b/packages/shared/src/components/sponsors/ValueRailSwitcher.tsx index b855e65b77..3a853f461b 100644 --- a/packages/shared/src/components/sponsors/ValueRailSwitcher.tsx +++ b/packages/shared/src/components/sponsors/ValueRailSwitcher.tsx @@ -58,7 +58,10 @@ export const ValueRailSwitcher = (): ReactElement => {