feat(snapshot): share a hot take as an image, and copy a link from reading history - #6610
Open
tomeredlich wants to merge 18 commits into
Open
feat(snapshot): share a hot take as an image, and copy a link from reading history#6610tomeredlich wants to merge 18 commits into
tomeredlich wants to merge 18 commits into
Conversation
Adds one Storybook page — Features / Snapshot / Surfaces / Hot takes & history — recording where a share control belongs on the two hot-take frames (the swipe modal, the profile list) and on reading history. Each surface is drawn at desktop, tablet and mobile, with what ships today beside the placement the page argues for. No production surface changes. The controls are inert: the page compares placement, not behaviour. SnapshotIcon comes with it because the page draws it and it is not on main yet. Mockup-to-eng-pass: 1 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Each of the three surfaces was drawn twice, before and after. A review page for a change should show the change, so the before halves go: the modal's Float snapshot, the profile list with no share route, and the history row's ⋯ menu. The placement prop that switched between them, and the menu furniture only the history row needed, go with them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The reading-history row is a pointer back to a post, so copying its link is the share that fits it. The control sits before the ⋯ menu, icon-only because the row already drops its vote buttons below laptop, and always visible rather than hover-gated so it survives touch. CopyStateIcon confirms on the button itself: the copy glyph and a green check share one grid cell, so nothing beside them moves during the swap. Gated on showCopyLink, which only the history list passes, so the reading-history modal keeps the DOM it has. ReadingHistoryList came into the strict-typecheck guard's scope with this change, which surfaced an untyped reduce accumulator and an unguarded Date built from an optional field. Both are typed now, with no change to what runs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Puts the snapshot capture path on main so the surfaces that want a snapshot can add one without carrying the machinery themselves. captureShareImage rasterizes a DOM node through snapdom at a fixed 1200x630 and brands it. SnapshotButton is the control on top: it plays the shutter, captures, and writes the PNG to the clipboard with the link beside it as text, so a rich composer takes the image and a plain one takes the link. A browser that refuses the clipboard write gets the download instead, and a failed capture reports rather than throws. Nothing renders SnapshotButton yet. The surfaces that will are drawn in Features / Snapshot / Surfaces in Storybook. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A hot take is a self-contained opinion with nowhere to link to, so the card is the whole payload and an image is the only share that carries it. In the swipe modal the control sits beside the upvote pill, labeled and filled, on the top card only: the cards stacked behind are rendered too, and a control on those would capture a take the reader has not reached. On the profile list it is icon-only at XSmall to match the upvote counter it sits next to, and placed before it so the count stays at the edge. Both HotTakeItem variants carry it, so engagement_bar_v2 does not change whether a hot take can be shared. Stacked on the capture path.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
tomeredlich
changed the base branch from
claude/snapshot-capture-infra
to
main
September 6, 2026 06:37
The image was a photograph of the card as it sits on screen, at 1200x630 with a logo bar bolted on. It should be the square card #6544 designs: 1080x1080, a gradient seeded from the take's id, the eyebrow, the take, the emoji as a watermark and the count read as agreement. SnapshotButton now takes that card and mounts it off-screen for as long as the button is mounted, so the capture still starts inside the press: Safari only honours a clipboard write in the task that handled it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…otdev/apps into claude/snapshot-hot-take-placements
tomeredlich
marked this pull request as ready for review
September 6, 2026 07:45
Picks up the treatment #6544 settled on since the card was copied across. The surface label rides the logo row instead of heading the copy, the take and its subtitle are set as one statement rather than split across two type styles that read as two voices, and the copy is centred. The frame grows to the card rather than holding 1:1, so the capture is measured through getSnapshotCaptureOptions instead of assuming the square: a short take gives a short image rather than one padded out with dead gradient. Carries the .snapshot-copy rule the cards have always referenced, which the first port missed — without it a long take wrapped ragged and a long unbroken word could leave the card. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Take the merged captureShareImage from #6426 and keep the width, height and capture timeout the snapshot cards need on top of it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
main now owns the snapshot capture path (#6556) and the designed share cards (#6544), which this branch had vendored copies of. Every conflict took main's version: - SnapshotButton, CopyStateIcon, copyShareImage (+ spec), captureShareImage: #6556's target-based button, image-only clipboard and cleared capture timeout. - SnapshotFrame, snapshotText: #6544's cards. - storybook surfaceChrome: main's copy. The hot take and reading history placements still call the vendored APIs; they are ported onto main's in the commits that follow.
The hot take placements used the vendored card-prop SnapshotButton, which mounted a 1080px card beside every button on render: one per take in a profile list, plus the modal's top card. They now go through HotTakeSnapshotButton, which follows TextSnapshotButton and PollSnapshotButton: useArmedCard mounts the card on hover, touch or focus, and it is portalled to the body because the swipe card it sits on is transformed while it moves. HotTakeSnapshotCard is rebuilt on main's SnapshotFrame and SnapshotEyebrow instead of the vendored SnapshotContent (which main keeps in Storybook), with HOT_TAKE_EYEBROW_GRADIENT defined beside it. The image is unchanged. A hot take is not a post, so the press logs a new ShareHotTake event with the SharePost extra scheme: provider snapshot, the placement as origin (hot and cold for the modal, hot take list for the profile), and the result. Removed with the vendored path: SnapshotContent, the shutter sound and its mp3 (#6556 dropped both), and the spec for the old SnapshotButton API, whose clipboard, download and error cases main already covers in useLogSnapshot.spec. The specs no longer need to skip aria-hidden text, since no card is mounted on load.
…link The history row's copy link copied the raw permalink and logged nothing. It now does what PostMenuOptions and SelectionSnapshotBar do on main: logs SharePost with provider copy link and the row's origin (history), and passes the permalink to useCopyLink's shorten path with the SharePost campaign, so the tracked short link replaces it without an await before the clipboard write (Safari drops the write otherwise). The button rests on the link glyph with a "Copy link" tooltip, like the post page's copy link; the check swap still confirms it. The label no longer flips to "Link copied", which only the old spec asserted. The spec now checks the copied link, the share event and that the click does not reach the row, and drops the "absent by default" case, which only pinned the prop's default.
It redrew the swipe modal, the profile list and the history row by hand to compare placements, and all three now ship for real. Main's Surfaces/Hot takes & reading history page already covers the design argument.
QA found the hot take snapshot had no byline, so sharing another member's
take produced an image that read as the sharer's own opinion. The card now
ends with main's SnapshotCredit (name and avatar), the same credit the post
and highlight cards use, and leaves it off when there is no author.
No query change. The swipe modal's discoverHotTakes query already selects
user { ...UserShortInfo }, so the card reads hotTake.user. The profile list
comes from the showcase query, which fetches takes without a user because
the profile already names its owner, so ProfileUserHotTakes passes that
profile through HotTakeItem (both variants) as the author.
A capture takes a second or two, and SnapshotButton set both loading and disabled for it. Disabled routes the label to text-disabled and drops the fill, and the loader paints in the label color, so a Primary button (the swipe modal's, and main's selection bar and poll prompt) turned into an empty grey pill with a spinner too faint to see. The call site cannot reach this without overriding the button's CSS variables, so the fix is in SnapshotButton: loading alone already sets aria-busy, which hides the content, shows the loader and blocks pointer events, and the design system documents it as its own state. A guard in the handler replaces what disabled did for a keyboard re-press. Icon-only Tertiary placements on the post page now show the spinner in their default color instead of the disabled one.
react-swipeable listens for touchstart natively on the swipe area, so a finger that landed on the Snapshot button and moved dragged the card, and past the threshold it registered a hot or cold vote. A React stopPropagation on the button would run too late to stop that listener. The swipe handlers now note in onTouchStartOrOnMouseDown whether the gesture began on a button and skip onSwiping when it did, which covers touch and mouse and leaves the rest of the card (author row, upvote pill) draggable. The swipe-end handlers read the refs onSwiping writes, so the card snaps back without a vote.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Base is
main. Builds on the snapshot capture path from #6556 and the designed cards from #6544; this PR no longer carries copies of either.What ships
No feature flag: both controls reach everyone on merge.
Snapshot a hot take. A hot take is a self-contained opinion with nowhere to link to, so an image is the share that carries it. The image is a 1080px wide card on #6544's
SnapshotFrame: a gradient seeded from the take's id, a "Hot take" eyebrow on the logo row, the title and subtitle set as one statement, the emoji as a watermark, the upvote count as "found this hot", and the author's name and avatar under a rule (main'sSnapshotCredit, as on the post and highlight cards). Anyone can share a take, so without the credit the image would read as the sharer's own opinion; a take with no author leaves the credit off. The frame grows with the copy, so the capture measures its height.HotAndColdModal.tsx): a labeled, filled Snapshot button beside the upvote pill, on the top card only. The card stacked behind is mounted too, and a second button there would capture a take the reader has not reached. A touch or mouse drag that starts on a button no longer drags the card, so reaching for Snapshot cannot register a hot or cold vote.HotTakeItem.tsxand.v2): icon-only atXSmall, before the upvote counter. Both variants carry it, becauseengagement_bar_v2decides which one renders, not whether a take can be shared.Copy a link from reading history. A history row points back to a post, so its share is the link. An icon-only copy link sits before the row's ⋯ menu, always visible so it works on touch. It works like the post page's copy link (
PostMenuOptions): link glyph, "Copy link" tooltip, check swap on success, and the tracked short link written throughuseCopyLink'sshortenpath so nothing is awaited before the clipboard write. OnlyReadingHistoryListpassesshowCopyLink.Snapshot button busy state (shared).
SnapshotButtonno longer setsdisabledwhile it captures, onlyloading. Disabled drops the fill and paints the loader intext-disabled, so a Primary button turned into an empty grey pill for the second or two a capture takes; this also affected main's selection bar and poll prompt.loadingalready setsaria-busy, which hides the label, shows the loader in the label color and blocks pointer events; a guard in the handler covers a keyboard re-press. Icon-only Tertiary placements on the post page keep the same look, with the spinner in their default color instead of the disabled one.Review first
features/snapshot/HotTakeSnapshotButton.tsx: the same pattern asTextSnapshotButton.useArmedCardmounts the off-screen card on hover, touch or focus, so a profile list with many takes mounts no cards until the reader reaches for one. The card is portalled to the body because the swipe card is transformed while it moves. The author defaults tohotTake.user; the profile list passes the profile's owner instead.features/snapshot/HotTakeSnapshotCard.tsx: rebuilt onSnapshotFrameandSnapshotEyebrow, credited withSnapshotCredit.HOT_TAKE_EYEBROW_GRADIENTnow lives here, next to the only production card that uses it.components/modals/hotTakes/HotAndColdModal.tsx:onTouchStartOrOnMouseDownrecords whether the gesture began on a button, andonSwipingskips it. react-swipeable bindstouchstartnatively on the swipe area, so a ReactstopPropagationon the button would run too late.components/imageShare/SnapshotButton.tsx: the busy state change above.lib/log.ts: a newShareHotTakeevent. A hot take is not a post, soSharePostanduseLogSnapshotdo not fit; the extra follows the same scheme.components/post/PostItemCard.tsx: the copy link and itsSharePostevent.No GraphQL change:
discoverHotTakes(the swipe modal) already selectsuser { ...UserShortInfo }, and the profile's showcase query fetches takes without a user because the profile being viewed already names their author.Events
target_idextra.providerextra.originextrashare hot takesnapshothot and coldresult:clipboard,downloadorerrorshare hot takesnapshothot take listresult:clipboard,downloadorerrorshare postcopy linkhistoryExperiment
None. This ships without a flag, so there is no GrowthBook experiment to set up and no rollback short of a deploy.
Testing
HotTakeSnapshotButton.spec.tsx: no card in the DOM until the button is hovered; the card creditshotTake.userby name and avatar and leaves the credit off a take without one; a press logsshare hot takewith provider, origin and result; while a capture runs the button is busy but not disabled, and a second press does not start another capture.HotAndColdModal.spec.tsx: one Snapshot button with a second take stacked behind the top card; a touch swipe that starts on the Snapshot button casts no vote, and the same swipe from the take's title votes hot.ProfileUserHotTakes.spec.tsx: one Snapshot button per take for visitors, and the card credits the profile's owner.ReadingHistory.spec.tsx: the copy link writes the post's permalink, logsshare postwith providercopy linkand originhistory, and does not reach the row's own click handler.--runInBand).typecheck-strict-changedclean, eslint clean on changed files, full webapptscclean apart from the known.next/typesand__tests__backlog.Preview domain
https://claude-snapshot-hot-take-placeme.preview.app.daily.dev