From 55dd00d685a61ad2379382590082ae072a799ab0 Mon Sep 17 00:00:00 2001
From: Ido Shamun <1993245+idoshamun@users.noreply.github.com>
Date: Fri, 16 Oct 2026 21:57:43 +0300
Subject: [PATCH 1/7] feat(storybook): policy-backed card reasons and a
personalization center
Explain mode now runs a copy of the For You challenger/5.0 policy over the
real /history posts, so every reason, chip and score comes from the same
math the feed uses, and adds a Personalization panel for full control.
- Cards keep their exact layout: the explanation overlays the real grid or
list card with the source and title at the card's own offsets, and the
summary bar is gone.
- Reason chips show what moved each post (topic match, follow, reader
response, freshness, seen before, rotation, variety) and open a popover
with the math and the policy knob behind it.
- The Personalization panel shows the full inferred profile with every
weight editable, sources and people, every ranking knob with its raw
retriever_config key, what was left out and why, links to explicit
settings, and resets, with a live preview of the next page.
- A spec story maps each control to what daily-feed supports today.
---
.../feed-explain/FeedExplain.stories.tsx | 455 +++++++++++----
.../components/AnchoredPopover.tsx | 99 ++++
.../feed-explain/components/ChangesDock.tsx | 72 +++
.../feed-explain/components/ExplainBar.tsx | 268 ---------
.../components/ExplainDetails.tsx | 337 ++++-------
.../feed-explain/components/ExplainPanel.tsx | 444 +++++++++------
.../feed-explain/components/FactorChips.tsx | 427 ++++++++++++++
.../components/FeedExplainPage.tsx | 536 +++++++++---------
.../components/FeedScreenContext.ts | 43 ++
.../components/FeedTuningContext.tsx | 188 ------
.../components/PersonalizationContext.tsx | 407 +++++++++++++
.../components/PersonalizationPanel.tsx | 293 ++++++++++
.../feed-explain/components/controls.tsx | 166 ++++++
.../feed-explain/components/feedExplain.ts | 201 -------
.../components/panel/InterestsSection.tsx | 518 +++++++++++++++++
.../feed-explain/components/panel/Preview.tsx | 323 +++++++++++
.../components/panel/RankingSection.tsx | 209 +++++++
.../feed-explain/components/panel/Section.tsx | 81 +++
.../components/panel/SettingsSection.tsx | 240 ++++++++
.../components/panel/SourcesSection.tsx | 203 +++++++
.../feed-explain/feedExplain.mocks.ts | 63 --
.../stories/features/feed-explain/personas.ts | 107 ++++
.../feed-explain/policy/challengerConfig.ts | 82 +++
.../features/feed-explain/policy/describe.ts | 279 +++++++++
.../features/feed-explain/policy/params.ts | 419 ++++++++++++++
.../features/feed-explain/policy/profile.ts | 200 +++++++
.../features/feed-explain/policy/rank.ts | 512 +++++++++++++++++
27 files changed, 5690 insertions(+), 1482 deletions(-)
create mode 100644 packages/storybook/stories/features/feed-explain/components/AnchoredPopover.tsx
create mode 100644 packages/storybook/stories/features/feed-explain/components/ChangesDock.tsx
delete mode 100644 packages/storybook/stories/features/feed-explain/components/ExplainBar.tsx
create mode 100644 packages/storybook/stories/features/feed-explain/components/FactorChips.tsx
create mode 100644 packages/storybook/stories/features/feed-explain/components/FeedScreenContext.ts
delete mode 100644 packages/storybook/stories/features/feed-explain/components/FeedTuningContext.tsx
create mode 100644 packages/storybook/stories/features/feed-explain/components/PersonalizationContext.tsx
create mode 100644 packages/storybook/stories/features/feed-explain/components/PersonalizationPanel.tsx
create mode 100644 packages/storybook/stories/features/feed-explain/components/controls.tsx
delete mode 100644 packages/storybook/stories/features/feed-explain/components/feedExplain.ts
create mode 100644 packages/storybook/stories/features/feed-explain/components/panel/InterestsSection.tsx
create mode 100644 packages/storybook/stories/features/feed-explain/components/panel/Preview.tsx
create mode 100644 packages/storybook/stories/features/feed-explain/components/panel/RankingSection.tsx
create mode 100644 packages/storybook/stories/features/feed-explain/components/panel/Section.tsx
create mode 100644 packages/storybook/stories/features/feed-explain/components/panel/SettingsSection.tsx
create mode 100644 packages/storybook/stories/features/feed-explain/components/panel/SourcesSection.tsx
delete mode 100644 packages/storybook/stories/features/feed-explain/feedExplain.mocks.ts
create mode 100644 packages/storybook/stories/features/feed-explain/personas.ts
create mode 100644 packages/storybook/stories/features/feed-explain/policy/challengerConfig.ts
create mode 100644 packages/storybook/stories/features/feed-explain/policy/describe.ts
create mode 100644 packages/storybook/stories/features/feed-explain/policy/params.ts
create mode 100644 packages/storybook/stories/features/feed-explain/policy/profile.ts
create mode 100644 packages/storybook/stories/features/feed-explain/policy/rank.ts
diff --git a/packages/storybook/stories/features/feed-explain/FeedExplain.stories.tsx b/packages/storybook/stories/features/feed-explain/FeedExplain.stories.tsx
index 9c2b7b6992..1a71b2916d 100644
--- a/packages/storybook/stories/features/feed-explain/FeedExplain.stories.tsx
+++ b/packages/storybook/stories/features/feed-explain/FeedExplain.stories.tsx
@@ -1,24 +1,31 @@
-import React from 'react';
+import React, { useMemo, useState } from 'react';
import type { ReactElement, ReactNode } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
+import { waitFor } from 'storybook/test';
import { graphql, HttpResponse } from 'msw';
import ExtensionProviders from '../../extension/_providers';
import { providerHandlers } from '../why-recommended/whyRecommended.mocks';
-import type { TuningSeed } from './components/FeedTuningContext';
-import { FeedTuningProvider } from './components/FeedTuningContext';
-import { ExplainDetailsHost } from './components/ExplainDetails';
+import type { Decision, PersonalizationState } from './policy/rank';
+import { getExclusion } from './policy/rank';
+import { TopicMode } from './policy/profile';
+import { policyVersion } from './policy/params';
import {
+ PersonalizationProvider,
+ usePersonalization,
+} from './components/PersonalizationContext';
+import {
+ ControlSection,
+ FeedScreenContext,
+ getSignalCounts,
+} from './components/FeedScreenContext';
+import { ExplainDetailsModal } from './components/ExplainDetails';
+import {
+ AppView,
CardFrame,
FeedExplainPage,
FeedLayout,
} from './components/FeedExplainPage';
-import { TuneLevel } from './components/feedExplain';
-import {
- feedItem,
- forYouFeed,
- newViewer,
- reserveFeed,
-} from './feedExplain.mocks';
+import { candidatePool, PersonaId, personas } from './personas';
// Follow and block in the full explanation update the feed settings cache
// optimistically and roll back on error, so the mutations have to succeed.
@@ -49,23 +56,24 @@ export default meta;
type Story = StoryObj;
const Screen = ({
+ persona = PersonaId.TwoWeeksIn,
layout = FeedLayout.Grid,
- initialExplain,
- initialDetailsId,
+ ...props
}: {
+ persona?: PersonaId;
layout?: FeedLayout;
initialExplain?: boolean;
+ initialView?: AppView;
+ initialSection?: ControlSection;
initialDetailsId?: string;
}): ReactElement => (
@@ -81,124 +89,204 @@ export const ExplainOn: Story = {
render: () => ,
};
+export const ReasonControls: Story = {
+ name: '3 · Reason controls',
+ render: () => ,
+ play: async ({ canvasElement }) => {
+ await waitFor(() => {
+ const chip = canvasElement.querySelector(
+ 'ul[aria-label="What moved this post"] button',
+ );
+ if (!chip) {
+ throw new Error('Waiting for reason chips');
+ }
+ chip.click();
+ });
+ },
+};
+
export const FullExplanation: Story = {
- name: '3 · Full explanation',
- render: () => ,
+ name: '4 · Full explanation',
+ render: () => ,
+};
+
+export const Controls: Story = {
+ name: '5 · Personalization controls',
+ render: () => ,
+};
+
+export const JustSignedUp: Story = {
+ name: '6 · Just signed up',
+ render: () => ,
};
export const Phone: Story = {
- name: '4 · Phone',
+ name: '7 · Phone',
globals: { viewport: { value: 'mobile2', isRotated: false } },
render: () => ,
};
-const feedCounts = forYouFeed.reduce>(
- (counts, { signals }) => {
- signals.forEach(({ key }) => {
- counts[key] = (counts[key] ?? 0) + 1;
- });
- return counts;
- },
- {},
-);
+export const PhoneControls: Story = {
+ name: '8 · Phone controls',
+ globals: { viewport: { value: 'mobile2', isRotated: false } },
+ render: () => (
+
+ ),
+};
interface CardState {
title: string;
note: string;
id: string;
- seed?: TuningSeed;
+ persona?: PersonaId;
+ change?: (state: PersonalizationState) => PersonalizationState;
layout?: FeedLayout;
}
+const withControl = (tag: string, mode: TopicMode) => (
+ state: PersonalizationState,
+): PersonalizationState => ({
+ ...state,
+ controls: { ...state.controls, [tag]: { mode } },
+});
+
const cardStates: CardState[] = [
{
- title: 'Topic you picked',
- note: 'The reason names the onboarding pick that matched.',
- id: 'XCmmeq8ZC',
+ title: 'Topic you follow',
+ note: 'A followed topic you read often: the strongest kind of match.',
+ id: 'EYFcaEcVd',
},
{
title: 'Source you follow',
- note: 'Follows outrank topics, so the source row leads.',
- id: 'Hd2eBXFkz',
+ note: 'A follow brings every post in and multiplies it.',
+ id: 'Rvo54Shm1',
},
{
- title: 'From your reading',
- note: 'Nothing followed matched; a topic you keep opening did.',
- id: 'VNv8IMYss',
+ title: 'Learned from reading',
+ note: 'Never followed; six stories in two weeks made it strong.',
+ id: 'MsFCKRbGk',
},
{
- title: 'Popular pick',
- note: 'No match at all. New feeds lean on these until they learn more.',
- id: 'BM2onQaux',
+ title: "A topic's turn",
+ note: 'Docker had not been on the first screen lately, so it jumped ahead.',
+ id: 'JKsEou23l',
},
{
- title: 'Showing more',
- note: 'Plus pressed on #rust. The row turns green.',
- id: '9tLNItvh5',
- seed: { levels: { 'topic:rust': TuneLevel.More } },
+ title: 'Variety pushed it down',
+ note: 'The third Fireship post on the page, so it gives way.',
+ id: '1XMQ9zlpA',
},
{
- title: 'Showing less',
- note: 'Minus pressed on #ai. The post stays, its topic ranks lower.',
- id: 'AzxnBHN3V',
- seed: { levels: { 'topic:ai': TuneLevel.Less } },
+ title: 'Popular fill-in',
+ note: 'A new viewer with two topics; the popular lane tops up the page.',
+ id: 'JKsEou23l',
+ persona: PersonaId.JustSignedUp,
+ },
+ {
+ title: 'Showing more',
+ note: 'More pressed on #rust. Its row turns green and it ranks higher next refresh.',
+ id: 'BjYAz50EA',
+ change: withControl('rust', TopicMode.Boosted),
},
{
- title: 'Blocked topic',
- note: 'Blocking anything on the post marks it to leave on refresh.',
- id: 'BM2onQaux',
- seed: { levels: { 'topic:gaming': TuneLevel.Blocked } },
+ title: 'Muted a learned topic',
+ note: 'Reading about #career stops counting, so this card leaves.',
+ id: 'MsFCKRbGk',
+ change: withControl('career', TopicMode.Muted),
},
{
- title: 'Blocked source',
- note: 'Blocked from another card: every XDA post shows this at once.',
- id: 'j4duNN9S2',
- seed: { levels: { 'source:xda-developers': TuneLevel.Blocked } },
+ title: 'Blocked a source',
+ note: 'Every XDA Developers card shows this at once.',
+ id: 'DFJTpz2Tw',
+ change: (state) => ({
+ ...state,
+ blockedSources: [...state.blockedSources, 'xda-developers'],
+ }),
},
{
title: 'Fewer like this',
- note: 'The post-level escape hatch when no single topic is the problem.',
- id: 'DVZBShH7e',
- seed: { dismissed: ['DVZBShH7e'] },
+ note: 'The post-level escape hatch when no single reason is the problem.',
+ id: 'AzxnBHN3V',
+ change: (state) => ({ ...state, hidden: [...state.hidden, 'AzxnBHN3V'] }),
},
];
const listStates: CardState[] = [
{
title: 'List card',
- note: 'On phones the card folds the same way, so a few posts fit on one screen.',
+ note: 'On phones the explanation covers the real list card, so nothing in the feed moves.',
id: 'DFJTpz2Tw',
layout: FeedLayout.List,
},
{
title: 'List card, leaving',
- note: 'The title fades; the notice keeps its Undo.',
- id: 'CrehqCUVO',
+ note: 'The title fades; the notice offers the matching fix.',
+ id: 'DFJTpz2Tw',
layout: FeedLayout.List,
- seed: { levels: { 'source:xda-developers': TuneLevel.Blocked } },
+ change: (state) => ({
+ ...state,
+ blockedSources: [...state.blockedSources, 'xda-developers'],
+ }),
},
];
-const StateCard = ({ state }: { state: CardState }): ReactElement => (
-
-
-
- {state.title}
-
- {state.note}
-
-
-
-
-
-
-
-);
+const StateCardBody = ({
+ id,
+ layout,
+}: {
+ id: string;
+ layout: FeedLayout;
+}): ReactElement => {
+ const store = usePersonalization();
+ const [details, setDetails] = useState(null);
+ const counts = useMemo(() => getSignalCounts(store.served), [store.served]);
+ const screen = useMemo(() => ({ counts, onOpenDetails: setDetails }), [counts]);
+ const decision = store.served.find(({ post }) => post.id === id);
+ if (!decision) {
+ return
@@ -223,15 +311,12 @@ export const CardStates: Story = {
Explain mode, card by card
- The Explain switch on For You keeps every card where it is and
- folds it to its source and title, then says why the post is here:
- one sentence for the reason, then the post's top topics and
- source, each with less, more and block. Full explanation opens the
- "Why am I seeing this?" view with every topic and the
- score breakdown. Changes save as you tap, every card sharing a
- topic or source updates at once, and Refresh feed swaps out what
- you blocked. Less and more are new: today the API only has follow
- and block.
+ Each card keeps its source and title and says why it is here: one
+ sentence for the reason, chips for what moved it up or down (tap
+ one to see the math and change that part of the policy), and the
+ post's topics and source with less, more and block. Full
+ explanation opens the "Why am I seeing this?" view with
+ the score breakdown.
@@ -252,3 +337,183 @@ export const CardStates: Story = {
),
};
+
+enum Status {
+ Exists = 'exists',
+ Config = 'config',
+ Missing = 'missing',
+}
+
+const statusLabel: Record = {
+ [Status.Exists]: {
+ label: 'Exists',
+ className: 'bg-accent-avocado-flat text-accent-avocado-default',
+ },
+ [Status.Config]: {
+ label: 'In the policy, not per user',
+ className: 'bg-accent-cheese-flat text-accent-cheese-default',
+ },
+ [Status.Missing]: {
+ label: 'New',
+ className: 'bg-accent-bun-flat text-accent-bun-default',
+ },
+};
+
+const specRows: Array<{
+ control: string;
+ status: Status;
+ today: string;
+ needs: string;
+}> = [
+ {
+ control: 'Follow and unfollow topics, sources, squads, people',
+ status: Status.Exists,
+ today: 'allowed_tags, followed_sources, squad_ids, followed_user_ids',
+ needs: 'Nothing',
+ },
+ {
+ control: 'Block topics, sources, people',
+ status: Status.Exists,
+ today: 'blocked_tags, blocked_sources, blocked_author_ids',
+ needs: 'Nothing',
+ },
+ {
+ control: 'Fewer like this',
+ status: Status.Exists,
+ today: 'Hide; remove_engaged_posts drops it',
+ needs: 'Nothing',
+ },
+ {
+ control: 'More and Less on a topic',
+ status: Status.Config,
+ today:
+ 'control_boost and control_demote are in the config but BuildInterests never applies them',
+ needs: 'Per-user, per-topic controls stored and applied in BuildInterests',
+ },
+ {
+ control: 'Mute a learned topic',
+ status: Status.Missing,
+ today: 'Only in the proof-of-concept notebook',
+ needs: 'Same storage as More and Less; drop the inferred interest',
+ },
+ {
+ control: 'Exact topic weight',
+ status: Status.Missing,
+ today: 'Weights are computed per request',
+ needs: 'A per-topic override that replaces the computed weight',
+ },
+ {
+ control: 'Less from a source',
+ status: Status.Missing,
+ today: 'Sources only have follow and block',
+ needs: 'A per-source demote on the follow factor',
+ },
+ {
+ control: 'Ranking knobs',
+ status: Status.Config,
+ today:
+ 'retriever_config is global per cohort; it travels inline on every request and is part of the cache key',
+ needs: 'daily-api or lofn merges the user’s overrides into retriever_config, clamped to policy.Parse ranges',
+ },
+ {
+ control: 'Learn from my reading',
+ status: Status.Config,
+ today: 'inferred_interests.enabled is per provider',
+ needs: 'Set it per user',
+ },
+ {
+ control: 'Popular fill-ins',
+ status: Status.Config,
+ today: 'The cs and po lanes fill what the challenger leaves empty',
+ needs: 'Drop those lanes per user',
+ },
+ {
+ control: 'Forget what the feed learned',
+ status: Status.Missing,
+ today:
+ 'The delta job rebuilds 90 days of evidence every 5 minutes, so deleting the profile does not stick',
+ needs: 'A per-user evidence cutoff date the job respects',
+ },
+ {
+ control: 'Forget what you scrolled past',
+ status: Status.Exists,
+ today: 'Impression counts in Redis per user',
+ needs: 'An endpoint that clears them',
+ },
+ {
+ control: 'Reasons and factors on each card',
+ status: Status.Missing,
+ today:
+ 'The policy computes them and logs them to fss_events provider_metadata; the API returns only post ids',
+ needs: 'Return the decision with each post, or look it up by request id',
+ },
+ {
+ control: 'Left out list',
+ status: Status.Missing,
+ today: 'feed_candidates logs up to 500 eligible cards per request',
+ needs: 'Log exclusion reasons too, and an endpoint to read them',
+ },
+ {
+ control: 'Your interest profile',
+ status: Status.Missing,
+ today: 'Stored per user in Redis and Postgres; no API returns it',
+ needs: 'An endpoint that returns the labelled, weighted profile',
+ },
+];
+
+export const Spec: Story = {
+ name: '10 · What it needs',
+ render: () => (
+
+
+
+ Full control over For You: what exists and what it needs
+
+
+ The story runs a copy of {policyVersion}, the challenger
+ policy For You uses, over real posts. Every reason, chip, weight and
+ left-out group comes from that copy, so changing a control re-ranks
+ the page the same way the feed would. It simplifies four things:
+ reader response compares posts here instead of using the source
+ prior, the first tag is the main topic, topic similarity is tag
+ overlap instead of tag embeddings, and placement swaps are left out.
+
- Every post now shows what put it here. Use{' '}
- or{' '}
- to see less or
- more of something, or block it.
-
- )}
-
-
-
- {counts.map(({ reason, count }) => (
-
- ))}
-
-
- {counts.map(({ reason, count }) => (
-
-
-
- ))}
-
-
- {isLearning && (
-
-
-
- Your feed is still learning what you like.
- {' '}
- {popularCount} of these {staying.length} posts are popular picks.
- Pick a few more topics and they'll make room for posts that fit
- you.
-
+ >
+ );
+ break;
+ }
+ case FactorId.Follow:
+ body = (
+ <>
+
+ You follow {sourceName}, so every post from it is
+ in and multiplied by {formatMultiplier(decision.factors.follow)}.
+
+
+
+ >
+ );
+ break;
+ case FactorId.Base:
+ body = (
+
+ Per view, readers upvoted and commented on this post{' '}
+ {formatMultiplier(decision.factors.base)} as much as
+ the typical post here. Well-liked posts also stay fresh longer. This
+ one is about other readers, so there is nothing to turn off.
+
+ );
+ break;
+ case FactorId.Age:
+ body = (
+ <>
+
+ Posted {Math.round(decision.ageDays)} days ago. A post loses half
+ its pull every {params['age.half_life_days']} days
+ , stretched up to {params['age.max_stretch']}× for well-liked
+ posts.
+
+ It was on your screen {decision.impressions}{' '}
+ {decision.impressions === 1 ? 'time' : 'times'} without a click.
+ Each view keeps{' '}
+ {Math.round(params['history.seen_factor_per_show'] * 100)}% of its
+ pull, and at {params['history.max_impressions_without_action']}{' '}
+ views it leaves.
+
+
+
+ >
+ );
+ break;
+ case FactorId.Waiting:
+ body = (
+ <>
+
+ #{decision.slot?.waitingTag} had not been on your
+ first screen lately, so its first post gets a turn and jumps ahead
+ of topics you just saw.
+
+
+ >
+ );
+ break;
+ case FactorId.SourceRepeat:
+ body = (
+ <>
+
+ {decision.slot?.sourceCount}{' '}
+ {decision.slot?.sourceCount === 1 ? 'post' : 'posts'} from{' '}
+ {sourceName} already sit above it. Each one lowers the next by{' '}
+ {Math.round(params['diversity.source_repeat_discount'] * 100)}%.
+
+
+ >
+ );
+ break;
+ case FactorId.TopicRepeat:
+ body = (
+ <>
+
+ Posts on similar topics already sit above it, so it moves down to
+ mix the page.
+
+
+ >
+ );
+ break;
+ case FactorId.SameHeadline:
+ body = (
+ <>
+
+ Its headline overlaps a post above it, so it is likely the same
+ story.
+
);
};
interface CardFrameProps {
- item: ExplainedPost;
+ decision: Decision;
+ leaving?: Decision;
index: number;
layout: FeedLayout;
isExplain: boolean;
}
export const CardFrame = ({
- item,
+ decision,
+ leaving,
index,
layout,
isExplain,
}: CardFrameProps): ReactElement => {
- const { focus, hoverKey, getLeaveReason } = useFeedTuning();
- const { counts } = useContext(FeedScreenContext);
- const isLeaving = isExplain && !!getLeaveReason(item);
- const isDimmed =
- isExplain && focus?.type === 'reason' && focus.reason !== item.reason;
+ const { hoverKey } = usePersonalization();
+ const { counts } = useFeedScreen();
+ const isLeaving = isExplain && !!leaving;
+ const isGrid = layout === FeedLayout.Grid;
+ const { post } = decision;
+ const keys = [
+ ...(post.tags ?? []).map((tag) => `topic:${tag}`),
+ `source:${post.source?.id}`,
+ ];
// Only worth pointing at when the signal is on more than this card.
const isHighlighted =
isExplain &&
!!hoverKey &&
(counts[hoverKey] ?? 0) > 1 &&
- item.signals.some(({ key }) => key === hoverKey);
- const frameClassName = classNames(
- 'relative rounded-16 transition-[opacity,box-shadow] duration-200',
- isDimmed && 'opacity-30',
- isHighlighted &&
- 'ring-2 ring-accent-cabbage-default ring-offset-2 ring-offset-background-default',
- );
+ keys.includes(hoverKey);
- // A phone fits one list card per screen, so Explain trades the cover and
- // action bar for the reason, letting you triage a few posts per screen.
- if (layout === FeedLayout.List) {
- return (
-
- {isExplain ? (
-
-
-
-
+ // The real card stays underneath so the feed keeps every size and gap; the
+ // overlay covers it with the explanation.
+ return (
+
+
+ {isGrid ? (
+
) : (
-
+
)}
- );
- }
-
- // The real card stays underneath so the grid keeps its size; the overlay
- // lines its source and title up with the card's own.
- return (
-
+ Everything that decides what For You shows you, and every knob
+ behind it. Ranked by {policyVersion}; your profile
+ updates within minutes of what you read.
+
+
+ store.setLearning(!state.isLearning)}
+ compact={false}
+ >
+ Learn from my reading
+
+
+ {state.followedAuthors.length
+ ? `${state.followedAuthors.length} people you follow, ${formatMultiplier(
+ state.params['follows.author_multiplier'],
+ )} each.`
+ : `No people followed. Following a writer multiplies their posts by ${formatMultiplier(
+ state.params['follows.author_multiplier'],
+ )}.`}
+
+
+
+ {suggestions.length > 0 && (
+
+
+ Sources in posts you could see, most common first.
+
+
+ )}
+
+ );
+};
diff --git a/packages/storybook/stories/features/feed-explain/feedExplain.mocks.ts b/packages/storybook/stories/features/feed-explain/feedExplain.mocks.ts
deleted file mode 100644
index b036e4354f..0000000000
--- a/packages/storybook/stories/features/feed-explain/feedExplain.mocks.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-import { fullPost } from '../../history/posts';
-import type { ExplainedPost, ViewerProfile } from './components/feedExplain';
-import { explainPost } from './components/feedExplain';
-
-/**
- * A viewer a few days in: three topics picked at sign-up, two more inferred
- * from what they opened, and one source followed. The posts are real ones
- * from the /history story, so every title, source and tag is what the API
- * returned.
- */
-export const newViewer: ViewerProfile = {
- pickedTags: ['webdev', 'react', 'ai'],
- readingTags: ['rust', 'docker'],
- followedSources: ['fireship'],
-};
-
-const explain = (ids: string[]): ExplainedPost[] =>
- ids.map((id) => explainPost(fullPost(id), newViewer));
-
-export const forYouFeed = explain([
- '3wLIGsLJr',
- 'Hd2eBXFkz',
- 'XCmmeq8ZC',
- 'BM2onQaux',
- 'VNv8IMYss',
- 'oBDUfGZ7N',
- 'j4duNN9S2',
- 'RFRxDV6yP',
- 'DVZBShH7e',
- 'DFJTpz2Tw',
- 'IGmufOLb8',
- 'AzxnBHN3V',
- '9tLNItvh5',
- 'CrehqCUVO',
- '8YmexVcM0',
- 'PFMO5JfPF',
-]);
-
-/** What a refresh can bring in, best ranked first. */
-export const reserveFeed = explain([
- 'BjYAz50EA',
- '4I7VllOCg',
- 'Rvo54Shm1',
- '7p9nRlTLf',
- 'hsSp9jc9T',
- 'Lfny6jDqe',
- 'jvlaswdix',
- '8eOv3hbFq',
- 'B2K4Imgfa',
- 'tbbNjYj52',
-]);
-
-const byId = new Map(
- [...forYouFeed, ...reserveFeed].map((item) => [item.post.id, item]),
-);
-
-export const feedItem = (id: string): ExplainedPost => {
- const item = byId.get(id);
- if (!item) {
- throw new Error(`No feed item ${id}`);
- }
- return item;
-};
diff --git a/packages/storybook/stories/features/feed-explain/personas.ts b/packages/storybook/stories/features/feed-explain/personas.ts
new file mode 100644
index 0000000000..768fdb4761
--- /dev/null
+++ b/packages/storybook/stories/features/feed-explain/personas.ts
@@ -0,0 +1,107 @@
+import type { Post } from '@dailydotdev/shared/src/graphql/posts';
+import { fullPost } from '../../history/posts';
+import postPages from '../../history/postPages.json';
+import { defaultParams } from './policy/params';
+import type { PersonalizationState } from './policy/rank';
+
+/**
+ * Every article and video from the /history story: real titles, sources,
+ * tags, reactions and views as the API returned them. The policy picks the
+ * For You page from these.
+ */
+export const candidatePool: Post[] = Object.keys(postPages)
+ .map(fullPost)
+ .filter(({ type }) => type === 'article' || type === 'video:youtube');
+
+export enum PersonaId {
+ TwoWeeksIn = 'two_weeks_in',
+ JustSignedUp = 'just_signed_up',
+}
+
+export interface Persona {
+ id: PersonaId;
+ label: string;
+ description: string;
+ state: PersonalizationState;
+}
+
+const baseState: PersonalizationState = {
+ followedTags: [],
+ blockedTags: [],
+ followedSources: [],
+ blockedSources: [],
+ followedAuthors: [],
+ blockedAuthors: [],
+ evidence: [],
+ isLearning: true,
+ controls: {},
+ sourceControls: {},
+ params: defaultParams,
+ isFillingWithPopular: true,
+ hidden: [],
+ impressions: {},
+ tagShownDays: {},
+};
+
+export const personas: Record = {
+ [PersonaId.TwoWeeksIn]: {
+ id: PersonaId.TwoWeeksIn,
+ label: 'Two weeks in',
+ description:
+ 'Followed #webdev, #react and #ai at sign-up, then read a lot about careers and some Rust and Docker.',
+ state: {
+ ...baseState,
+ followedTags: ['webdev', 'react', 'ai'],
+ followedSources: ['fireship'],
+ evidence: [
+ { topic: 'ai', stories: 7, strongStories: 2, daysSinceLastAction: 1 },
+ {
+ topic: 'career',
+ stories: 6,
+ strongStories: 1,
+ daysSinceLastAction: 2,
+ },
+ {
+ topic: 'ai-coding',
+ stories: 4,
+ strongStories: 0,
+ daysSinceLastAction: 3,
+ },
+ {
+ topic: 'webdev',
+ stories: 3,
+ strongStories: 0,
+ daysSinceLastAction: 4,
+ },
+ { topic: 'rust', stories: 3, strongStories: 1, daysSinceLastAction: 6 },
+ {
+ topic: 'docker',
+ stories: 2,
+ strongStories: 0,
+ daysSinceLastAction: 12,
+ },
+ {
+ topic: 'productivity',
+ stories: 2,
+ strongStories: 0,
+ daysSinceLastAction: 30,
+ },
+ { topic: 'react', stories: 1, strongStories: 0, daysSinceLastAction: 9 },
+ { topic: 'linux', stories: 1, strongStories: 0, daysSinceLastAction: 5 },
+ { topic: 'css', stories: 1, strongStories: 0, daysSinceLastAction: 20 },
+ ],
+ impressions: { DVZBShH7e: 2, KfPEpQdOl: 1, mGfmauFls: 6 },
+ tagShownDays: { ai: 0.3, career: 0.5, webdev: 1 },
+ },
+ },
+ [PersonaId.JustSignedUp]: {
+ id: PersonaId.JustSignedUp,
+ label: 'Just signed up',
+ description:
+ 'Picked #react and #rust in onboarding and has not read anything yet.',
+ state: {
+ ...baseState,
+ followedTags: ['react', 'rust'],
+ },
+ },
+};
diff --git a/packages/storybook/stories/features/feed-explain/policy/challengerConfig.ts b/packages/storybook/stories/features/feed-explain/policy/challengerConfig.ts
new file mode 100644
index 0000000000..89dec53fca
--- /dev/null
+++ b/packages/storybook/stories/features/feed-explain/policy/challengerConfig.ts
@@ -0,0 +1,82 @@
+/** `retriever_config` from daily-feed's `feed_configs/challenger_v2.json`. */
+export const challengerRetrieverConfig = {
+ policy_version: 'challenger/5.0',
+ feed: {
+ page_size: 12,
+ pages: 2,
+ first_screen_posts: 8,
+ max_candidate_age_days: 60,
+ },
+ interests: {
+ base_weight: 0.3,
+ declared_boost: {
+ confirmed: 0.25,
+ early_signal: 0,
+ moderate: 0.4,
+ strong: 1,
+ },
+ inferred_weight: {
+ confirmed: 0.4,
+ early_signal: 0,
+ moderate: 0.5,
+ strong: 0.85,
+ },
+ inferred_recency_half_life_days: 25,
+ inferred_recency_floor: 0.2,
+ control_boost: 1.15,
+ control_demote: 0.8,
+ },
+ relevance: {
+ main_topic_weight: 1,
+ side_topic_weight: 0.56,
+ },
+ engagement: {
+ proof_viewers: 200,
+ caution_sd: 2,
+ lift_floor: 0.02,
+ disliked_below: 0.75,
+ reaction_weights: {
+ comment: 2,
+ downvote: -1.5,
+ open: 1,
+ report: -2,
+ save: 2,
+ share: 2,
+ upvote: 2,
+ },
+ prior: 'source',
+ source_prior_viewers: 2000,
+ evidence: {
+ proof_viewers: 2000,
+ caution_sd: 2,
+ lift_floor: 0.2,
+ },
+ },
+ age: {
+ half_life_days: 7,
+ max_stretch: 2,
+ },
+ follows: {
+ source_multiplier: 1.7,
+ author_multiplier: 1.6,
+ },
+ history: {
+ max_impressions_without_action: 6,
+ seen_factor_per_show: 0.69,
+ },
+ diversity: {
+ topic_repeat_discount: 0.15,
+ topic_similarity_floor: 0.35,
+ source_repeat_discount: 0.09,
+ same_headline_discount: 0.175,
+ same_headline_jaccard: 0.35,
+ same_headline_min_tokens: 3,
+ waiting_interest_lift: 1,
+ waiting_half_life_days: 3,
+ },
+ placement: {
+ share: 1,
+ near_tie_ratio: 0.95,
+ swap_probability: 0.5,
+ },
+};
diff --git a/packages/storybook/stories/features/feed-explain/policy/describe.ts b/packages/storybook/stories/features/feed-explain/policy/describe.ts
new file mode 100644
index 0000000000..85e12b7e94
--- /dev/null
+++ b/packages/storybook/stories/features/feed-explain/policy/describe.ts
@@ -0,0 +1,279 @@
+import type { Decision, PersonalizationState } from './rank';
+import { Exclusion, FollowKind, Lane, TopicRole } from './rank';
+import { InterestOrigin, Strength, TopicMode } from './profile';
+import type { Interest } from './profile';
+
+/** What put a card on the page, for the mix bar and the reason dot. */
+export enum ReasonCategory {
+ Followed = 'followed',
+ Learned = 'learned',
+ Following = 'following',
+ Popular = 'popular',
+}
+
+export const reasonCategories: Array<{
+ category: ReasonCategory;
+ label: string;
+ dotClassName: string;
+}> = [
+ {
+ category: ReasonCategory.Followed,
+ label: 'Topics you follow',
+ dotClassName: 'bg-accent-cabbage-default',
+ },
+ {
+ category: ReasonCategory.Learned,
+ label: 'Learned from reading',
+ dotClassName: 'bg-accent-blueCheese-default',
+ },
+ {
+ category: ReasonCategory.Following,
+ label: 'Sources you follow',
+ dotClassName: 'bg-accent-water-default',
+ },
+ {
+ category: ReasonCategory.Popular,
+ label: 'Popular fill-ins',
+ dotClassName: 'bg-accent-cheese-default',
+ },
+];
+
+export const getCategory = (decision: Decision): ReasonCategory => {
+ if (decision.lane === Lane.Popular) {
+ return ReasonCategory.Popular;
+ }
+ if (decision.followedBy) {
+ return ReasonCategory.Following;
+ }
+ return decision.best?.origin === InterestOrigin.Inferred
+ ? ReasonCategory.Learned
+ : ReasonCategory.Followed;
+};
+
+export const getCategoryDot = (category: ReasonCategory): string =>
+ reasonCategories.find((item) => item.category === category)?.dotClassName ??
+ '';
+
+export enum FactorId {
+ Match = 'match',
+ Follow = 'follow',
+ Base = 'base',
+ Age = 'age',
+ Seen = 'seen',
+ Waiting = 'waiting',
+ SourceRepeat = 'source_repeat',
+ TopicRepeat = 'topic_repeat',
+ SameHeadline = 'same_headline',
+ Popular = 'popular',
+}
+
+export interface FactorChip {
+ id: FactorId;
+ label: string;
+ value: number;
+}
+
+const notable = (value: number): boolean => Math.abs(Math.log(value)) >= 0.05;
+
+const formatAge = (days: number): string => {
+ if (days < 1) {
+ return 'Today';
+ }
+ const rounded = Math.round(days);
+ return `${rounded} ${rounded === 1 ? 'day' : 'days'} old`;
+};
+
+/** The multipliers that moved this card, biggest first. */
+export const getFactorChips = (decision: Decision): FactorChip[] => {
+ if (decision.lane === Lane.Popular) {
+ return [
+ { id: FactorId.Popular, label: 'Feed ran short', value: 1 },
+ {
+ id: FactorId.Base,
+ label:
+ decision.factors.base >= 1 ? 'Readers liked it' : 'Few reactions yet',
+ value: decision.factors.base,
+ },
+ {
+ id: FactorId.Age,
+ label: formatAge(decision.ageDays),
+ value: decision.factors.age,
+ },
+ ];
+ }
+ const { factors, slot, best } = decision;
+ const sourceName = decision.post.source?.name ?? 'this source';
+ const chips: FactorChip[] = [
+ ...(best
+ ? [
+ {
+ id: FactorId.Match,
+ label: `#${best.tag}${best.role === TopicRole.Side ? ' (side)' : ''}`,
+ value: factors.match,
+ },
+ ]
+ : []),
+ {
+ id: FactorId.Follow,
+ label:
+ decision.followedBy === FollowKind.Author
+ ? 'Author you follow'
+ : `Following ${sourceName}`,
+ value: factors.follow,
+ },
+ {
+ id: FactorId.Base,
+ label: factors.base >= 1 ? 'Readers liked it' : 'Few reactions yet',
+ value: factors.base,
+ },
+ { id: FactorId.Age, label: formatAge(decision.ageDays), value: factors.age },
+ {
+ id: FactorId.Seen,
+ label: `Seen ${decision.impressions}×`,
+ value: factors.seen,
+ },
+ ...(slot
+ ? [
+ {
+ id: FactorId.Waiting,
+ label: `#${slot.waitingTag}'s turn`,
+ value: slot.waiting,
+ },
+ {
+ id: FactorId.SourceRepeat,
+ label: `More from ${sourceName}`,
+ value: slot.sourceRepeat,
+ },
+ {
+ id: FactorId.TopicRepeat,
+ label: 'Similar above',
+ value: slot.topicRepeat,
+ },
+ {
+ id: FactorId.SameHeadline,
+ label: 'Same story above',
+ value: slot.sameHeadline,
+ },
+ ]
+ : []),
+ ];
+
+ return chips
+ .filter((chip) => chip.id === FactorId.Match || notable(chip.value))
+ .sort((a, b) => Math.abs(Math.log(b.value)) - Math.abs(Math.log(a.value)));
+};
+
+const strengthPhrase: Partial> = {
+ [Strength.Strong]: 'and read about it often',
+ [Strength.Moderate]: 'and read about it sometimes',
+ [Strength.Confirmed]: 'and opened a story on it',
+};
+
+export interface ReasonParts {
+ lead: string;
+ name?: string;
+ tail: string;
+}
+
+/** The one-sentence reason, split so the name can be bold. */
+export const getReason = (
+ decision: Decision,
+ interests: Interest[],
+): ReasonParts => {
+ if (decision.lane === Lane.Popular) {
+ return {
+ lead: 'Popular this week.',
+ tail: " Your interests didn't fill the page, so this tops it up.",
+ };
+ }
+ const { best } = decision;
+ const interest = interests.find(({ tag }) => tag === best?.tag);
+ if (decision.followedBy === FollowKind.Source) {
+ return {
+ lead: 'You follow ',
+ name: decision.post.source?.name,
+ tail: best ? `. It's also about #${best.tag}.` : '.',
+ };
+ }
+ if (decision.followedBy === FollowKind.Author) {
+ return { lead: 'Written by someone you follow', tail: '.' };
+ }
+ if (!best) {
+ return { lead: 'Picked for you', tail: '.' };
+ }
+ const side = best.role === TopicRole.Side ? ', here a side topic' : '';
+ if (best.origin === InterestOrigin.Declared) {
+ const phrase = interest?.strength ? strengthPhrase[interest.strength] : '';
+ return {
+ lead: 'You follow ',
+ name: `#${best.tag}`,
+ tail: `${phrase ? ` ${phrase}` : ''}${side}.`,
+ };
+ }
+ return interest?.strength === Strength.Strong
+ ? { lead: "You've read a lot about ", name: `#${best.tag}`, tail: `${side}.` }
+ : {
+ lead: "You've read a few posts about ",
+ name: `#${best.tag}`,
+ tail: `${side}.`,
+ };
+};
+
+export const exclusionLabel: Record = {
+ [Exclusion.Hidden]: 'You asked for fewer like it',
+ [Exclusion.BlockedSource]: 'Source you blocked',
+ [Exclusion.BlockedAuthor]: 'Author you blocked',
+ [Exclusion.BlockedTag]: 'Has a topic you blocked',
+ [Exclusion.TooOld]: 'Older than your limit',
+ [Exclusion.ShownTooOften]: 'Scrolled past too many times',
+ [Exclusion.NoInterestOrFollow]: 'Nothing you follow or read',
+ [Exclusion.SideTopicUnproven]: 'Side topic, not proven yet',
+ [Exclusion.SideTopicsOff]: 'Side topics are off',
+ [Exclusion.PopularFillOff]: 'Popular fill-ins are off',
+ [Exclusion.BelowThePage]: "Ranked lower, didn't make the page",
+};
+
+/** Why a card on screen would leave, in the viewer's terms. */
+export const getLeaveMessage = (
+ served: Decision,
+ now: Decision,
+ state: PersonalizationState,
+): string => {
+ const sourceName = served.post.source?.name ?? 'this source';
+ switch (now.exclusion) {
+ case Exclusion.Hidden:
+ return 'You asked for fewer posts like this.';
+ case Exclusion.BlockedSource:
+ return `You blocked ${sourceName}.`;
+ case Exclusion.BlockedAuthor:
+ return 'You blocked its author.';
+ case Exclusion.BlockedTag:
+ return `You blocked #${now.exclusionTag}.`;
+ case Exclusion.TooOld:
+ return `Older than your ${state.params['feed.max_candidate_age_days']}-day limit.`;
+ case Exclusion.ShownTooOften:
+ return `You scrolled past it ${now.impressions} times.`;
+ case Exclusion.SideTopicsOff:
+ return 'You turned off side-topic matches.';
+ case Exclusion.SideTopicUnproven:
+ return 'It only matches a side topic and readers have not proven it yet.';
+ case Exclusion.PopularFillOff:
+ return 'You turned off popular fill-ins.';
+ default: {
+ const tag = served.best?.tag;
+ if (tag && state.controls[tag]?.mode === TopicMode.Muted) {
+ return `You muted #${tag}.`;
+ }
+ if (tag && !state.isLearning) {
+ return 'You stopped learning from reading.';
+ }
+ if (served.followedBy && !now.followedBy) {
+ return `You unfollowed ${sourceName}.`;
+ }
+ if (tag && served.best?.origin === InterestOrigin.Declared) {
+ return `You unfollowed #${tag}.`;
+ }
+ return 'None of its topics are in your interests anymore.';
+ }
+ }
+};
diff --git a/packages/storybook/stories/features/feed-explain/policy/params.ts b/packages/storybook/stories/features/feed-explain/policy/params.ts
new file mode 100644
index 0000000000..c25ed78a43
--- /dev/null
+++ b/packages/storybook/stories/features/feed-explain/policy/params.ts
@@ -0,0 +1,419 @@
+/**
+ * The For You ranking knobs, named after `retriever_config` in daily-feed's
+ * `feed_configs/challenger_v2.json` (policy `challenger/5.0`). Defaults are
+ * that config's values; ranges stay inside what `policy.Parse` accepts.
+ */
+
+export const policyVersion = 'challenger/5.0';
+
+export enum ParamGroup {
+ Relevance = 'relevance',
+ Learning = 'learning',
+ Freshness = 'freshness',
+ Follows = 'follows',
+ Variety = 'variety',
+ Repeats = 'repeats',
+ Adjustments = 'adjustments',
+ Scope = 'scope',
+}
+
+export type ParamKey =
+ | 'interests.base_weight'
+ | 'interests.declared_boost.strong'
+ | 'interests.declared_boost.moderate'
+ | 'interests.declared_boost.confirmed'
+ | 'interests.inferred_weight.strong'
+ | 'interests.inferred_weight.moderate'
+ | 'interests.inferred_recency_half_life_days'
+ | 'interests.inferred_recency_floor'
+ | 'interests.control_boost'
+ | 'interests.control_demote'
+ | 'relevance.side_topic_weight'
+ | 'age.half_life_days'
+ | 'age.max_stretch'
+ | 'follows.source_multiplier'
+ | 'follows.author_multiplier'
+ | 'history.max_impressions_without_action'
+ | 'history.seen_factor_per_show'
+ | 'diversity.topic_repeat_discount'
+ | 'diversity.source_repeat_discount'
+ | 'diversity.same_headline_discount'
+ | 'diversity.waiting_interest_lift'
+ | 'diversity.waiting_half_life_days'
+ | 'feed.max_candidate_age_days';
+
+export type ParamValues = Record;
+
+export enum ParamFormat {
+ Weight = 'weight',
+ Multiplier = 'multiplier',
+ Percent = 'percent',
+ Days = 'days',
+ Count = 'count',
+}
+
+export interface ParamMeta {
+ key: ParamKey;
+ group: ParamGroup;
+ label: string;
+ help: string;
+ min: number;
+ max: number;
+ step: number;
+ format: ParamFormat;
+ /** Shown only with advanced settings on. */
+ isAdvanced?: boolean;
+}
+
+export const defaultParams: ParamValues = {
+ 'interests.base_weight': 0.3,
+ 'interests.declared_boost.strong': 1,
+ 'interests.declared_boost.moderate': 0.4,
+ 'interests.declared_boost.confirmed': 0.25,
+ 'interests.inferred_weight.strong': 0.85,
+ 'interests.inferred_weight.moderate': 0.5,
+ 'interests.inferred_recency_half_life_days': 25,
+ 'interests.inferred_recency_floor': 0.2,
+ 'interests.control_boost': 1.15,
+ 'interests.control_demote': 0.8,
+ 'relevance.side_topic_weight': 0.56,
+ 'age.half_life_days': 7,
+ 'age.max_stretch': 2,
+ 'follows.source_multiplier': 1.7,
+ 'follows.author_multiplier': 1.6,
+ 'history.max_impressions_without_action': 6,
+ 'history.seen_factor_per_show': 0.69,
+ 'diversity.topic_repeat_discount': 0.15,
+ 'diversity.source_repeat_discount': 0.09,
+ 'diversity.same_headline_discount': 0.175,
+ 'diversity.waiting_interest_lift': 1,
+ 'diversity.waiting_half_life_days': 3,
+ 'feed.max_candidate_age_days': 60,
+};
+
+export const paramMeta: ParamMeta[] = [
+ {
+ key: 'interests.base_weight',
+ group: ParamGroup.Relevance,
+ label: 'Followed topic, before any reading',
+ help: 'What a topic you follow is worth on its own. Reading about it adds to this.',
+ min: 0.05,
+ max: 1,
+ step: 0.05,
+ format: ParamFormat.Weight,
+ },
+ {
+ key: 'relevance.side_topic_weight',
+ group: ParamGroup.Relevance,
+ label: 'Side topics',
+ help: 'How much a match counts when your topic is only a side topic of the post. At 0, side-topic matches never bring a post in.',
+ min: 0,
+ max: 1,
+ step: 0.02,
+ format: ParamFormat.Percent,
+ },
+ {
+ key: 'interests.inferred_weight.strong',
+ group: ParamGroup.Learning,
+ label: 'Topics you read a lot (6+ stories)',
+ help: "Weight of a topic you haven't followed but keep reading.",
+ min: 0,
+ max: 1.5,
+ step: 0.05,
+ format: ParamFormat.Weight,
+ },
+ {
+ key: 'interests.inferred_weight.moderate',
+ group: ParamGroup.Learning,
+ label: 'Topics you read sometimes (2 to 5)',
+ help: "Weight of a topic you haven't followed and opened a few times.",
+ min: 0,
+ max: 1.5,
+ step: 0.05,
+ format: ParamFormat.Weight,
+ },
+ {
+ key: 'interests.inferred_recency_half_life_days',
+ group: ParamGroup.Learning,
+ label: 'How fast reading fades',
+ help: 'Reading evidence loses half its pull after this many days without new activity on the topic.',
+ min: 3,
+ max: 90,
+ step: 1,
+ format: ParamFormat.Days,
+ },
+ {
+ key: 'interests.inferred_recency_floor',
+ group: ParamGroup.Learning,
+ label: 'Fading stops at',
+ help: 'Old reading never drops below this share of its weight.',
+ min: 0,
+ max: 1,
+ step: 0.05,
+ format: ParamFormat.Percent,
+ },
+ {
+ key: 'interests.declared_boost.strong',
+ group: ParamGroup.Learning,
+ label: 'Extra for a followed topic you read a lot',
+ help: 'Added to a followed topic once you have read 6 or more stories on it.',
+ min: 0,
+ max: 2,
+ step: 0.05,
+ format: ParamFormat.Weight,
+ isAdvanced: true,
+ },
+ {
+ key: 'interests.declared_boost.moderate',
+ group: ParamGroup.Learning,
+ label: 'Extra for a followed topic you read sometimes',
+ help: 'Added to a followed topic after 2 to 5 stories.',
+ min: 0,
+ max: 2,
+ step: 0.05,
+ format: ParamFormat.Weight,
+ isAdvanced: true,
+ },
+ {
+ key: 'interests.declared_boost.confirmed',
+ group: ParamGroup.Learning,
+ label: 'Extra for a followed topic you read once',
+ help: 'Added to a followed topic after its first story.',
+ min: 0,
+ max: 2,
+ step: 0.05,
+ format: ParamFormat.Weight,
+ isAdvanced: true,
+ },
+ {
+ key: 'age.half_life_days',
+ group: ParamGroup.Freshness,
+ label: 'Freshness',
+ help: 'A post loses half its pull every this many days. Lower means newer posts first.',
+ min: 1,
+ max: 30,
+ step: 1,
+ format: ParamFormat.Days,
+ },
+ {
+ key: 'age.max_stretch',
+ group: ParamGroup.Freshness,
+ label: 'Well-liked posts age slower, up to',
+ help: 'Posts readers respond well to stretch the freshness window by up to this much.',
+ min: 1,
+ max: 4,
+ step: 0.1,
+ format: ParamFormat.Multiplier,
+ },
+ {
+ key: 'follows.source_multiplier',
+ group: ParamGroup.Follows,
+ label: 'Sources and squads you follow',
+ help: 'Multiplies every post from a source or squad you follow.',
+ min: 1,
+ max: 3,
+ step: 0.05,
+ format: ParamFormat.Multiplier,
+ },
+ {
+ key: 'follows.author_multiplier',
+ group: ParamGroup.Follows,
+ label: 'People you follow',
+ help: 'Multiplies every post written by someone you follow.',
+ min: 1,
+ max: 3,
+ step: 0.05,
+ format: ParamFormat.Multiplier,
+ },
+ {
+ key: 'diversity.topic_repeat_discount',
+ group: ParamGroup.Variety,
+ label: 'Spread out similar topics',
+ help: 'Each similar post already on the page lowers the next one by this much.',
+ min: 0,
+ max: 0.6,
+ step: 0.01,
+ format: ParamFormat.Percent,
+ },
+ {
+ key: 'diversity.source_repeat_discount',
+ group: ParamGroup.Variety,
+ label: 'Spread out sources',
+ help: 'Each post from the same source already on the page lowers the next one by this much.',
+ min: 0,
+ max: 0.5,
+ step: 0.01,
+ format: ParamFormat.Percent,
+ },
+ {
+ key: 'diversity.same_headline_discount',
+ group: ParamGroup.Variety,
+ label: 'Same story, different headline',
+ help: 'Lowers a post whose headline overlaps one already on the page.',
+ min: 0,
+ max: 0.6,
+ step: 0.01,
+ format: ParamFormat.Percent,
+ },
+ {
+ key: 'diversity.waiting_interest_lift',
+ group: ParamGroup.Variety,
+ label: 'Rotate my interests',
+ help: "Lifts the first post on a topic that hasn't been on your first screen lately. At 0, topics don't take turns.",
+ min: 0,
+ max: 3,
+ step: 0.1,
+ format: ParamFormat.Weight,
+ },
+ {
+ key: 'diversity.waiting_half_life_days',
+ group: ParamGroup.Variety,
+ label: 'A topic is due again after',
+ help: "Days until a topic's turn is half due again.",
+ min: 0.5,
+ max: 14,
+ step: 0.5,
+ format: ParamFormat.Days,
+ isAdvanced: true,
+ },
+ {
+ key: 'history.seen_factor_per_show',
+ group: ParamGroup.Repeats,
+ label: 'Posts I scrolled past',
+ help: 'Each time a post was on your screen without you acting on it, it keeps this much of its pull. 100% means no penalty.',
+ min: 0.2,
+ max: 1,
+ step: 0.01,
+ format: ParamFormat.Percent,
+ },
+ {
+ key: 'history.max_impressions_without_action',
+ group: ParamGroup.Repeats,
+ label: 'Stop showing a post after',
+ help: 'Views without any action before a post leaves your feed for good.',
+ min: 1,
+ max: 12,
+ step: 1,
+ format: ParamFormat.Count,
+ },
+ {
+ key: 'interests.control_boost',
+ group: ParamGroup.Adjustments,
+ label: 'What More does',
+ help: 'Multiplies a topic you pressed More on.',
+ min: 1,
+ max: 2,
+ step: 0.05,
+ format: ParamFormat.Multiplier,
+ },
+ {
+ key: 'interests.control_demote',
+ group: ParamGroup.Adjustments,
+ label: 'What Less does',
+ help: 'Multiplies a topic you pressed Less on.',
+ min: 0.1,
+ max: 1,
+ step: 0.05,
+ format: ParamFormat.Multiplier,
+ },
+ {
+ key: 'feed.max_candidate_age_days',
+ group: ParamGroup.Scope,
+ label: 'Oldest post I want to see',
+ help: 'Posts older than this never make it in. The index keeps 60 days.',
+ min: 3,
+ max: 60,
+ step: 1,
+ format: ParamFormat.Days,
+ },
+];
+
+export const paramGroups: Array<{
+ group: ParamGroup;
+ title: string;
+ description: string;
+}> = [
+ {
+ group: ParamGroup.Relevance,
+ title: 'Relevance',
+ description:
+ "A post's best matching topic multiplies its score by 1 + that topic's weight.",
+ },
+ {
+ group: ParamGroup.Learning,
+ title: 'Learning from your reading',
+ description:
+ 'Topics you open, upvote, save, comment on or share in the last 90 days.',
+ },
+ {
+ group: ParamGroup.Freshness,
+ title: 'Freshness',
+ description: 'How quickly a post loses its place as it gets older.',
+ },
+ {
+ group: ParamGroup.Follows,
+ title: 'Follows',
+ description: 'A follow brings every post in and multiplies its score.',
+ },
+ {
+ group: ParamGroup.Variety,
+ title: 'Variety',
+ description: 'How much each page mixes topics, sources and stories.',
+ },
+ {
+ group: ParamGroup.Repeats,
+ title: 'Repeats',
+ description: 'What happens to posts you keep scrolling past.',
+ },
+ {
+ group: ParamGroup.Adjustments,
+ title: 'Your More and Less',
+ description: 'How hard the More and Less buttons push a topic.',
+ },
+ {
+ group: ParamGroup.Scope,
+ title: 'Scope',
+ description: 'Which posts are considered at all.',
+ },
+];
+
+export const getParamMeta = (key: ParamKey): ParamMeta => {
+ const meta = paramMeta.find((item) => item.key === key);
+ if (!meta) {
+ throw new Error(`Unknown param ${key}`);
+ }
+ return meta;
+};
+
+export const formatParam = (format: ParamFormat, value: number): string => {
+ switch (format) {
+ case ParamFormat.Percent:
+ return `${Math.round(value * 100)}%`;
+ case ParamFormat.Multiplier:
+ return `×${value.toFixed(2)}`;
+ case ParamFormat.Days:
+ return `${value} ${value === 1 ? 'day' : 'days'}`;
+ case ParamFormat.Count:
+ return `${value} ${value === 1 ? 'view' : 'views'}`;
+ default:
+ return value.toFixed(2);
+ }
+};
+
+/** Writes changed values into the config the policy reads. */
+export const toRetrieverConfig = (
+ params: ParamValues,
+ base: Record,
+): Record => {
+ const config = structuredClone(base);
+ (Object.keys(params) as ParamKey[]).forEach((key) => {
+ const path = key.split('.');
+ let node = config as Record;
+ path.slice(0, -1).forEach((part) => {
+ node[part] = { ...(node[part] as Record) };
+ node = node[part] as Record;
+ });
+ node[path[path.length - 1]] = params[key];
+ });
+ return config;
+};
diff --git a/packages/storybook/stories/features/feed-explain/policy/profile.ts b/packages/storybook/stories/features/feed-explain/policy/profile.ts
new file mode 100644
index 0000000000..f95349571e
--- /dev/null
+++ b/packages/storybook/stories/features/feed-explain/policy/profile.ts
@@ -0,0 +1,200 @@
+import type { ParamValues } from './params';
+
+/**
+ * The inferred profile as daily-feed stores it (`services/inferred_interests`):
+ * per topic, the distinct stories with a positive action in the last 90 days,
+ * how many of them were upvoted, saved, commented on or shared, and when the
+ * latest action was. Labels and weights are computed per request.
+ */
+export interface TopicEvidence {
+ topic: string;
+ stories: number;
+ strongStories: number;
+ daysSinceLastAction: number;
+}
+
+export enum Strength {
+ Strong = 'strong',
+ Moderate = 'moderate',
+ Confirmed = 'confirmed',
+ EarlySignal = 'early_signal',
+}
+
+export enum InterestOrigin {
+ Declared = 'declared',
+ Inferred = 'inferred',
+}
+
+/** Proposed per-topic user controls; the policy has the knobs, not the UI. */
+export enum TopicMode {
+ Boosted = 'boosted',
+ Demoted = 'demoted',
+ Muted = 'muted',
+}
+
+export interface TopicControl {
+ mode?: TopicMode;
+ /** An exact weight the user set, replacing the computed one. */
+ weight?: number;
+}
+
+export interface Interest {
+ tag: string;
+ origin: InterestOrigin;
+ strength?: Strength;
+ evidence?: TopicEvidence;
+ recency: number;
+ /** The policy's weight before the user's control. */
+ computedWeight: number;
+ weight: number;
+ control?: TopicControl;
+ /** Has reading behind it, so it can take a rotation turn. */
+ isBacked: boolean;
+}
+
+const strongMinStories = 6;
+const moderateMinStories = 2;
+
+export const getStrength = (
+ evidence: TopicEvidence | undefined,
+ isDeclared: boolean,
+): Strength | undefined => {
+ if (!evidence || evidence.stories < 1) {
+ return undefined;
+ }
+ if (evidence.stories >= strongMinStories) {
+ return Strength.Strong;
+ }
+ if (evidence.stories >= moderateMinStories) {
+ return Strength.Moderate;
+ }
+ return isDeclared ? Strength.Confirmed : Strength.EarlySignal;
+};
+
+export const getRecency = (days: number, params: ParamValues): number =>
+ Math.max(
+ params['interests.inferred_recency_floor'],
+ Math.min(
+ 1,
+ 0.5 ** (days / params['interests.inferred_recency_half_life_days']),
+ ),
+ );
+
+export const strengthLabel: Record = {
+ [Strength.Strong]: 'Strong',
+ [Strength.Moderate]: 'Moderate',
+ [Strength.Confirmed]: 'Confirmed',
+ [Strength.EarlySignal]: 'Early signal',
+};
+
+export const strengthRule: Record = {
+ [Strength.Strong]: '6 or more stories',
+ [Strength.Moderate]: '2 to 5 stories',
+ [Strength.Confirmed]: '1 story on a topic you follow',
+ [Strength.EarlySignal]: '1 story; counts once there are 2',
+};
+
+interface BuildInterestsInput {
+ followedTags: string[];
+ blockedTags: string[];
+ evidence: TopicEvidence[];
+ controls: Record;
+ isLearning: boolean;
+ params: ParamValues;
+}
+
+const declaredBoost = (strength: Strength, params: ParamValues): number => {
+ switch (strength) {
+ case Strength.Strong:
+ return params['interests.declared_boost.strong'];
+ case Strength.Moderate:
+ return params['interests.declared_boost.moderate'];
+ case Strength.Confirmed:
+ return params['interests.declared_boost.confirmed'];
+ default:
+ return 0;
+ }
+};
+
+const inferredWeight = (strength: Strength, params: ParamValues): number => {
+ switch (strength) {
+ case Strength.Strong:
+ return params['interests.inferred_weight.strong'];
+ case Strength.Moderate:
+ return params['interests.inferred_weight.moderate'];
+ default:
+ return 0;
+ }
+};
+
+const applyControl = (
+ weight: number,
+ control: TopicControl | undefined,
+ params: ParamValues,
+): number => {
+ if (control?.weight !== undefined) {
+ return control.weight;
+ }
+ switch (control?.mode) {
+ case TopicMode.Boosted:
+ return weight * params['interests.control_boost'];
+ case TopicMode.Demoted:
+ return weight * params['interests.control_demote'];
+ case TopicMode.Muted:
+ return 0;
+ default:
+ return weight;
+ }
+};
+
+/**
+ * `BuildInterests` from daily-feed's challenger policy, plus the controls its
+ * proof of concept defined: boosted and demoted multiply the weight, muted
+ * drops it.
+ */
+export const buildInterests = ({
+ followedTags,
+ blockedTags,
+ evidence,
+ controls,
+ isLearning,
+ params,
+}: BuildInterestsInput): Interest[] => {
+ const evidenceByTopic = new Map(
+ (isLearning ? evidence : []).map((item) => [item.topic, item]),
+ );
+ const tags = [
+ ...new Set([...followedTags, ...evidenceByTopic.keys()]),
+ ].filter((tag) => !blockedTags.includes(tag));
+
+ return tags.map((tag): Interest => {
+ const isDeclared = followedTags.includes(tag);
+ const topicEvidence = evidenceByTopic.get(tag);
+ const strength = getStrength(topicEvidence, isDeclared);
+ const recency = topicEvidence
+ ? getRecency(topicEvidence.daysSinceLastAction, params)
+ : 1;
+ const computedWeight = isDeclared
+ ? params['interests.base_weight'] +
+ (strength ? declaredBoost(strength, params) * recency : 0)
+ : (strength ? inferredWeight(strength, params) : 0) * recency;
+ const control = controls[tag];
+
+ return {
+ tag,
+ origin: isDeclared ? InterestOrigin.Declared : InterestOrigin.Inferred,
+ strength,
+ evidence: topicEvidence,
+ recency,
+ computedWeight,
+ weight: Math.max(0, applyControl(computedWeight, control, params)),
+ control,
+ isBacked:
+ !!strength && strength !== Strength.EarlySignal && !!topicEvidence,
+ };
+ });
+};
+
+/** The interests the policy actually ranks with. */
+export const getActiveInterests = (interests: Interest[]): Interest[] =>
+ interests.filter(({ weight }) => weight > 0);
diff --git a/packages/storybook/stories/features/feed-explain/policy/rank.ts b/packages/storybook/stories/features/feed-explain/policy/rank.ts
new file mode 100644
index 0000000000..4f4e924ade
--- /dev/null
+++ b/packages/storybook/stories/features/feed-explain/policy/rank.ts
@@ -0,0 +1,512 @@
+import type { Post } from '@dailydotdev/shared/src/graphql/posts';
+import type { ParamValues } from './params';
+import type { Interest, TopicControl, TopicEvidence } from './profile';
+import { buildInterests, getActiveInterests, InterestOrigin } from './profile';
+
+/**
+ * A client-side copy of daily-feed's challenger/5.0 policy (`policy/gate.go`,
+ * `score.go`, `pick.go`), run over the posts in the story so every number on
+ * a card comes from the same math the feed uses. Simplified where the real
+ * inputs aren't in the story: reader response compares each post's
+ * reactions per view with the other posts here, the first tag is the main
+ * topic, topic similarity is tag overlap instead of tag embeddings, and
+ * placement swaps are left out.
+ */
+
+export enum SourceMode {
+ Demoted = 'demoted',
+}
+
+export interface PersonalizationState {
+ followedTags: string[];
+ blockedTags: string[];
+ followedSources: string[];
+ blockedSources: string[];
+ followedAuthors: string[];
+ blockedAuthors: string[];
+ evidence: TopicEvidence[];
+ isLearning: boolean;
+ controls: Record;
+ sourceControls: Record;
+ params: ParamValues;
+ isFillingWithPopular: boolean;
+ hidden: string[];
+ /** Views without an action, per post. */
+ impressions: Record;
+ /** Days since each topic was the main topic on your first screen. */
+ tagShownDays: Record;
+}
+
+export enum Lane {
+ Challenger = 'chal',
+ Popular = 'po',
+}
+
+export enum Exclusion {
+ Hidden = 'hidden',
+ BlockedSource = 'blocked_source',
+ BlockedAuthor = 'blocked_author',
+ BlockedTag = 'blocked_tag',
+ TooOld = 'older_than_max_age',
+ ShownTooOften = 'shown_too_often_without_action',
+ NoInterestOrFollow = 'no_interest_or_follow',
+ SideTopicUnproven = 'side_topic_on_an_unproven_post',
+ SideTopicsOff = 'side_topics_off',
+ PopularFillOff = 'popular_fill_off',
+ BelowThePage = 'below_the_page',
+}
+
+export enum TopicRole {
+ Main = 'main',
+ Side = 'side',
+}
+
+export interface Match {
+ tag: string;
+ origin: InterestOrigin;
+ role: TopicRole;
+ weight: number;
+ roleWeight: number;
+ contribution: number;
+}
+
+export enum FollowKind {
+ Source = 'source',
+ Author = 'author',
+}
+
+export interface ScoreFactors {
+ base: number;
+ match: number;
+ follow: number;
+ age: number;
+ seen: number;
+}
+
+export interface SlotFactors {
+ sourceRepeat: number;
+ sourceCount: number;
+ topicRepeat: number;
+ similarAbove: number;
+ sameHeadline: number;
+ waiting: number;
+ waitingTag?: string;
+}
+
+export interface Decision {
+ post: Post;
+ lane: Lane;
+ exclusion?: Exclusion;
+ /** The tag behind a blocked_tag exclusion. */
+ exclusionTag?: string;
+ followedBy?: FollowKind;
+ sourceMode?: SourceMode;
+ best?: Match;
+ matches: Match[];
+ ageDays: number;
+ impressions: number;
+ earned: number;
+ factors: ScoreFactors;
+ score: number;
+ slot?: SlotFactors;
+ pickScore?: number;
+ /** 1-based place on the page. */
+ position?: number;
+ /** 1-based place among every eligible post, before page variety. */
+ eligibleRank?: number;
+}
+
+export interface RankResult {
+ served: Decision[];
+ leftOut: Decision[];
+ interests: Interest[];
+}
+
+export const pageSize = 12;
+const firstScreenSize = 8;
+const topicSimilarityFloor = 0.35;
+const sameHeadlineJaccard = 0.35;
+const proofFloor = 0.2;
+const dayMs = 86_400_000;
+/** The day the posts in the story were captured. */
+export const storyNow = Date.parse('2026-10-15T12:00:00Z');
+
+const stopWords = new Set([
+ 'the',
+ 'and',
+ 'for',
+ 'you',
+ 'your',
+ 'with',
+ 'how',
+ 'why',
+ 'this',
+ 'that',
+ 'are',
+ 'was',
+ 'not',
+ 'just',
+]);
+
+const titleTokens = (title = ''): Set =>
+ new Set(
+ title
+ .toLowerCase()
+ .split(/[^a-z0-9+#]+/)
+ .filter((word) => word.length >= 3 && !stopWords.has(word)),
+ );
+
+const jaccard = (a: Set, b: Set): number => {
+ const shared = [...a].filter((word) => b.has(word)).length;
+ const union = new Set([...a, ...b]).size;
+ return union ? shared / union : 0;
+};
+
+const tagSimilarity = (a: string[], b: string[]): number => {
+ if (!a.length || !b.length) {
+ return 0;
+ }
+ const shared = a.filter((tag) => b.includes(tag)).length;
+ return shared / Math.sqrt(a.length * b.length);
+};
+
+const reactionRate = (post: Post): number =>
+ (2 * (post.numUpvotes ?? 0) + 2 * (post.numComments ?? 0)) /
+ Math.max(1, post.analytics?.impressions ?? 1);
+
+const medianRates = new WeakMap();
+
+const getMedianRate = (pool: Post[]): number => {
+ const cached = medianRates.get(pool);
+ if (cached !== undefined) {
+ return cached;
+ }
+ const rates = pool.map(reactionRate).sort((a, b) => a - b);
+ const median = rates[Math.floor(rates.length / 2)] || 1;
+ medianRates.set(pool, median);
+ return median;
+};
+
+export const getMainTopic = (post: Post): string | undefined => post.tags?.[0];
+
+interface Context {
+ state: PersonalizationState;
+ interests: Map;
+ medianRate: number;
+ now: number;
+}
+
+const assess = (post: Post, context: Context): Decision => {
+ const { state, interests, medianRate, now } = context;
+ const { params } = state;
+ const tags = post.tags ?? [];
+ const sourceId = post.source?.id ?? '';
+ const authorId = post.author?.id;
+ const base = Math.max(0.02, reactionRate(post) / medianRate);
+ const earned = Math.max(0, base - 0.5);
+ const ageDays = Math.max(
+ 0,
+ (now - Date.parse(post.createdAt ?? '')) / dayMs || 0,
+ );
+ const impressions = state.impressions[post.id] ?? 0;
+ const matches = tags
+ .map((tag, index): Match | undefined => {
+ const interest = interests.get(tag);
+ if (!interest) {
+ return undefined;
+ }
+ const role = index === 0 ? TopicRole.Main : TopicRole.Side;
+ const roleWeight =
+ role === TopicRole.Main ? 1 : params['relevance.side_topic_weight'];
+ return {
+ tag,
+ origin: interest.origin,
+ role,
+ weight: interest.weight,
+ roleWeight,
+ contribution: interest.weight * roleWeight,
+ };
+ })
+ .filter((match): match is Match => !!match && match.roleWeight > 0)
+ .sort((a, b) => b.contribution - a.contribution);
+ const best = matches[0];
+ const isSourceFollowed = state.followedSources.includes(sourceId);
+ const isAuthorFollowed =
+ !!authorId && state.followedAuthors.includes(authorId);
+ const sourceFollow = isSourceFollowed
+ ? params['follows.source_multiplier']
+ : 1;
+ const authorFollow = isAuthorFollowed
+ ? params['follows.author_multiplier']
+ : 1;
+ let followedBy: FollowKind | undefined;
+ if (isSourceFollowed || isAuthorFollowed) {
+ followedBy =
+ sourceFollow >= authorFollow ? FollowKind.Source : FollowKind.Author;
+ }
+ const sourceMode = state.sourceControls[sourceId];
+ const factors: ScoreFactors = {
+ base,
+ match: 1 + (best?.contribution ?? 0),
+ follow:
+ Math.max(sourceFollow, authorFollow) *
+ (sourceMode === SourceMode.Demoted
+ ? params['interests.control_demote']
+ : 1),
+ age:
+ 0.5 **
+ (ageDays /
+ (params['age.half_life_days'] *
+ Math.min(params['age.max_stretch'], Math.max(1, base)))),
+ seen: params['history.seen_factor_per_show'] ** impressions,
+ };
+ const decision: Decision = {
+ post,
+ lane: Lane.Challenger,
+ followedBy,
+ sourceMode,
+ best,
+ matches,
+ ageDays,
+ impressions,
+ earned,
+ factors,
+ score:
+ factors.base * factors.match * factors.follow * factors.age * factors.seen,
+ };
+ const exclude = (exclusion: Exclusion, exclusionTag?: string): Decision => ({
+ ...decision,
+ exclusion,
+ exclusionTag,
+ });
+ const blockedTag = tags.find((tag) => state.blockedTags.includes(tag));
+
+ if (state.hidden.includes(post.id)) {
+ return exclude(Exclusion.Hidden);
+ }
+ if (state.blockedSources.includes(sourceId)) {
+ return exclude(Exclusion.BlockedSource);
+ }
+ if (authorId && state.blockedAuthors.includes(authorId)) {
+ return exclude(Exclusion.BlockedAuthor);
+ }
+ if (blockedTag) {
+ return exclude(Exclusion.BlockedTag, blockedTag);
+ }
+ if (ageDays > params['feed.max_candidate_age_days']) {
+ return exclude(Exclusion.TooOld);
+ }
+ if (impressions >= params['history.max_impressions_without_action']) {
+ return exclude(Exclusion.ShownTooOften);
+ }
+ if (!followedBy && !matches.length) {
+ const hasSideOnly =
+ params['relevance.side_topic_weight'] === 0 &&
+ tags.slice(1).some((tag) => interests.has(tag));
+ return exclude(
+ hasSideOnly ? Exclusion.SideTopicsOff : Exclusion.NoInterestOrFollow,
+ );
+ }
+ if (
+ !followedBy &&
+ !matches.some(({ role }) => role === TopicRole.Main) &&
+ earned <= proofFloor
+ ) {
+ return exclude(Exclusion.SideTopicUnproven);
+ }
+ return decision;
+};
+
+const createContext = (
+ pool: Post[],
+ state: PersonalizationState,
+ now: number,
+): Context & { allInterests: Interest[] } => {
+ const allInterests = buildInterests({
+ followedTags: state.followedTags,
+ blockedTags: state.blockedTags,
+ evidence: state.evidence,
+ controls: state.controls,
+ isLearning: state.isLearning,
+ params: state.params,
+ });
+
+ return {
+ state,
+ allInterests,
+ interests: new Map(
+ getActiveInterests(allInterests).map((interest) => [
+ interest.tag,
+ interest,
+ ]),
+ ),
+ medianRate: getMedianRate(pool),
+ now,
+ };
+};
+
+const fillableExclusions = [
+ Exclusion.NoInterestOrFollow,
+ Exclusion.SideTopicUnproven,
+ Exclusion.SideTopicsOff,
+];
+
+/** Why a post on screen would not come back, under the current state. */
+export const getExclusion = (
+ served: Decision,
+ pool: Post[],
+ state: PersonalizationState,
+ now = storyNow,
+): Decision | undefined => {
+ const decision = assess(served.post, createContext(pool, state, now));
+ if (served.lane === Lane.Popular) {
+ if (decision.exclusion && fillableExclusions.includes(decision.exclusion)) {
+ return state.isFillingWithPopular
+ ? undefined
+ : { ...decision, exclusion: Exclusion.PopularFillOff };
+ }
+ }
+ return decision.exclusion ? decision : undefined;
+};
+
+export const rankFeed = (
+ pool: Post[],
+ state: PersonalizationState,
+ now = storyNow,
+): RankResult => {
+ const context = createContext(pool, state, now);
+ const { params } = state;
+ const assessed = pool.map((post) => assess(post, context));
+ const eligible = assessed
+ .filter(({ exclusion }) => !exclusion)
+ .sort((a, b) => b.score - a.score)
+ .map((decision, index) => ({ ...decision, eligibleRank: index + 1 }));
+ const rotation = getActiveInterests(context.allInterests)
+ .filter(({ isBacked }) => isBacked)
+ .map(({ tag, weight }) => ({
+ tag,
+ evidence: Math.min(1, weight - params['interests.base_weight']),
+ }))
+ .filter(({ evidence }) => evidence > 0);
+ const maxEvidence = Math.max(0, ...rotation.map(({ evidence }) => evidence));
+ const evidenceByTag = new Map(
+ rotation.map(({ tag, evidence }) => [tag, evidence / maxEvidence]),
+ );
+ const tookTurn = new Set();
+ const sourceCounts = new Map();
+ const served: Decision[] = [];
+ let remaining = eligible;
+
+ const slotFor = (decision: Decision): SlotFactors => {
+ const { post } = decision;
+ const sourceCount = sourceCounts.get(post.source?.id ?? '') ?? 0;
+ const similarity = served.slice(-50).reduce((sum, picked) => {
+ const cos = tagSimilarity(post.tags ?? [], picked.post.tags ?? []);
+ return (
+ sum +
+ Math.min(
+ 1,
+ Math.max(0, (cos - topicSimilarityFloor) / (1 - topicSimilarityFloor)),
+ )
+ );
+ }, 0);
+ const tokens = titleTokens(post.title);
+ const isSameHeadline = served.some(
+ (picked) =>
+ jaccard(tokens, titleTokens(picked.post.title)) >= sameHeadlineJaccard,
+ );
+ const mainTopic = getMainTopic(post);
+ const evidence =
+ mainTopic && !tookTurn.has(mainTopic)
+ ? evidenceByTag.get(mainTopic)
+ : undefined;
+ let waiting = 1;
+ if (evidence !== undefined) {
+ const shownDays = state.tagShownDays[mainTopic ?? ''];
+ const wait =
+ shownDays === undefined
+ ? 1
+ : 1 - 0.5 ** (shownDays / params['diversity.waiting_half_life_days']);
+ waiting =
+ 1 +
+ params['diversity.waiting_interest_lift'] *
+ wait *
+ Math.max(evidence, Math.min(1, decision.factors.base));
+ }
+ return {
+ sourceCount,
+ sourceRepeat:
+ (1 - params['diversity.source_repeat_discount']) ** sourceCount,
+ similarAbove: similarity,
+ topicRepeat: (1 - params['diversity.topic_repeat_discount']) ** similarity,
+ sameHeadline: isSameHeadline
+ ? 1 - params['diversity.same_headline_discount']
+ : 1,
+ waiting,
+ waitingTag: waiting > 1 ? mainTopic : undefined,
+ };
+ };
+
+ while (served.length < pageSize && remaining.length) {
+ const scored = remaining.map((decision) => {
+ const slot = slotFor(decision);
+ return {
+ ...decision,
+ slot,
+ pickScore:
+ decision.score *
+ slot.sourceRepeat *
+ slot.topicRepeat *
+ slot.sameHeadline *
+ slot.waiting,
+ };
+ });
+ const next = scored.reduce((top, decision) =>
+ (decision.pickScore ?? 0) > (top.pickScore ?? 0) ? decision : top,
+ );
+ served.push({ ...next, position: served.length + 1 });
+ const sourceId = next.post.source?.id ?? '';
+ sourceCounts.set(sourceId, (sourceCounts.get(sourceId) ?? 0) + 1);
+ if (next.slot?.waitingTag) {
+ tookTurn.add(next.slot.waitingTag);
+ }
+ remaining = remaining.filter(({ post }) => post.id !== next.post.id);
+ }
+
+ // The challenger only fills what it can; the popular lane tops up the rest.
+ const fillable = assessed.filter(
+ ({ exclusion }) => exclusion && fillableExclusions.includes(exclusion),
+ );
+ const fill =
+ state.isFillingWithPopular && served.length < pageSize
+ ? [...fillable]
+ .sort(
+ (a, b) => b.factors.base * b.factors.age - a.factors.base * a.factors.age,
+ )
+ .slice(0, pageSize - served.length)
+ : [];
+ fill.forEach((decision) =>
+ served.push({
+ ...decision,
+ exclusion: undefined,
+ lane: Lane.Popular,
+ position: served.length + 1,
+ }),
+ );
+
+ const servedIds = new Set(served.map(({ post }) => post.id));
+ const leftOut = [
+ ...remaining.map((decision) => ({
+ ...decision,
+ exclusion: Exclusion.BelowThePage,
+ })),
+ ...assessed.filter(
+ ({ exclusion, post }) => exclusion && !servedIds.has(post.id),
+ ),
+ ];
+
+ return { served, leftOut, interests: context.allInterests };
+};
+
+export const firstScreen = (decisions: Decision[]): Decision[] =>
+ decisions.slice(0, firstScreenSize);
From eb4917c97c77b0c98b597989af26a9e3f71cdc3e Mon Sep 17 00:00:00 2001
From: Ido Shamun <1993245+idoshamun@users.noreply.github.com>
Date: Fri, 16 Oct 2026 22:06:40 +0300
Subject: [PATCH 2/7] feat(storybook): plainer, roomier Explain cards
Drop the reason chips from the card, the hover ring that pointed at
other cards sharing a topic, and the signal counts behind both. Rows
now say what a topic or source means to the reader (You follow it,
You've read 3 posts on it, Not one of your topics, You don't follow it)
and get more height and spacing. Reason and leave copy loses the policy
terms (side topic, fill-ins).
Story 3 now shows pressing less on a topic, since the chip popover it
opened is gone, and the two card states only the chips explained are
removed.
---
.../feed-explain/FeedExplain.stories.tsx | 38 +++++-----------
.../feed-explain/components/ExplainPanel.tsx | 44 +++++++------------
.../components/FeedExplainPage.tsx | 29 ++----------
.../components/FeedScreenContext.ts | 21 +--------
.../features/feed-explain/policy/describe.ts | 39 +++++++++-------
5 files changed, 54 insertions(+), 117 deletions(-)
diff --git a/packages/storybook/stories/features/feed-explain/FeedExplain.stories.tsx b/packages/storybook/stories/features/feed-explain/FeedExplain.stories.tsx
index 1a71b2916d..d59d4cd2d7 100644
--- a/packages/storybook/stories/features/feed-explain/FeedExplain.stories.tsx
+++ b/packages/storybook/stories/features/feed-explain/FeedExplain.stories.tsx
@@ -16,7 +16,6 @@ import {
import {
ControlSection,
FeedScreenContext,
- getSignalCounts,
} from './components/FeedScreenContext';
import { ExplainDetailsModal } from './components/ExplainDetails';
import {
@@ -89,18 +88,18 @@ export const ExplainOn: Story = {
render: () => ,
};
-export const ReasonControls: Story = {
- name: '3 · Reason controls',
+export const CardControls: Story = {
+ name: '3 · Showing less',
render: () => ,
play: async ({ canvasElement }) => {
await waitFor(() => {
- const chip = canvasElement.querySelector(
- 'ul[aria-label="What moved this post"] button',
+ const less = canvasElement.querySelector(
+ 'button[aria-label^="Less #"]',
);
- if (!chip) {
- throw new Error('Waiting for reason chips');
+ if (!less) {
+ throw new Error('Waiting for the topic rows');
}
- chip.click();
+ less.click();
});
},
};
@@ -170,16 +169,6 @@ const cardStates: CardState[] = [
note: 'Never followed; six stories in two weeks made it strong.',
id: 'MsFCKRbGk',
},
- {
- title: "A topic's turn",
- note: 'Docker had not been on the first screen lately, so it jumped ahead.',
- id: 'JKsEou23l',
- },
- {
- title: 'Variety pushed it down',
- note: 'The third Fireship post on the page, so it gives way.',
- id: '1XMQ9zlpA',
- },
{
title: 'Popular fill-in',
note: 'A new viewer with two topics; the popular lane tops up the page.',
@@ -243,8 +232,7 @@ const StateCardBody = ({
}): ReactElement => {
const store = usePersonalization();
const [details, setDetails] = useState(null);
- const counts = useMemo(() => getSignalCounts(store.served), [store.served]);
- const screen = useMemo(() => ({ counts, onOpenDetails: setDetails }), [counts]);
+ const screen = useMemo(() => ({ onOpenDetails: setDetails }), []);
const decision = store.served.find(({ post }) => post.id === id);
if (!decision) {
return
Not on the page.
;
@@ -311,12 +299,10 @@ export const CardStates: Story = {
Explain mode, card by card
- Each card keeps its source and title and says why it is here: one
- sentence for the reason, chips for what moved it up or down (tap
- one to see the math and change that part of the policy), and the
- post's topics and source with less, more and block. Full
- explanation opens the "Why am I seeing this?" view with
- the score breakdown.
+ Each card keeps its source and title and says why it is here in
+ one sentence, then lists the post's topics and source with
+ less, more and block. Full explanation opens the "Why am I
+ seeing this?" view with the score breakdown.
{getCardSignals(decision, rowLimit[variant]).map((signal) => (
{
- const { hoverKey } = usePersonalization();
- const { counts } = useFeedScreen();
const isLeaving = isExplain && !!leaving;
const isGrid = layout === FeedLayout.Grid;
const { post } = decision;
- const keys = [
- ...(post.tags ?? []).map((tag) => `topic:${tag}`),
- `source:${post.source?.id}`,
- ];
- // Only worth pointing at when the signal is on more than this card.
- const isHighlighted =
- isExplain &&
- !!hoverKey &&
- (counts[hoverKey] ?? 0) > 1 &&
- keys.includes(hoverKey);
// The real card stays underneath so the feed keeps every size and gap; the
// overlay covers it with the explanation.
return (
@@ -346,7 +328,7 @@ const FeedExplainScreen = ({
{isExplain && (
onOpenControls())}
@@ -385,10 +367,8 @@ const App = ({
() =>
store.served.find(({ post }) => post.id === initialDetailsId) ?? null,
);
- const counts = useMemo(() => getSignalCounts(store.served), [store.served]);
const screen = useMemo(
() => ({
- counts,
onOpenDetails: setDetails,
onOpenControls: (nextSection?: ControlSection) => {
setSection(nextSection);
@@ -396,14 +376,13 @@ const App = ({
window.scrollTo({ top: 0 });
},
}),
- [counts],
+ [],
);
const onToggleExplain = () => {
if (isExplain && store.pendingCount) {
store.refresh();
}
- store.setHoverKey(null);
setIsExplain(!isExplain);
};
diff --git a/packages/storybook/stories/features/feed-explain/components/FeedScreenContext.ts b/packages/storybook/stories/features/feed-explain/components/FeedScreenContext.ts
index 7d86e5d24e..42a4620d14 100644
--- a/packages/storybook/stories/features/feed-explain/components/FeedScreenContext.ts
+++ b/packages/storybook/stories/features/feed-explain/components/FeedScreenContext.ts
@@ -12,32 +12,13 @@ export enum ControlSection {
}
interface FeedScreenValue {
- /** How many posts on screen carry each signal key. */
- counts: Record;
/** Opens the full "Why am I seeing this?" explanation. */
onOpenDetails?: (decision: Decision) => void;
/** Opens the personalization controls, at a section. */
onOpenControls?: (section?: ControlSection) => void;
}
-export const FeedScreenContext = createContext({
- counts: {},
-});
+export const FeedScreenContext = createContext({});
export const useFeedScreen = (): FeedScreenValue =>
useContext(FeedScreenContext);
-
-export const getSignalCounts = (
- decisions: Decision[],
-): Record => {
- const counts: Record = {};
- decisions.forEach(({ post }) => {
- [
- ...(post.tags ?? []).map((tag) => `topic:${tag}`),
- ...(post.source?.id ? [`source:${post.source.id}`] : []),
- ].forEach((key) => {
- counts[key] = (counts[key] ?? 0) + 1;
- });
- });
- return counts;
-};
diff --git a/packages/storybook/stories/features/feed-explain/policy/describe.ts b/packages/storybook/stories/features/feed-explain/policy/describe.ts
index 85e12b7e94..2a44e6da0d 100644
--- a/packages/storybook/stories/features/feed-explain/policy/describe.ts
+++ b/packages/storybook/stories/features/feed-explain/policy/describe.ts
@@ -166,7 +166,7 @@ export const getFactorChips = (decision: Decision): FactorChip[] => {
const strengthPhrase: Partial> = {
[Strength.Strong]: 'and read about it often',
[Strength.Moderate]: 'and read about it sometimes',
- [Strength.Confirmed]: 'and opened a story on it',
+ [Strength.Confirmed]: 'and read a post on it',
};
export interface ReasonParts {
@@ -183,7 +183,7 @@ export const getReason = (
if (decision.lane === Lane.Popular) {
return {
lead: 'Popular this week.',
- tail: " Your interests didn't fill the page, so this tops it up.",
+ tail: " Your topics didn't have enough new posts, so we added it.",
};
}
const { best } = decision;
@@ -201,22 +201,27 @@ export const getReason = (
if (!best) {
return { lead: 'Picked for you', tail: '.' };
}
- const side = best.role === TopicRole.Side ? ', here a side topic' : '';
+ const name = `#${best.tag}`;
+ const isStrong = interest?.strength === Strength.Strong;
if (best.origin === InterestOrigin.Declared) {
const phrase = interest?.strength ? strengthPhrase[interest.strength] : '';
+ const follow = `${phrase ? ` ${phrase}` : ''}.`;
+ return best.role === TopicRole.Side
+ ? { lead: 'Partly about ', name, tail: `. You follow it${follow}` }
+ : { lead: 'You follow ', name, tail: follow };
+ }
+ if (best.role === TopicRole.Side) {
return {
- lead: 'You follow ',
- name: `#${best.tag}`,
- tail: `${phrase ? ` ${phrase}` : ''}${side}.`,
+ lead: 'Partly about ',
+ name,
+ tail: isStrong
+ ? ". You've read a lot about it."
+ : ". You've read a few posts on it.",
};
}
- return interest?.strength === Strength.Strong
- ? { lead: "You've read a lot about ", name: `#${best.tag}`, tail: `${side}.` }
- : {
- lead: "You've read a few posts about ",
- name: `#${best.tag}`,
- tail: `${side}.`,
- };
+ return isStrong
+ ? { lead: "You've read a lot about ", name, tail: '.' }
+ : { lead: "You've read a few posts about ", name, tail: '.' };
};
export const exclusionLabel: Record = {
@@ -254,18 +259,18 @@ export const getLeaveMessage = (
case Exclusion.ShownTooOften:
return `You scrolled past it ${now.impressions} times.`;
case Exclusion.SideTopicsOff:
- return 'You turned off side-topic matches.';
+ return 'You turned off posts that are only partly about your topics.';
case Exclusion.SideTopicUnproven:
- return 'It only matches a side topic and readers have not proven it yet.';
+ return "It's only partly about your topics and hasn't caught on with readers yet.";
case Exclusion.PopularFillOff:
- return 'You turned off popular fill-ins.';
+ return 'You turned off topping up your feed with popular posts.';
default: {
const tag = served.best?.tag;
if (tag && state.controls[tag]?.mode === TopicMode.Muted) {
return `You muted #${tag}.`;
}
if (tag && !state.isLearning) {
- return 'You stopped learning from reading.';
+ return 'You turned off learning from your reading.';
}
if (served.followedBy && !now.followedBy) {
return `You unfollowed ${sourceName}.`;
From 65857983146e8bde97fe0cee18a5ebdfeb9c0329 Mon Sep 17 00:00:00 2001
From: Ido Shamun <1993245+idoshamun@users.noreply.github.com>
Date: Fri, 16 Oct 2026 22:17:12 +0300
Subject: [PATCH 3/7] feat(storybook): follow, block and an exact value on
Explain card rows
Each topic and source row now has an icon-only follow toggle (green when
followed, label in the tooltip) and block, replacing less and more.
Every topic row shows how much it counts in the profile; tapping the
number opens the profile's 0 to 1.5 slider with a reset and a link to
the profile.
An exact value now puts a topic in the profile on its own, so a topic
you neither follow nor read about can be raised from 0. Its reason
reads "You set #tag to 0.81." Setting a card's main topic to 0 shows
the leave notice with Undo.
maxTopicWeight moves to profile.ts so the panel can share it. Story 3
opens the value popover, and the card states swap Showing more and
Muted for an exact value and a topic set to 0.
---
.../feed-explain/FeedExplain.stories.tsx | 35 ++--
.../feed-explain/components/ExplainPanel.tsx | 198 +++++++++++++-----
.../components/FeedExplainPage.tsx | 2 +-
.../features/feed-explain/policy/describe.ts | 13 ++
.../features/feed-explain/policy/profile.ts | 11 +-
5 files changed, 189 insertions(+), 70 deletions(-)
diff --git a/packages/storybook/stories/features/feed-explain/FeedExplain.stories.tsx b/packages/storybook/stories/features/feed-explain/FeedExplain.stories.tsx
index d59d4cd2d7..9aa8588c0e 100644
--- a/packages/storybook/stories/features/feed-explain/FeedExplain.stories.tsx
+++ b/packages/storybook/stories/features/feed-explain/FeedExplain.stories.tsx
@@ -7,7 +7,7 @@ import ExtensionProviders from '../../extension/_providers';
import { providerHandlers } from '../why-recommended/whyRecommended.mocks';
import type { Decision, PersonalizationState } from './policy/rank';
import { getExclusion } from './policy/rank';
-import { TopicMode } from './policy/profile';
+import type { TopicControl } from './policy/profile';
import { policyVersion } from './policy/params';
import {
PersonalizationProvider,
@@ -88,18 +88,18 @@ export const ExplainOn: Story = {
render: () => ,
};
-export const CardControls: Story = {
- name: '3 · Showing less',
+export const TopicValue: Story = {
+ name: '3 · Topic value',
render: () => ,
play: async ({ canvasElement }) => {
await waitFor(() => {
- const less = canvasElement.querySelector(
- 'button[aria-label^="Less #"]',
+ const value = canvasElement.querySelector(
+ 'button[aria-label^="How much #"]',
);
- if (!less) {
+ if (!value) {
throw new Error('Waiting for the topic rows');
}
- less.click();
+ value.click();
});
},
};
@@ -146,11 +146,11 @@ interface CardState {
layout?: FeedLayout;
}
-const withControl = (tag: string, mode: TopicMode) => (
+const withControl = (tag: string, control: TopicControl) => (
state: PersonalizationState,
): PersonalizationState => ({
...state,
- controls: { ...state.controls, [tag]: { mode } },
+ controls: { ...state.controls, [tag]: control },
});
const cardStates: CardState[] = [
@@ -176,16 +176,16 @@ const cardStates: CardState[] = [
persona: PersonaId.JustSignedUp,
},
{
- title: 'Showing more',
- note: 'More pressed on #rust. Its row turns green and it ranks higher next refresh.',
+ title: 'Set an exact value',
+ note: '#rust set to 1.20 from its number on the card. It ranks higher next refresh.',
id: 'BjYAz50EA',
- change: withControl('rust', TopicMode.Boosted),
+ change: withControl('rust', { weight: 1.2 }),
},
{
- title: 'Muted a learned topic',
- note: 'Reading about #career stops counting, so this card leaves.',
+ title: 'Set a topic to 0',
+ note: '#career stops counting, so this card leaves. Undo puts the learned value back.',
id: 'MsFCKRbGk',
- change: withControl('career', TopicMode.Muted),
+ change: withControl('career', { weight: 0 }),
},
{
title: 'Blocked a source',
@@ -301,8 +301,9 @@ export const CardStates: Story = {
Each card keeps its source and title and says why it is here in
one sentence, then lists the post's topics and source with
- less, more and block. Full explanation opens the "Why am I
- seeing this?" view with the score breakdown.
+ follow and block. A topic in your profile shows how much it
+ counts; tap the number to set it. Full explanation opens the
+ "Why am I seeing this?" view with the score breakdown.
diff --git a/packages/storybook/stories/features/feed-explain/components/ExplainPanel.tsx b/packages/storybook/stories/features/feed-explain/components/ExplainPanel.tsx
index 0f5425873d..cf20833198 100644
--- a/packages/storybook/stories/features/feed-explain/components/ExplainPanel.tsx
+++ b/packages/storybook/stories/features/feed-explain/components/ExplainPanel.tsx
@@ -1,5 +1,5 @@
import type { ReactElement } from 'react';
-import React from 'react';
+import React, { useState } from 'react';
import classNames from 'classnames';
import {
Button,
@@ -12,16 +12,17 @@ import { ArrowIcon } from '@dailydotdev/shared/src/components/icons/Arrow';
import { BlockIcon } from '@dailydotdev/shared/src/components/icons/Block';
import { EyeCancelIcon } from '@dailydotdev/shared/src/components/icons/EyeCancel';
import { HashtagIcon } from '@dailydotdev/shared/src/components/icons/Hashtag';
-import { MinusIcon } from '@dailydotdev/shared/src/components/icons/Minus';
import { PlusIcon } from '@dailydotdev/shared/src/components/icons/Plus';
import { UndoIcon } from '@dailydotdev/shared/src/components/icons/Undo';
import { IconSize } from '@dailydotdev/shared/src/components/Icon';
+import { Slider } from '@dailydotdev/shared/src/components/fields/Slider';
import { SourceAvatar } from '@dailydotdev/shared/src/components/profile/source/SourceAvatar';
import { ProfileImageSize } from '@dailydotdev/shared/src/components/ProfilePicture';
import type { Source } from '@dailydotdev/shared/src/graphql/sources';
import type { Decision } from '../policy/rank';
import { Exclusion, FollowKind, SourceMode } from '../policy/rank';
-import { InterestOrigin, TopicMode } from '../policy/profile';
+import type { Interest } from '../policy/profile';
+import { maxTopicWeight, TopicMode } from '../policy/profile';
import {
getCategory,
getCategoryDot,
@@ -30,8 +31,9 @@ import {
} from '../policy/describe';
import { defaultParams } from '../policy/params';
import { usePersonalization } from './PersonalizationContext';
-import { useFeedScreen } from './FeedScreenContext';
+import { ControlSection, useFeedScreen } from './FeedScreenContext';
import { Strong } from './controls';
+import { AnchoredPopover } from './AnchoredPopover';
export enum ExplainVariant {
Grid = 'grid',
@@ -131,6 +133,106 @@ const SignalGlyph = ({ signal }: { signal: CardSignal }): ReactElement =>
);
+const TopicValue = ({
+ tag,
+ interest,
+}: {
+ tag: string;
+ /** Unset when the topic is not in the profile yet. */
+ interest?: Interest;
+}): ReactElement => {
+ const store = usePersonalization();
+ const { onOpenControls } = useFeedScreen();
+ const [anchor, setAnchor] = useState(null);
+ const weight = interest?.weight ?? 0;
+ const isSet = interest?.control?.weight !== undefined;
+ const label = `How much #${tag} counts`;
+ const valueClassName = isSet
+ ? 'text-accent-cabbage-default'
+ : 'text-text-secondary';
+
+ return (
+ <>
+
+ {anchor && (
+ setAnchor(null)}
+ >
+
+
+ {label}
+
+
+ {weight.toFixed(2)}
+
+
+ store.setTopicWeight(tag, value)}
+ />
+
+ {interest
+ ? `Higher brings more posts about #${tag}. At 0 it stops counting.`
+ : `Not in your profile yet. Raise it to get posts about #${tag}.`}
+
{isExplain && (
onOpenControls())}
isCompact={isList}
diff --git a/packages/storybook/stories/features/feed-explain/components/PersonalizationContext.tsx b/packages/storybook/stories/features/feed-explain/components/PersonalizationContext.tsx
index 42307e5dde..26af5bb92b 100644
--- a/packages/storybook/stories/features/feed-explain/components/PersonalizationContext.tsx
+++ b/packages/storybook/stories/features/feed-explain/components/PersonalizationContext.tsx
@@ -341,7 +341,7 @@ export const PersonalizationProvider = ({
),
setHidden: (postId, isHidden) =>
apply(
- isHidden ? 'Got it, fewer posts like this one.' : 'Post is back in your feed.',
+ isHidden ? 'Got it, fewer like this.' : 'Post is back in your feed.',
(current) => ({
...current,
hidden: toggle(current.hidden, postId, isHidden),
From 77505fa75dd52f0b6b041a1594b84be18312be62 Mon Sep 17 00:00:00 2001
From: Ido Shamun <1993245+idoshamun@users.noreply.github.com>
Date: Fri, 16 Oct 2026 22:53:14 +0300
Subject: [PATCH 5/7] feat(storybook): show card topic values as boosts
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Match the Personalization page: a topic's value reads as the boost it
gives a post (×1.36), or Off at 0, in the row tag and the popover. The
popover is titled "Boost for #tag", steps by 0.05 like the page, and
resets with "Back to automatic (×1.36)". Reasons and leave notices
follow ("You gave #nginx a ×1.81 boost.", "You turned #career off.").
formatBoost lives in describe.ts until the page's ParamFormat.Boost
lands on this branch.
---
.../feed-explain/FeedExplain.stories.tsx | 18 ++++++------
.../feed-explain/components/ExplainPanel.tsx | 28 ++++++++++---------
.../features/feed-explain/policy/describe.ts | 12 +++++---
3 files changed, 32 insertions(+), 26 deletions(-)
diff --git a/packages/storybook/stories/features/feed-explain/FeedExplain.stories.tsx b/packages/storybook/stories/features/feed-explain/FeedExplain.stories.tsx
index 9aa8588c0e..d66d5a03ff 100644
--- a/packages/storybook/stories/features/feed-explain/FeedExplain.stories.tsx
+++ b/packages/storybook/stories/features/feed-explain/FeedExplain.stories.tsx
@@ -88,13 +88,13 @@ export const ExplainOn: Story = {
render: () => ,
};
-export const TopicValue: Story = {
- name: '3 · Topic value',
+export const TopicBoost: Story = {
+ name: '3 · Topic boost',
render: () => ,
play: async ({ canvasElement }) => {
await waitFor(() => {
const value = canvasElement.querySelector(
- 'button[aria-label^="How much #"]',
+ 'button[aria-label^="Boost for #"]',
);
if (!value) {
throw new Error('Waiting for the topic rows');
@@ -176,14 +176,14 @@ const cardStates: CardState[] = [
persona: PersonaId.JustSignedUp,
},
{
- title: 'Set an exact value',
- note: '#rust set to 1.20 from its number on the card. It ranks higher next refresh.',
+ title: 'Boosted a topic',
+ note: '#rust raised to ×2.20 from its boost on the card. It ranks higher next refresh.',
id: 'BjYAz50EA',
change: withControl('rust', { weight: 1.2 }),
},
{
- title: 'Set a topic to 0',
- note: '#career stops counting, so this card leaves. Undo puts the learned value back.',
+ title: 'Turned a topic off',
+ note: '#career dragged all the way left, so this card leaves. Undo brings it back.',
id: 'MsFCKRbGk',
change: withControl('career', { weight: 0 }),
},
@@ -301,8 +301,8 @@ export const CardStates: Story = {
Each card keeps its source and title and says why it is here in
one sentence, then lists the post's topics and source with
- follow and block. A topic in your profile shows how much it
- counts; tap the number to set it. Full explanation opens the
+ follow and block. Each topic shows its boost; tap it to change
+ how much it counts. Full explanation opens the
"Why am I seeing this?" view with the score breakdown.
{interest
- ? `Higher brings more posts about #${tag}. At 0 it stops counting.`
- : `Not in your profile yet. Raise it to get posts about #${tag}.`}
+ ? `Posts about #${tag} get this boost. All the way left turns it off.`
+ : `Not in your profile yet. Drag right to get posts about #${tag}.`}