Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
c1a079e
feat(base): let SelectableCard host arbitrary content
talissoncosta Jul 2, 2026
4581b15
feat(onboarding): make the "next quest" cards actionable
talissoncosta Jul 2, 2026
a553b1f
feat(onboarding): lock hint on the flags table
talissoncosta Jul 2, 2026
12e69c9
test(onboarding): e2e for the next-quest cards
talissoncosta Jul 2, 2026
3292d24
chore(onboarding): import classnames as cn to match SelectableCard
talissoncosta Jul 10, 2026
a8fb6bd
fix(onboarding): only show the code-card scrollbar on overflow
talissoncosta Jul 8, 2026
fd9d68a
style(onboarding): show the selected SDK as a badge in the code card
talissoncosta Jul 8, 2026
233b661
fix(onboarding): make the JavaScript SDK logo legible on light mode
talissoncosta Jul 8, 2026
8250b5d
fix(code-help): correct the React init snippet
talissoncosta Jul 8, 2026
2cbe93a
style(onboarding): match SDK picker chips to the badge weight
talissoncosta Jul 10, 2026
c16c0ad
style(onboarding): add breathing room above the theme toggle
talissoncosta Jul 10, 2026
d1d4388
style(onboarding): make inline-edit fields read as editable at rest
talissoncosta Jul 10, 2026
4550c7a
fix(onboarding): avoid Safari contact autofill on inline-edit fields
talissoncosta Jul 13, 2026
21e67aa
test(onboarding): match e2e to the Edit-<field> aria-label
talissoncosta Jul 14, 2026
3f2c258
fix(onboarding): use font-weight-semibold, fw-semibold is a no-op here
talissoncosta Jul 15, 2026
ae7a277
style(onboarding): soften inline-edit radius and spacing
talissoncosta Jul 15, 2026
8767248
style(onboarding): round only the top of the inline-edit fields
talissoncosta Jul 15, 2026
765339e
style(onboarding): medium weight on the SDK badges
talissoncosta Jul 15, 2026
2e1fdc2
style(onboarding): tighten inline-edit radius to 2px
talissoncosta Jul 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 17 additions & 19 deletions frontend/common/code-help/init/init-react.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,20 @@ import Constants from 'common/constants'
export default (
envId,
{ FEATURE_NAME, FEATURE_NAME_ALT, LIB_NAME, NPM_CLIENT },
) => `// App root
import ${LIB_NAME} from "${NPM_CLIENT}";
import { FlagsmithProvider } from '@flagsmith/flagsmith/react';
) => `import ${LIB_NAME} from "${NPM_CLIENT}";
import { FlagsmithProvider, useFlags } from '${NPM_CLIENT}/react';

