Skip to content

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
mainfrom
claude/snapshot-hot-take-placements
Open

feat(snapshot): share a hot take as an image, and copy a link from reading history#6610
tomeredlich wants to merge 18 commits into
mainfrom
claude/snapshot-hot-take-placements

Conversation

@tomeredlich

@tomeredlich tomeredlich commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

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's SnapshotCredit, 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.

  • Swipe modal (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.
  • Profile list (HotTakeItem.tsx and .v2): icon-only at XSmall, before the upvote counter. Both variants carry it, because engagement_bar_v2 decides 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 through useCopyLink's shorten path so nothing is awaited before the clipboard write. Only ReadingHistoryList passes showCopyLink.

Snapshot button busy state (shared). SnapshotButton no longer sets disabled while it captures, only loading. Disabled drops the fill and paints the loader in text-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. loading already sets aria-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

  1. features/snapshot/HotTakeSnapshotButton.tsx: the same pattern as TextSnapshotButton. useArmedCard mounts 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 to hotTake.user; the profile list passes the profile's owner instead.
  2. features/snapshot/HotTakeSnapshotCard.tsx: rebuilt on SnapshotFrame and SnapshotEyebrow, credited with SnapshotCredit. HOT_TAKE_EYEBROW_GRADIENT now lives here, next to the only production card that uses it.
  3. components/modals/hotTakes/HotAndColdModal.tsx: onTouchStartOrOnMouseDown records whether the gesture began on a button, and onSwiping skips it. react-swipeable binds touchstart natively on the swipe area, so a React stopPropagation on the button would run too late.
  4. components/imageShare/SnapshotButton.tsx: the busy state change above.
  5. lib/log.ts: a new ShareHotTake event. A hot take is not a post, so SharePost and useLogSnapshot do not fit; the extra follows the same scheme.
  6. components/post/PostItemCard.tsx: the copy link and its SharePost event.

No GraphQL change: discoverHotTakes (the swipe modal) already selects user { ...UserShortInfo }, and the profile's showcase query fetches takes without a user because the profile being viewed already names their author.

Events

Action Event target_id extra.provider extra.origin Other extra
Snapshot in the swipe modal share hot take hot take id snapshot hot and cold result: clipboard, download or error
Snapshot in a profile's hot takes list share hot take hot take id snapshot hot take list result: clipboard, download or error
Copy link on a reading history row share post post id copy link history

Experiment

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 credits hotTake.user by name and avatar and leaves the credit off a take without one; a press logs share hot take with 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, logs share post with provider copy link and origin history, and does not reach the row's own click handler.
  • Card and button states checked in a local Storybook render (not committed): the credit sits under the count as a byline, and a busy Primary button keeps its fill with the spinner visible.
  • shared: 409 suites, 2961 tests passed (--runInBand).
  • webapp: 86 suites, 689 tests passed.
  • typecheck-strict-changed clean, eslint clean on changed files, full webapp tsc clean apart from the known .next/types and __tests__ backlog.

Preview domain

https://claude-snapshot-hot-take-placeme.preview.app.daily.dev

tomeredlich and others added 5 commits September 2, 2026 17:47
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.
@vercel

vercel Bot commented Sep 6, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
daily-webapp Ready Ready Preview Sep 10, 2026 2:38pm UTC
1 Skipped Deployment
Project Deployment Actions Updated
storybook Ignored Ignored Sep 10, 2026 2:38pm UTC

Request Review

tomeredlich and others added 2 commits September 6, 2026 09:45
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 tomeredlich changed the title feat(hot-takes): snapshot a hot take from the modal and the profile list feat(snapshot): share a hot take as an image, and copy a link from reading history Sep 6, 2026
@tomeredlich
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants