Repository navigation
feat(feed): daily.dev tips over the rail's ad - #7004
Conversation
A slim tip strip sits on top of the pinned ad, in the ad's column, with a random tip per page view from a client-side set. The ad switches to a new compact AdGrid variant (isCompact) so it fits under the strip; the feed's AdGrid renders exactly as before without the prop. Readers remove the tips from an edit-mode minus badge, with the rail's undo toast, and the ad takes the whole column again. A new /tips page lists every tip. The dismissal is a rail-level field (tipsHidden). The API requires `widgets`, so a reader still on the GrowthBook default gets the default's widgets written for older builds plus a fingerprint of them (fromDefault): while the stored widgets still match it, this build keeps rendering the live default, so hiding tips never freezes their rail. Any edit, by this build or an older one that keeps rail-level fields, breaks the match and the rail counts as customized. Read-only (newer) rails are never written. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
The streak tip used ReadingStreakIcon, whose paths carry no fill of their own and draw nothing on the tip's tile; it now uses the Hot flame, and the extension tip swaps the full-color browser group for NewTab. A spec checks every icon the tips import has a light fill svgr turns into currentcolor (Jest mocks the svgs, so a render could not tell). The /tips text column gets min-w-0 flex-1: base.css stops flex items shrinking, so long descriptions ran past the card. With a tip over the slot, the edit-mode "Pinned by daily.dev" label moves inside the ad's top-right instead of sitting on the strip's bottom edge. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Undo only lasted as long as the toast. While the tips are hidden, the Add a widget gallery now leads with a "daily.dev tips" row whose Add clears tipsHidden, offered only where the ad slot shows so the strip visibly comes back, and never on a read-only rail. It is not a widget, so it never counts against the widget cap. The /tips page shows a "Show tips in my feed" button to a signed-in reader who hid them, with a confirming toast. Both go through setTipHidden(false), the undo path: a rail on the default with nothing else stored goes back to null. New event: restore feed widget tip (origin feed widgets or tips page). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…/feed-widget-tips
The glow was an accent circle under a blur filter, animated on hover and clipped by the strip's rounded overflow. Composited that way, Chrome cut the blurred layer off at its own box, leaving a sharp vertical line near the arrow. It is now a radial gradient that fades to transparent by itself, so there is no filter output to clip. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
rebelchris
left a comment
There was a problem hiding this comment.
Reviewed the full diff at 0ae1926. Four inline comments: one blocking (the fromDefault fingerprint vs jsonb key order, a persisted contract), one product/ads question (tips and the compact ad landing inside the running feed_widgets experiment), and two non-blocking (analytics target ids, the touch layout of All tips).
What checks out: the write paths keep the stored list and its rail-level fields, a read-only rail never writes, reset and undo go back to null when nothing else is stored, the handlers sit on the anchor rather than on Link, all new Tailwind utilities exist in the config, and there are no committed binaries. CI was still running test_shared/build when this was posted. The unchecked visual items in the test plan (compact ad with real and blurred creatives, edit mode, /tips on v1/v2/phone) are still open.
Reviewed by AI.
| * by this build or an older one that keeps the field, tells them apart. | ||
| */ | ||
| export const feedRailFingerprint = (widgets: FeedWidget[]): string => | ||
| JSON.stringify( |
There was a problem hiding this comment.
Blocking (persisted contract): fromDefault compares a JSON.stringify of the widgets as this build holds them with the same widgets after a round trip through the API. Settings.flags is a jsonb column, and jsonb does not keep object key order: keys come back sorted (shorter keys first, then bytewise). Once feed_widgets_default serves a widget whose config has two or more keys in a different order (for example { period, tags }), the fingerprint saved at write time no longer matches the widgets read back after reload. customFeedWidgets then returns the stored widgets, and a reader who only hid the tips is treated as customized and frozen on that day's default. Nothing errors, and the specs can't catch it because they never round-trip through jsonb.
This function also becomes a stored format from the first build that writes it. A later edit here (another field, a different null rule, a nested config change) would quietly flip every stored on-default rail to customized.
Suggested fix: fingerprint with a canonical, key-sorted stringify (recursive over config), add a spec where the stored config keys come back reordered, and put a note on the function saying its output is persisted and must not change without a migration.
Reviewed by AI.
There was a problem hiding this comment.
Fixed in 66c3148. The fingerprint now sorts object keys at every depth before stringifying, so jsonb's key order no longer matters. Added storedRail.spec.ts: a rail read back with reordered config keys stays on the default, and a spec pins the exact stored format. The function's comment now says its output is persisted and can only change with a migration of fromDefault.
| // One tip per page view, over our slot: it is known from the settings and | ||
| // the slot alone, so it is there from the first paint. | ||
| const [tip] = useState(pickFeedWidgetTip); | ||
| const showTip = showAd && !isTipHidden; |
There was a problem hiding this comment.
Question (product/ads): feed_widgets is an experiment, and the tips go on for every reader in its variant who has the slot. With a tip showing, the pinned ad also switches to isCompact: the creative shrinks, tags are dropped, the title is clamped, and on a mouse the CTA, Advertise and Remove row stays hidden until hover or focus. That changes a sold ad unit, and the experiment's ad CTR and revenue readout, partway through the run.
Does this need its own flag (or an arm) so the ad impact can be measured on its own, or at least ads sign-off and a before/after look at the slot's CTR? Leaving this non-blocking because it's a product call, but the answer should be on the PR before merge.
Reviewed by AI.
There was a problem hiding this comment.
Agreed. Tips now sit behind their own experiment flag, feed_widget_tips, which defaults to off. The compact ad only switches on when a tip shows, so the ad slot's CTR and revenue can be compared across that flag's arms without moving the feed_widgets readout. The flag also gates the gallery row and the /tips page's "Show tips in my feed" button. Rollout and ads sign-off happen in GrowthBook.
|
|
||
| const tipLogEvent = (eventName: LogEvent, tip: FeedWidgetTipItem) => ({ | ||
| event_name: eventName, | ||
| target_type: TargetType.FeedWidget, |
There was a problem hiding this comment.
Non-blocking (analytics): tips log target_type: feed widget with target_id = tip id, while the widget events use the same target type with target_id = widget kind. Two tip ids are also widget kinds: briefing and notifications. Any report grouped by target type and id will mix tip clicks with the Briefing and Notifications widgets. A separate target type (e.g. feed widget tip), or prefixed ids, would keep them apart.
The same payload is also built three times: tipLogEvent here, logTip in FeedWidgetRail, and inline in pages/tips.tsx. Exporting one builder from tips.ts (with origin as a parameter) would keep the three in sync.
Reviewed by AI.
There was a problem hiding this comment.
Fixed in 66c3148. Tip events now use a new feed widget tip target type, so they don't mix with the Briefing and Notifications widgets. One feedWidgetTipLogEvent(eventName, origin, tip?) in tips.ts builds the payload for the rail, the strip and the tips page.
| <Link href={tipsPageUrl} passHref> | ||
| <a | ||
| href={tipsPageUrl} | ||
| className="focus-outline absolute right-2 top-1/2 z-1 flex h-6 -translate-y-1/2 items-center rounded-8 bg-background-default px-2 font-bold text-text-secondary shadow-2 transition-opacity typo-caption2 hover:text-text-primary motion-reduce:transition-none mouse:opacity-0 mouse:focus-visible:opacity-100 mouse:group-hover/tip:opacity-100" |
There was a problem hiding this comment.
Non-blocking (touch): on a touch screen (mouse: doesn't apply), the All tips pill is always visible. It's roughly 3–4× the width of the 16px arrow it sits over, so it covers the end of the truncated title. The comment says the title keeps its width, but that's only true with a mouse. The rail does render on touch devices with more than 2 columns, such as a tablet in landscape. Could you check a long title there, and either reserve the pill's width in the row on touch or keep the arrow-only layout?
Reviewed by AI.
There was a problem hiding this comment.
Fixed in 66c3148. On touch, the strip keeps pr-20 free for the "All tips" pill and drops the arrow beneath it, so the end of the title is no longer covered. With a mouse the layout is unchanged: the arrow shows, and the pill appears over it on hover or focus. Edit mode keeps the plain padding.
- fromDefault fingerprints with keys sorted at every depth: Settings.flags is jsonb, which returns object keys in its own order, so a default whose widget config has two keys would read back as customized. The output is persisted, so a spec now pins its format. - Tips sit behind their own experiment flag, feed_widget_tips (off by default), so the compact ad they bring can be measured apart from feed_widgets. - Tip events use a new `feed widget tip` target type, since tip ids such as `briefing` are also widget kinds, built by one helper shared by the rail, the strip and the tips page. - On touch, the strip keeps room for the always-shown "All tips" pill and drops the arrow under it, so the title is no longer covered. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Summary
RichStriprow) sits on top of the pinned ad in the ad's column, withgap-2. One random tip per page view (picked once at mount, never reshuffled) fromfeatures/feedWidgets/tips.ts. It links to its feature, and an All tips link (shown on hover/focus with a mouse, always on touch) opens the new/tipspage.AdGridpropisCompact: the creative flexes into the height the copy leaves (title clamps to 2 lines, tags hidden), and the CTA / Advertise / Remove row floats over the creative on a solid surface. With a mouse it appears on hover orfocus-within, so it stays keyboard reachable; on touch it's always visible. Without the prop, the feed'sAdGridrenders exactly as before (covered by a spec).FEED_WIDGET_UNDO_MS) and gives the ad its whole column back. A read-only (newer-version) rail never gets the badge and never writes.tipsHiddenand the strip returns over the ad. It's not a widget, so it never counts againstMAX_FEED_WIDGETS. It's only offered where the ad slot shows (so the reader sees the strip return) and never on a read-only rail. The/tipspage also shows a Show tips in my feed button to a signed-in reader who hid them, with a confirming toast. Both use the same write as undo, so a rail on the default with nothing else stored goes back tonull./tipspage (packages/webapp/pages/tips.tsx): a lean list of every tip with its description, using the same layout primitives and SEO props as the daily quests page.Storage and the tradeoff
The dismissal is a rail-level field,
tipsHidden: true, insideSettings.flags.feedWidgets, so there's no API change. The API schema requireswidgets, so a reader who never customized can't store a rail-level field alone. Writing the default widgets like a first edit would freeze them at today's default. Instead, for a reader on the default the write stores the default's widgets (so older builds still render a sensible rail) plusfromDefault, a fingerprint of those widgets. While the stored widgets still match the fingerprint, this build treats the reader as not customized and keeps rendering the livefeed_widgets_default. Any widget edit, whether by this build or by an older build that keeps rail-level fields, breaks the match, and from then on the rail counts as customized. Undo on a rail that then holds nothing else writesnullagain. Edge to know about: until a reader on an older build edits, that build shows the default as it was when the tips were hidden.Analytics
New
LogEvents, all withtarget_type: feed widget,target_id= tip id,extra.origin = feed widgets:impression feed widget tip: once per page view, when half the strip is on screen.click feed widget tip: on the tip link. The/tipspage logs the same event withorigin: tips page.dismiss feed widget tipandundo dismiss feed widget tip: logged once the settings write succeeds.restore feed widget tip: re-enabled from the gallery (origin: feed widgets,target_id= the tip that comes back) or from/tips(origin: tips page, notarget_id).Open questions
/tips.Test plan
packages/shared:jest src/features/feedWidgets src/components/cards/ad, 126 tests passing. Covers: strip and ad share the column, compact ad under the strip; removing writestipsHiddenand the ad gets the full column; no strip without the slot; no fill keeps the tip plus the add tile; the on-default write and the live-default follow; a stale fingerprint counts as customized; a read-only rail never writes; random pick from the set; compactAdGridvs the untouched default.packages/webapp: tips page spec, plus the full suite (645 tests passing).typecheck-strict-changed, webapptsc --noEmit./tipspage on desktop (v1 and v2 layouts) and phone.🤖 Generated with Claude Code
Preview domain
https://feat-feed-widget-tips.preview.app.daily.dev