export function HomePage() {
const flags = useFlags(['${FEATURE_NAME}','${FEATURE_NAME_ALT}']); // only causes re-render if specified flag values / traits change
const ${FEATURE_NAME} = flags.${FEATURE_NAME}.enabled
const ${FEATURE_NAME_ALT} = flags.${FEATURE_NAME_ALT}.value
return (
&lt;>
{\`${FEATURE_NAME}: \${${FEATURE_NAME}}\`}
{\`${FEATURE_NAME_ALT}: \${${FEATURE_NAME_ALT}}\`}
&lt;/>
);
}

export default function App() {
return (
Expand All @@ -17,21 +28,8 @@ export default function App() {
: ''
}
}}
flagsmith={flagsmith}&gt;
{...Your app}
&lt;/FlagsmithProvider>
);
}

// Home Page
import ${LIB_NAME} from '${NPM_CLIENT}';
import { useFlags, useFlagsmith } from '${NPM_CLIENT}/react';

export default function HomePage() {
const flags = useFlags(['${FEATURE_NAME}','${FEATURE_NAME_ALT}']); // only causes re-render if specified flag values / traits change
const ${FEATURE_NAME} = flags.${FEATURE_NAME}.enabled
const ${FEATURE_NAME_ALT} = flags.${FEATURE_NAME_ALT}.value
return (
&lt;>{...}&lt;/>
flagsmith={${LIB_NAME}}&gt;
&lt;HomePage /&gt;
&lt;/FlagsmithProvider&gt;
);
}`
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { Meta, StoryObj } from 'storybook'

import OnboardingNextSteps from 'components/pages/onboarding/OnboardingNextSteps'

const meta: Meta<typeof OnboardingNextSteps> = {
args: {
locked: false,
onSelect: () => {},
},
component: OnboardingNextSteps,
parameters: {
docs: {
description: {
component:
'The "Choose your next quest" section at the bottom of the onboarding flow: the three ways the demo flag can level up (gradual rollout, experiment, remote config), each linking to its real config. Locked and dimmed until the app connects.',
},
},
layout: 'padded',
},
title: 'Pages/Onboarding/OnboardingNextSteps',
}
export default meta

type Story = StoryObj<typeof OnboardingNextSteps>

export const Connected: Story = {}

export const Locked: Story = {
args: { locked: true },
}
31 changes: 29 additions & 2 deletions frontend/e2e/tests/onboarding-tests.pw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ test.describe('Onboarding', () => {
await expect(page.getByText('LISTENING')).toBeVisible();
await expect(page.getByText('Copy install command')).not.toContainText('✓');

// Before the first evaluation, the next-quest cards are locked.
await expect(
page.getByText('Unlocks after your first evaluation'),
).toBeVisible();

log('Copy snippets, checklist ticks');
await page.getByRole('button', { name: 'Copy install command' }).click();
await expect(page.getByText('Copy install command')).toContainText('✓');
Expand Down Expand Up @@ -81,7 +86,7 @@ test.describe('Onboarding', () => {
// Rename the flag. Names are immutable, so this delete + recreates; the
// Onboarding tag must survive (the recreate carries the old flag's tags).
log('Rename the flag');
const flagInput = page.getByLabel('Flag name');
const flagInput = page.getByLabel('Edit flag');
await flagInput.fill('renamed_demo_flag');
await flagInput.press('Enter');

Expand All @@ -94,12 +99,34 @@ test.describe('Onboarding', () => {
// Reload to prove it persisted (bootstrap reuses the renamed flag).
await page.reload();
await flowReady();
await expect(page.getByLabel('Flag name')).toHaveValue('renamed_demo_flag');
await expect(page.getByLabel('Edit flag')).toHaveValue('renamed_demo_flag');
await expect(
page
.getByRole('region', { name: 'Your flags' })
.getByText('Onboarding', { exact: true }),
).toBeVisible();
await visualSnapshot(page, 'onboarding-renamed', testInfo);

// The next-quest cards unlock once connected, each deep-linking to the
// flag's real config (nothing faked).
log('Next-quest cards link to the real config');
await expect(
page.getByRole('heading', { name: 'Choose your next quest' }),
).toBeVisible();

await page.getByRole('button', { name: /Gradual rollout/ }).click();
await expect(page).toHaveURL(
/\/features\?feature=\d+&tab=segment-overrides/,
);

await page.goto('/getting-started?connected');
await flowReady();
await page.getByRole('button', { name: /Remote config/ }).click();
await expect(page).toHaveURL(/\/features\?feature=\d+&tab=value/);

await page.goto('/getting-started?connected');
await flowReady();
await page.getByRole('button', { name: /Experiment/ }).click();
await expect(page).toHaveURL(/\/experiments$/);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
min-width: 0;
}

&__icon {
Expand Down
22 changes: 18 additions & 4 deletions frontend/web/components/base/SelectableCard/SelectableCard.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,30 @@
import React, { FC, ReactNode } from 'react'
import cn from 'classnames'
import BareButton from 'components/base/forms/BareButton'
import './SelectableCard.scss'

type BadgeVariant = 'primary' | 'secondary'

type SelectableCardProps = {
selected: boolean
// Selection state. Omit for a card that acts on click without a chosen state
// (e.g. a card that navigates).
selected?: boolean
onClick: () => void
icon?: ReactNode
title: string
description: string
badge?: { label: string; variant: BadgeVariant }
tags?: string[]
disabled?: boolean
className?: string
// Extra content below the description (e.g. an illustrative preview).
children?: ReactNode
}

const SelectableCard: FC<SelectableCardProps> = ({
badge,
children,
className,
description,
disabled,
icon,
Expand All @@ -27,9 +35,14 @@ const SelectableCard: FC<SelectableCardProps> = ({
}) => {
return (
<BareButton
className={`selectable-card${
selected ? ' selectable-card--selected' : ''
}${disabled ? ' selectable-card--disabled' : ''}`}
className={cn(
'selectable-card',
{
'selectable-card--disabled': disabled,
'selectable-card--selected': selected,
},
className,
)}
onClick={onClick}
disabled={disabled}
>
Expand All @@ -46,6 +59,7 @@ const SelectableCard: FC<SelectableCardProps> = ({
))}
</div>
)}
{children}
</div>
{badge && (
<div className='selectable-card__aside'>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,32 +1,24 @@
// Inline editable value in the welcome sentence: an action underline + pencil,
// with a faint highlight on hover/focus. Rationale lives in InlineInput.tsx.
.inline-input {
display: inline-flex;
align-items: center;
vertical-align: baseline;
gap: 4px;
padding: 0 4px;
border-radius: var(--radius-xs);
padding: 1px 8px;
// Round the top only; the bottom stays square so it sits flush with the
// action underline (rounded bottom corners fight the border).
border-radius: var(--radius-xs) var(--radius-xs) 0 0;
border-bottom: 1px solid var(--color-border-action);
// Resting fill so it reads as editable, not only on hover.
background-color: var(--color-surface-subtle);
cursor: text;
transition: background-color var(--duration-fast) var(--easing-standard);

svg {
opacity: 0.8;
transition: opacity var(--duration-fast) var(--easing-standard);
}

&:hover,
&:focus-within {
background-color: var(--color-surface-subtle);

svg {
opacity: 1;
}
background-color: var(--color-surface-muted);
}

// The hero value (flag name): a filled soft-purple pill instead of an
// underline, so it reads as the thing the user will reference in code.
// Flag name: a filled pill rather than an underline (it's referenced in code).
&--accent {
padding: 1px 8px;
border-bottom: none;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ export type InlineInputProps = {
}

// Onboarding-local inline editable value (GhostInput + pencil) for the welcome
// sentence: an action underline + pencil mark it editable, it commits on
// blur/Enter, and an empty value reverts. Shares the VariationKeyLabel inline
// sentence: a resting fill, action underline and pencil mark it editable; it
// commits on blur/Enter, and an empty value reverts. Shares the VariationKeyLabel inline
// edit's visual language but drops its buttons/validation to stay prose-like;
// feature-local for now, both should converge on one primitive.
const InlineInput: FC<InlineInputProps> = ({
Expand Down Expand Up @@ -67,7 +67,8 @@ const InlineInput: FC<InlineInputProps> = ({
value={draft}
placeholder={label}
maxLength={maxLength}
aria-label={`${label} name`}
// Avoid a "name" label here: Safari reads it as a contact field.
aria-label={`Edit ${label.toLowerCase()}`}
onChange={(e) => {
const raw = e.target.value
setDraft(transform ? transform(raw) : raw)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ Detect my stack, install the SDK, and wire ${featureName} into one place. Then r

return (
<>
<span className='text-default fw-semibold'>
<span className='text-default font-weight-semibold'>
Paste this into your AI coding agent’s chat
</span>
<div className='bg-surface-muted p-3 d-flex align-items-start gap-3 rounded-md'>
Expand All @@ -50,7 +50,9 @@ Detect my stack, install the SDK, and wire ${featureName} into one place. Then r
</Button>
</div>
<div>
<span className='text-default fw-semibold'>What happens next</span>
<span className='text-default font-weight-semibold'>
What happens next
</span>
<ul className='onboarding-connect__steps text-muted mt-2 mb-0'>
<li>Detects your stack: language, framework and package manager.</li>
<li>Installs the Flagsmith SDK and wires it into your code.</li>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, { FC, useState } from 'react'
import classNames from 'classnames'
import Chip from 'components/base/Chip'
import CodeCard from './CodeCard'
import SdkPicker from './SdkPicker'
import { getSdkSnippet } from './sdkSnippets'
Expand All @@ -9,6 +10,18 @@ type PackageManager = 'npm' | 'yarn'

const PACKAGE_MANAGERS: PackageManager[] = ['npm', 'yarn']

// The chosen SDK as an accent badge (logo + label), matching the picker's
// selected chip - the code card labels its language with this.
const SdkBadge: FC<{ lang: SdkLang }> = ({ lang }) => {
const Logo = lang.logo
return (
<Chip variant='accent' className='font-weight-medium'>
<Logo />
{lang.label}
</Chip>
)
}

export type ConnectYourCodePanelProps = {
environmentKey: string
featureName: string
Expand Down Expand Up @@ -40,7 +53,9 @@ const ConnectYourCodePanel: FC<ConnectYourCodePanelProps> = ({
<div>
<div className='onboarding-connect__step-head d-flex align-items-center gap-2 mb-2'>
<span className='onboarding-connect__step-num'>1</span>
<span className='text-default fw-semibold'>Install the SDK</span>
<span className='text-default font-weight-semibold'>
Install the SDK
</span>
</div>
<CodeCard
code={installCode}
Expand All @@ -64,17 +79,15 @@ const ConnectYourCodePanel: FC<ConnectYourCodePanelProps> = ({
))}
</div>
) : (
<span className='onboarding-connect__codecard-lang'>
{sdkLang.label}
</span>
<SdkBadge lang={sdkLang} />
)
}
/>
</div>
<div>
<div className='onboarding-connect__step-head d-flex align-items-center gap-2 mb-2'>
<span className='onboarding-connect__step-num'>2</span>
<span className='text-default fw-semibold'>
<span className='text-default font-weight-semibold'>
Wire it in &amp; take instant control of what users see
</span>
</div>
Expand All @@ -83,11 +96,7 @@ const ConnectYourCodePanel: FC<ConnectYourCodePanelProps> = ({
language={sdkSnippet.language}
onCopy={onCopyWire}
copyLabel='Copy code snippet'
headerLeft={
<span className='onboarding-connect__codecard-lang'>
{sdkLang.label}
</span>
}
headerLeft={<SdkBadge lang={sdkLang} />}
/>
</div>
</>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,10 @@
border: 0;
border-radius: 0;
color: var(--color-text-default);
// Reserve the scrollbar gutter permanently so hover doesn't shove the
// layout (the global dark-scroll mixin toggles display:none on a
// space-reserving webkit bar otherwise).
overflow-x: scroll;
// Scroll only when the snippet overflows. When it does, the webkit rule
// below forces the bar visible over the global dark-scroll mixin (which
// display:none-s it on hover), so a long snippet's bar doesn't flicker.
overflow-x: auto;
overflow-y: hidden;

&::-webkit-scrollbar,
Expand Down Expand Up @@ -100,12 +100,6 @@
border-bottom: 1px solid var(--color-border-default);
}

&__codecard-lang {
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-secondary);
}

&__pm {
border-radius: 6px;
overflow: hidden;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ const SdkPicker: FC<SdkPickerProps> = ({ onSelect, selected }) => {
ref={(el) => {
refs.current[lang.label] = el
}}
className='font-weight-medium'
role='radio'
aria-checked={isSelected}
tabIndex={lang.label === tabStopLabel ? 0 : -1}
Expand All @@ -91,6 +92,7 @@ const SdkPicker: FC<SdkPickerProps> = ({ onSelect, selected }) => {
<div className='d-flex flex-wrap align-items-center gap-2'>
{popularLangs.map(renderOption)}
<Chip
className='font-weight-medium'
onClick={() => setMoreOpen((open) => !open)}
aria-expanded={moreOpen}
>
Expand Down
Loading
Loading