diff --git a/packages/shared/src/components/cards/ad/AdCard.spec.tsx b/packages/shared/src/components/cards/ad/AdCard.spec.tsx index d543379dfe4..b1f9f14d801 100644 --- a/packages/shared/src/components/cards/ad/AdCard.spec.tsx +++ b/packages/shared/src/components/cards/ad/AdCard.spec.tsx @@ -11,6 +11,23 @@ import type { AdCardProps } from './common/common'; import { TestBootProvider } from '../../../../__tests__/helpers/boot'; import { ActiveFeedContext } from '../../../contexts'; import { businessWebsiteUrl } from '../../../lib/constants'; +import { useFeature } from '../../GrowthBookProvider'; +import { AdLabelVariant, featureAdLabel } from '../../../lib/featureManagement'; + +jest.mock('../../GrowthBookProvider', () => ({ + ...(jest.requireActual('../../GrowthBookProvider') as Record< + string, + unknown + >), + useFeature: jest.fn(), +})); + +const mockUseFeature = jest.mocked(useFeature); + +const mockAdLabelVariant = (variant: AdLabelVariant): void => { + mockUseFeature.mockImplementation(((feature: { id: string }) => + feature?.id === featureAdLabel.id ? variant : undefined) as never); +}; const defaultProps: AdCardProps = { ad, @@ -21,6 +38,8 @@ const defaultProps: AdCardProps = { beforeEach(() => { jest.clearAllMocks(); + // clearAllMocks keeps implementations, so reset the arm for every test. + mockAdLabelVariant(AdLabelVariant.Control); }); const renderListComponent = ( @@ -232,6 +251,58 @@ it('should render Promoted attribution in signal variant', async () => { expect(await screen.findByText(promotedMatcher)).toBeInTheDocument(); }); +const adLabelMatcher = (_: string, element?: Element | null): boolean => + getNormalizedText(element) === 'Ad'; + +describe('ad_label experiment', () => { + const referralAd = { ...ad, referralLink: 'https://example.com/referral' }; + + it('should replace the advertiser attribution with "Ad" on the grid card', async () => { + mockAdLabelVariant(AdLabelVariant.Ad); + renderGridComponent({ ad: referralAd }); + + expect(await screen.findByText(adLabelMatcher)).toBeInTheDocument(); + expect(screen.queryByText(promotedMatcher)).not.toBeInTheDocument(); + }); + + it('should drop the advertiser referral link with the "Ad" label', async () => { + mockAdLabelVariant(AdLabelVariant.Ad); + renderListComponent({ ad: referralAd }); + + const attribution = await screen.findByText(adLabelMatcher); + expect(attribution.closest('a')).toBeNull(); + }); + + it('should keep the advertise link on the ad arm', async () => { + mockAdLabelVariant(AdLabelVariant.Ad); + renderGridComponent({ ad: referralAd }); + + expect( + await screen.findByRole('link', { name: 'Advertise here' }), + ).toBeInTheDocument(); + }); + + it('should remove the advertise link on the ad_only arm', async () => { + mockAdLabelVariant(AdLabelVariant.AdOnly); + renderListComponent({ ad: referralAd }); + + await screen.findByText(adLabelMatcher); + expect( + screen.queryByRole('link', { name: 'Advertise here' }), + ).not.toBeInTheDocument(); + }); + + it('should keep the control wording when the flag is off', async () => { + mockAdLabelVariant(AdLabelVariant.Control); + renderGridComponent({ ad: referralAd }); + + expect(await screen.findByText(promotedMatcher)).toBeInTheDocument(); + expect( + screen.getByRole('link', { name: 'Advertise here' }), + ).toBeInTheDocument(); + }); +}); + it('should render advertise link on list ad', () => { renderListComponent(); diff --git a/packages/shared/src/components/cards/ad/AdGrid.tsx b/packages/shared/src/components/cards/ad/AdGrid.tsx index 5268bcb5b54..ba092630753 100644 --- a/packages/shared/src/components/cards/ad/AdGrid.tsx +++ b/packages/shared/src/components/cards/ad/AdGrid.tsx @@ -29,6 +29,7 @@ import { adImprovementsV3Feature } from '../../../lib/featureManagement'; import { TargetId } from '../../../lib/log'; import { AdvertiseLink } from './common/AdvertiseLink'; import { useFeedCardGlassActions } from '../../../hooks/useFeedCardGlassActions'; +import { useAdLabel } from '../../../features/monetization/useAdLabel'; export const AdGrid = forwardRef(function AdGrid( { ad, onLinkClick, domProps, index, feedIndex }, @@ -37,6 +38,7 @@ export const AdGrid = forwardRef(function AdGrid( const { isPlus } = usePlusSubscription(); const adImprovementsV3 = useFeature(adImprovementsV3Feature); const useGlass = useFeedCardGlassActions(); + const { showAdvertiseLink } = useAdLabel(); const { ref } = useAutoRotatingAds( ad, index, @@ -80,11 +82,13 @@ export const AdGrid = forwardRef(function AdGrid( {ad.callToAction} )} - + {showAdvertiseLink && ( + + )}
{!isPlus && ( (function AdCard( ): ReactElement { const { isPlus } = usePlusSubscription(); const adImprovementsV3 = useFeature(adImprovementsV3Feature); + const { showAdvertiseLink } = useAdLabel(); const { ref } = useAutoRotatingAds( ad, index, @@ -104,11 +106,13 @@ export const AdList = forwardRef(function AdCard( {ad.callToAction} )} - + {showAdvertiseLink && ( + + )}
{!isPlus && ( { + const variant = useFeature(featureAdLabel); + + return { + hideAdvertiser: !!variant && variant !== AdLabelVariant.Control, + showAdvertiseLink: variant !== AdLabelVariant.AdOnly, + }; +}; diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index a7d61e52f0f..ccb84f64001 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -155,6 +155,21 @@ export const boostSettingsFeature = new Feature('boost_settings', { export const adImprovementsV3Feature = new Feature('ad_improvements_v3', false); +// Experiment: ad disclosure wording. Control names the advertiser ("Promoted by +// Vercel"); the treatments drop the advertiser and disclose with a plain "Ad", +// with the strictest arm also removing the "Advertise here" self-promo so the +// card carries a single disclosure. Measured on ad CTR (see `adLogEvent`). +// Default MUST stay Control — GrowthBook ramps the arms. +export enum AdLabelVariant { + Control = 'control', + Ad = 'ad', + AdOnly = 'ad_only', +} +export const featureAdLabel = new Feature( + 'ad_label', + AdLabelVariant.Control, +); + export const featureYearInReview = new Feature('year_in_review_2025', false); export const featureProfileCompletionIndicator = new Feature( diff --git a/packages/storybook/.storybook/main.ts b/packages/storybook/.storybook/main.ts index cb956bc7f7a..1f6ac08af5c 100644 --- a/packages/storybook/.storybook/main.ts +++ b/packages/storybook/.storybook/main.ts @@ -25,6 +25,10 @@ const config: StorybookConfig = { __dirname, '../mock/GrowthBookProvider.tsx', ); + const ConditionalFeatureMockPath = path.resolve( + __dirname, + '../mock/hooks/useConditionalFeature.ts', + ); return mergeConfig(config, { core: { @@ -45,7 +49,18 @@ const config: StorybookConfig = { ), 'next/router': path.resolve(__dirname, '../mock/next-router.ts'), './GrowthBookProvider': GrowthBookMockPath, + '../GrowthBookProvider': GrowthBookMockPath, '../../GrowthBookProvider': GrowthBookMockPath, + '../../../GrowthBookProvider': GrowthBookMockPath, + '../components/GrowthBookProvider': GrowthBookMockPath, + '../../components/GrowthBookProvider': GrowthBookMockPath, + '../../../components/GrowthBookProvider': GrowthBookMockPath, + './useConditionalFeature': ConditionalFeatureMockPath, + '../useConditionalFeature': ConditionalFeatureMockPath, + '../hooks/useConditionalFeature': ConditionalFeatureMockPath, + '../../hooks/useConditionalFeature': ConditionalFeatureMockPath, + '../../../hooks/useConditionalFeature': ConditionalFeatureMockPath, + '../../../../hooks/useConditionalFeature': ConditionalFeatureMockPath, '@dailydotdev/shared/src/lib/boot': path.resolve( __dirname, '../mock/boot.ts', diff --git a/packages/storybook/mock/GrowthBookProvider.tsx b/packages/storybook/mock/GrowthBookProvider.tsx index 700e1dbf5f1..27af2d990a9 100644 --- a/packages/storybook/mock/GrowthBookProvider.tsx +++ b/packages/storybook/mock/GrowthBookProvider.tsx @@ -1,8 +1,38 @@ +import React, { createContext, useContext } from 'react'; +import type { PropsWithChildren, ReactElement } from 'react'; import { fn } from 'storybook/test'; -import * as actual from '@dailydotdev/shared/src/components/GrowthBookProvider'; -export const useFeature = fn(actual.useFeature) - .mockName('useFeature') - .mockReturnValue('control'); +type FeatureLike = { id: string }; +type FeatureOverrideValues = Record; + +const FeatureOverridesContext = createContext({}); + +/** + * Pins specific flags for one subtree, so a single story can render several + * experiment arms next to each other. Flags left out keep the `control` value + * every other story relies on. + */ +export const FeatureOverrides = ({ + values, + children, +}: PropsWithChildren<{ values: FeatureOverrideValues }>): ReactElement => ( + + {children} + +); + +/** Returns the story-pinned value for a flag, or undefined when unpinned. */ +export const useFeatureOverride = (feature?: FeatureLike): unknown => { + const overrides = useContext(FeatureOverridesContext); + + return feature && feature.id in overrides ? overrides[feature.id] : undefined; +}; + +export const useFeature = fn((feature?: FeatureLike) => { + // eslint-disable-next-line react-hooks/rules-of-hooks -- runs during render + const override = useFeatureOverride(feature); + + return override === undefined ? 'control' : override; +}).mockName('useFeature'); export * from '@dailydotdev/shared/src/components/GrowthBookProvider'; diff --git a/packages/storybook/mock/hooks/useConditionalFeature.ts b/packages/storybook/mock/hooks/useConditionalFeature.ts index 27eb3c1d200..0f677aaf389 100644 --- a/packages/storybook/mock/hooks/useConditionalFeature.ts +++ b/packages/storybook/mock/hooks/useConditionalFeature.ts @@ -1,6 +1,22 @@ -export const useConditionalFeature = () => { +import { useFeatureOverride } from '../GrowthBookProvider'; + +type FeatureLike = { id: string; defaultValue: unknown }; + +/** + * Mirrors the real hook, minus GrowthBook: a story-pinned value when one is + * provided (see `FeatureOverrides`), otherwise the flag's own default — which + * is what the unmocked hook resolves to without a GrowthBook instance. + */ +export const useConditionalFeature = ({ + feature, +}: { + feature: FeatureLike; + shouldEvaluate?: boolean; +}): { value: unknown; isLoading: boolean } => { + const override = useFeatureOverride(feature); + return { - value: 'control', + value: override === undefined ? feature?.defaultValue : override, isLoading: false, }; }; diff --git a/packages/storybook/stories/experiments/AdLabel.stories.tsx b/packages/storybook/stories/experiments/AdLabel.stories.tsx new file mode 100644 index 00000000000..454346b973f --- /dev/null +++ b/packages/storybook/stories/experiments/AdLabel.stories.tsx @@ -0,0 +1,427 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { AdGrid } from '@dailydotdev/shared/src/components/cards/ad/AdGrid'; +import { AdList } from '@dailydotdev/shared/src/components/cards/ad/AdList'; +import { SignalAdList } from '@dailydotdev/shared/src/components/cards/ad/SignalAdList'; +import { PostSidebarAdWidget } from '@dailydotdev/shared/src/components/post/PostSidebarAdWidget'; +import { AdAsComment } from '@dailydotdev/shared/src/components/comments/AdAsComment'; +import type { Ad } from '@dailydotdev/shared/src/graphql/posts'; +import { + adImprovementsV3Feature, + featureFeedCardGlassActions, +} from '@dailydotdev/shared/src/lib/featureManagement'; +import { + AdProviders, + Arm, + arms, + baseAd, + carbonAd, + COMMENT_POST_ID, + longCopyAd, + noCtaAd, + noop, + noReferralAd, + Page, + Section, + SIDEBAR_POST_ID, + taggedAd, + type ArmConfig, +} from './adLabel.mocks'; + +// --------------------------------------------------------------------------- +// Experiment: `ad_label` +// +// Control names the advertiser under the ad ("Promoted by Vercel"). The +// treatments hide the advertiser and disclose with a plain "Ad", to see how +// disclosure wording moves ad CTR. Every ad surface and card use case is laid +// out here, control first, so the arms can be compared side by side. +// --------------------------------------------------------------------------- + +const feedProps = { index: 0, feedIndex: 0, onLinkClick: noop }; + +const ArmRow = ({ + render, + className, + overrides, +}: { + render: (arm: ArmConfig) => ReactElement; + className?: string; + overrides?: Record; +}): ReactElement => ( +
+ {arms.map((arm) => ( + + {render(arm)} + + ))} +
+); + +const meta: Meta = { + title: 'Experiments/Ad label', + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: + 'A/B test `ad_label`: hide the advertiser on ad cards and disclose with a plain "Ad" instead of "Promoted by {advertiser}". Primary metric is ad CTR (clicks / impressions on `target_type: "ad"` events).', + }, + }, + }, +}; + +export default meta; + +export const Brief: StoryObj = { + name: '0. Experiment brief', + render: () => ( + +
+

Ad label experiment

+

+ Ad cards currently name the advertiser under the creative + ("Promoted by Vercel") and carry an "Advertise + here" self-promo. The hypothesis is that the advertiser name + reads as a disclosure of a paid placement twice over, and that a + single neutral "Ad" label lets the creative do the work — + moving CTR. +

+
+ Flag + + ad_label (string) — default: control + +
+
+ Arms +
    + {arms.map((arm) => ( +
  • + {arm.variant} — {arm.summary} +
  • + ))} +
+
+
+ Measurement +

+ All arms already emit the ad events built by adLogEvent{' '} + — impression and click with{' '} + target_type: "ad" and{' '} + target_id: ad.source. CTR = ad clicks / ad impressions, + split by experiment arm. Guardrails: revenue per mille, and the{' '} + advertise here cta click rate, which variant B removes + from the card entirely. +

+
+
+ Coverage today +
    +
  • + Feed grid + list cards — label swap and the "Advertise + here" removal. +
  • +
  • + Signal card and post sidebar widget — label swap only; both still + print the company name elsewhere on the card. +
  • +
  • + Ad as comment — not covered; it builds its own + attribution string. +
  • +
  • + The advertiser logo stays in every arm. Dropping it too would be a + stronger "de-brand the ad" test. +
  • +
+
+
+
+ ), +}; + +export const FeedGrid: StoryObj = { + name: '1. Feed card — grid', + render: () => ( + + +
+ } + /> +
+
+ } + /> +
+
+
+ ), +}; + +export const FeedList: StoryObj = { + name: '2. Feed card — list', + render: () => ( + + +
+ } + /> +
+
+
+ ), +}; + +export const SignalCard: StoryObj = { + name: '3. Signal feed card', + render: () => ( + + +
+ Advertiser still named. This card prints the + company next to the avatar, so the treatments turn the line into + "Vercel · Ad". Hiding the advertiser here needs the name + and avatar handled too — a product decision for the rollout. + + } + > + } + /> +
+
+
+ ), +}; + +export const SidebarWidget: StoryObj = { + name: '4. Post sidebar widget', + render: () => ( + + +
+ Advertiser still named. The company sits on its + own bold line above the attribution. The "Advertise + here" removal is scoped to the feed cards, so the link stays + here in every arm. + + } + > + } + /> +
+
+ ( + + )} + /> +
+
+
+ ), +}; + +export const AdComment: StoryObj = { + name: '5. Ad as comment', + render: () => ( + + +
+ Not covered by the flag today. This component + builds its own "Promoted by {'{advertiser}'}" string + instead of using the shared AdAttribution, so all + three arms look identical. Moving it onto the shared component is + part of the product implementation. + + } + > + } + /> +
+
+
+ ), +}; + +const useCases: Array<{ + title: string; + description: string; + ad: Ad; + overrides?: Record; + isPlus?: boolean; +}> = [ + { + title: 'No call to action', + description: + 'Without `callToAction` the row starts with the advertise link — so variant B leaves only "Remove".', + ad: noCtaAd, + }, + { + title: 'No referral link', + description: + 'Control degrades to a bare "Promoted" (no advertiser to name), which makes it the closest arm to the treatments.', + ad: noReferralAd, + }, + { + title: 'Network creative (Carbon)', + description: + 'Carbon and EthicalAds creatives render contained over a blurred copy of themselves.', + ad: carbonAd, + }, + { + title: 'Long copy', + description: 'A description long enough to push the attribution down.', + ad: longCopyAd, + }, + { + title: 'Matching tags (ad_improvements_v3)', + description: + 'With `ad_improvements_v3` on, matched tags render between the copy and the attribution.', + ad: taggedAd, + overrides: { [adImprovementsV3Feature.id]: true }, + }, + { + title: 'Plus subscriber', + description: 'Plus members never see the "Remove" upsell.', + ad: baseAd, + isPlus: true, + }, +]; + +export const UseCases: StoryObj = { + name: '6. Use cases', + render: () => ( + + {useCases.map((useCase) => ( + +
+ } + /> +
+
+ ))} +
+ ), +}; + +export const ControlOnly: StoryObj = { + name: 'Reference — control, every surface', + render: () => ( + + +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+
+ ), +}; + +export const Treatment: StoryObj = { + name: 'Reference — variant B, every surface', + render: () => ( + + +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+
+
+ ), +}; diff --git a/packages/storybook/stories/experiments/adLabel.mocks.tsx b/packages/storybook/stories/experiments/adLabel.mocks.tsx new file mode 100644 index 00000000000..1c1f566c742 --- /dev/null +++ b/packages/storybook/stories/experiments/adLabel.mocks.tsx @@ -0,0 +1,277 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { useMemo } from 'react'; +import classNames from 'classnames'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { AuthContextProvider } from '@dailydotdev/shared/src/contexts/AuthContext'; +import { getLogContextStatic } from '@dailydotdev/shared/src/contexts/LogContext'; +import { ActiveFeedContext } from '@dailydotdev/shared/src/contexts'; +import SettingsContext from '@dailydotdev/shared/src/contexts/SettingsContext'; +import { + generateQueryKey, + RequestKey, +} from '@dailydotdev/shared/src/lib/query'; +import type { Ad } from '@dailydotdev/shared/src/graphql/posts'; +import { + adImprovementsV3Feature, + AdLabelVariant, + featureAdLabel, + featureAutorotateAds, + featureFeedCardGlassActions, +} from '@dailydotdev/shared/src/lib/featureManagement'; +import { FeatureOverrides } from '../../mock/GrowthBookProvider'; + +export const noop = (): void => undefined; + +// `providers` is what makes AuthContext treat this as a signed-in user. +const user = { id: 'sb-user', name: 'Dev Dana', providers: ['github'] }; + +// Feed cards read layout settings through SettingsContext, which defaults to +// null outside the app shell. +const settings = { + insaneMode: false, + spaciness: 'roomy', + loadedSettings: true, + openNewTab: false, +}; + +/** Both ad widgets read their ad from the query cache, keyed by post id. */ +export const SIDEBAR_POST_ID = 'sb-post-sidebar'; +export const COMMENT_POST_ID = 'sb-post-comment'; + +// --------------------------------------------------------------------------- +// Ad fixtures — one per use case a reviewer needs to see. +// --------------------------------------------------------------------------- + +export const baseAd: Ad = { + company: 'Vercel', + source: 'Vercel', + tagLine: 'Deploy without the ops.', + description: + 'Ship your Next.js app in seconds. Zero-config deploys, preview URLs on every push.', + image: + 'https://media.daily.dev/image/upload/v1675852969/squads/c0457b66-e89b-4fc0-b06d-48f920c7caa2.jpg', + link: 'https://vercel.com', + referralLink: 'https://vercel.com', + companyLogo: 'https://svgl.app/library/vercel.svg', + callToAction: 'Start deploying', + adDomain: 'vercel.com', + providerId: 'sb-provider', +}; + +export const noCtaAd: Ad = { ...baseAd, callToAction: undefined }; + +/** No referral link — control says a bare "Promoted", with no advertiser. */ +export const noReferralAd: Ad = { ...baseAd, referralLink: undefined }; + +/** Carbon and EthicalAds creatives render contained over a blurred backdrop. */ +export const carbonAd: Ad = { + ...baseAd, + company: 'Carbon', + source: 'Carbon', + companyLogo: undefined, + description: + 'A network-served creative that keeps its original aspect ratio.', +}; + +export const longCopyAd: Ad = { + ...baseAd, + description: + 'Observability for teams that ship daily: distributed tracing, log search, RUM and synthetic checks in one place, with alerts that route to the on-call engineer who owns the service.', +}; + +export const taggedAd: Ad = { + ...baseAd, + matchingTags: ['nextjs', 'react', 'devops', 'webdev'], +}; + +// --------------------------------------------------------------------------- +// Providers +// --------------------------------------------------------------------------- + +interface AdProvidersProps { + children: ReactNode; + /** Plus subscribers never see the "Remove" upsell. */ + isPlus?: boolean; + /** Ad served to `PostSidebarAdWidget` / `AdAsComment` via the query cache. */ + widgetAd?: Ad; +} + +export const AdProviders = ({ + children, + isPlus = false, + widgetAd = baseAd, +}: AdProvidersProps): ReactElement => { + const LogContext = getLogContextStatic(); + const queryClient = useMemo(() => { + const client = new QueryClient({ + defaultOptions: { + queries: { retry: false, refetchOnWindowFocus: false }, + }, + }); + + // Seeded for both the signed-in and anonymous key, so the widgets read + // their ad from the cache instead of hitting the (unmocked) ad server. + [user, undefined].forEach((keyUser) => { + client.setQueryData( + generateQueryKey( + RequestKey.Ads, + keyUser, + SIDEBAR_POST_ID, + 'post-sidebar', + ), + widgetAd, + ); + client.setQueryData( + generateQueryKey(RequestKey.Ads, keyUser, COMMENT_POST_ID), + widgetAd, + ); + }); + + return client; + }, [widgetAd]); + + return ( + + ''} + updateUser={noop as never} + refetchBoot={noop as never} + visit={{ visitId: 'sb', sessionId: 'sb' } as never} + accessToken={null as never} + squads={[]} + feeds={undefined} + geo={{} as never} + isAndroidApp={false} + > + + + + {children} + + + + + + ); +}; + +// --------------------------------------------------------------------------- +// Experiment arms +// --------------------------------------------------------------------------- + +export interface ArmConfig { + variant: AdLabelVariant; + name: string; + summary: string; +} + +export const arms: ArmConfig[] = [ + { + variant: AdLabelVariant.Control, + name: 'Control', + summary: '"Promoted by {advertiser}" + "Advertise here"', + }, + { + variant: AdLabelVariant.Ad, + name: 'Variant A — ad', + summary: 'Advertiser hidden, disclosed as "Ad". "Advertise here" stays.', + }, + { + variant: AdLabelVariant.AdOnly, + name: 'Variant B — ad_only', + summary: 'Advertiser hidden, disclosed as "Ad". "Advertise here" removed.', + }, +]; + +// `control` is the blanket mock value for every flag, which would leave +// autorotation on a NaN timer and force the v3 tag row on. Pin the flags that +// change the ad card to their production defaults, so only `ad_label` differs. +const baseOverrides: Record = { + [featureAutorotateAds.id]: 0, + [adImprovementsV3Feature.id]: false, + [featureFeedCardGlassActions.id]: false, +}; + +interface ArmProps { + arm: ArmConfig; + className?: string; + /** Extra flags to pin for this column, e.g. glass actions or v3 tags. */ + overrides?: Record; + children: ReactNode; +} + +export const Arm = ({ + arm, + className, + overrides, + children, +}: ArmProps): ReactElement => ( +
+
+ {arm.name} + + ad_label = {arm.variant} + + {arm.summary} +
+ + {children} + +
+); + +interface SectionProps { + title: string; + description?: ReactNode; + note?: ReactNode; + children: ReactNode; +} + +export const Section = ({ + title, + description, + note, + children, +}: SectionProps): ReactElement => ( +
+
+

{title}

+ {description && ( +

+ {description} +

+ )} + {note && ( +

+ {note} +

+ )} +
+ {children} +
+); + +export const Page = ({ children }: { children: ReactNode }): ReactElement => ( +
+ {children} +
+);