From 3365c0a56a66cf3929b5ca48680bd390353fac8c Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 14 May 2026 09:57:04 +0300 Subject: [PATCH 01/17] feat: reader modal install prompt demo (mockup) DEMO ONLY: reshape the reader_modal experiment into a two-step flow for team review. Force-enable the experiment, route card clicks to the classic post modal, gate the "Read post" CTAs behind a new Nikita-Bier- style install pop-up, and resize / swap sides on the reader modal so the iframe sits on the left and the rail (TL;DR only, title dropped) sits on the right. Not intended to ship as-is; engineering will build the production version in a separate PR. Co-authored-by: Cursor --- packages/shared/src/components/Feed.tsx | 24 +- .../cards/common/PostCardHeader.tsx | 12 +- .../modals/ReaderInstallPromptModal.tsx | 216 ++++++++++++++++++ .../src/components/modals/ReaderPostModal.tsx | 2 +- .../modals/ReaderPreviewLazyModal.tsx | 30 +++ .../shared/src/components/modals/common.tsx | 16 ++ .../src/components/modals/common/types.ts | 2 + .../src/components/post/PostHeaderActions.tsx | 12 +- .../components/post/reader/EngagementRail.tsx | 17 -- .../post/reader/ReaderPostLayout.tsx | 35 +-- .../reader/hooks/useReaderModalEligibility.ts | 12 +- .../src/hooks/useReaderInstallPromptGate.ts | 72 ++++++ packages/webapp/pages/posts/[id]/index.tsx | 5 + 13 files changed, 398 insertions(+), 57 deletions(-) create mode 100644 packages/shared/src/components/modals/ReaderInstallPromptModal.tsx create mode 100644 packages/shared/src/components/modals/ReaderPreviewLazyModal.tsx create mode 100644 packages/shared/src/hooks/useReaderInstallPromptGate.ts diff --git a/packages/shared/src/components/Feed.tsx b/packages/shared/src/components/Feed.tsx index bb5b8ad2377..3c8b9c20dad 100644 --- a/packages/shared/src/components/Feed.tsx +++ b/packages/shared/src/components/Feed.tsx @@ -147,13 +147,6 @@ const SocialTwitterPostModal = dynamic( ), ); -const ReaderPostModal = dynamic( - () => - import( - /* webpackChunkName: "readerPostModal" */ './modals/ReaderPostModal' - ), -); - const BriefCardFeed = dynamic( () => import( @@ -544,20 +537,11 @@ export default function Feed({ if (!selectedPost) { return undefined; } - const readerEligibleTypes = new Set([ - PostType.Article, - PostType.Digest, - PostType.VideoYouTube, - ]); - if ( - isReaderModalFeatureReady && - isReaderModalOn && - readerEligibleTypes.has(selectedPost.type) - ) { - return ReaderPostModal; - } + // DEMO ONLY: card clicks now always open the classic post modal so the + // user can see title / TL;DR first. The reader modal is reached via the + // new install-prompt pop-up's "Preview the experience" CTA instead. return PostModalMap[selectedPost.type]; - }, [selectedPost, isReaderModalFeatureReady, isReaderModalOn]); + }, [selectedPost]); if (!loadedSettings || isFallback) { return <>; diff --git a/packages/shared/src/components/cards/common/PostCardHeader.tsx b/packages/shared/src/components/cards/common/PostCardHeader.tsx index ef129257a81..ee64cd8c81c 100644 --- a/packages/shared/src/components/cards/common/PostCardHeader.tsx +++ b/packages/shared/src/components/cards/common/PostCardHeader.tsx @@ -9,6 +9,7 @@ import { isSourceUserSource } from '../../../graphql/sources'; import { ReadArticleButton, getReadPostButtonIcon } from './ReadArticleButton'; import { getGroupedHoverContainer } from './common'; import { useBookmarkProvider, useFeedPreviewMode } from '../../../hooks'; +import { useReaderInstallPromptGate } from '../../../hooks/useReaderInstallPromptGate'; import type { Post } from '../../../graphql/posts'; import { getReadPostButtonText, @@ -66,6 +67,15 @@ export const PostCardHeader = ({ const { highlightBookmarkedPost } = useBookmarkProvider({ bookmarked: (post.bookmarked && !showFeedback) ?? false, }); + const { onReadClick: onReaderInstallGateClick } = + useReaderInstallPromptGate(post); + + const handleReadArticleClick = (event: React.MouseEvent) => { + if (onReaderInstallGateClick(event)) { + return; + } + onReadArticleClick?.(event); + }; const articleLink = useMemo(() => { if (post.sharedPost) { @@ -118,7 +128,7 @@ export const PostCardHeader = ({ className="mr-2" variant={ButtonVariant.Primary} href={articleLink ?? ''} - onClick={onReadArticleClick} + onClick={handleReadArticleClick} openNewTab={openNewTab} icon={getReadPostButtonIcon(post)} /> diff --git a/packages/shared/src/components/modals/ReaderInstallPromptModal.tsx b/packages/shared/src/components/modals/ReaderInstallPromptModal.tsx new file mode 100644 index 00000000000..95011c9967c --- /dev/null +++ b/packages/shared/src/components/modals/ReaderInstallPromptModal.tsx @@ -0,0 +1,216 @@ +import type { MouseEvent, ReactElement } from 'react'; +import React, { useEffect } from 'react'; +import classNames from 'classnames'; +import { Modal } from './common/Modal'; +import type { LazyModalCommonProps } from './common/Modal'; +import { LazyModal, ModalKind, ModalSize } from './common/types'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import { ClickableText } from '../buttons/ClickableText'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '../typography/Typography'; +import { ChromeIcon, EdgeIcon } from '../icons'; +import { downloadBrowserExtension, isChrome } from '../../lib/constants'; +import { useLogContext } from '../../contexts/LogContext'; +import { LogEvent } from '../../lib/log'; +import { useLazyModal } from '../../hooks/useLazyModal'; +import type { Post } from '../../graphql/posts'; + +interface ReaderInstallPromptModalProps extends LazyModalCommonProps { + post: Post; +} + +const FAKE_PARAGRAPHS: ReadonlyArray = [ + 'The browser tab was the wrong unit of attention. Every link opened a new context, every context broke the loop, and the article you actually wanted to read got buried four tabs deep.', + 'Embedded browsing keeps the loop. The link opens beside the feed, the summary stays in view, and you decide what to read next without ever leaving the rail.', + 'It feels small. It is not. Once the friction is gone, you read more in less time, and the things you actually wanted to read stop slipping through the cracks.', +]; + +const FakePreview = (): ReactElement => ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {FAKE_PARAGRAPHS.map((line) => ( +
+
+
+
+
+
+ ))} +
+
+
+
+
+ {[0, 1, 2, 3].map((idx) => ( +
+
+
+
+
+
+
+ ))} +
+
+
+
+
+
+); + +function ReaderInstallPromptModal({ + post, + isOpen, + onRequestClose, +}: ReaderInstallPromptModalProps): ReactElement { + const { logEvent } = useLogContext(); + const { openModal, closeModal } = useLazyModal(); + const isChromeBrowser = isChrome(); + const BrowserIcon = isChromeBrowser ? ChromeIcon : EdgeIcon; + const installButtonLabel = isChromeBrowser + ? 'Install Chrome extension' + : 'Install Edge extension'; + const browser = isChromeBrowser ? 'chrome' : 'edge'; + + useEffect(() => { + logEvent({ + event_name: LogEvent.ImpressionReaderInstallPrompt, + extra: JSON.stringify({ browser, post_id: post.id }), + }); + // Fire once per mount + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const onInstallClick = () => { + logEvent({ + event_name: LogEvent.ClickReaderInstallExtension, + extra: JSON.stringify({ browser, post_id: post.id }), + }); + }; + + const onPreviewClick = (event: MouseEvent) => { + event.preventDefault(); + closeModal(); + openModal({ + type: LazyModal.ReaderPreview, + props: { post }, + }); + }; + + return ( + +
+ +
+ + Two clicks away + + + Read it without leaving daily.dev. + + + Install the extension and every link opens beside your feed. Title, + TL;DR, and the article — side by side. No new tabs. No losing the + loop. + +
+ + +
+ {post.permalink && ( + + or open in a new tab + + )} +
+
+
+ ); +} + +export default ReaderInstallPromptModal; diff --git a/packages/shared/src/components/modals/ReaderPostModal.tsx b/packages/shared/src/components/modals/ReaderPostModal.tsx index 85223f22e40..992e9df2812 100644 --- a/packages/shared/src/components/modals/ReaderPostModal.tsx +++ b/packages/shared/src/components/modals/ReaderPostModal.tsx @@ -68,7 +68,7 @@ export default function ReaderPostModal({ overlayClassName="post-modal-overlay bg-overlay-quaternary-onion" className={classNames( className, - 'reader-post-modal !mx-0 h-full max-h-screen !max-w-full !bg-background-default focus:outline-none tablet:!mx-auto tablet:!max-w-[min(100vw-1rem,100rem)] laptop:!mb-2 laptop:!mt-2 laptop:h-[calc(100vh-1rem)] laptop:max-h-[calc(100vh-1rem)] laptop:overflow-hidden', + 'reader-post-modal !mx-0 h-full max-h-screen !max-w-full !bg-background-default focus:outline-none tablet:!mx-auto tablet:!max-w-[min(95vw,118rem)] laptop:!mb-2 laptop:!mt-2 laptop:h-[calc(100vh-1rem)] laptop:max-h-[calc(100vh-1rem)] laptop:overflow-hidden', '!overscroll-y-auto', )} > diff --git a/packages/shared/src/components/modals/ReaderPreviewLazyModal.tsx b/packages/shared/src/components/modals/ReaderPreviewLazyModal.tsx new file mode 100644 index 00000000000..d12b76ad8a9 --- /dev/null +++ b/packages/shared/src/components/modals/ReaderPreviewLazyModal.tsx @@ -0,0 +1,30 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import type { LazyModalCommonProps } from './common/Modal'; +import type { Post } from '../../graphql/posts'; +import { PostPosition } from '../../hooks/usePostModalNavigation'; +import ReaderPostModal from './ReaderPostModal'; + +interface ReaderPreviewLazyModalProps extends LazyModalCommonProps { + post: Post; +} + +function ReaderPreviewLazyModal({ + post, + isOpen, + onRequestClose, +}: ReaderPreviewLazyModalProps): ReactElement { + return ( + undefined} + onNextPost={() => undefined} + /> + ); +} + +export default ReaderPreviewLazyModal; diff --git a/packages/shared/src/components/modals/common.tsx b/packages/shared/src/components/modals/common.tsx index b75b7d6d95b..d3665bb412e 100644 --- a/packages/shared/src/components/modals/common.tsx +++ b/packages/shared/src/components/modals/common.tsx @@ -487,6 +487,20 @@ const IntroQuestModal = dynamic( () => import(/* webpackChunkName: "introQuestModal" */ './IntroQuestModal'), ); +const ReaderInstallPromptModal = dynamic( + () => + import( + /* webpackChunkName: "readerInstallPromptModal" */ './ReaderInstallPromptModal' + ), +); + +const ReaderPreviewLazyModal = dynamic( + () => + import( + /* webpackChunkName: "readerPreviewLazyModal" */ './ReaderPreviewLazyModal' + ), +); + export const modals = { [LazyModal.SquadMember]: SquadMemberModal, [LazyModal.UpvotedPopup]: UpvotedPopupModal, @@ -566,6 +580,8 @@ export const modals = { [LazyModal.CompareAchievements]: CompareAchievementsModal, [LazyModal.AchievementShowcase]: AchievementShowcaseModal, [LazyModal.IntroQuests]: IntroQuestModal, + [LazyModal.ReaderInstallPrompt]: ReaderInstallPromptModal, + [LazyModal.ReaderPreview]: ReaderPreviewLazyModal, }; type GetComponentProps = T extends diff --git a/packages/shared/src/components/modals/common/types.ts b/packages/shared/src/components/modals/common/types.ts index e0f59663375..9429ac184cb 100644 --- a/packages/shared/src/components/modals/common/types.ts +++ b/packages/shared/src/components/modals/common/types.ts @@ -103,6 +103,8 @@ export enum LazyModal { CompareAchievements = 'compareAchievements', AchievementShowcase = 'achievementShowcase', IntroQuests = 'introQuests', + ReaderInstallPrompt = 'readerInstallPrompt', + ReaderPreview = 'readerPreview', } export type ModalTabItem = { diff --git a/packages/shared/src/components/post/PostHeaderActions.tsx b/packages/shared/src/components/post/PostHeaderActions.tsx index c53bb986de4..baf9e503bb5 100644 --- a/packages/shared/src/components/post/PostHeaderActions.tsx +++ b/packages/shared/src/components/post/PostHeaderActions.tsx @@ -23,6 +23,7 @@ import { useShowBoostButton } from '../../features/boost/useShowBoostButton'; import { ReaderLegacyLayoutToggleButton } from './reader/ReaderHeaderActionButtons'; import { useLegacyPostLayoutOptOut } from './reader/hooks/useLegacyPostLayoutOptOut'; import { useReaderModalEligibility } from './reader/hooks/useReaderModalEligibility'; +import { useReaderInstallPromptGate } from '../../hooks/useReaderInstallPromptGate'; const Container = classed('div', 'flex flex-row items-center'); @@ -56,6 +57,15 @@ export function PostHeaderActions({ const { isOptedOut: isLegacyLayoutOptedOut } = useLegacyPostLayoutOptOut(); const showReaderToggle = isArticle && isReaderEligible && isReaderModalEnabled; + const { onReadClick: onReaderInstallGateClick } = + useReaderInstallPromptGate(post); + + const handleReadArticle = (event: React.MouseEvent) => { + if (onReaderInstallGateClick(event)) { + return; + } + onReadArticle?.(); + }; return ( @@ -81,7 +91,7 @@ export function PostHeaderActions({ iconPosition={ isTwitter ? ButtonIconPosition.Right : (undefined as never) } - onClick={onReadArticle} + onClick={handleReadArticle} data-testid="postActionsRead" size={buttonSize} > diff --git a/packages/shared/src/components/post/reader/EngagementRail.tsx b/packages/shared/src/components/post/reader/EngagementRail.tsx index 0265b25fa01..8c5c4208931 100644 --- a/packages/shared/src/components/post/reader/EngagementRail.tsx +++ b/packages/shared/src/components/post/reader/EngagementRail.tsx @@ -18,8 +18,6 @@ import type { NewCommentRef } from '../NewComment'; import { NewComment } from '../NewComment'; import { PostTagList } from '../tags/PostTagList'; import PostMetadata from '../../cards/common/PostMetadata'; -import { useSmartTitle } from '../../../hooks/post/useSmartTitle'; -import { PostClickbaitShield } from '../common/PostClickbaitShield'; import { useSettingsContext } from '../../../contexts/SettingsContext'; import ShowMoreContent from '../../cards/common/ShowMoreContent'; import { @@ -31,11 +29,6 @@ import { import { TimeSortIcon } from '../../icons/Sort/Time'; import { AnalyticsIcon, ArrowIcon } from '../../icons'; import { PostMenuOptions } from '../PostMenuOptions'; -import { - Typography, - TypographyTag, - TypographyType, -} from '../../typography/Typography'; import { SortCommentsBy } from '../../../graphql/comments'; import { Tooltip } from '../../tooltip/Tooltip'; import { ClickableText } from '../../buttons/ClickableText'; @@ -99,7 +92,6 @@ export function EngagementRail({ const [isComposerOpen, setIsComposerOpen] = useState(false); const { onShowUpvoted } = useUpvoteQuery(); const { openShareComment } = useShareComment(Origin.ReaderModal); - const { title: displayTitle } = useSmartTitle(post); const isVideoType = isVideoPost(post); const upvotes = post.numUpvotes || 0; const comments = post.numComments || 0; @@ -220,15 +212,6 @@ export function EngagementRail({ aria-label="Article summary" className="flex min-w-0 flex-col gap-2" > - - {displayTitle} - - {post.clickbaitTitleDetected && } {post.summary && (
{ - setRailWidthPx(railWidthPx + deltaPx); + setRailWidthPx(railWidthPx - deltaPx); }, [railWidthPx, setRailWidthPx], ); @@ -192,25 +195,11 @@ export function ReaderPostLayout({ style={ isRailOpen ? { - gridTemplateColumns: `${clampedRailWidth}px auto minmax(0,1fr)`, + gridTemplateColumns: `minmax(0,1fr) auto ${clampedRailWidth}px`, } : { gridTemplateColumns: 'minmax(0,1fr)' } } > - {isRailOpen && ( - <> - - - - )}
+ {isRailOpen && ( + <> + + + + )}
diff --git a/packages/shared/src/components/post/reader/hooks/useReaderModalEligibility.ts b/packages/shared/src/components/post/reader/hooks/useReaderModalEligibility.ts index 314bc2bf095..9b510c3982e 100644 --- a/packages/shared/src/components/post/reader/hooks/useReaderModalEligibility.ts +++ b/packages/shared/src/components/post/reader/hooks/useReaderModalEligibility.ts @@ -34,5 +34,15 @@ export function useReaderModalEligibility(): UseReaderModalEligibilityResult { shouldEvaluate: isEligible, }); - return { isEligible, isReaderModalEnabled, isReaderFeatureLoading }; + // DEMO ONLY: force-enable the reader modal experiment so we can record a + // walkthrough of the next iteration (install-prompt + layout swap) before + // engineering wires up the real GrowthBook variant. Remove this override + // when the production implementation lands. + const demoForceEnabled = isEligible; + + return { + isEligible, + isReaderModalEnabled: demoForceEnabled || isReaderModalEnabled, + isReaderFeatureLoading, + }; } diff --git a/packages/shared/src/hooks/useReaderInstallPromptGate.ts b/packages/shared/src/hooks/useReaderInstallPromptGate.ts new file mode 100644 index 00000000000..171676ee455 --- /dev/null +++ b/packages/shared/src/hooks/useReaderInstallPromptGate.ts @@ -0,0 +1,72 @@ +import type { MouseEvent } from 'react'; +import { useCallback } from 'react'; +import { useLazyModal } from './useLazyModal'; +import { LazyModal } from '../components/modals/common/types'; +import type { Post } from '../graphql/posts'; +import { useReaderModalEligibility } from '../components/post/reader/hooks/useReaderModalEligibility'; +import { PostType } from '../graphql/posts'; + +const READER_GATE_ELIGIBLE_TYPES = new Set([ + PostType.Article, + PostType.Digest, + PostType.VideoYouTube, +]); + +interface UseReaderInstallPromptGateResult { + isGated: boolean; + /** + * Returns `true` when the gate has intercepted the click and opened the + * install prompt; the caller should then skip its default click behavior. + * Returns `false` otherwise (gate inactive, modifier keys, non-eligible + * post) so the caller can fall through to its normal handler. + */ + onReadClick: (event: MouseEvent) => boolean; +} + +/** + * DEMO ONLY: gate the "Read post" click on eligible posts behind the new + * install-extension prompt. Replaces the default browser navigation with the + * `LazyModal.ReaderInstallPrompt` flow so the user can preview the next-step + * UX without installing. + * + * The hook never swallows modifier-key clicks (cmd / ctrl / middle-click) + * so users can still open the article in a new tab if they want to. + */ +export function useReaderInstallPromptGate( + post: Post | undefined, +): UseReaderInstallPromptGateResult { + const { openModal } = useLazyModal(); + const { isEligible, isReaderModalEnabled } = useReaderModalEligibility(); + + const isGated = + !!post && + isEligible && + isReaderModalEnabled && + READER_GATE_ELIGIBLE_TYPES.has(post.type); + + const onReadClick = useCallback( + (event: MouseEvent): boolean => { + if (!isGated || !post) { + return false; + } + if ( + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.button > 0 + ) { + return false; + } + event.preventDefault(); + event.stopPropagation(); + openModal({ + type: LazyModal.ReaderInstallPrompt, + props: { post }, + }); + return true; + }, + [isGated, openModal, post], + ); + + return { isGated, onReadClick }; +} diff --git a/packages/webapp/pages/posts/[id]/index.tsx b/packages/webapp/pages/posts/[id]/index.tsx index cb1b096f71f..8b3c16261fc 100644 --- a/packages/webapp/pages/posts/[id]/index.tsx +++ b/packages/webapp/pages/posts/[id]/index.tsx @@ -290,7 +290,12 @@ export const PostPage = ({ router.push(webappUrl); }; + // DEMO ONLY: the /posts/[id] page now always shows the classic layout + // (title + TL;DR + "Read post" button). The reader-modal v2 layout is only + // reachable via the new install-extension prompt to keep the demo flow + // identical between feed cards and direct post links. const shouldUseReaderLayout = + false && isReaderModalFeatureReady && isReaderModalOn && READER_ELIGIBLE_POST_TYPES.has(post.type); From 0604f097b76ea15829d88f2cccf079c8ece135ce Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 14 May 2026 10:36:51 +0300 Subject: [PATCH 02/17] feat: polish reader chrome and install prompt UX - Move close (X) from the iframe preview header to the engagement rail header next to the three-dots menu in modal mode. The iframe header keeps the X only when the rail is collapsed so users always have an escape hatch. - Re-enable shouldUseReaderLayout on /posts/[id] and cap the reader page container to min(95vw, 118rem) centered, so the preview no longer stretches edge-to-edge on large monitors and matches the modal's footprint. - Redesign the install prompt as a full-height reader-mimicking shell: - URL bar header with favicon + permalink (click to copy) on the left - Disabled three-dots + X cluster on the right - Two-column body: blurred fake article on the left, mocked rail on the right, install card overlaid in the center - Card copy and visual hierarchy reworked: badge, headline that promises the outcome, bullet list, primary install CTA, secondary preview CTA, and a trust microcopy line Co-authored-by: Cursor --- .../modals/ReaderInstallPromptModal.tsx | 450 +++++++++++++----- .../post/reader/ArticleReaderFrame.tsx | 2 +- .../components/post/reader/EngagementRail.tsx | 9 + .../components/post/reader/ReaderChrome.tsx | 2 +- .../post/reader/ReaderPostLayout.tsx | 12 +- packages/webapp/pages/posts/[id]/index.tsx | 11 +- 6 files changed, 351 insertions(+), 135 deletions(-) diff --git a/packages/shared/src/components/modals/ReaderInstallPromptModal.tsx b/packages/shared/src/components/modals/ReaderInstallPromptModal.tsx index 95011c9967c..bbbf284ae49 100644 --- a/packages/shared/src/components/modals/ReaderInstallPromptModal.tsx +++ b/packages/shared/src/components/modals/ReaderInstallPromptModal.tsx @@ -1,88 +1,193 @@ import type { MouseEvent, ReactElement } from 'react'; -import React, { useEffect } from 'react'; +import React, { useEffect, useMemo } from 'react'; import classNames from 'classnames'; import { Modal } from './common/Modal'; import type { LazyModalCommonProps } from './common/Modal'; import { LazyModal, ModalKind, ModalSize } from './common/types'; import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; -import { ClickableText } from '../buttons/ClickableText'; import { Typography, TypographyColor, TypographyTag, TypographyType, } from '../typography/Typography'; -import { ChromeIcon, EdgeIcon } from '../icons'; +import { + ChromeIcon, + EdgeIcon, + MiniCloseIcon as CloseIcon, + MenuIcon, + CopyIcon, + ArrowIcon, + UpvoteIcon, + DiscussIcon, + BookmarkIcon, +} from '../icons'; +import { Tooltip } from '../tooltip/Tooltip'; import { downloadBrowserExtension, isChrome } from '../../lib/constants'; +import { apiUrl } from '../../lib/config'; import { useLogContext } from '../../contexts/LogContext'; import { LogEvent } from '../../lib/log'; import { useLazyModal } from '../../hooks/useLazyModal'; +import { useToastNotification } from '../../hooks/useToastNotification'; import type { Post } from '../../graphql/posts'; +import styles from './BasePostModal.module.css'; interface ReaderInstallPromptModalProps extends LazyModalCommonProps { post: Post; } -const FAKE_PARAGRAPHS: ReadonlyArray = [ - 'The browser tab was the wrong unit of attention. Every link opened a new context, every context broke the loop, and the article you actually wanted to read got buried four tabs deep.', - 'Embedded browsing keeps the loop. The link opens beside the feed, the summary stays in view, and you decide what to read next without ever leaving the rail.', - 'It feels small. It is not. Once the friction is gone, you read more in less time, and the things you actually wanted to read stop slipping through the cracks.', -]; - -const FakePreview = (): ReactElement => ( -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+const FAKE_PARAGRAPHS: ReadonlyArray = ['p1', 'p2', 'p3', 'p4']; + +const headerActionGroupClassName = + 'flex h-9 items-center gap-px rounded-12 border border-border-subtlest-tertiary bg-background-default p-px shadow-3'; + +const iconButtonClassName = '!h-8 !w-8 !min-w-8 !rounded-10 !p-0'; + +const useFaviconSrc = (host: string | undefined): string | undefined => { + return useMemo(() => { + if (!host) { + return undefined; + } + const pixelRatio = globalThis?.window?.devicePixelRatio ?? 1; + const iconSize = Math.max(Math.round(16 * pixelRatio), 96); + return `${apiUrl}/icon?url=${encodeURIComponent(host)}&size=${iconSize}`; + }, [host]); +}; + +const getPostHost = (post: Post): string | undefined => { + if (post.domain) { + return post.domain; + } + if (!post.permalink) { + return undefined; + } + try { + return new URL(post.permalink).hostname; + } catch { + return undefined; + } +}; + +function FakePreview(): ReactElement { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{FAKE_PARAGRAPHS.map((line) => (
+
))}
-
-
-
-
- {[0, 1, 2, 3].map((idx) => ( -
-
-
-
-
-
-
- ))} +
+
+
+ ); +} + +interface FakeRailProps { + post: Post; +} + +function FakeRail({ post }: FakeRailProps): ReactElement { + const previewSummary = + post.summary || + 'A short, scannable summary appears here once the reader is unlocked — title, TL;DR, and the article side by side.'; + + return ( + + ); +} function ReaderInstallPromptModal({ post, @@ -91,12 +196,16 @@ function ReaderInstallPromptModal({ }: ReaderInstallPromptModalProps): ReactElement { const { logEvent } = useLogContext(); const { openModal, closeModal } = useLazyModal(); + const { displayToast } = useToastNotification(); const isChromeBrowser = isChrome(); const BrowserIcon = isChromeBrowser ? ChromeIcon : EdgeIcon; const installButtonLabel = isChromeBrowser ? 'Install Chrome extension' : 'Install Edge extension'; const browser = isChromeBrowser ? 'chrome' : 'edge'; + const host = getPostHost(post); + const faviconSrc = useFaviconSrc(host); + const displayDomain = host || 'daily.dev'; useEffect(() => { logEvent({ @@ -123,90 +232,181 @@ function ReaderInstallPromptModal({ }); }; + const onCopyUrl = () => { + if (!post.permalink) { + return; + } + if (navigator?.clipboard?.writeText) { + navigator.clipboard.writeText(post.permalink); + displayToast('Link copied'); + } + }; + return ( -
- -
- - Two clicks away - - - Read it without leaving daily.dev. - - - Install the extension and every link opens beside your feed. Title, - TL;DR, and the article — side by side. No new tabs. No losing the - loop. - -
- - + +
- {post.permalink && ( - - or open in a new tab - - )} +
+
+ +
+
+ +
+
+
+
+
+ +
+
+ + + one click away + + + Open every link inside daily.dev. + + + Install the extension and {displayDomain} loads here, next to + its TL;DR, your highlights, and the discussion. No new tab. No + context switching. No losing the loop. + +
    + {[ + 'Read any article without leaving the feed', + 'See the TL;DR and discussion side by side', + 'Pick up where you left off across devices', + ].map((line) => ( +
  • + + {line} +
  • + ))} +
+
+ + +
+ + Free. Works offline. Two clicks to install. + +
+
+
+
diff --git a/packages/shared/src/components/post/reader/ArticleReaderFrame.tsx b/packages/shared/src/components/post/reader/ArticleReaderFrame.tsx index 063929fdd15..955014ad6f5 100644 --- a/packages/shared/src/components/post/reader/ArticleReaderFrame.tsx +++ b/packages/shared/src/components/post/reader/ArticleReaderFrame.tsx @@ -13,7 +13,7 @@ type ArticleReaderFrameProps = { targetUrl: string | null; isEmbeddable: boolean; className?: string; - onClose: () => void; + onClose?: () => void; isPostPage?: boolean; fallbackScrollRef?: Ref; contentTopOffsetPx?: number; diff --git a/packages/shared/src/components/post/reader/EngagementRail.tsx b/packages/shared/src/components/post/reader/EngagementRail.tsx index 8c5c4208931..990c9bb68bf 100644 --- a/packages/shared/src/components/post/reader/EngagementRail.tsx +++ b/packages/shared/src/components/post/reader/EngagementRail.tsx @@ -41,6 +41,7 @@ import { PostPosition } from '../../../hooks/usePostModalNavigation'; import { SourceStrip } from './SourceStrip'; import { ReaderRailActionBar } from './ReaderRailActionBar'; import ShareBar from '../../ShareBar'; +import { ReaderCloseButton } from './ReaderHeaderActionButtons'; const SquadEntityCard = dynamic( () => @@ -71,6 +72,12 @@ type EngagementRailProps = { * back-to-feed arrow button at the top of the discussion rail. */ onBackToFeed?: () => void; + /** + * Modal only: when provided, renders a close (X) button on the right side + * of the rail header next to the three-dots menu. The modal surface uses + * the rail close while the standalone post page uses `onBackToFeed`. + */ + onClose?: () => void; }; const noopFocus = (): void => {}; @@ -83,6 +90,7 @@ export function EngagementRail({ onRegisterFocusComment, className, onBackToFeed, + onClose, }: EngagementRailProps): ReactElement { const { tokenRefreshed } = useContext(AuthContext); const { user } = useAuthContext(); @@ -194,6 +202,7 @@ export function EngagementRail({ origin={Origin.ReaderModal} buttonSize={ButtonSize.Small} /> + {onClose && }
diff --git a/packages/shared/src/components/post/reader/ReaderChrome.tsx b/packages/shared/src/components/post/reader/ReaderChrome.tsx index 7bce93e2494..e08ce405736 100644 --- a/packages/shared/src/components/post/reader/ReaderChrome.tsx +++ b/packages/shared/src/components/post/reader/ReaderChrome.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { ReaderHeaderActionGroup } from './ReaderHeaderActionButtons'; type ReaderChromeProps = { - onClose: () => void; + onClose?: () => void; isPostPage?: boolean; }; diff --git a/packages/shared/src/components/post/reader/ReaderPostLayout.tsx b/packages/shared/src/components/post/reader/ReaderPostLayout.tsx index 6e87c708cfa..53f810000c6 100644 --- a/packages/shared/src/components/post/reader/ReaderPostLayout.tsx +++ b/packages/shared/src/components/post/reader/ReaderPostLayout.tsx @@ -207,7 +207,12 @@ export function ReaderPostLayout({ isEmbeddable={isEmbeddable} fallbackScrollRef={fallbackScrollRef} className="min-h-0 flex-1" - onClose={onCloseWithLog} + // DEMO ONLY: close lives in the rail header now, but + // when the rail is collapsed we still need an escape + // hatch in the iframe header. + onClose={ + isPostPage || isRailOpen ? undefined : onCloseWithLog + } isPostPage={isPostPage} contentTopOffsetPx={CHROME_TOP_OFFSET_PX} onEmbedReady={onEmbedReady} @@ -217,7 +222,9 @@ export function ReaderPostLayout({ /> {!hasEmbeddedReaderHeader && ( )} @@ -237,6 +244,7 @@ export function ReaderPostLayout({ onRegisterFocusComment={onRegisterFocusComment} className="min-w-0" onBackToFeed={isPostPage ? onCloseWithLog : undefined} + onClose={!isPostPage ? onCloseWithLog : undefined} /> )} diff --git a/packages/webapp/pages/posts/[id]/index.tsx b/packages/webapp/pages/posts/[id]/index.tsx index 8b3c16261fc..a8c629275cd 100644 --- a/packages/webapp/pages/posts/[id]/index.tsx +++ b/packages/webapp/pages/posts/[id]/index.tsx @@ -139,8 +139,12 @@ const READER_ELIGIBLE_POST_TYPES = new Set([ PostType.VideoYouTube, ]); +// DEMO ONLY: cap the standalone reader page to the same width as the +// reader modal so the preview is centered with breathing room and the +// rail aligns with the right side of the centered shell instead of +// stretching edge-to-edge across large monitors. const READER_PAGE_LAYOUT_CLASS_NAME = - 'flex h-[calc(100vh-4rem)] max-h-[calc(100vh-4rem)] min-h-0 w-full flex-col'; + 'mx-auto flex h-[calc(100vh-4rem)] max-h-[calc(100vh-4rem)] min-h-0 w-full max-w-[min(95vw,118rem)] flex-col'; const CONTENT_MAP: Record> = { article: PostContent as PostContentComponent, @@ -290,12 +294,7 @@ export const PostPage = ({ router.push(webappUrl); }; - // DEMO ONLY: the /posts/[id] page now always shows the classic layout - // (title + TL;DR + "Read post" button). The reader-modal v2 layout is only - // reachable via the new install-extension prompt to keep the demo flow - // identical between feed cards and direct post links. const shouldUseReaderLayout = - false && isReaderModalFeatureReady && isReaderModalOn && READER_ELIGIBLE_POST_TYPES.has(post.type); From a5bc685e568dcf40c901011204cb63a537e82e0a Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 14 May 2026 11:03:22 +0300 Subject: [PATCH 03/17] feat(reader-demo): bigger reader shell + browser-style install prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bump default rail ratio (0.30 → 0.36) so the engagement rail starts with more breathing room without a manual drag. - Bump reader modal + standalone post page max-width to min(96vw,132rem) for a more theater-sized reading shell on large displays. - Redesign install prompt as a believable in-app browser: tab strip with traffic lights, back/forward/refresh, lock + permalink address bar, bookmark/menu/close. Drop the engagement rail and the misleading "Free. Works offline." microcopy. Sharper Nikita-Bier copy: "Read this inside daily.dev? Install the extension and {host} opens right here — no new tab, no context switching." Co-authored-by: Cursor --- .../modals/ReaderInstallPromptModal.tsx | 467 +++++++++--------- .../src/components/modals/ReaderPostModal.tsx | 2 +- .../post/reader/hooks/useReaderLayoutPrefs.ts | 5 +- packages/webapp/pages/posts/[id]/index.tsx | 2 +- 4 files changed, 232 insertions(+), 244 deletions(-) diff --git a/packages/shared/src/components/modals/ReaderInstallPromptModal.tsx b/packages/shared/src/components/modals/ReaderInstallPromptModal.tsx index bbbf284ae49..a7df16520d7 100644 --- a/packages/shared/src/components/modals/ReaderInstallPromptModal.tsx +++ b/packages/shared/src/components/modals/ReaderInstallPromptModal.tsx @@ -12,17 +12,15 @@ import { TypographyType, } from '../typography/Typography'; import { + ArrowIcon, ChromeIcon, EdgeIcon, - MiniCloseIcon as CloseIcon, + LockIcon, MenuIcon, - CopyIcon, - ArrowIcon, - UpvoteIcon, - DiscussIcon, - BookmarkIcon, + MiniCloseIcon as CloseIcon, + RefreshIcon, + StarIcon, } from '../icons'; -import { Tooltip } from '../tooltip/Tooltip'; import { downloadBrowserExtension, isChrome } from '../../lib/constants'; import { apiUrl } from '../../lib/config'; import { useLogContext } from '../../contexts/LogContext'; @@ -36,12 +34,7 @@ interface ReaderInstallPromptModalProps extends LazyModalCommonProps { post: Post; } -const FAKE_PARAGRAPHS: ReadonlyArray = ['p1', 'p2', 'p3', 'p4']; - -const headerActionGroupClassName = - 'flex h-9 items-center gap-px rounded-12 border border-border-subtlest-tertiary bg-background-default p-px shadow-3'; - -const iconButtonClassName = '!h-8 !w-8 !min-w-8 !rounded-10 !p-0'; +const FAKE_PARAGRAPHS: ReadonlyArray = ['p1', 'p2', 'p3', 'p4', 'p5']; const useFaviconSrc = (host: string | undefined): string | undefined => { return useMemo(() => { @@ -68,7 +61,161 @@ const getPostHost = (post: Post): string | undefined => { } }; -function FakePreview(): ReactElement { +const getDisplayUrl = (post: Post, host: string | undefined): string => { + if (post.permalink) { + return post.permalink; + } + return host || 'daily.dev'; +}; + +interface BrowserChromeProps { + post: Post; + faviconSrc: string | undefined; + displayHost: string; + displayUrl: string; + onCopyUrl: () => void; + onClose: (event: MouseEvent) => void; +} + +function TrafficLight({ tone }: { tone: 'red' | 'amber' | 'green' }) { + const palette = { + red: 'bg-action-downvote-default', + amber: 'bg-action-bookmark-default', + green: 'bg-action-upvote-default', + } as const; + return ( + + ); +} + +function BrowserChrome({ + post, + faviconSrc, + displayHost, + displayUrl, + onCopyUrl, + onClose, +}: BrowserChromeProps): ReactElement { + const tabTitle = post.title || displayHost; + return ( +
+
+
+ + + +
+
+ {faviconSrc && ( + + )} + + {tabTitle} + + +
+ +
+
+
+ + + +
+ +
+ +
+
+
+ ); +} + +function FakeArticle(): ReactElement { return (
-
-
+
+
-
-
+
+
@@ -105,90 +252,11 @@ function FakePreview(): ReactElement { ))}
-
+
); } -interface FakeRailProps { - post: Post; -} - -function FakeRail({ post }: FakeRailProps): ReactElement { - const previewSummary = - post.summary || - 'A short, scannable summary appears here once the reader is unlocked — title, TL;DR, and the article side by side.'; - - return ( - - ); -} - function ReaderInstallPromptModal({ post, isOpen, @@ -205,7 +273,8 @@ function ReaderInstallPromptModal({ const browser = isChromeBrowser ? 'chrome' : 'edge'; const host = getPostHost(post); const faviconSrc = useFaviconSrc(host); - const displayDomain = host || 'daily.dev'; + const displayHost = host || 'daily.dev'; + const displayUrl = getDisplayUrl(post, host); useEffect(() => { logEvent({ @@ -242,6 +311,10 @@ function ReaderInstallPromptModal({ } }; + const onCloseFromChrome = (event: MouseEvent) => { + onRequestClose(event); + }; + return (
-
-
- {faviconSrc && ( - - )} - +
+ +
+
- - -
-
-
- + Read this inside daily.dev? + + + Install the extension and {displayHost} opens right here — no + new tab, no context switching. + +
-
- + tag="a" + variant={ButtonVariant.Primary} + size={ButtonSize.Large} + href={downloadBrowserExtension} + target="_blank" + rel="noopener noreferrer" + icon={} + onClick={onInstallClick} + > + {installButtonLabel} +
-
-
-
-
- -
-
- - - one click away - - - Open every link inside daily.dev. - - - Install the extension and {displayDomain} loads here, next to - its TL;DR, your highlights, and the discussion. No new tab. No - context switching. No losing the loop. - -
    - {[ - 'Read any article without leaving the feed', - 'See the TL;DR and discussion side by side', - 'Pick up where you left off across devices', - ].map((line) => ( -
  • - - {line} -
  • - ))} -
-
- - -
- - Free. Works offline. Two clicks to install. - + Preview the experience +
-
diff --git a/packages/shared/src/components/modals/ReaderPostModal.tsx b/packages/shared/src/components/modals/ReaderPostModal.tsx index 992e9df2812..f2c9e2be4ae 100644 --- a/packages/shared/src/components/modals/ReaderPostModal.tsx +++ b/packages/shared/src/components/modals/ReaderPostModal.tsx @@ -68,7 +68,7 @@ export default function ReaderPostModal({ overlayClassName="post-modal-overlay bg-overlay-quaternary-onion" className={classNames( className, - 'reader-post-modal !mx-0 h-full max-h-screen !max-w-full !bg-background-default focus:outline-none tablet:!mx-auto tablet:!max-w-[min(95vw,118rem)] laptop:!mb-2 laptop:!mt-2 laptop:h-[calc(100vh-1rem)] laptop:max-h-[calc(100vh-1rem)] laptop:overflow-hidden', + 'reader-post-modal !mx-0 h-full max-h-screen !max-w-full !bg-background-default focus:outline-none tablet:!mx-auto tablet:!max-w-[min(96vw,132rem)] laptop:!mb-2 laptop:!mt-2 laptop:h-[calc(100vh-1rem)] laptop:max-h-[calc(100vh-1rem)] laptop:overflow-hidden', '!overscroll-y-auto', )} > diff --git a/packages/shared/src/components/post/reader/hooks/useReaderLayoutPrefs.ts b/packages/shared/src/components/post/reader/hooks/useReaderLayoutPrefs.ts index 9daca6bdef2..ee6bc89072f 100644 --- a/packages/shared/src/components/post/reader/hooks/useReaderLayoutPrefs.ts +++ b/packages/shared/src/components/post/reader/hooks/useReaderLayoutPrefs.ts @@ -9,7 +9,10 @@ const LEGACY_STORAGE_KEY_RAIL_WIDTH_PX = 'readerModal.railWidthPx'; // Persisted rail width is stored as a fraction of the layout container so the // chosen proportion carries across screen sizes and between the reader modal // (capped container) and the standalone post page (full-width container). -const DEFAULT_RAIL_WIDTH_RATIO = 0.3; +// DEMO ONLY: bumped the default ratio so the rail starts noticeably wider +// — there's enough room for the TL;DR + comments without immediately +// dragging the divider. +const DEFAULT_RAIL_WIDTH_RATIO = 0.36; const MIN_RAIL_WIDTH_RATIO = 0.2; const MAX_RAIL_WIDTH_RATIO = 0.55; // Absolute pixel bounds keep the rail usable on very small or very large diff --git a/packages/webapp/pages/posts/[id]/index.tsx b/packages/webapp/pages/posts/[id]/index.tsx index a8c629275cd..4ec01b2706c 100644 --- a/packages/webapp/pages/posts/[id]/index.tsx +++ b/packages/webapp/pages/posts/[id]/index.tsx @@ -144,7 +144,7 @@ const READER_ELIGIBLE_POST_TYPES = new Set([ // rail aligns with the right side of the centered shell instead of // stretching edge-to-edge across large monitors. const READER_PAGE_LAYOUT_CLASS_NAME = - 'mx-auto flex h-[calc(100vh-4rem)] max-h-[calc(100vh-4rem)] min-h-0 w-full max-w-[min(95vw,118rem)] flex-col'; + 'mx-auto flex h-[calc(100vh-4rem)] max-h-[calc(100vh-4rem)] min-h-0 w-full max-w-[min(96vw,132rem)] flex-col'; const CONTENT_MAP: Record> = { article: PostContent as PostContentComponent, From 2578feca70928fa195d3197d31b98a45e455b4da Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 14 May 2026 11:21:57 +0300 Subject: [PATCH 04/17] feat(reader-demo): tighter reader shell + cleaner browser-chrome install prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reader modal / post page: - Cap width at min(96vw, 76rem) so the article + rail stay legible and centered instead of stretching across ultrawide monitors. - Lock rail to 380px and remove the drag-to-resize PaneDivider so the layout is deliberate (preview ~720-760px, rail 380px). Install prompt: - Collapse the two-row faux chrome (tab strip + URL bar) into one single browser bar: traffic lights, back/forward/refresh, URL pill (lock + favicon + permalink + star), menu, close. - Replace the empty placeholder block with a full-width article preview that uses the real post title, source name/avatar, hero image, and read time. Only the body paragraphs stay blurred, so the surface reads as "we opened your article inside daily.dev". - Drop the em-dash in the install card subtitle: now reads "Install the extension and {host} opens right here inside daily.dev. No new tab. No context switching." - Match install modal width to reader modal width so the install → preview transition feels like just removing the overlay. Co-authored-by: Cursor --- .../modals/ReaderInstallPromptModal.tsx | 297 +++++++++--------- .../src/components/modals/ReaderPostModal.tsx | 2 +- .../post/reader/ReaderPostLayout.tsx | 67 ++-- packages/webapp/pages/posts/[id]/index.tsx | 2 +- 4 files changed, 177 insertions(+), 191 deletions(-) diff --git a/packages/shared/src/components/modals/ReaderInstallPromptModal.tsx b/packages/shared/src/components/modals/ReaderInstallPromptModal.tsx index a7df16520d7..be18f5aa9c5 100644 --- a/packages/shared/src/components/modals/ReaderInstallPromptModal.tsx +++ b/packages/shared/src/components/modals/ReaderInstallPromptModal.tsx @@ -28,13 +28,14 @@ import { LogEvent } from '../../lib/log'; import { useLazyModal } from '../../hooks/useLazyModal'; import { useToastNotification } from '../../hooks/useToastNotification'; import type { Post } from '../../graphql/posts'; +import { cloudinaryPostImageCoverPlaceholder } from '../../lib/image'; import styles from './BasePostModal.module.css'; interface ReaderInstallPromptModalProps extends LazyModalCommonProps { post: Post; } -const FAKE_PARAGRAPHS: ReadonlyArray = ['p1', 'p2', 'p3', 'p4', 'p5']; +const FAKE_PARAGRAPHS = ['p1', 'p2', 'p3', 'p4', 'p5', 'p6'] as const; const useFaviconSrc = (host: string | undefined): string | undefined => { return useMemo(() => { @@ -68,15 +69,6 @@ const getDisplayUrl = (post: Post, host: string | undefined): string => { return host || 'daily.dev'; }; -interface BrowserChromeProps { - post: Post; - faviconSrc: string | undefined; - displayHost: string; - displayUrl: string; - onCopyUrl: () => void; - onClose: (event: MouseEvent) => void; -} - function TrafficLight({ tone }: { tone: 'red' | 'amber' | 'green' }) { const palette = { red: 'bg-action-downvote-default', @@ -91,168 +83,182 @@ function TrafficLight({ tone }: { tone: 'red' | 'amber' | 'green' }) { ); } +interface BrowserChromeProps { + faviconSrc: string | undefined; + displayUrl: string; + onCopyUrl: () => void; + onClose: (event: MouseEvent) => void; +} + function BrowserChrome({ - post, faviconSrc, - displayHost, displayUrl, onCopyUrl, onClose, }: BrowserChromeProps): ReactElement { - const tabTitle = post.title || displayHost; return ( -
-
-
- - - -
-
- {faviconSrc && ( - - )} - - {tabTitle} - - -
+
+
+ + + +
+
-
-
-
- - - -
+ +
+ +
+ -
- -
+
); } -function FakeArticle(): ReactElement { +interface FakeArticlePreviewProps { + post: Post; +} + +function FakeArticlePreview({ post }: FakeArticlePreviewProps): ReactElement { + const heroImage = post.image || cloudinaryPostImageCoverPlaceholder; + const sourceName = post.source?.name; + const sourceImage = post.source?.image; + const readTimeLabel = + typeof post.readTime === 'number' ? `${post.readTime} min read` : null; + return (
-
-
-
-
-
-
-
+
+
-
-
-
-
+ {sourceImage && ( + + )} +
+ {sourceName && ( + + {sourceName} + + )} + + {readTimeLabel ?? 'Article'} +
-
- {FAKE_PARAGRAPHS.map((line) => ( -
-
-
-
-
-
-
- ))} -
+ {post.title && ( + + {post.title} + + )} + +
+ {FAKE_PARAGRAPHS.map((line) => ( +
+
+
+
+
+
+ ))} +
+
-
+
); } @@ -281,7 +287,6 @@ function ReaderInstallPromptModal({ event_name: LogEvent.ImpressionReaderInstallPrompt, extra: JSON.stringify({ browser, post_id: post.id }), }); - // Fire once per mount // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -325,22 +330,20 @@ function ReaderInstallPromptModal({ overlayClassName="post-modal-overlay bg-overlay-quaternary-onion" className={classNames( 'reader-install-prompt-modal !mx-0 h-full max-h-screen !max-w-full overflow-hidden !bg-background-default focus:outline-none', - 'tablet:!mx-auto tablet:!max-w-[min(96vw,132rem)]', + 'tablet:!mx-auto tablet:!max-w-[min(96vw,76rem)]', 'laptop:!mb-2 laptop:!mt-2 laptop:h-[calc(100vh-1rem)] laptop:max-h-[calc(100vh-1rem)] laptop:overflow-hidden', '!overscroll-y-auto', )} >
- +
- Install the extension and {displayHost} opens right here — no - new tab, no context switching. + Install the extension and {displayHost} opens right here inside + daily.dev. No new tab. No context switching.
diff --git a/packages/webapp/pages/posts/[id]/index.tsx b/packages/webapp/pages/posts/[id]/index.tsx index 4ec01b2706c..fe5ee6a249e 100644 --- a/packages/webapp/pages/posts/[id]/index.tsx +++ b/packages/webapp/pages/posts/[id]/index.tsx @@ -144,7 +144,7 @@ const READER_ELIGIBLE_POST_TYPES = new Set([ // rail aligns with the right side of the centered shell instead of // stretching edge-to-edge across large monitors. const READER_PAGE_LAYOUT_CLASS_NAME = - 'mx-auto flex h-[calc(100vh-4rem)] max-h-[calc(100vh-4rem)] min-h-0 w-full max-w-[min(96vw,132rem)] flex-col'; + 'mx-auto flex h-[calc(100vh-4rem)] max-h-[calc(100vh-4rem)] min-h-0 w-full max-w-[min(96vw,76rem)] flex-col'; const CONTENT_MAP: Record> = { article: PostContent as PostContentComponent, From 5e263986354bf56705f684862e19a697b26771f5 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 14 May 2026 11:30:25 +0300 Subject: [PATCH 05/17] fix(reader-demo): post page matches modal shell exactly - MainLayout was adding `laptop:!pl-60` (240px expanded-sidebar inset) because the post page had `screenCentered: false`. That offset pushed the reader shell off-center and made the engagement rail anchor to the right edge of the viewport. Flipping the page to `screenCentered: true` removes the inset so the shell can center on the viewport like the modal portal does. - Move the 76rem width cap + mx-auto centering INSIDE `ReaderPostLayout` itself (on a new inner wrapper) so the shell is always capped to the modal's footprint regardless of what the outer `outerClassName` does. The page now only sets viewport-height bounds via `outerClassName`. - Net result: post page and modal render the exact same shell at the exact same width and behavior. Co-authored-by: Cursor --- .../components/post/reader/ReaderPostLayout.tsx | 9 ++++++++- packages/webapp/pages/posts/[id]/index.tsx | 15 +++++++++------ 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/packages/shared/src/components/post/reader/ReaderPostLayout.tsx b/packages/shared/src/components/post/reader/ReaderPostLayout.tsx index fad50878de7..510d26a6969 100644 --- a/packages/shared/src/components/post/reader/ReaderPostLayout.tsx +++ b/packages/shared/src/components/post/reader/ReaderPostLayout.tsx @@ -29,6 +29,10 @@ const DEFAULT_OUTER_CLASS_NAME = 'flex h-full min-h-0 w-full flex-col'; // and rail keep a deliberate, balanced layout instead of letting users // collapse one pane into the other. const FIXED_RAIL_WIDTH_PX = 380; +// DEMO ONLY: matches the modal's `tablet:!max-w-[min(96vw,76rem)]` cap so +// the standalone post page renders the same shell at the same width as the +// modal portal, even if the outer `outerClassName` ever stops constraining. +const SHELL_MAX_WIDTH = 'min(96vw, 76rem)'; type ReaderPostLayoutProps = { post: Post; @@ -174,7 +178,10 @@ export function ReaderPostLayout({ className={outerClassName ?? DEFAULT_OUTER_CLASS_NAME} data-testid="readerPostLayout" > -
+
([ PostType.VideoYouTube, ]); -// DEMO ONLY: cap the standalone reader page to the same width as the -// reader modal so the preview is centered with breathing room and the -// rail aligns with the right side of the centered shell instead of -// stretching edge-to-edge across large monitors. +// DEMO ONLY: only set the viewport-height bounds here. Width capping and +// centering are owned by `ReaderPostLayout` itself so the post page and +// the modal use the exact same shell. const READER_PAGE_LAYOUT_CLASS_NAME = - 'mx-auto flex h-[calc(100vh-4rem)] max-h-[calc(100vh-4rem)] min-h-0 w-full max-w-[min(96vw,76rem)] flex-col'; + 'flex h-[calc(100vh-4rem)] max-h-[calc(100vh-4rem)] min-h-0 w-full flex-col'; const CONTENT_MAP: Record> = { article: PostContent as PostContentComponent, @@ -352,8 +351,12 @@ export const PostPage = ({ }; PostPage.getLayout = getLayout; +// DEMO ONLY: `screenCentered: true` removes the `laptop:!pl-60` expansion +// that MainLayout otherwise adds when the sidebar is expanded, so the +// reader-layout post page can be centered on the viewport like the +// reader modal portal — same shell, same width, same behavior. PostPage.layoutProps = { - screenCentered: false, + screenCentered: true, customBanner: , }; From 3c84ce118efe46a3c6f49c6227baadd5ec6c5d45 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 14 May 2026 11:43:42 +0300 Subject: [PATCH 06/17] =?UTF-8?q?fix(reader-demo):=20cleaner=20install=20p?= =?UTF-8?q?rompt=20=E2=80=94=20mockup=20chrome,=20full-width,=20opaque=20c?= =?UTF-8?q?ard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Force inner wrapper to w-full and add !w-full on the modal class so the prompt actually fills the modal width instead of shrinking to content. - Browser chrome is now obviously a mockup: - drop the three-dots / menu button entirely - URL pill is a rounded rectangle (rounded-10) instead of an oval pill; lock + star icons removed; just favicon + URL - back / forward / refresh rendered as aria-hidden spans at low opacity so they read as decoration, not clickable controls - only the close (X) button stays interactive - Background preview is now a heavily blurred (18px) abstract layout — no real title, source, or cover image to compete with the install card. Gradient overlay biases toward the page background so the card pops. - Overlay card is fully opaque (`bg-background-default`, no backdrop blur, no /95 translucency) and gets a stronger shadow, so the heading is always crisp regardless of what's behind it. Co-authored-by: Cursor --- .../modals/ReaderInstallPromptModal.tsx | 213 ++++++------------ 1 file changed, 63 insertions(+), 150 deletions(-) diff --git a/packages/shared/src/components/modals/ReaderInstallPromptModal.tsx b/packages/shared/src/components/modals/ReaderInstallPromptModal.tsx index be18f5aa9c5..9d8c59f5b4c 100644 --- a/packages/shared/src/components/modals/ReaderInstallPromptModal.tsx +++ b/packages/shared/src/components/modals/ReaderInstallPromptModal.tsx @@ -15,28 +15,21 @@ import { ArrowIcon, ChromeIcon, EdgeIcon, - LockIcon, - MenuIcon, MiniCloseIcon as CloseIcon, RefreshIcon, - StarIcon, } from '../icons'; import { downloadBrowserExtension, isChrome } from '../../lib/constants'; import { apiUrl } from '../../lib/config'; import { useLogContext } from '../../contexts/LogContext'; import { LogEvent } from '../../lib/log'; import { useLazyModal } from '../../hooks/useLazyModal'; -import { useToastNotification } from '../../hooks/useToastNotification'; import type { Post } from '../../graphql/posts'; -import { cloudinaryPostImageCoverPlaceholder } from '../../lib/image'; import styles from './BasePostModal.module.css'; interface ReaderInstallPromptModalProps extends LazyModalCommonProps { post: Post; } -const FAKE_PARAGRAPHS = ['p1', 'p2', 'p3', 'p4', 'p5', 'p6'] as const; - const useFaviconSrc = (host: string | undefined): string | undefined => { return useMemo(() => { if (!host) { @@ -86,64 +79,49 @@ function TrafficLight({ tone }: { tone: 'red' | 'amber' | 'green' }) { interface BrowserChromeProps { faviconSrc: string | undefined; displayUrl: string; - onCopyUrl: () => void; onClose: (event: MouseEvent) => void; } +// The header is a non-interactive mockup of a browser bar so users +// understand the article is "opening inside daily.dev". Only the close +// (X) button is real; navigation icons are decorative and aria-hidden. function BrowserChrome({ faviconSrc, displayUrl, - onCopyUrl, onClose, }: BrowserChromeProps): ReactElement { return ( -
+
-
- - - +
- -
- -
+
); } -interface FakeArticlePreviewProps { - post: Post; -} - -function FakeArticlePreview({ post }: FakeArticlePreviewProps): ReactElement { - const heroImage = post.image || cloudinaryPostImageCoverPlaceholder; - const sourceName = post.source?.name; - const sourceImage = post.source?.image; - const readTimeLabel = - typeof post.readTime === 'number' ? `${post.readTime} min read` : null; - +// Heavily blurred, full-width placeholder for the article body. We +// deliberately render only abstract shapes (no real title/image) so the +// install card stays the focal point without competing content. +function BlurredArticleBackdrop(): ReactElement { return (
-
-
+
+
+
+
+
+
+
+
- {sourceImage && ( - - )} -
- {sourceName && ( - - {sourceName} - - )} - - {readTimeLabel ?? 'Article'} - +
+
+
+
- {post.title && ( - - {post.title} - - )} - -
- {FAKE_PARAGRAPHS.map((line) => ( -
-
-
-
-
-
- ))} +
+
+
+
+
+
-
+
+
+
+
+
+
-
+
); } @@ -270,7 +196,6 @@ function ReaderInstallPromptModal({ }: ReaderInstallPromptModalProps): ReactElement { const { logEvent } = useLogContext(); const { openModal, closeModal } = useLazyModal(); - const { displayToast } = useToastNotification(); const isChromeBrowser = isChrome(); const BrowserIcon = isChromeBrowser ? ChromeIcon : EdgeIcon; const installButtonLabel = isChromeBrowser @@ -306,16 +231,6 @@ function ReaderInstallPromptModal({ }); }; - const onCopyUrl = () => { - if (!post.permalink) { - return; - } - if (navigator?.clipboard?.writeText) { - navigator.clipboard.writeText(post.permalink); - displayToast('Link copied'); - } - }; - const onCloseFromChrome = (event: MouseEvent) => { onRequestClose(event); }; @@ -329,28 +244,26 @@ function ReaderInstallPromptModal({ portalClassName={styles.postModal} overlayClassName="post-modal-overlay bg-overlay-quaternary-onion" className={classNames( - 'reader-install-prompt-modal !mx-0 h-full max-h-screen !max-w-full overflow-hidden !bg-background-default focus:outline-none', + 'reader-install-prompt-modal !mx-0 h-full max-h-screen !w-full !max-w-full overflow-hidden !bg-background-default focus:outline-none', 'tablet:!mx-auto tablet:!max-w-[min(96vw,76rem)]', 'laptop:!mb-2 laptop:!mt-2 laptop:h-[calc(100vh-1rem)] laptop:max-h-[calc(100vh-1rem)] laptop:overflow-hidden', '!overscroll-y-auto', )} > -
+
-
- -
+
+ +
Date: Thu, 14 May 2026 11:52:14 +0300 Subject: [PATCH 07/17] =?UTF-8?q?feat(reader-demo):=20post-page=20chrome?= =?UTF-8?q?=20polish=20=E2=80=94=20back-to-feed,=20inline=20menu,=20promin?= =?UTF-8?q?ent=20CTAs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Surface "Back to feed" inside the article preview header on the post page (left of the URL/favicon) via a new `leftHeaderActions` prop on `ArticleReaderFrame`. The rail header no longer renders its own back button. - Add `border-l` to the article column so the shell has a visible left edge that matches the rail's `border-l` on the right. - On the standalone post page, drop the rail's sticky header entirely and move `PostMenuOptions` inline next to the source's Follow button via a new `inlineHeaderMenu` prop on `EngagementRail`. The modal keeps its existing sticky header (three-dots + close). - Make the `NewComment` composer the obvious discussion entry point: BlueCheese-accent border, action-comment surface, larger avatar, taller hit area, and a soft shadow so it can't be missed. - Beef up `ReaderFloatingActionBar`: medium-size icons, h-11/w-11 buttons, gap-2 between actions, px-3/py-2 padding, rounded-20 pill so the bar reads as the primary "react to this post" surface instead of a thin chip. Co-authored-by: Cursor --- .../post/reader/ArticleReaderFrame.tsx | 8 + .../components/post/reader/EngagementRail.tsx | 192 ++++++++++-------- .../post/reader/ReaderFloatingActionBar.tsx | 14 +- .../post/reader/ReaderPostLayout.tsx | 22 +- 4 files changed, 140 insertions(+), 96 deletions(-) diff --git a/packages/shared/src/components/post/reader/ArticleReaderFrame.tsx b/packages/shared/src/components/post/reader/ArticleReaderFrame.tsx index 955014ad6f5..6f29f7fa8b7 100644 --- a/packages/shared/src/components/post/reader/ArticleReaderFrame.tsx +++ b/packages/shared/src/components/post/reader/ArticleReaderFrame.tsx @@ -21,6 +21,12 @@ type ArticleReaderFrameProps = { targetHref?: string; onTargetLinkClick?: () => void; targetLinkInNewTab?: boolean; + /** + * Renders to the left of the preview URL inside the iframe chrome + * header. Used by the standalone post page to surface a + * "Back to feed" arrow next to the URL bar. + */ + leftHeaderActions?: ReactElement | null; }; export function ArticleReaderFrame({ @@ -36,6 +42,7 @@ export function ArticleReaderFrame({ targetHref, onTargetLinkClick, targetLinkInNewTab, + leftHeaderActions, }: ArticleReaderFrameProps): ReactElement { const { optOut } = useLegacyPostLayoutOptOut(); const onInstallPromptOptOut = useCallback( @@ -73,6 +80,7 @@ export function ArticleReaderFrame({ void; onRegisterFocusComment: (fn: () => void) => void; className?: string; - /** - * Standalone post page only: when provided, renders a left-aligned - * back-to-feed arrow button at the top of the discussion rail. - */ - onBackToFeed?: () => void; /** * Modal only: when provided, renders a close (X) button on the right side - * of the rail header next to the three-dots menu. The modal surface uses - * the rail close while the standalone post page uses `onBackToFeed`. + * of the rail header next to the three-dots menu. */ onClose?: () => void; + /** + * Post page only: drop the sticky rail header entirely and surface the + * three-dots menu inline next to the source's Follow button so the rail + * doesn't render a near-empty header bar. + */ + inlineHeaderMenu?: boolean; }; const noopFocus = (): void => {}; @@ -89,8 +89,8 @@ export function EngagementRail({ onNextPost, onRegisterFocusComment, className, - onBackToFeed, onClose, + inlineHeaderMenu = false, }: EngagementRailProps): ReactElement { const { tokenRefreshed } = useContext(AuthContext); const { user } = useAuthContext(); @@ -133,88 +133,94 @@ export function EngagementRail({ )} aria-label="Discussion and related" > -
-
- {onBackToFeed && ( -
- -
+ )} +
+
+ + {onClose && } +
+
+ )} +
+ {source && ( +
+
+ {source.type === SourceType.Squad ? ( + - -
- )} - {showNavigation && ( -
- {onPreviousPost && ( - -
- )} -
-
- - {onClose && } -
-
-
- {source && source.type === SourceType.Squad && ( - - )} - {source && source.type !== SourceType.Squad && ( - + {inlineHeaderMenu && ( +
+ +
+ )} +
)}
} shouldHandleCommentQuery onComposerOpenChange={setIsComposerOpen} - size={ProfileImageSize.Medium} + size={ProfileImageSize.Large} CommentInputOrModal={CommentInputOrModal} + className={{ + // DEMO ONLY: lift the composer trigger so the discussion CTA is + // unmissable — accent surface, blueCheese border, taller hit area + // so users can't miss the entry point to add a comment. + container: classNames( + '!border-accent-blueCheese-default/40 !rounded-16 !border-2 !bg-action-comment-float', + 'hover:!border-accent-blueCheese-default hover:!bg-action-comment-float', + '!p-3 shadow-3 typo-body tablet:!p-3', + ), + }} /> void; }; -const FLOATING_ICON_SIZE = IconSize.XSmall; +// DEMO ONLY: bigger touch targets + more breathing room between actions so +// the floating bar reads as the primary "react to this post" surface. +const FLOATING_ICON_SIZE = IconSize.Medium; const countActionButtonClasses = - '!h-8 !min-w-0 !gap-1 !rounded-10 !px-2 !justify-center !font-normal'; + '!h-11 !min-w-0 !gap-1.5 !rounded-12 !px-3 !justify-center !font-normal'; const iconActionButtonClasses = - '!h-8 !w-8 !min-w-8 !rounded-10 !p-0 !justify-center'; -const countClasses = 'text-text-tertiary typo-footnote tabular-nums'; + '!h-11 !w-11 !min-w-11 !rounded-12 !p-0 !justify-center'; +const countClasses = 'text-text-tertiary typo-callout tabular-nums'; export function ReaderFloatingActionBar({ post, @@ -46,7 +48,7 @@ export function ReaderFloatingActionBar({ return (
@@ -120,7 +122,7 @@ export function ReaderFloatingActionBar({ () => {}, ); }, - className: '!h-8 !w-8 !shrink-0', + className: '!h-11 !w-11 !shrink-0', buttonClassName: classNames( iconActionButtonClasses, 'btn-tertiary-bun', diff --git a/packages/shared/src/components/post/reader/ReaderPostLayout.tsx b/packages/shared/src/components/post/reader/ReaderPostLayout.tsx index 510d26a6969..86aa454948a 100644 --- a/packages/shared/src/components/post/reader/ReaderPostLayout.tsx +++ b/packages/shared/src/components/post/reader/ReaderPostLayout.tsx @@ -22,6 +22,9 @@ import { ReaderFloatingActionBar } from './ReaderFloatingActionBar'; import { useReaderLayoutPrefs } from './hooks/useReaderLayoutPrefs'; import { useIframeEmbed } from './hooks/useIframeEmbed'; import { useReadArticle } from '../../../hooks/usePostContent'; +import { Button, ButtonSize, ButtonVariant } from '../../buttons/Button'; +import { ArrowIcon } from '../../icons'; +import { Tooltip } from '../../tooltip/Tooltip'; const CHROME_TOP_OFFSET_PX = 72; const DEFAULT_OUTER_CLASS_NAME = 'flex h-full min-h-0 w-full flex-col'; @@ -193,7 +196,7 @@ export function ReaderPostLayout({ : { gridTemplateColumns: 'minmax(0,1fr)' } } > -
+
+
From 53633a38e5ea0e97d949f9c38b2a9d72e229e061 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 14 May 2026 12:00:42 +0300 Subject: [PATCH 08/17] feat(reader-demo): warmer permission prompt + floating opt-out button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Context: user has just installed the extension, returned to daily.dev, and lands inside the iframe asking for embedded-browsing permission. The previous screen ("Enable embedded browsing" / "Enable for this browser" / "I'd rather not read inside daily.dev") read as a generic, slightly intimidating second ask after the install. Rewrite the copy in the Nikita-Bier register: continuation, not obstacle. - Permission prompt (`packages/extension/src/frame/render.ts`): - Heading: "One tap to read in here." - Body: "Turn on embedded browsing and articles open right inside daily.dev. No new tabs, no bouncing around." - Reassurance: "We only use it on links you open from daily.dev." - Primary CTA: "Let's do it" (was "Enable for this browser") - Secondary CTA: "Maybe later" (was "I'd rather not read inside daily.dev") - Dynamic status lines warmer: "Just a sec…", "You're in. Reloading…", etc. - Secondary button is now a real floating button (subtle bg + 1px border, matching the primary's dimensions) instead of a transparent text link, so it reads as a clear escape hatch. - Web fallback install prompt (`EmbeddedBrowsingWebPrompt.tsx`): - Heading: "Read this inside daily.dev." (matches the install modal so the journey feels cohesive) - Body: shorter, value-first copy - Opt-out switched from `Tertiary` text button to `Float` button sized to match the install CTA. Co-authored-by: Cursor --- packages/extension/src/frame/render.ts | 35 ++++++++++--------- .../post/PostArticlePreviewEmbed.tsx | 4 +-- .../EmbeddedBrowsingWebPrompt.tsx | 13 +++---- 3 files changed, 27 insertions(+), 25 deletions(-) diff --git a/packages/extension/src/frame/render.ts b/packages/extension/src/frame/render.ts index 5851011186a..82328ae40a1 100644 --- a/packages/extension/src/frame/render.ts +++ b/packages/extension/src/frame/render.ts @@ -141,16 +141,17 @@ const ensureStyles = (): void => { } .embedded-browsing-button:disabled { opacity: 0.6; cursor: not-allowed; } .embedded-browsing-button-secondary { - background: transparent; - color: #cfd6e6; - font-weight: 500; - min-width: 0; - padding: 0 0.75rem; + background: rgba(255, 255, 255, 0.06); + color: #e3e8f3; + border: 1px solid rgba(255, 255, 255, 0.14); + font-weight: 600; } .embedded-browsing-button-secondary:hover { - opacity: 1; + background: rgba(255, 255, 255, 0.12); + border-color: rgba(255, 255, 255, 0.22); color: #ffffff; - transform: none; + opacity: 1; + transform: translateY(-1px); } `; document.head.appendChild(style); @@ -210,17 +211,16 @@ export const renderPermissionPrompt = ({ const heading = document.createElement('h2'); heading.className = 'embedded-browsing-heading'; - heading.textContent = 'Enable embedded browsing'; + heading.textContent = 'One tap to read in here.'; const description = document.createElement('p'); description.className = 'embedded-browsing-body'; description.textContent = - 'To load websites inside daily.dev, allow this extension to modify response headers on embedded pages.'; + 'Turn on embedded browsing and articles open right inside daily.dev. No new tabs, no bouncing around.'; const status = document.createElement('p'); status.className = 'embedded-browsing-status'; - status.textContent = - 'This is optional and only applies to pages embedded by daily.dev.'; + status.textContent = 'We only use it on links you open from daily.dev.'; const actions = document.createElement('div'); actions.className = 'embedded-browsing-actions'; @@ -228,7 +228,7 @@ export const renderPermissionPrompt = ({ const button = document.createElement('button'); button.type = 'button'; button.className = 'embedded-browsing-button'; - button.textContent = 'Enable for this browser'; + button.textContent = "Let's do it"; const resetButton = () => { button.disabled = false; @@ -236,21 +236,22 @@ export const renderPermissionPrompt = ({ button.addEventListener('click', async () => { button.disabled = true; - status.textContent = 'Requesting permissions...'; + status.textContent = 'Just a sec…'; const outcome = await onRequestPermission(); if (outcome === 'granted') { - status.textContent = 'Permissions granted. Reloading extension...'; + status.textContent = "You're in. Reloading…"; return; } if (outcome === 'dismissed') { - status.textContent = 'Permission request was dismissed.'; + status.textContent = + 'Permission was dismissed — tap below when you’re ready.'; resetButton(); return; } - status.textContent = 'Failed to enable embedded browsing.'; + status.textContent = "Something didn't quite work. Try again?"; resetButton(); }); @@ -261,7 +262,7 @@ export const renderPermissionPrompt = ({ optOutButton.type = 'button'; optOutButton.className = 'embedded-browsing-button embedded-browsing-button-secondary'; - optOutButton.textContent = "I'd rather not read inside daily.dev"; + optOutButton.textContent = 'Maybe later'; optOutButton.addEventListener('click', () => { onOptOut(); }); diff --git a/packages/shared/src/components/post/PostArticlePreviewEmbed.tsx b/packages/shared/src/components/post/PostArticlePreviewEmbed.tsx index 7a084634162..63944793d9b 100644 --- a/packages/shared/src/components/post/PostArticlePreviewEmbed.tsx +++ b/packages/shared/src/components/post/PostArticlePreviewEmbed.tsx @@ -60,8 +60,8 @@ type PostArticlePreviewEmbedProps = { targetLinkInNewTab?: boolean; /** * Opt-out action shown inside the "install the extension" prompt. When - * provided, the prompt surfaces an "I'd rather not read inside daily.dev" - * button below the Install CTA. + * provided, the prompt surfaces a "Maybe later" floating button below + * the Install CTA so users have an obvious escape hatch. */ onInstallPromptOptOut?: () => void; /** diff --git a/packages/shared/src/features/extensionEmbed/EmbeddedBrowsingWebPrompt.tsx b/packages/shared/src/features/extensionEmbed/EmbeddedBrowsingWebPrompt.tsx index a2821ba1a6c..e2647626c1d 100644 --- a/packages/shared/src/features/extensionEmbed/EmbeddedBrowsingWebPrompt.tsx +++ b/packages/shared/src/features/extensionEmbed/EmbeddedBrowsingWebPrompt.tsx @@ -80,7 +80,7 @@ export function EmbeddedBrowsingWebPrompt({ color={TypographyColor.Primary} bold > - Enable embedded browsing + Read this inside daily.dev. - Preview and open sites directly inside daily.dev. To use this - feature, install the daily.dev browser extension. + Install the extension and articles open right here. No new tabs, no + bouncing around.
) : null}
From 29da72fd1aadabd0567a2e0680170e814dfd0222 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 14 May 2026 12:23:00 +0300 Subject: [PATCH 09/17] feat(reader-demo): bigger reader shell + redesigned install prompt - Drop traffic-light circles from the install prompt browser bar - Repaint the backdrop as a heavily blurred white article page with richer skeleton content so it reads as a real webpage behind glass - Sharpen the install prompt copy in a Nikita-Bier register ("Stop opening tabs.") - Bump reader modal + post page max width to min(96vw, 88rem) - Flatten the comment composer (neutral surface, slight border, no shadow) - Wrap the post-page "Back to feed" button in the same chrome group as the eye toggle so they match visually - Show "Close browser mode" label on the eye toggle inside the modal, keep icon-only on the standalone post page Co-authored-by: Cursor --- .../modals/ReaderInstallPromptModal.tsx | 134 +++++++++++------- .../src/components/modals/ReaderPostModal.tsx | 2 +- .../post/reader/ArticleReaderFrame.tsx | 1 + .../components/post/reader/EngagementRail.tsx | 12 +- .../post/reader/ReaderHeaderActionButtons.tsx | 48 +++++-- .../post/reader/ReaderPostLayout.tsx | 29 ++-- 6 files changed, 145 insertions(+), 81 deletions(-) diff --git a/packages/shared/src/components/modals/ReaderInstallPromptModal.tsx b/packages/shared/src/components/modals/ReaderInstallPromptModal.tsx index 9d8c59f5b4c..2eb71783c09 100644 --- a/packages/shared/src/components/modals/ReaderInstallPromptModal.tsx +++ b/packages/shared/src/components/modals/ReaderInstallPromptModal.tsx @@ -62,20 +62,6 @@ const getDisplayUrl = (post: Post, host: string | undefined): string => { return host || 'daily.dev'; }; -function TrafficLight({ tone }: { tone: 'red' | 'amber' | 'green' }) { - const palette = { - red: 'bg-action-downvote-default', - amber: 'bg-action-bookmark-default', - green: 'bg-action-upvote-default', - } as const; - return ( - - ); -} - interface BrowserChromeProps { faviconSrc: string | undefined; displayUrl: string; @@ -92,11 +78,6 @@ function BrowserChrome({ }: BrowserChromeProps): ReactElement { return (
-
- - - -
-
-
-
+
+
+
+ + + +
-
-
-
+
+
+
-
+
-
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
+
+ {ARTICLE_PARAGRAPHS.map((id) => ( +
+
+
+
+
+
+ ))}
-
+
); } @@ -204,7 +239,6 @@ function ReaderInstallPromptModal({ const browser = isChromeBrowser ? 'chrome' : 'edge'; const host = getPostHost(post); const faviconSrc = useFaviconSrc(host); - const displayHost = host || 'daily.dev'; const displayUrl = getDisplayUrl(post, host); useEffect(() => { @@ -245,7 +279,7 @@ function ReaderInstallPromptModal({ overlayClassName="post-modal-overlay bg-overlay-quaternary-onion" className={classNames( 'reader-install-prompt-modal !mx-0 h-full max-h-screen !w-full !max-w-full overflow-hidden !bg-background-default focus:outline-none', - 'tablet:!mx-auto tablet:!max-w-[min(96vw,76rem)]', + 'tablet:!mx-auto tablet:!max-w-[min(96vw,88rem)]', 'laptop:!mb-2 laptop:!mt-2 laptop:h-[calc(100vh-1rem)] laptop:max-h-[calc(100vh-1rem)] laptop:overflow-hidden', '!overscroll-y-auto', )} @@ -273,7 +307,7 @@ function ReaderInstallPromptModal({ bold className="!leading-tight" > - Read this inside daily.dev? + Stop opening tabs. - Install the extension and {displayHost} opens right here inside - daily.dev. No new tab. No context switching. + Install the extension and every link opens inside daily.dev. + Article on one side, the conversation on the other.
+ ); + } return ( - +
) : undefined } /> From b253def7e879ddbe13feba2f4c58b5d9dbad8543 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 14 May 2026 12:29:54 +0300 Subject: [PATCH 10/17] fix(reader-demo): flat outlined Maybe later button in permission prompt Make the secondary opt-out button read clearly as a button: transparent background with a 1.5px visible outline instead of a near-invisible 6% white fill that looked like plain text. Co-authored-by: Cursor --- packages/extension/src/frame/render.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/extension/src/frame/render.ts b/packages/extension/src/frame/render.ts index 82328ae40a1..d9c6169a64d 100644 --- a/packages/extension/src/frame/render.ts +++ b/packages/extension/src/frame/render.ts @@ -141,14 +141,14 @@ const ensureStyles = (): void => { } .embedded-browsing-button:disabled { opacity: 0.6; cursor: not-allowed; } .embedded-browsing-button-secondary { - background: rgba(255, 255, 255, 0.06); + background: transparent; color: #e3e8f3; - border: 1px solid rgba(255, 255, 255, 0.14); + border: 1.5px solid rgba(255, 255, 255, 0.28); font-weight: 600; } .embedded-browsing-button-secondary:hover { - background: rgba(255, 255, 255, 0.12); - border-color: rgba(255, 255, 255, 0.22); + background: rgba(255, 255, 255, 0.08); + border-color: rgba(255, 255, 255, 0.42); color: #ffffff; opacity: 1; transform: translateY(-1px); From 227dd5fe7229d409daf0c57930b06d772d59016f Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 14 May 2026 12:31:37 +0300 Subject: [PATCH 11/17] fix(reader-demo): warmer install prompt headline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swap "Stop opening tabs." for "Read it right here." — invitational rather than pain-led, with a positive follow-up that highlights the benefit (article + discussion side by side). Co-authored-by: Cursor --- .../src/components/modals/ReaderInstallPromptModal.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/components/modals/ReaderInstallPromptModal.tsx b/packages/shared/src/components/modals/ReaderInstallPromptModal.tsx index 2eb71783c09..28b168a8b46 100644 --- a/packages/shared/src/components/modals/ReaderInstallPromptModal.tsx +++ b/packages/shared/src/components/modals/ReaderInstallPromptModal.tsx @@ -307,7 +307,7 @@ function ReaderInstallPromptModal({ bold className="!leading-tight" > - Stop opening tabs. + Read it right here. - Install the extension and every link opens inside daily.dev. - Article on one side, the conversation on the other. + Install the extension and every article opens inside daily.dev + — with the discussion right next to it.
+
+ + + or + + +
+
diff --git a/packages/shared/src/components/modals/ReaderPostModal.tsx b/packages/shared/src/components/modals/ReaderPostModal.tsx index bd7bb2ae1cc..8dcd10465c5 100644 --- a/packages/shared/src/components/modals/ReaderPostModal.tsx +++ b/packages/shared/src/components/modals/ReaderPostModal.tsx @@ -68,7 +68,7 @@ export default function ReaderPostModal({ overlayClassName="post-modal-overlay bg-overlay-quaternary-onion" className={classNames( className, - 'reader-post-modal !mx-0 h-full max-h-screen !max-w-full !bg-background-default focus:outline-none tablet:!mx-auto tablet:!max-w-[min(96vw,88rem)] laptop:!mb-2 laptop:!mt-2 laptop:h-[calc(100vh-1rem)] laptop:max-h-[calc(100vh-1rem)] laptop:overflow-hidden', + 'reader-post-modal !mx-0 h-full max-h-screen !max-w-full !bg-background-default focus:outline-none tablet:!mx-auto tablet:!max-w-[min(96vw,100rem)] laptop:!mb-2 laptop:!mt-2 laptop:h-[calc(100vh-1rem)] laptop:max-h-[calc(100vh-1rem)] laptop:overflow-hidden', '!overscroll-y-auto', )} > diff --git a/packages/shared/src/components/post/reader/EngagementRail.tsx b/packages/shared/src/components/post/reader/EngagementRail.tsx index 67a7ae3093f..733cc4135a7 100644 --- a/packages/shared/src/components/post/reader/EngagementRail.tsx +++ b/packages/shared/src/components/post/reader/EngagementRail.tsx @@ -317,16 +317,16 @@ export function EngagementRail({ ref={commentRef as LegacyRef} shouldHandleCommentQuery onComposerOpenChange={setIsComposerOpen} - size={ProfileImageSize.Large} + size={ProfileImageSize.Medium} CommentInputOrModal={CommentInputOrModal} className={{ - // DEMO ONLY: flat composer trigger — neutral surface, a slightly - // thicker subtle border, no shadow, no accent fills. The size / - // hit area stays generous so the entry point is still obvious. + // DEMO ONLY: flat composer trigger — neutral surface, slight + // border, no shadow. Sized down so the rail stays focused on + // the discussion instead of an oversized empty CTA. container: classNames( - '!rounded-16 !border !border-border-subtlest-secondary !bg-surface-float', + '!rounded-12 !border !border-border-subtlest-secondary !bg-surface-float', 'hover:!border-border-subtlest-primary hover:!bg-surface-float', - '!p-3 typo-body tablet:!p-3', + '!p-2 typo-callout tablet:!p-2', ), }} /> diff --git a/packages/shared/src/components/post/reader/ReaderHeaderActionButtons.tsx b/packages/shared/src/components/post/reader/ReaderHeaderActionButtons.tsx index e70d6fe1702..1882debb89c 100644 --- a/packages/shared/src/components/post/reader/ReaderHeaderActionButtons.tsx +++ b/packages/shared/src/components/post/reader/ReaderHeaderActionButtons.tsx @@ -75,7 +75,7 @@ export function ReaderLegacyLayoutToggleButton({ }: ReaderLegacyLayoutToggleButtonProps): ReactElement { const { optIn, optOut } = useLegacyPostLayoutOptOut(); const isClassicTarget = target === 'classic'; - const label = isClassicTarget ? 'Close browser mode' : 'Enable browser mode'; + const label = isClassicTarget ? 'Classic view' : 'Reader view'; const ariaLabel = isClassicTarget ? 'Use classic post layout' : 'Use embedded reader'; diff --git a/packages/shared/src/components/post/reader/ReaderPostLayout.tsx b/packages/shared/src/components/post/reader/ReaderPostLayout.tsx index 7624a6ca97b..97a2e248dfa 100644 --- a/packages/shared/src/components/post/reader/ReaderPostLayout.tsx +++ b/packages/shared/src/components/post/reader/ReaderPostLayout.tsx @@ -33,10 +33,10 @@ const DEFAULT_OUTER_CLASS_NAME = 'flex h-full min-h-0 w-full flex-col'; // and rail keep a deliberate, balanced layout instead of letting users // collapse one pane into the other. const FIXED_RAIL_WIDTH_PX = 380; -// DEMO ONLY: matches the modal's `tablet:!max-w-[min(96vw,88rem)]` cap so +// DEMO ONLY: matches the modal's `tablet:!max-w-[min(96vw,100rem)]` cap so // the standalone post page renders the same shell at the same width as the // modal portal, even if the outer `outerClassName` ever stops constraining. -const SHELL_MAX_WIDTH = 'min(96vw, 88rem)'; +const SHELL_MAX_WIDTH = 'min(96vw, 100rem)'; type ReaderPostLayoutProps = { post: Post; diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts index 67409532ad4..e05f65db081 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -467,6 +467,7 @@ export enum LogEvent { ToggleEmbeddedReader = 'toggle embedded reader', ImpressionReaderInstallPrompt = 'impression reader install prompt', ClickReaderInstallExtension = 'click reader install extension', + ClickReaderInstallSkip = 'click reader install skip', ImpressionReaderFallback = 'impression reader fallback', ReaderEmbedReady = 'reader embed ready', ReaderEmbedPermissionRequired = 'reader embed permission required', From efc5d1f54d291ef634262f4930f4223170c6f6ee Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 14 May 2026 12:55:45 +0300 Subject: [PATCH 13/17] fix(reader-demo): match new-tab button style to preview button Co-authored-by: Cursor --- .../shared/src/components/modals/ReaderInstallPromptModal.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/components/modals/ReaderInstallPromptModal.tsx b/packages/shared/src/components/modals/ReaderInstallPromptModal.tsx index bc0e4196882..94645f5ebad 100644 --- a/packages/shared/src/components/modals/ReaderInstallPromptModal.tsx +++ b/packages/shared/src/components/modals/ReaderInstallPromptModal.tsx @@ -373,8 +373,8 @@ function ReaderInstallPromptModal({
diff --git a/packages/shared/src/components/post/PostArticlePreviewEmbed.tsx b/packages/shared/src/components/post/PostArticlePreviewEmbed.tsx index 67594a4fc4d..cb15fab659d 100644 --- a/packages/shared/src/components/post/PostArticlePreviewEmbed.tsx +++ b/packages/shared/src/components/post/PostArticlePreviewEmbed.tsx @@ -527,7 +527,7 @@ export function PostArticlePreviewEmbed({ aria-label="Article preview" >
-
+
{leftHeaderActions ? (
diff --git a/packages/shared/src/components/post/reader/EngagementRail.tsx b/packages/shared/src/components/post/reader/EngagementRail.tsx index 733cc4135a7..63e8619e08e 100644 --- a/packages/shared/src/components/post/reader/EngagementRail.tsx +++ b/packages/shared/src/components/post/reader/EngagementRail.tsx @@ -134,7 +134,7 @@ export function EngagementRail({ aria-label="Discussion and related" > {!inlineHeaderMenu && ( -
+
{showNavigation && (
optOut() : optIn; if (showLabel) { return ( ) : null}