Add profile form navigation: sticky section index for desktop and collapsible bar for mobile; introduce useLongPressReveal hook for touch actions; improve form and layout styles for better usability and consistency.

This commit is contained in:
MartinBraquet
2026-07-28 03:20:51 +02:00
parent c999ed4786
commit 476fe214db
18 changed files with 912 additions and 453 deletions

View File

@@ -165,6 +165,7 @@
"common.and": "und",
"common.back": "Zurück",
"common.change": "Ändern",
"common.click_to_clear": "Zum Löschen klicken",
"common.close": "Schließen",
"common.compatible": "Kompatibel",
"common.continue": "Weiter",
@@ -600,7 +601,9 @@
"profile.age_min_compact": "{min}+",
"profile.age_range_compact": "{min}-{max}",
"profile.connect.no_shared_types": "Du und {name} seid nicht für dieselbe Art von Verbindung offen.",
"profile.form.sections": "Abschnitte",
"profile.interests": "Interessen",
"profile.optional.add_photos": "Fotos hinzufügen",
"send_message.placeholder": "Was hat dich an {name}s Profil wirklich angesprochen? Was möchtest du gemeinsam erkunden?",
"send_message.title": "Beginne ein bedeutungsvolles Gespräch",
"send_message.guidance": "Compass geht um Tiefe. Nimm dir einen Moment, um etwas Echtes zu schreiben.",

View File

@@ -165,6 +165,7 @@
"common.and": "et",
"common.back": "Retour",
"common.change": "Modifier",
"common.click_to_clear": "Cliquer pour effacer",
"common.close": "Fermer",
"common.compatible": "Compatible",
"common.continue": "Continuer",
@@ -600,7 +601,9 @@
"profile.age_min_compact": "{min}+",
"profile.age_range_compact": "{min}-{max}",
"profile.connect.no_shared_types": "Vous et {name} nêtes pas ouverts au même type de relation.",
"profile.form.sections": "Sections",
"profile.interests": "Centres dintérêt",
"profile.optional.add_photos": "Ajouter des photos",
"send_message.placeholder": "Qu'est-ce qui vous a vraiment touché dans le profil de {name} ? Qu'aimeriez-vous explorer ensemble ?",
"send_message.title": "Commencez une conversation qui a du sens",
"send_message.guidance": "Prenez un moment pour écrire quelque chose d'authentique.",

View File

@@ -31,6 +31,7 @@ import {Linkify} from 'web/components/widgets/linkify'
import {Pagination} from 'web/components/widgets/pagination'
import {Tooltip} from 'web/components/widgets/tooltip'
import {shortenName} from 'web/components/widgets/user-link'
import {useLongPressReveal} from 'web/hooks/use-long-press-reveal'
import {usePersistentInMemoryState} from 'web/hooks/use-persistent-in-memory-state'
import {usePinnedQuestionIds} from 'web/hooks/use-pinned-question-ids'
import {useProfile} from 'web/hooks/use-profile'
@@ -387,6 +388,13 @@ export function CompatibilityAnswerBlock(props: {
setNewAnswer(props.answer)
}, [props.answer])
// Touch has no hover to uncover the pin with, so a press and hold on the prompt does it instead.
const {
containerRef: rowRef,
revealed: showPin,
handlers: longPressHandlers,
} = useLongPressReveal<HTMLDivElement>()
const comparedProfile = isCurrentUser
? null
: fromProfilePage
@@ -420,11 +428,16 @@ export function CompatibilityAnswerBlock(props: {
return (
<Col
ref={rowRef}
data-testid="profile-compatibility-section"
{...longPressHandlers}
className={clsx(
// No card. A prompt is question → answer → alternatives, which is structurally the same object
// as a Details row, so it gets the same treatment: a rule, not a box.
'group border-canvas-200 flex-grow gap-2 whitespace-pre-line border-b py-6 leading-relaxed',
// Stops the hold from turning into a text selection / iOS callout — but only where there is
// no hover, so a pointer user can still select and copy the question and answer.
'[@media(hover:none)]:select-none [@media(hover:none)]:[-webkit-touch-callout:none]',
className,
)}
>
@@ -459,12 +472,21 @@ export function CompatibilityAnswerBlock(props: {
</div>
)}
{/* Pinning is a rare action that was occupying permanent space in every row. It appears on
hover where there is a pointer, and stays put where there is not — a touch device has no
hover state to reveal it with. Focus reveals it too, so it stays keyboard-reachable. */}
hover where there is a pointer and on a press and hold where there is not. Focus reveals
it too, so it stays keyboard-reachable. While hidden it is also unclickable on touch —
an invisible button there is just a trap next to the question. */}
{!!currentUser && (
<PinQuestionButton
questionId={question.id}
className="opacity-0 transition-opacity focus-visible:opacity-100 group-focus-within:opacity-100 group-hover:opacity-100 [@media(hover:none)]:opacity-100"
className={clsx(
'transition-opacity',
showPin
? 'opacity-100'
: [
'opacity-0 [@media(hover:none)]:pointer-events-none',
'focus-visible:opacity-100 group-focus-within:opacity-100 group-hover:opacity-100',
],
)}
/>
)}
{isCurrentUser && isAnswered && (

View File

@@ -444,16 +444,16 @@ export const OptionalProfileUserForm = (props: {
nothing below it to scroll into — the document simply ends, and no amount of
`scrollIntoView` can lift its toolbar off the bottom edge. This reserves scrollable room
below the form so the toolbar can clear the viewport edge (and the floating submit button). */}
<Col className={'gap-8 max-w-3xl pb-32'}>
<Col className={'gap-8 max-w-3xl pb-32'} data-profile-form>
{/* This sentence is the single most abandonment-reducing thing on the page — it tells you the
eighteen sections below are all skippable. It was rendered in `.guidance`: 14px at 70%
opacity, i.e. styled like fine print, which is exactly backwards. */}
<div className="flex items-start gap-3 rounded-xl bg-primary-100/60 ring-1 ring-primary-200 p-4">
<div className="border-primary-400 flex items-start gap-3 border-l-2 pl-4">
<InformationCircleIcon
className="w-5 h-5 text-primary-700 shrink-0 mt-0.5"
className="w-5 h-5 text-primary-600 shrink-0 mt-0.5"
strokeWidth={1.8}
/>
<p className="text-sm text-ink-700 leading-relaxed">
<p className="text-sm text-ink-600 leading-relaxed">
{t(
'profile.optional.subtitle',
'Although all the fields below are optional, they will help people better understand you and connect with you.',
@@ -463,7 +463,10 @@ export const OptionalProfileUserForm = (props: {
<Category title={t('profile.llm.extract.title', 'Auto-fill')} className={'mt-0'} />
{/* Two ways in, one at a time: showing both a recorder and a text editor at once reads as
two things to do rather than a choice between them. */}
<Row className="w-full gap-1 rounded-xl bg-canvas-100 p-1 ring-1 ring-canvas-200">
{/* Underlined tabs rather than a segmented pill. The selected segment was `bg-canvas-0` —
pure black in the dark ramp — so the active tab read as a hole punched in the page rather
than the raised one. An underline needs no fill to say which is selected. */}
<Row className="border-canvas-300 w-full gap-6 border-b">
{(
[
['voice', t('profile.autofill.by_voice', 'By voice')],
@@ -477,10 +480,10 @@ export const OptionalProfileUserForm = (props: {
aria-pressed={autofillMode === mode}
disabled={isExtracting}
className={clsx(
'flex-1 rounded-lg px-3 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed',
'-mb-px border-b-2 px-1 pb-3 text-sm transition-colors disabled:cursor-not-allowed',
autofillMode === mode
? 'bg-canvas-0 text-ink-900 shadow-sm'
: 'text-ink-700 hover:text-ink-900',
? 'border-primary-500 text-ink-1000'
: 'border-transparent text-ink-500 hover:text-ink-800',
)}
>
{label}
@@ -608,13 +611,15 @@ export const OptionalProfileUserForm = (props: {
)}
{showAllGenders && (
<>
<p className="mt-1">{t('profile.optional.details', 'Details')}</p>
<p className={clsx(labelClassName, 'mt-1')}>
{t('profile.optional.details', 'Details')}
</p>
<Input
type="text"
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setProfile('gender_details', e.target.value)
}
className={'w-full sm:w-[700px]'}
className={'w-full'}
value={(profile as any)['gender_details'] ?? undefined}
placeholder={t(
'profile.gender.details_placeholder',
@@ -658,7 +663,9 @@ export const OptionalProfileUserForm = (props: {
<label className={clsx(labelClassName)}>{t('profile.optional.height', 'Height')}</label>
<Row className={'gap-2'}>
<Col>
<span>{t('profile.optional.feet', 'Feet')}</span>
<span className={clsx(labelClassName, 'mb-1')}>
{t('profile.optional.feet', 'Feet')}
</span>
<Input
type="number"
data-testid="height-feet"
@@ -673,7 +680,9 @@ export const OptionalProfileUserForm = (props: {
/>
</Col>
<Col>
<span>{t('profile.optional.inches', 'Inches')}</span>
<span className={clsx(labelClassName, 'mb-1')}>
{t('profile.optional.inches', 'Inches')}
</span>
<Input
type="number"
data-testid="height-inches"
@@ -693,7 +702,9 @@ export const OptionalProfileUserForm = (props: {
{t('common.or', 'OR').toUpperCase()}
</div>
<Col>
<span>{t('profile.optional.centimeters', 'Centimeters')}</span>
<span className={clsx(labelClassName, 'mb-1')}>
{t('profile.optional.centimeters', 'Centimeters')}
</span>
<Input
type="number"
data-testid="height-centimeters"
@@ -814,7 +825,9 @@ export const OptionalProfileUserForm = (props: {
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
setProfile('headline', e.target.value)
}
className={'w-full md:w-[700px] bg-canvas-50 border rounded-md p-2'}
className={
'w-full bg-canvas-50 border border-canvas-300 rounded-xl p-3 transition-all duration-150 hover:border-primary-300 focus:border-primary-400 focus:outline-none focus:ring-2 focus:ring-primary-500/25'
}
value={profile['headline'] ?? undefined}
maxLength={250}
/>
@@ -841,7 +854,7 @@ export const OptionalProfileUserForm = (props: {
.filter(Boolean)
setProfile('keywords', keywords)
}}
className={'w-full sm:w-[600px]'}
className={'w-full'}
value={keywordsString}
placeholder={t(
'profile.optional.keywords_placeholder',
@@ -850,6 +863,26 @@ export const OptionalProfileUserForm = (props: {
/>
</Col>
{/* Third, not last. Bio is the longest thing anyone writes and the third thing anyone reads on
the finished profile — burying it under fifty attribute fields meant most people met it
with the form already exhausted. It follows the hero fields it belongs with: photos,
headline, keywords. */}
<Category title={t('profile.basics.bio', 'Bio')} />
<label className={clsx('guidance')}>
{t(
'profile.optional.bio_description',
'Here you can write a long document about who you are and what you are looking for. It includes nice formatting like headers, bold, italic, lists, links, embedded images, and more.',
)}
</label>
<SignupBio
profile={profile}
onChange={(e: Editor) => {
debug('bio changed', e, profile.bio)
setProfile('bio', e.getJSON())
setProfile('bio_length', e.getText().length)
}}
/>
<Category title={t('profile.optional.category.interested_in', "Who I'm looking for")} />
<Col className={clsx(colClassName)}>
@@ -865,7 +898,7 @@ export const OptionalProfileUserForm = (props: {
</label>
<Row className={'gap-2'}>
<Col>
<span>{t('common.min', 'Min')}</span>
<span className={clsx(labelClassName, 'mb-1')}>{t('common.min', 'Min')}</span>
<Select
data-testid="pref-age-min"
value={profile['pref_age_min'] ?? ''}
@@ -885,7 +918,7 @@ export const OptionalProfileUserForm = (props: {
</Select>
</Col>
<Col>
<span>{t('common.max', 'Max')}</span>
<span className={clsx(labelClassName, 'mb-1')}>{t('common.max', 'Max')}</span>
<Select
data-testid="pref-age-max"
value={profile['pref_age_max'] ?? ''}
@@ -921,6 +954,84 @@ export const OptionalProfileUserForm = (props: {
/>
</Col>
<Category title={t('profile.optional.category.work', 'Work')} />
<Col className={clsx(colClassName)}>
<label className={clsx(labelClassName)}>
{profile['company']
? t('profile.optional.job_title_at_company', 'Job title at {company}', {
company: profile['company'],
})
: t('profile.optional.job_title', 'Job title')}
</label>
<Input
data-testid="job-title"
type="text"
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setProfile('occupation_title', e.target.value)
}
className={'w-52'}
value={profile['occupation_title'] ?? undefined}
/>
</Col>
<Col className={clsx(colClassName)}>
<label className={clsx(labelClassName)}>{t('profile.optional.company', 'Company')}</label>
<Input
data-testid="company"
type="text"
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setProfile('company', e.target.value)
}
className={'w-52'}
value={profile['company'] ?? undefined}
/>
</Col>
<AddOptionEntry
title={t('profile.optional.work', 'Work Area')}
choices={workChoices}
setChoices={setWorkChoices}
profile={profile}
setProfile={setProfile}
label={'work'}
/>
<Category title={t('profile.optional.category.education', 'Education')} />
<Col className={clsx(colClassName)}>
<label className={clsx(labelClassName)}>
{t('profile.optional.education_level', 'Highest completed education level')}
</label>
<Carousel className="max-w-full">
<ChoicesToggleGroup
currentChoice={profile['education_level']}
choicesMap={Object.fromEntries(
Object.entries(EDUCATION_CHOICES).map(([k, v]) => [
t(`profile.education.${v}`, k),
v,
]) as any,
)}
setChoice={(c) => setProfile('education_level', c)}
/>
</Carousel>
</Col>
<Col className={clsx(colClassName)}>
<label className={clsx(labelClassName)}>
{t('profile.optional.university', 'University')}
</label>
<Input
data-testid="university"
type="text"
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setProfile('university', e.target.value)
}
className={'w-52'}
value={profile['university'] ?? undefined}
/>
</Col>
<Category title={t('profile.optional.category.relationships', 'Relationships')} />
<Col className={clsx(colClassName)}>
@@ -968,13 +1079,15 @@ export const OptionalProfileUserForm = (props: {
)}
{showAllOrientations && (
<>
<p className="mt-1">{t('profile.optional.details', 'Details')}</p>
<p className={clsx(labelClassName, 'mt-1')}>
{t('profile.optional.details', 'Details')}
</p>
<Input
type="text"
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setProfile('orientation_details', e.target.value)
}
className={'w-full sm:w-[700px]'}
className={'w-full'}
value={(profile as any)['orientation_details'] ?? undefined}
placeholder={t(
'profile.orientation.details_placeholder',
@@ -1040,104 +1153,6 @@ export const OptionalProfileUserForm = (props: {
</>
)}
<Category title={t('profile.optional.interests', 'Interests')} />
<AddOptionEntry
// title={t('profile.optional.interests', 'Interests')}
choices={interestChoices}
setChoices={setInterestChoices}
profile={profile}
setProfile={setProfile}
label={'interests'}
/>
<Category title={t('profile.optional.category.morality', 'Morality')} />
<AddOptionEntry
title={t('profile.optional.causes', 'Causes')}
choices={causeChoices}
setChoices={setCauseChoices}
profile={profile}
setProfile={setProfile}
label={'causes'}
/>
<Category title={t('profile.optional.category.education', 'Education')} />
<Col className={clsx(colClassName)}>
<label className={clsx(labelClassName)}>
{t('profile.optional.education_level', 'Highest completed education level')}
</label>
<Carousel className="max-w-full">
<ChoicesToggleGroup
currentChoice={profile['education_level']}
choicesMap={Object.fromEntries(
Object.entries(EDUCATION_CHOICES).map(([k, v]) => [
t(`profile.education.${v}`, k),
v,
]) as any,
)}
setChoice={(c) => setProfile('education_level', c)}
/>
</Carousel>
</Col>
<Col className={clsx(colClassName)}>
<label className={clsx(labelClassName)}>
{t('profile.optional.university', 'University')}
</label>
<Input
data-testid="university"
type="text"
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setProfile('university', e.target.value)
}
className={'w-52'}
value={profile['university'] ?? undefined}
/>
</Col>
<Category title={t('profile.optional.category.work', 'Work')} />
<Col className={clsx(colClassName)}>
<label className={clsx(labelClassName)}>
{profile['company']
? t('profile.optional.job_title_at_company', 'Job title at {company}', {
company: profile['company'],
})
: t('profile.optional.job_title', 'Job title')}
</label>
<Input
data-testid="job-title"
type="text"
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setProfile('occupation_title', e.target.value)
}
className={'w-52'}
value={profile['occupation_title'] ?? undefined}
/>
</Col>
<Col className={clsx(colClassName)}>
<label className={clsx(labelClassName)}>{t('profile.optional.company', 'Company')}</label>
<Input
data-testid="company"
type="text"
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setProfile('company', e.target.value)
}
className={'w-52'}
value={profile['company'] ?? undefined}
/>
</Col>
<AddOptionEntry
title={t('profile.optional.work', 'Work Area')}
choices={workChoices}
setChoices={setWorkChoices}
profile={profile}
setProfile={setProfile}
label={'work'}
/>
<Category title={t('profile.optional.political_beliefs', 'Political beliefs')} />
<Col className={clsx(colClassName)}>
@@ -1150,14 +1165,14 @@ export const OptionalProfileUserForm = (props: {
translationPrefix={'profile.political'}
onChange={(selected) => setProfile('political_beliefs', selected)}
/>
<p>{t('profile.optional.details', 'Details')}</p>
<p className={clsx(labelClassName)}>{t('profile.optional.details', 'Details')}</p>
<Input
data-testid="political-belief-details"
type="text"
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setProfile('political_details', e.target.value)
}
className={'w-full sm:w-[700px]'}
className={'w-full'}
value={profile['political_details'] ?? undefined}
/>
</Col>
@@ -1174,191 +1189,18 @@ export const OptionalProfileUserForm = (props: {
translationPrefix={'profile.religion'}
onChange={(selected) => setProfile('religion', selected)}
/>
<p>{t('profile.optional.details', 'Details')}</p>
<p className={clsx(labelClassName)}>{t('profile.optional.details', 'Details')}</p>
<Input
data-testid="religious-belief-details"
type="text"
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setProfile('religious_beliefs', e.target.value)
}
className={'w-full sm:w-[700px]'}
className={'w-full'}
value={profile['religious_beliefs'] ?? undefined}
/>
</Col>
<Category title={t('profile.optional.category.psychology', 'Psychology')} />
{/* Sits with MBTI/Big Five rather than under Relationships: it describes how someone's mind
works, and it is just as relevant to friendship and collaboration as to dating. */}
<Col className={clsx(colClassName)}>
<label className={clsx(labelClassName)}>
{t('profile.optional.neurotype', 'Neurotype')}
</label>
<MultiCheckbox
choices={
Object.fromEntries(
Object.entries(NEUROTYPE_CHOICES).filter(
([, v]) =>
showAllNeurotypes ||
DEFAULT_NEUROTYPES.includes(v as any) ||
((profile as any)['neurotype'] ?? []).includes(v),
),
) as any
}
selected={(profile as any)['neurotype'] ?? []}
translationPrefix={'profile.neurotype'}
onChange={(selected) => setProfile('neurotype' as any, selected)}
/>
{!showAllNeurotypes && (
<button
type="button"
className="text-primary-700 mt-1 text-sm font-medium hover:underline"
onClick={() => setShowAllNeurotypes(true)}
>
{t('profile.neurotype.show_more', 'Show more options')}
</button>
)}
{showAllNeurotypes && (
<>
<p className="mt-1">{t('profile.optional.details', 'Details')}</p>
<Input
type="text"
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setProfile('neurotype_details' as any, e.target.value)
}
className={'w-full sm:w-[700px]'}
value={(profile as any)['neurotype_details'] ?? undefined}
placeholder={t(
'profile.neurotype.details_placeholder',
'Anything helpful to know about how you think or communicate…',
)}
/>
</>
)}
</Col>
<Col className={clsx(colClassName, 'max-w-[550px]')}>
<label className={clsx(labelClassName)}>
{t('profile.optional.mbti', 'MBTI Personality Type')}
</label>
<ChoicesToggleGroup
currentChoice={profile['mbti'] as any}
choicesMap={MBTI_CHOICES}
setChoice={(c) => setProfile('mbti', c)}
className="grid grid-cols-4 xs:grid-cols-8"
/>
</Col>
{/* Big Five personality traits (0100) */}
<Col className={clsx(colClassName)}>
<Row className="w-full items-center justify-between gap-3">
<label className={clsx(labelClassName)}>
{t('profile.big5', 'Big Five Personality Traits')}
</label>
{/* A slider cannot express "unset": drag one and there is no gesture that puts it back,
so before this the first accidental touch permanently committed a score. Clearing
writes `null` rather than `undefined` on purpose — the edit path
(`pages/profile.tsx`) strips undefined but persists null, so undefined would silently
leave the old value in the database. Signup strips both, which is also correct there:
an unset trait should not be sent at all. */}
{anyBig5Set && (
<button
type="button"
onClick={resetBig5}
className="text-sm font-medium text-primary-700 hover:underline shrink-0"
>
{t('profile.big5_reset', 'Reset')}
</button>
)}
</Row>
<p className="guidance custom-link">
{t(
'profile.big5_guidance',
'The Big Five personality trait model is a scientific model that groups variation in personality into five separate factors (',
)}
{/* CustomLink ships no colour of its own, so this inherited a 2.70:1 amber. */}
<CustomLink
href={'https://en.wikipedia.org/wiki/Big_Five_personality_traits'}
className="text-primary-700 font-medium hover:underline"
>
{t('profile.big5_wikipedia_link', 'Wikipedia article')}
</CustomLink>
{t(
'profile.big5_guidance_suffix',
'). You can take a free well-cited public-domain approximate test ',
)}
<CustomLink href={'https://emilywilloughby.com/research/bfas'}>
{t('profile.big5_test_link', 'here')}
</CustomLink>
{t('profile.big5_guidance_end', '.')}
</p>
<div className={clsx('space-y-4', 'w-full max-w-[550px]')}>
<Big5Slider
label={t('profile.big5_openness', 'Openness')}
value={profile.big5_openness ?? 50}
isSet={typeof profile.big5_openness === 'number'}
onChange={(v) => setProfile('big5_openness', v)}
/>
<Big5Slider
label={t('profile.big5_conscientiousness', 'Conscientiousness')}
value={profile.big5_conscientiousness ?? 50}
isSet={typeof profile.big5_conscientiousness === 'number'}
onChange={(v) => setProfile('big5_conscientiousness', v)}
/>
<Big5Slider
label={t('profile.big5_extraversion', 'Extraversion')}
value={profile.big5_extraversion ?? 50}
isSet={typeof profile.big5_extraversion === 'number'}
onChange={(v) => setProfile('big5_extraversion', v)}
/>
<Big5Slider
label={t('profile.big5_agreeableness', 'Agreeableness')}
value={profile.big5_agreeableness ?? 50}
isSet={typeof profile.big5_agreeableness === 'number'}
onChange={(v) => setProfile('big5_agreeableness', v)}
/>
<Big5Slider
label={t('profile.big5_neuroticism', 'Neuroticism')}
value={profile.big5_neuroticism ?? 50}
isSet={typeof profile.big5_neuroticism === 'number'}
onChange={(v) => setProfile('big5_neuroticism', v)}
/>
</div>
<p className="text-sm text-ink-700">
{t(
'profile.big5_hint',
'Drag each slider to set where you see yourself on these traits (0 = low, 100 = high).',
)}
</p>
</Col>
<Category title={t('profile.optional.accessibility_notes', 'Accessibility')} />
{/* Free text on purpose, not a checkbox list of conditions: a taxonomy exists mainly to be
filtered on, and filtering people out by disability is what this field must not enable.
Free text leaves the framing and the depth of disclosure with the member. */}
<Col className={clsx(colClassName)}>
<label className={clsx('guidance')}>
{t(
'profile.optional.accessibility_notes_hint',
'Optional, and public like the rest of your profile. Anything practical that helps someone meet you well — access needs, energy levels, sensory preferences, quieter venues.',
)}
</label>
<Textarea
data-testid="accessibility_notes"
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
setProfile('accessibility_notes' as any, e.target.value)
}
className={'w-full md:w-[700px] bg-canvas-50 border rounded-md p-2'}
value={(profile as any)['accessibility_notes'] ?? undefined}
maxLength={500}
placeholder={t(
'profile.optional.accessibility_notes_placeholder',
'e.g. I use a wheelchair, so step-free venues work best. Happy to answer questions.',
)}
/>
</Col>
<Category title={t('profile.optional.diet', 'Diet')} />
<Col className={clsx(colClassName)}>
@@ -1373,6 +1215,26 @@ export const OptionalProfileUserForm = (props: {
/>
</Col>
<Category title={t('profile.optional.category.morality', 'Morality')} />
<AddOptionEntry
title={t('profile.optional.causes', 'Causes')}
choices={causeChoices}
setChoices={setCauseChoices}
profile={profile}
setProfile={setProfile}
label={'causes'}
/>
<Category title={t('profile.optional.interests', 'Interests')} />
<AddOptionEntry
// title={t('profile.optional.interests', 'Interests')}
choices={interestChoices}
setChoices={setInterestChoices}
profile={profile}
setProfile={setProfile}
label={'interests'}
/>
<Category title={t('profile.optional.category.substances', 'Substances')} />
<Col className={clsx(colClassName)}>
@@ -1500,6 +1362,154 @@ export const OptionalProfileUserForm = (props: {
/>
</Col>
<Category title={t('profile.optional.category.psychology', 'Psychology')} />
{/* Sits with MBTI/Big Five rather than under Relationships: it describes how someone's mind
works, and it is just as relevant to friendship and collaboration as to dating. */}
<Col className={clsx(colClassName)}>
<label className={clsx(labelClassName)}>
{t('profile.optional.neurotype', 'Neurotype')}
</label>
<MultiCheckbox
choices={
Object.fromEntries(
Object.entries(NEUROTYPE_CHOICES).filter(
([, v]) =>
showAllNeurotypes ||
DEFAULT_NEUROTYPES.includes(v as any) ||
((profile as any)['neurotype'] ?? []).includes(v),
),
) as any
}
selected={(profile as any)['neurotype'] ?? []}
translationPrefix={'profile.neurotype'}
onChange={(selected) => setProfile('neurotype' as any, selected)}
/>
{!showAllNeurotypes && (
<button
type="button"
className="text-primary-700 mt-1 text-sm font-medium hover:underline"
onClick={() => setShowAllNeurotypes(true)}
>
{t('profile.neurotype.show_more', 'Show more options')}
</button>
)}
{showAllNeurotypes && (
<>
<p className={clsx(labelClassName, 'mt-1')}>
{t('profile.optional.details', 'Details')}
</p>
<Input
type="text"
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setProfile('neurotype_details' as any, e.target.value)
}
className={'w-full'}
value={(profile as any)['neurotype_details'] ?? undefined}
placeholder={t(
'profile.neurotype.details_placeholder',
'Anything helpful to know about how you think or communicate…',
)}
/>
</>
)}
</Col>
<Col className={clsx(colClassName, 'max-w-[550px]')}>
<label className={clsx(labelClassName)}>
{t('profile.optional.mbti', 'MBTI Personality Type')}
</label>
<ChoicesToggleGroup
currentChoice={profile['mbti'] as any}
choicesMap={MBTI_CHOICES}
setChoice={(c) => setProfile('mbti', c)}
className="grid grid-cols-4 xs:grid-cols-8"
/>
</Col>
{/* Big Five personality traits (0100) */}
<Col className={clsx(colClassName)}>
<Row className="w-full items-center justify-between gap-3">
<label className={clsx(labelClassName)}>
{t('profile.big5', 'Big Five Personality Traits')}
</label>
{/* A slider cannot express "unset": drag one and there is no gesture that puts it back,
so before this the first accidental touch permanently committed a score. Clearing
writes `null` rather than `undefined` on purpose — the edit path
(`pages/profile.tsx`) strips undefined but persists null, so undefined would silently
leave the old value in the database. Signup strips both, which is also correct there:
an unset trait should not be sent at all. */}
{anyBig5Set && (
<button
type="button"
onClick={resetBig5}
className="text-sm font-medium text-primary-700 hover:underline shrink-0"
>
{t('profile.big5_reset', 'Reset')}
</button>
)}
</Row>
<p className="guidance custom-link">
{t(
'profile.big5_guidance',
'The Big Five personality trait model is a scientific model that groups variation in personality into five separate factors (',
)}
{/* CustomLink ships no colour of its own, so this inherited a 2.70:1 amber. */}
<CustomLink
href={'https://en.wikipedia.org/wiki/Big_Five_personality_traits'}
className="text-primary-700 font-medium hover:underline"
>
{t('profile.big5_wikipedia_link', 'Wikipedia article')}
</CustomLink>
{t(
'profile.big5_guidance_suffix',
'). You can take a free well-cited public-domain approximate test ',
)}
<CustomLink href={'https://emilywilloughby.com/research/bfas'}>
{t('profile.big5_test_link', 'here')}
</CustomLink>
{t('profile.big5_guidance_end', '.')}
</p>
<div className={clsx('space-y-4', 'w-full max-w-[550px]')}>
<Big5Slider
label={t('profile.big5_openness', 'Openness')}
value={profile.big5_openness ?? 50}
isSet={typeof profile.big5_openness === 'number'}
onChange={(v) => setProfile('big5_openness', v)}
/>
<Big5Slider
label={t('profile.big5_conscientiousness', 'Conscientiousness')}
value={profile.big5_conscientiousness ?? 50}
isSet={typeof profile.big5_conscientiousness === 'number'}
onChange={(v) => setProfile('big5_conscientiousness', v)}
/>
<Big5Slider
label={t('profile.big5_extraversion', 'Extraversion')}
value={profile.big5_extraversion ?? 50}
isSet={typeof profile.big5_extraversion === 'number'}
onChange={(v) => setProfile('big5_extraversion', v)}
/>
<Big5Slider
label={t('profile.big5_agreeableness', 'Agreeableness')}
value={profile.big5_agreeableness ?? 50}
isSet={typeof profile.big5_agreeableness === 'number'}
onChange={(v) => setProfile('big5_agreeableness', v)}
/>
<Big5Slider
label={t('profile.big5_neuroticism', 'Neuroticism')}
value={profile.big5_neuroticism ?? 50}
isSet={typeof profile.big5_neuroticism === 'number'}
onChange={(v) => setProfile('big5_neuroticism', v)}
/>
</div>
<p className="text-sm text-ink-700">
{t(
'profile.big5_hint',
'Drag each slider to set where you see yourself on these traits (0 = low, 100 = high).',
)}
</p>
</Col>
<Category title={t('profile.optional.languages', 'Languages')} />
<Col className={clsx(colClassName)}>
@@ -1508,7 +1518,7 @@ export const OptionalProfileUserForm = (props: {
{/*</label>*/}
<div className="grid grid-cols-1 gap-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="col-span-full max-h-60 overflow-y-auto w-full">
<div className="scrollbar-visible col-span-full max-h-60 overflow-y-auto w-full pr-2">
<MultiCheckbox
choices={LANGUAGE_CHOICES}
selected={profile.languages || []}
@@ -1539,26 +1549,39 @@ export const OptionalProfileUserForm = (props: {
{/* />*/}
{/*</Col>*/}
<Category title={t('profile.optional.accessibility_notes', 'Accessibility')} />
{/* Free text on purpose, not a checkbox list of conditions: a taxonomy exists mainly to be
filtered on, and filtering people out by disability is what this field must not enable.
Free text leaves the framing and the depth of disclosure with the member. */}
<Col className={clsx(colClassName)}>
<label className={clsx('guidance')}>
{t(
'profile.optional.accessibility_notes_hint',
'Optional, and public like the rest of your profile. Anything practical that helps someone meet you well — access needs, energy levels, sensory preferences, quieter venues.',
)}
</label>
<Textarea
data-testid="accessibility_notes"
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
setProfile('accessibility_notes' as any, e.target.value)
}
className={
'w-full bg-canvas-50 border border-canvas-300 rounded-xl p-3 transition-all duration-150 hover:border-primary-300 focus:border-primary-400 focus:outline-none focus:ring-2 focus:ring-primary-500/25'
}
value={(profile as any)['accessibility_notes'] ?? undefined}
maxLength={500}
placeholder={t(
'profile.optional.accessibility_notes_placeholder',
'e.g. I use a wheelchair, so step-free venues work best. Happy to answer questions.',
)}
/>
</Col>
<Category title={t('profile.optional.socials', 'Socials')} />
<SocialLinksSection profile={profile} setProfile={setProfile} />
<Category title={t('profile.basics.bio', 'Bio')} className={'mt-0'} />
<label className={clsx('guidance')}>
{t(
'profile.optional.bio_description',
'Here you can write a long document about who you are and what you are looking for. It includes nice formatting like headers, bold, italic, lists, links, embedded images, and more.',
)}
</label>
<SignupBio
profile={profile}
onChange={(e: Editor) => {
debug('bio changed', e, profile.bio)
setProfile('bio', e.getJSON())
setProfile('bio_length', e.getText().length)
}}
/>
<Row className={'justify-end'}>
<Button
className={clsx(
@@ -1598,6 +1621,7 @@ const CitySearchBox = (props: {onCitySelected: (city: City | undefined) => void}
value={query}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setQuery(e.target.value)}
placeholder={t('profile.optional.search_city', 'Search city...')}
className={'w-full sm:w-[420px]'}
onFocus={() => setFocused(true)}
onBlur={(e) => {
// Do not hide the dropdown if clicking inside the dropdown
@@ -1642,16 +1666,39 @@ const CitySearchBox = (props: {onCitySelected: (city: City | undefined) => void}
* the header now closes the previous section as much as it opens its own. The negative margin is gone
* — the parent `Col` already supplies `gap-8`, which is the spacing this was fighting.
*/
function slugifySectionTitle(title: string) {
return (
'form-section-' +
title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
)
}
function Category({title, className}: {title: string; className?: string}) {
return (
// `mt-6` on top of the parent's `gap-8`, against `pt-5` below: the rule has to sit closer to the
// heading it introduces than to the section it closes, or it reads as a trailing underline for the
// previous block. Even spacing on both sides is the default mistake here.
<div className={clsx('w-full border-t border-canvas-200 mt-6 pt-5', className)}>
{/* `!mt-0`: the global h1h6 rule puts a 24px margin on this, which silently doubled the space
below the rule and made the spacing symmetric again. It is also what the old `mb-[-8px]` on
this component was compensating for. */}
<h3 className="font-heading text-2xl font-bold tracking-tight text-ink-900 !mt-0">{title}</h3>
// Label above the rule, not below it — the same object as a section heading on the rendered
// profile, so the form is built in the visual language of the thing it produces. `mt-6` on top of
// the parent's `gap-8` keeps the heading closer to the section it opens than to the one it closes.
//
// `data-form-section` is what ProfileFormNav reads to build its index: the markup stays the source
// of truth for which sections exist and in what order. `scroll-mt-24` keeps a jumped-to heading
// clear of the sticky header rather than tucked under it.
<div
id={slugifySectionTitle(title)}
data-form-section={title}
className={clsx('w-full mt-6 scroll-mt-24', className)}
>
{/* `!mt-0` / `!mb-0`: the global h1h6 rule sets Newsreader at weight 700 with a 24px margin, so
a heading element has to opt out of all three explicitly or it opts out of none. */}
<h3
className="font-dm-sans text-ink-400 !mt-0 !mb-3 font-normal uppercase !leading-none"
style={{fontSize: '10px', letterSpacing: '0.18em'}}
>
{title}
</h3>
<div className="border-canvas-300 border-t" />
</div>
)
}

View File

@@ -3,7 +3,8 @@ import {User} from 'common/user'
import {useState} from 'react'
import {Col} from 'web/components/layout/col'
import {Modal} from 'web/components/layout/modal'
import {ShareProfileButtons} from 'web/components/widgets/share-profile-button'
import {ShareCTAButton} from 'web/components/widgets/share-cta-button'
import {useProfileShareUrl} from 'web/components/widgets/share-profile-button'
import {useT} from 'web/lib/locale'
import {ProfileCardViewer} from './profile-card-viewer'
@@ -70,8 +71,9 @@ export const ViewProfileCardButton = (props: {
const {user, profile, width, height} = props
const [open, setOpen] = useState<boolean>(false)
const t = useT()
// Hooks run before the guard below, so the empty-username fallback is only ever a placeholder.
const shareUrl = useProfileShareUrl(user?.username ?? '')
if (!user || !profile) return
const username = user.username
return (
<>
<button
@@ -96,11 +98,21 @@ export const ViewProfileCardButton = (props: {
<Modal open={open} setOpen={setOpen} size={'lg'} className={''}>
<Col className="gap-4 bg-canvas-100/75 rounded-2xl justify-center">
<ProfileCardViewer user={user} profile={profile} width={width} height={height} />
<ShareProfileButtons
username={username}
className={'justify-center gap-4 text-3xl pb-4'}
buttonClassName={'hover:bg-canvas-200'}
/>
{/* One primary share action rather than the former X / LinkedIn / share trio: the OS share
sheet already covers every destination those two buttons hard-coded, so the row was three
controls competing to be the same thing. Same CTA styling as the /about closing block. */}
<div className="flex justify-center pb-4">
<ShareCTAButton
url={shareUrl}
shareTitle={t('share_profile.share.title', 'A profile worth seeing on Compass')}
shareText={t(
'share_profile.share.text',
'Thought you might want to see this profile on Compass — a free directory for finding your people, searchable by values, interests, and demographics. No ads, no swiping, no dubious algorithm.',
)}
label={t('button.share.label', 'Share')}
copiedLabel={t('copy_link_button.link_copied', 'Link copied!')}
/>
</div>
</Col>
</Modal>
</>

View File

@@ -0,0 +1,225 @@
import clsx from 'clsx'
import {ChevronDownIcon} from 'lucide-react'
import {useEffect, useState} from 'react'
import {Col} from 'web/components/layout/col'
import {Modal, MODAL_CLASS} from 'web/components/layout/modal'
import {Row} from 'web/components/layout/row'
import {useT} from 'web/lib/locale'
type FormSection = {id: string; title: string}
/**
* The profile form's sections and which one is currently being read.
*
* The list is read from the DOM rather than declared here. The form's markup is the source of truth
* for what exists and in what order, and a hand-kept copy of that list would silently drift the first
* time a section moved — which it did.
*
* Shared by both presentations below so the rail and the mobile chip cannot disagree about where you
* are.
*/
function useFormSections() {
const [sections, setSections] = useState<FormSection[]>([])
const [activeId, setActiveId] = useState<string | null>(null)
useEffect(() => {
const form = document.querySelector('[data-profile-form]')
if (!form) return
// Bail when the list is unchanged. `setSections` with a fresh array on every call re-renders,
// which mutates the DOM, which fires the observer again — a loop that also thrashed the scroll
// listener below, since it re-subscribes whenever `sections` changes identity.
const read = () =>
setSections((prev) => {
const next = Array.from(form.querySelectorAll<HTMLElement>('[data-form-section]')).map(
(node) => ({id: node.id, title: node.dataset.formSection ?? ''}),
)
const same =
prev.length === next.length &&
prev.every((p, i) => p.id === next[i].id && p.title === next[i].title)
return same ? prev : next
})
read()
// Sections come and go with the form's own conditionals — Family only exists once a relationship
// is being sought — so the list is re-read when the form's subtree changes.
const mutations = new MutationObserver(read)
mutations.observe(form, {childList: true, subtree: true})
return () => mutations.disconnect()
}, [])
useEffect(() => {
if (sections.length === 0) return
// Read straight from scroll position rather than with an IntersectionObserver. The observer
// version needed a band across the upper viewport to fire in, and a section heading is a few
// pixels tall — during any normal scroll the headings jumped clean over the band and nothing ever
// became active. Position always answers: the active section is simply the last one whose heading
// has passed the line.
const update = () => {
const line = window.innerHeight * 0.25
let current = sections[0].id
for (const {id} of sections) {
const el = document.getElementById(id)
if (!el || el.getBoundingClientRect().top > line) break
current = id
}
setActiveId(current)
}
update()
window.addEventListener('scroll', update, {passive: true})
window.addEventListener('resize', update)
return () => {
window.removeEventListener('scroll', update)
window.removeEventListener('resize', update)
}
}, [sections])
const activeIndex = sections.findIndex((s) => s.id === activeId)
return {
sections,
activeId,
activeIndex: activeIndex === -1 ? 0 : activeIndex,
goTo: (id: string) => document.getElementById(id)?.scrollIntoView({behavior: 'smooth'}),
}
}
/**
* Sticky section index for wide screens, in the gutter beside the form.
*
* The form is eighteen sections and several thousand pixels tall. Without an index the only way to
* reach Substances is to scroll past everything before it, and there is no way to tell how much is
* left — which is the difference between a form that feels long and one that feels endless.
*/
export function ProfileFormNav(props: {className?: string}) {
const {className} = props
const {sections, activeId, goTo} = useFormSections()
if (sections.length === 0) return null
return (
<nav className={clsx('flex flex-col gap-0.5', className)} aria-label="Form sections">
{sections.map(({id, title}) => {
const isActive = id === activeId
return (
<button
key={id}
type="button"
onClick={() => goTo(id)}
aria-current={isActive ? 'true' : undefined}
className={clsx(
'border-l-2 py-1.5 pl-3 text-left text-sm transition-colors',
isActive
? 'border-primary-500 text-ink-1000'
: 'border-canvas-300 text-ink-500 hover:border-primary-400 hover:text-ink-800',
)}
>
{title}
</button>
)
})}
</nav>
)
}
/**
* The same index for narrow screens, as one sticky row: position in the form, the current section,
* and the full list one tap away.
*
* Not a reflow of the rail — stacked above the form, eighteen links would be a screenful of
* navigation to scroll past before reaching the first field, on the very form whose problem is
* length. The need is greater here than on desktop, since every field stacks and the page runs
* roughly three times longer; the answer just has to cost one row instead of a screen.
*
* It hides while you scroll down and returns when you scroll up. A permanent bar would spend 44px of
* an already short viewport on something wanted a few times per session, and the bottom edge is
* already taken by the nav bar and the floating Save button.
*/
export function ProfileFormSectionBar(props: {className?: string}) {
const {className} = props
const {sections, activeId, activeIndex, goTo} = useFormSections()
const [open, setOpen] = useState(false)
const [hidden, setHidden] = useState(false)
const t = useT()
useEffect(() => {
let lastY = window.scrollY
const onScroll = () => {
const y = window.scrollY
// The 8px deadband stops momentum jitter from flickering the bar on and off.
if (Math.abs(y - lastY) > 8) {
setHidden(y > lastY && y > 160)
lastY = y
}
}
window.addEventListener('scroll', onScroll, {passive: true})
return () => window.removeEventListener('scroll', onScroll)
}, [])
if (sections.length === 0) return null
const active = sections[activeIndex]
return (
<>
{/* `--tnh` is the native top inset (globals.css). Pinned at `top-0` this sat under the Android
status bar in the Capacitor shell; the variable is 0 in a browser, so one rule covers both. */}
<div
className={clsx(
'bg-canvas-100/95 border-canvas-300 sticky top-[var(--tnh)] z-20 -mx-6 border-b px-6 backdrop-blur transition-transform duration-200',
hidden && '-translate-y-full',
className,
)}
>
<button
type="button"
onClick={() => setOpen(true)}
aria-haspopup="dialog"
className="flex w-full items-center gap-3 py-3 text-left"
>
<span className="text-ink-400 font-dm-sans tabular-nums" style={{fontSize: '11px'}}>
{activeIndex + 1}/{sections.length}
</span>
<span className="text-ink-900 min-w-0 flex-1 truncate text-sm">{active?.title}</span>
<ChevronDownIcon className="text-ink-500 h-4 w-4 flex-none" />
</button>
</div>
<Modal open={open} setOpen={setOpen}>
<Col className={MODAL_CLASS}>
<div
className="text-ink-400 font-dm-sans mb-2 uppercase"
style={{fontSize: '10px', letterSpacing: '0.18em'}}
>
{t('profile.form.sections', 'Sections')}
</div>
<Col className="w-full">
{sections.map(({id, title}, i) => (
<button
key={id}
type="button"
onClick={() => {
setOpen(false)
goTo(id)
}}
aria-current={id === activeId ? 'true' : undefined}
className={clsx(
'border-canvas-200 flex w-full items-center gap-3 border-b py-3 text-left text-sm last:border-b-0',
id === activeId ? 'text-primary-700' : 'text-ink-700',
)}
>
<Row className="text-ink-400 w-6 flex-none tabular-nums" style={{fontSize: '11px'}}>
{i + 1}
</Row>
{title}
</button>
))}
</Col>
</Col>
</Modal>
</>
)
}

View File

@@ -20,7 +20,7 @@ import {
Wine,
} from 'lucide-react'
import Link from 'next/link'
import React, {useEffect, useMemo, useRef, useState} from 'react'
import React, {useMemo, useRef, useState} from 'react'
import {PiMagnifyingGlassBold} from 'react-icons/pi'
import {LocationFilterProps} from 'web/components/filters/location-filter'
import GenderIcon from 'web/components/gender-icon'
@@ -38,6 +38,7 @@ import {LoadMoreUntilNotVisible} from 'web/components/widgets/visibility-observe
import {BookmarkedSearchesType} from 'web/hooks/use-bookmarked-searches'
import {useChoicesContext} from 'web/hooks/use-choices'
import {ColumnCountOptions, useColumnCount} from 'web/hooks/use-column-count'
import {useLongPressReveal} from 'web/hooks/use-long-press-reveal'
import {isDark, useTheme} from 'web/hooks/use-theme'
import {useUser} from 'web/hooks/use-user'
import {useT} from 'web/lib/locale'
@@ -83,9 +84,6 @@ type CardSizeConfig = {
const MIN_BIO_LINES = 2
/** How long a touch has to stay down before it reveals the card actions instead of navigating. */
const LONG_PRESS_MS = 400
const CARD_SIZE_CONFIG: Record<CardSize, CardSizeConfig> = {
small: {
columns: {minCardWidth: 300, maxColumns: 4},
@@ -488,30 +486,15 @@ function ProfilePreview(props: {
const pointerStartRef = useRef<{x: number; y: number} | null>(null)
// Touch has no hover, so the actions can't hide behind one — a press and hold reveals them.
const cardRef = useRef<HTMLDivElement>(null)
const [showActions, setShowActions] = useState(false)
const longPressTimeoutRef = useRef<NodeJS.Timeout | null>(null)
/** Set once the hold fires, so the release that follows opens the actions instead of the profile. */
const longPressedRef = useRef(false)
const clearLongPress = () => {
if (longPressTimeoutRef.current) {
clearTimeout(longPressTimeoutRef.current)
longPressTimeoutRef.current = null
}
}
// Any press outside puts them away again — including a press on another card, which reveals its own.
useEffect(() => {
if (!showActions) return
const onDocumentPointerDown = (e: PointerEvent) => {
if (!cardRef.current?.contains(e.target as Node)) setShowActions(false)
}
document.addEventListener('pointerdown', onDocumentPointerDown)
return () => document.removeEventListener('pointerdown', onDocumentPointerDown)
}, [showActions])
useEffect(() => clearLongPress, [])
const {
containerRef: cardRef,
revealed: showActions,
longPressedRef,
start: startLongPress,
move: moveLongPress,
cancel: clearLongPress,
handlers: longPressHandlers,
} = useLongPressReveal<HTMLDivElement>()
const {theme} = useTheme()
const isDarkTheme = isDark(theme)
@@ -551,23 +534,12 @@ function ProfilePreview(props: {
}
pointerStartRef.current = {x: e.clientX, y: e.clientY}
longPressedRef.current = false
// Mouse users get the actions on hover, so holding the button down shouldn't hijack their click.
if (e.pointerType !== 'mouse') {
clearLongPress()
longPressTimeoutRef.current = setTimeout(() => {
longPressTimeoutRef.current = null
longPressedRef.current = true
setShowActions(true)
}, LONG_PRESS_MS)
}
startLongPress(e)
}
// A press that turns into a scroll is not a hold.
const handlePointerMove = (e: React.PointerEvent) => {
const start = pointerStartRef.current
if (!start || !longPressTimeoutRef.current) return
if (Math.abs(e.clientX - start.x) > 10 || Math.abs(e.clientY - start.y) > 10) clearLongPress()
moveLongPress(e)
}
const handlePointerUp = (e: React.PointerEvent) => {
@@ -605,20 +577,9 @@ function ProfilePreview(props: {
pointerStartRef.current = null
}
const handleClick = (e: React.MouseEvent) => {
// Browsers still fire a click after the hold, which would navigate away from the actions we
// just revealed.
if (longPressedRef.current) {
e.preventDefault()
longPressedRef.current = false
}
}
// The hold would otherwise also pop the browser's own link menu on top of the actions. Only
// suppressed for a touch hold — right-click ("open in new tab") stays intact.
const handleContextMenu = (e: React.MouseEvent) => {
if (longPressedRef.current) e.preventDefault()
}
// Browsers still fire a click after the hold, which would navigate away from the actions we just
// revealed; the hold would likewise pop the browser's own link menu on top of them.
const {onClick: handleClick, onContextMenu: handleContextMenu} = longPressHandlers
// If this profile was just hidden, render a compact placeholder with Undo action.
if (isHidden) {

View File

@@ -94,8 +94,11 @@ export function ProfileInfo(props: {
return (
<>
{/* The bar sticks to the viewport top, which on native sits under the Android/iOS status bar.
Pull it up through the body's safe-area padding and add that inset back as padding, so the
bar's background fills the status-bar strip while its buttons stay clear of the native nav. */}
<div
className="bg-canvas-50/95 border-canvas-300 sticky top-0 z-20 mb-6 flex items-center border-b px-4 sm:px-9 py-4 backdrop-blur"
className="bg-canvas-50/95 border-canvas-300 sticky top-0 z-20 mb-6 flex items-center border-b px-4 sm:px-9 py-4 -mt-[env(safe-area-inset-top)] pt-[calc(1rem+env(safe-area-inset-top))] backdrop-blur"
style={{
borderBottomWidth: '1px',
gap: '10px',

View File

@@ -161,8 +161,10 @@ export function VoiceAutofillSection(props: {
fold on short viewports. Talking points stay visible while recording — they are the
script. On mobile they stack, mic underneath. */}
<Col className="gap-4 sm:flex-row sm:items-center">
<div className="flex-1 rounded-xl bg-canvas-50 ring-1 ring-canvas-200 p-4">
<p className="text-sm font-semibold text-ink-900">
{/* A keyline, not a ringed card. These are talking points beside the mic, not a panel of
their own — and the page has no other boxes left to be consistent with. */}
<div className="border-canvas-300 flex-1 border-l pl-4">
<p className="text-ink-800 text-sm">
{t('profile.voice.prompts.title', 'Talk freely, or use these as a starting point:')}
</p>
<ul className="mt-2 space-y-1 text-sm text-ink-700">

View File

@@ -70,17 +70,24 @@ export const AddPhotosWidget = (props: {
/>
<Row className="flex-wrap gap-2">
<div className="relative" data-testid="profile-upload-photo">
{/* Dashed and captioned, matching the empty states elsewhere. A flat grey rectangle with a
64px plus in it read as a failed image rather than an invitation — and this is the most
valuable field on the form. `text-gray-500` was also an off-palette literal that does not
flip with the theme. */}
<label
className={clsx(
'bg-canvas-50 hover:bg-ink-300 text-ink-0 dark:text-ink-500 hover:dark:text-ink-600 flex h-[200px] w-[200px] cursor-pointer flex-col items-center rounded-md transition-colors',
'border-canvas-300 text-ink-500 hover:border-primary-400 hover:text-primary-600 flex h-[200px] w-[200px] cursor-pointer flex-col items-center justify-center gap-2 rounded-xl border border-dashed transition-colors',
uploadingImages && 'opacity-50 cursor-not-allowed',
)}
htmlFor="photo-upload"
>
{uploadingImages ? (
<div className="mx-auto my-auto h-16 w-16 animate-spin rounded-full border-b-2 border-gray-500"></div>
<div className="border-primary-500 h-10 w-10 animate-spin rounded-full border-b-2" />
) : (
<PlusIcon className="mx-auto my-auto h-16 w-16 text-gray-500" />
<>
<PlusIcon className="h-8 w-8" />
<span className="text-sm">{t('profile.optional.add_photos', 'Add photos')}</span>
</>
)}
</label>
</div>

View File

@@ -3,12 +3,18 @@ import clsx from 'clsx'
import {toKey} from 'common/parsing'
import {useT} from 'web/lib/locale'
// Selected states mirror MultiCheckbox's chip exactly, including the amber lift on the filled one, so
// a single-choice control and a multi-choice control read as the same kind of thing.
const SELECTED_CTA =
'aria-checked:border-cta aria-checked:bg-cta aria-checked:text-white aria-checked:shadow-[0_2px_8px_rgba(193,127,62,0.28)]'
const UNSELECTED =
'border-canvas-300 bg-canvas-0 text-ink-600 hover:border-primary-400 hover:text-primary-700'
const colorClasses = {
'indigo-dark':
'text-ink-700 hover:bg-primary-100 hover:text-ink-700 aria-checked:bg-cta aria-checked:text-white',
indigo: 'text-ink-700 hover:bg-ink-50 aria-checked:bg-primary-100 aria-checked:text-primary-900',
green: 'text-ink-700 hover:bg-ink-50 aria-checked:bg-teal-500 aria-checked:text-ink-0',
red: 'text-ink-700 hover:bg-ink-50 aria-checked:bg-scarlet-500 aria-checked:text-ink-0',
'indigo-dark': `${UNSELECTED} ${SELECTED_CTA}`,
indigo: `${UNSELECTED} aria-checked:border-primary-400 aria-checked:bg-primary-100 aria-checked:text-primary-900`,
green: `${UNSELECTED} aria-checked:border-teal-500 aria-checked:bg-teal-500 aria-checked:text-ink-0`,
red: `${UNSELECTED} aria-checked:border-scarlet-500 aria-checked:bg-scarlet-500 aria-checked:text-ink-0`,
}
export type ColorType = keyof typeof colorClasses
@@ -18,7 +24,10 @@ export function ChoicesToggleGroup<T extends Record<string, string | number | bo
choicesMap: T
disabled?: boolean
disabledOptions?: Array<T[keyof T]>
setChoice: (val: T[keyof T]) => void
/** Receives `null` when the selected option is clicked again to clear it. */
setChoice: (val: T[keyof T] | null) => void
/** Opt out where the field genuinely has no empty state. */
allowDeselect?: boolean
color?: ColorType
className?: string
toggleClassName?: string
@@ -36,17 +45,21 @@ export function ChoicesToggleGroup<T extends Record<string, string | number | bo
children,
toggleClassName,
translationPrefix,
allowDeselect = true,
} = props
const t = useT()
// Object.entries(choicesMap).map(([choiceKey, choice]) => (
// console.log(`${translationPrefix}.${toKey(String(choice))}`)
// ))
return (
// No container box. A bordered well holding segments was a second selection language sitting
// beside the pills that MultiCheckbox renders — and on the profile form the two appear within a
// few hundred pixels of each other. Free-floating pills, styled to match, are the one language.
<RadioGroup
className={clsx(
className,
'flex flex-wrap border-ink-300 text-ink-400 bg-canvas-50 flex-row gap-2 rounded-md border p-1 text-sm shadow-sm',
disabled && 'bg-canvas-50 !cursor-not-allowed',
'flex flex-row flex-wrap gap-2',
disabled && '!cursor-not-allowed',
)}
value={currentChoice ?? undefined}
onChange={setChoice}
@@ -57,12 +70,25 @@ export function ChoicesToggleGroup<T extends Record<string, string | number | bo
key={choiceKey}
value={choice}
disabled={disabledOptions?.includes(choice as any)}
// A radio group has no way back to empty: once an answer is picked the field is committed
// for good, which is wrong on a form where every field is optional. Clicking the selected
// option clears it. RadioGroup fires no `onChange` when the value is unchanged, so this
// handler is the only thing that runs on that click.
onClick={() => {
if (allowDeselect && choice === currentChoice) setChoice(null)
}}
title={
allowDeselect && choice === currentChoice
? t('common.click_to_clear', 'Click to clear')
: undefined
}
className={({disabled}) =>
clsx(
'inline-flex cursor-pointer select-none items-center rounded-full border px-3.5 py-1.5 text-sm font-medium outline-none transition-all duration-150',
'focus-visible:ring-2 focus-visible:ring-primary-400 focus-visible:ring-offset-1 focus-visible:ring-offset-canvas-50',
disabled
? 'text-ink-300 aria-checked:bg-ink-300 aria-checked:text-ink-0 cursor-not-allowed'
: 'cursor-pointer ' + colorClasses[color],
'ring-primary-500 flex items-center rounded-md p-2 outline-none transition-all focus-visible:ring-2 sm:px-3',
? 'border-canvas-200 bg-canvas-100 text-ink-300 cursor-not-allowed'
: colorClasses[color],
toggleClassName,
)
}

View File

@@ -15,8 +15,13 @@ export const Input = forwardRef(
const {error, searchIcon, className, value, onChange, ...rest} = props
const [hasValue, setHasValue] = useState(!!value)
// `focus-within` rather than `focus`: with `searchIcon` the border lives on this wrapper while the
// caret is in a child input, so a plain `focus:` variant never fires and the field lights up
// nowhere. The ring is the only thing telling you which of forty fields you are typing into.
const rowClassName = clsx(
'bg-canvas-50 h-12 rounded-xl border border-canvas-200 px-4 shadow-sm transition-colors items-center gap-2',
'bg-canvas-50 h-12 rounded-xl border border-canvas-300 px-4 shadow-sm items-center gap-2',
'transition-all duration-150 hover:border-primary-300',
'focus-within:border-primary-400 focus-within:ring-2 focus-within:ring-primary-500/25',
className,
)
@@ -41,10 +46,10 @@ export const Input = forwardRef(
value={value}
onChange={handleChange}
className={clsx(
'w-full bg-canvas-50 invalid:border-error invalid:text-error invalid:placeholder-rose-700 focus:outline-none focus:ring-1 disabled:cursor-not-allowed md:text-sm [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:m-0 [&::-webkit-outer-spin-button]:m-0',
'w-full bg-canvas-50 invalid:border-error invalid:text-error invalid:placeholder-rose-700 focus:outline-none focus:ring-0 disabled:cursor-not-allowed md:text-sm [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:m-0 [&::-webkit-outer-spin-button]:m-0',
error
? 'border-error text-error focus:border-error focus:ring-error placeholder-rose-700' // matches invalid: styles
: 'focus:border-canvas-200 focus:ring-transparent',
: '',
!searchIcon && rowClassName,
)}
{...rest}
@@ -54,13 +59,13 @@ export const Input = forwardRef(
if (searchIcon)
return (
<Row className={rowClassName}>
<Search className="w-4 h-4 text-canvas-400" />
<Search className="w-4 h-4 text-ink-400" />
{elem}
{hasValue && (
<button
type="button"
onClick={handleClear}
className="text-canvas-400 hover:text-primary-600 transition-colors"
className="text-ink-400 hover:text-primary-600 transition-colors"
>
<X className="w-4 h-4" />
</button>

View File

@@ -9,7 +9,9 @@ export const Select = forwardRef<HTMLSelectElement, ComponentPropsWithoutRef<'se
<select
ref={ref}
className={clsx(
'bg-canvas-50 text-ink-1000 border-ink-300 focus:border-primary-500 focus:ring-primary-500 h-12 cursor-pointer self-start overflow-hidden rounded-xl border pl-4 pr-10 text-sm shadow-sm focus:outline-none',
// Matches Input: same border token, same hover, same focus ring. It sat on `border-ink-300` with a
// bare 1px focus border, so a select and a text field next to each other looked unrelated.
'bg-canvas-50 text-ink-1000 border-canvas-300 h-12 cursor-pointer self-start overflow-hidden rounded-xl border pl-4 pr-10 text-sm shadow-sm transition-all duration-150 hover:border-primary-300 focus:border-primary-400 focus:outline-none focus:ring-2 focus:ring-primary-500/25',
className,
)}
{...rest}

View File

@@ -11,18 +11,25 @@ import {Row} from 'web/components/layout/row'
import {useUser} from 'web/hooks/use-user'
import {useT} from 'web/lib/locale'
/**
* The link a share of `username`'s profile should carry: tagged with the sharer's `?referrer=` so the
* share gets credited to them, bare when the sharer is signed out or is the profile's own owner.
*/
export const useProfileShareUrl = (username: string) => {
const currentUser = useUser()
return currentUser && currentUser.username !== username
? `https://${ENV_CONFIG.domain}/${username}?referrer=${currentUser.username}`
: `https://${ENV_CONFIG.domain}/${username}`
}
export const ShareProfileButton = (props: {
username: string
className?: string
color?: ColorType
}) => {
const {username, className, color} = props
const currentUser = useUser()
const t = useT()
const shareUrl =
currentUser && currentUser.username !== username
? `https://${ENV_CONFIG.domain}/${username}?referrer=${currentUser.username}`
: `https://${ENV_CONFIG.domain}/${username}`
const shareUrl = useProfileShareUrl(username)
return (
<CopyLinkOrShareButton

View File

@@ -0,0 +1,104 @@
import {useEffect, useRef, useState} from 'react'
/** How long a touch has to stay down before it reveals the hidden actions. */
export const LONG_PRESS_MS = 400
/** A press that drifts further than this is a scroll, not a hold. */
const MOVE_TOLERANCE_PX = 10
/**
* Reveals actions that are otherwise hidden behind hover, for devices that have no hover: a press
* and hold uncovers them, and the next press anywhere else puts them away again.
*
* The caller owns the markup — attach `containerRef` to the element that should count as "inside"
* for the dismiss-on-outside-press, spread `handlers` onto it, and drive the actions' visibility
* from `revealed`. Consumers with their own pointer logic (press feedback, navigation) can call
* `start` / `move` / `cancel` from inside their own handlers instead of spreading `handlers`.
*/
export function useLongPressReveal<T extends HTMLElement = HTMLElement>() {
const containerRef = useRef<T>(null)
const [revealed, setRevealed] = useState(false)
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
const startRef = useRef<{x: number; y: number} | null>(null)
/** Set once the hold fires, so the release that follows opens the actions instead of the link. */
const longPressedRef = useRef(false)
const cancel = () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
}
// Any press outside puts them away again — including a press on a sibling, which reveals its own.
useEffect(() => {
if (!revealed) return
const onDocumentPointerDown = (e: PointerEvent) => {
if (!containerRef.current?.contains(e.target as Node)) setRevealed(false)
}
document.addEventListener('pointerdown', onDocumentPointerDown)
return () => document.removeEventListener('pointerdown', onDocumentPointerDown)
}, [revealed])
useEffect(() => cancel, [])
const start = (e: React.PointerEvent) => {
longPressedRef.current = false
// Mouse users get the actions on hover, so holding the button down shouldn't hijack their click.
if (e.pointerType === 'mouse') return
// A press that lands on one of the revealed actions is that action's, not a fresh hold.
const target = e.target as HTMLElement | null
if (!target || target.closest('button, [role="button"]')) return
startRef.current = {x: e.clientX, y: e.clientY}
cancel()
timeoutRef.current = setTimeout(() => {
timeoutRef.current = null
longPressedRef.current = true
setRevealed(true)
}, LONG_PRESS_MS)
}
const move = (e: React.PointerEvent) => {
const start = startRef.current
if (!start || !timeoutRef.current) return
if (
Math.abs(e.clientX - start.x) > MOVE_TOLERANCE_PX ||
Math.abs(e.clientY - start.y) > MOVE_TOLERANCE_PX
)
cancel()
}
// Browsers still fire a click after the hold, which would act on whatever sits under the finger.
const onClick = (e: React.MouseEvent) => {
if (longPressedRef.current) {
e.preventDefault()
longPressedRef.current = false
}
}
// The hold would otherwise also pop the browser's own menu on top of the actions. Only suppressed
// for a touch hold — right-click ("open in new tab") stays intact.
const onContextMenu = (e: React.MouseEvent) => {
if (longPressedRef.current) e.preventDefault()
}
return {
containerRef,
revealed,
setRevealed,
/** True between the hold firing and the click it produces — check before acting on that click. */
longPressedRef,
start,
move,
cancel,
handlers: {
onPointerDown: start,
onPointerMove: move,
onPointerUp: cancel,
onPointerCancel: cancel,
onClick,
onContextMenu,
},
}
}

View File

@@ -12,6 +12,7 @@ import {BackButton} from 'web/components/back-button'
import {Col} from 'web/components/layout/col'
import {OptionalProfileUserForm} from 'web/components/optional-profile-form'
import {PageBase} from 'web/components/page-base'
import {ProfileFormNav, ProfileFormSectionBar} from 'web/components/profile-form-nav'
import {RequiredProfileUserForm} from 'web/components/required-profile-form'
import {SEO} from 'web/components/SEO'
import {CompassLoadingIndicator} from 'web/components/widgets/loading-indicator'
@@ -110,23 +111,31 @@ function ProfilePageInner(props: {user: User; profile: Profile}) {
description={t('profile.seo.description', 'Your Profile')}
url={`/profile`}
/>
<Col className="items-center">
<Col className="w-full">
<BackButton className="ml-2 mb-2 self-start" />
<Col className={'w-full px-6 py-4'}>
<RequiredProfileUserForm
data={baseUser}
setData={setBaseUserState}
profileCreatedAlready
/>
<div className={'h-4'} />
<OptionalProfileUserForm
profile={profile}
setProfile={setProfileState}
user={baseUser}
buttonLabel={t('profile.save', 'Save')}
onSubmit={submitForm}
/>
</Col>
{/* The index sits in the left gutter from `xl` up, where there is room for it beside a form
capped at 3xl. Below that it is hidden rather than stacked: a list of eighteen links above
the first field would be a second thing to read before starting. */}
<div className="mx-auto flex w-full max-w-6xl gap-10 px-6 py-4">
<ProfileFormNav className="sticky top-8 hidden h-fit w-52 shrink-0 xl:flex" />
<Col className={'w-full min-w-0 max-w-3xl'}>
{/* Same index, one row instead of a column, wherever the rail does not fit. */}
<ProfileFormSectionBar className="xl:hidden" />
<RequiredProfileUserForm
data={baseUser}
setData={setBaseUserState}
profileCreatedAlready
/>
<div className={'h-4'} />
<OptionalProfileUserForm
profile={profile}
setProfile={setProfileState}
user={baseUser}
buttonLabel={t('profile.save', 'Save')}
onSubmit={submitForm}
/>
</Col>
</div>
</Col>
</PageBase>
)

View File

@@ -12,6 +12,7 @@ import {ensureDeviceToken} from 'web/components/auth-context'
import {BackButton} from 'web/components/back-button'
import {Col} from 'web/components/layout/col'
import {OptionalProfileUserForm} from 'web/components/optional-profile-form'
import {ProfileFormNav, ProfileFormSectionBar} from 'web/components/profile-form-nav'
import {initialRequiredState, RequiredProfileUserForm} from 'web/components/required-profile-form'
import {CompassLoadingIndicator} from 'web/components/widgets/loading-indicator'
import {StepProgress} from 'web/components/widgets/step-progress'
@@ -140,21 +141,26 @@ export default function SignupPage() {
</div>
</Col>
) : (
<Col className={'w-full max-w-4xl px-6 py-4'}>
<BackButton className="-ml-2 mb-2 self-start" />
{/* The flow had no progress indicator at all: two substantial form steps that looked alike,
// Width is set per step rather than on the shared column. Step 1 is the eighteen-section form
// plus its index, and inside a 4xl column the index left it about 550px wide — narrower than
// the same form gets on /profile, with the page's whole right half empty.
<Col className={'w-full px-6 py-4'}>
<div className="mx-auto w-full max-w-4xl">
<BackButton className="-ml-2 mb-2 self-start" />
{/* The flow had no progress indicator at all: two substantial form steps that looked alike,
so the only way to learn there were two was to finish the first. The bar is deliberately
the same component as /onboarding — those three screens lead directly here, and a reader
who has just watched a 3-segment bar fill should recognise this as the next one. */}
<StepProgress
current={step + 1}
total={TOTAL_STEPS}
label={t('common.step_progress', 'Step {current} of {total}', {
current: step + 1,
total: TOTAL_STEPS,
})}
className="mb-7"
/>
<StepProgress
current={step + 1}
total={TOTAL_STEPS}
label={t('common.step_progress', 'Step {current} of {total}', {
current: step + 1,
total: TOTAL_STEPS,
})}
className="mb-7"
/>
</div>
{step === 0 ? (
// Card + centered, matching the /onboarding screens that lead directly here — two short
// fields floating left in a wide column read as unfinished, not deliberate.
@@ -166,15 +172,22 @@ export default function SignupPage() {
/>
</div>
) : step === 1 ? (
<Col className={'w-full px-2 sm:px-6 py-4 mb-2 '}>
<OptionalProfileUserForm
profile={profileForm}
setProfile={setProfileState}
user={baseUser}
bottomNavBarVisible={false}
onSubmit={handleFinalSubmit}
/>
</Col>
// Same index as /profile: this is the same eighteen-section form, and it is longer here
// because nothing is filled in yet. Hidden below `xl`, where there is no gutter to put it
// in without pushing a list of links above the first field.
<div className={'mx-auto mb-2 flex w-full max-w-6xl gap-10 py-4'}>
<ProfileFormNav className="sticky top-8 hidden h-fit w-52 shrink-0 xl:flex" />
<Col className={'w-full min-w-0 max-w-3xl'}>
<ProfileFormSectionBar className="xl:hidden" />
<OptionalProfileUserForm
profile={profileForm}
setProfile={setProfileState}
user={baseUser}
bottomNavBarVisible={false}
onSubmit={handleFinalSubmit}
/>
</Col>
</div>
) : (
<CompassLoadingIndicator />
)}
@@ -189,7 +202,9 @@ export default function SignupPage() {
// they sat as far apart from each other as consecutive fields did. The vertical rhythm between
// fields comes from the parent `Col`'s `gap-8`, not from here.
export const colClassName = 'items-start gap-1.5 w-full'
export const labelClassName = 'font-semibold text-md text-ink-900'
// Quiet, not bold. A form label names the field; the input is the thing with content in it, and
// `font-semibold text-ink-900` gave the two equal weight down a fifty-field page.
export const labelClassName = 'text-ink-500 text-sm font-normal'
function getInitialBaseUser() {
const emailName = auth.currentUser?.email?.replace(/@.*$/, '')

View File

@@ -100,13 +100,19 @@ function HeroBand({
aria-hidden
className="pointer-events-none absolute -right-16 -top-20 h-64 w-64 rounded-full bg-primary-500/20 blur-3xl"
/>
<div className="relative flex flex-wrap gap-x-10 gap-y-7 sm:gap-x-14">
{/* Mobile is a 2-column grid rather than a wrapping flex row: with flex-wrap the tiles are sized by
their digits, so the labels never line up and the last stat is left dangling at a different
x-offset than the one above it. The grid keeps every label on a shared baseline and gives the
headline number its own full-width row, which is where the emphasis belonged anyway. From `sm`
up there is room for the single-row band, so it reverts to the flex layout. */}
<div className="relative grid grid-cols-2 gap-x-8 gap-y-7 sm:flex sm:flex-wrap sm:gap-x-14">
{items.map((item, i) => (
<div
key={item.label}
className={clsx(
i === 0 && 'sm:border-r sm:border-white/10 sm:pr-14',
i > 0 && 'min-w-[92px]',
i === 0 &&
'col-span-2 border-b border-white/10 pb-7 sm:border-b-0 sm:border-r sm:border-white/10 sm:pb-0 sm:pr-14',
i > 0 && 'sm:min-w-[92px]',
)}
>
<div