Refine profile page: unify small text styles with new font-microcaps, replace custom separators with DottedList, and update layout gaps for improved spacing logic and theme consistency.

This commit is contained in:
MartinBraquet
2026-07-29 14:12:53 +02:00
parent 69872de7a5
commit 33b047b02c
5 changed files with 133 additions and 75 deletions

View File

@@ -216,30 +216,79 @@ export function ProfileConnectionGoals(props: {profile: Profile}) {
const {seekingText, relationshipText, romanticStyles} = goals
const status = [relationshipText, ...(romanticStyles ?? [])].filter(Boolean).join(' · ')
const status = [relationshipText, ...(romanticStyles ?? [])].filter(Boolean).join(DOT_SEPARATOR)
return (
<div
className="border-canvas-300 mt-2 flex max-w-2xl flex-wrap gap-x-16 gap-y-6 border-t pt-6"
data-testid="profile-connection-goals"
>
<HeroFact label={t('profile.looking_for', 'Looking for')}>{seekingText}</HeroFact>
{status && <HeroFact label={t('profile.status', 'Status')}>{status}</HeroFact>}
{/* Split back apart at render: `seekingText` is still consumed as one plain string by
`SeekingAndRelationship`, so the builders keep returning strings. */}
<HeroFact label={t('profile.looking_for', 'Looking for')}>
<DottedList items={seekingText.split(DOT_SEPARATOR)} />
</HeroFact>
{status && (
<HeroFact label={t('profile.status', 'Status')}>
<DottedList items={status.split(DOT_SEPARATOR)} />
</HeroFact>
)}
</div>
)
}
export const DOT_SEPARATOR = ' · '
/**
* A run of fragments joined by middots, with the middots stepped back from the words.
*
* The separator is punctuation, not content, so it should not sit at the same weight as the text it
* divides — the eyebrow over the name has always dimmed its own slashes, and these now match.
*
* Carries no colour or size of its own: every caller has a different register (a hero fact, a tag
* run, a qualifier under a rail value) and the run should inherit whichever it lands in.
*/
function DottedList(props: {items: string[]}) {
const {items} = props
return (
<>
{items.map((item, i) => (
<React.Fragment key={i}>
{/* The spaces around the middot are load-bearing, not decoration: they are the only
soft-wrap opportunities in the run. Spacing the separator with padding instead leaves the
whole list one unbreakable inline chain, which overflows any container narrower than the
full run — the Interests block pushed the grid wide enough to scroll the page sideways. */}
{i > 0 && (
<>
{' '}
<span aria-hidden className="text-ink-500/50 select-none">
·
</span>{' '}
</>
)}
{item}
</React.Fragment>
))}
</>
)
}
/**
* Label over value, for the two facts that close the hero.
*
* Both lines stepped down from where they were. The label was `ink-400`, the one neutral grey in a
* warm ramp, at 10px — 2.15:1 in light and 3.0:1 in dark, under AA either way for text that small.
* The value was `ink-900`, which in dark (#F7F4EF) is within a percent of the name's white, so a
* third-tier fact was rendering at the brightest value on the page. Only the name keeps that now.
*/
function HeroFact(props: {label: string; children: ReactNode}) {
const {label, children} = props
return (
<div className="min-w-0">
<div
className="text-ink-400 font-dm-sans uppercase"
style={{fontSize: '10px', letterSpacing: '0.18em', marginBottom: '7px'}}
>
<div className="text-ink-500 font-microcaps" style={{marginBottom: '7px'}}>
{label}
</div>
<div className="text-ink-900" style={{fontSize: '15.5px'}}>
<div className="text-ink-700 font-dm-sans" style={{fontSize: '15.5px'}}>
{children}
</div>
</div>
@@ -361,7 +410,7 @@ export function getCompactSeekingText(profile: Profile, t: any) {
? t('profile.age_max_compact', 'under {max}', {max})
: t('profile.age_range_compact', '{min}-{max}', {min, max})
return [connection, genderText, ageText].filter(Boolean).join(' · ')
return [connection, genderText, ageText].filter(Boolean).join(DOT_SEPARATOR)
}
function capitalizeFirst(s: string) {
@@ -610,16 +659,19 @@ function Cannabis(props: {profile: Profile}) {
const prefText = formatPartnerPreferences(profile.cannabis_pref, t)
return (
<AboutRow title={t('profile.cannabis', 'Cannabis')} text={parts}>
{showIntentions && (
<div className="flex flex-wrap gap-2 mt-2">
{profile.cannabis_intention!.map((i) => (
<Chip key={i}>
{t(`profile.substance_intention.${i}`, INVERTED_SUBSTANCE_INTENTION_CHOICES[i])}
</Chip>
))}
</div>
)}
<AboutRow
title={t('profile.cannabis', 'Cannabis')}
text={parts}
details={
showIntentions && (
<DottedList
items={profile.cannabis_intention!.map((i) =>
t(`profile.substance_intention.${i}`, INVERTED_SUBSTANCE_INTENTION_CHOICES[i]),
)}
/>
)
}
>
{prefText && (
<div className={'text-ink-500 mt-2'} style={{fontSize: '12.5px'}}>
{prefText}
@@ -645,16 +697,19 @@ function Psychedelics(props: {profile: Profile}) {
const prefText = formatPartnerPreferences(profile.psychedelics_pref, t)
return (
<AboutRow title={t('profile.psychedelics', 'Psychedelics')} text={parts}>
{showIntentions && (
<div className="flex flex-wrap gap-2 mt-2">
{profile.psychedelics_intention!.map((i) => (
<Chip key={i}>
{t(`profile.substance_intention.${i}`, INVERTED_SUBSTANCE_INTENTION_CHOICES[i])}
</Chip>
))}
</div>
)}
<AboutRow
title={t('profile.psychedelics', 'Psychedelics')}
text={parts}
details={
showIntentions && (
<DottedList
items={profile.psychedelics_intention!.map((i) =>
t(`profile.substance_intention.${i}`, INVERTED_SUBSTANCE_INTENTION_CHOICES[i]),
)}
/>
)
}
>
{prefText && (
<div className={'text-ink-500 mt-2'} style={{fontSize: '12.5px'}}>
{prefText}
@@ -834,16 +889,7 @@ function TagList(props: {items: string[]}) {
const {items} = props
return (
<div className="text-primary-900" style={{fontSize: '15px', lineHeight: '1.65'}}>
{items.map((item, i) => (
<React.Fragment key={item}>
{i > 0 && (
<span aria-hidden className="text-ink-400 select-none">
{' · '}
</span>
)}
{item}
</React.Fragment>
))}
<DottedList items={items} />
</div>
)
}

View File

@@ -121,7 +121,17 @@ export default function ProfileHero(props: {
</Col>
{/* The tagline is the one thing on this page written to be read as a voice, so it gets the
serif italic at display size instead of a quoted aside pinned behind a rule. */}
serif italic at display size instead of a quoted aside pinned behind a rule.
The colour has to be per-theme, because the primary ramp inverts and no single step is an
accent on both grounds. Dark gets `primary-700` (#DCAB71), warm gold that steps down from
the name's white — as `primary-900` it was #F3E4CE, within a few percent of the name, so
the amber did no work. Light gets no accent at all: any amber dark enough to clear AA on
the beige canvas is a mid-brown (700 is #855022), which reads as muddy rather than warm.
There the italic serif is already doing the differentiating, and `ink-700` just steps it
back from the black of the name.
The negative indent hangs the opening quote into the margin so the first word lines up
with the name's first letter; without it the glyph pushes the text right by ~0.35em and
the tagline looks accidentally indented. */}
{profile.headline && (
<div
className="font-heading text-primary-900 max-w-[38ch] italic sm:max-w-[52ch]"
@@ -130,29 +140,29 @@ export default function ProfileHero(props: {
fontSize: 'clamp(1.15rem, 1.5vw, 1.5rem)',
lineHeight: '1.5',
fontWeight: 400,
textIndent: '-0.38em',
}}
>
{profile.headline}
</div>
)}
{/* Outlined rather than filled, squared rather than pill: five filled pills read as five
buttons. These are labels, and the eye should land on them last, after the tagline.
Same tracking as the eyebrow they echo, and a border kept under the eyebrow's weight so
a row of them does not out-shout the line that opens the block. */}
{/* Unboxed: the rail already established that an outline means "you can click this" (see the
`Chip` docstring in profile-about), and nothing here is clickable. The border was also
carrying almost no weight — `canvas-300` sits at 1.30:1 on the light canvas and 1.75:1 on
the dark one, so the boxes read as ghosts rather than as objects, and no single value
fixes both: the ink ramp inverts, so any border strong enough to register in light is a
cage in dark.
Separation comes from the gap instead, opened to 24px because without a box the only
thing dividing two tags is space, and 8px against the 0.16em tracking inside them read as
one continuous run.
Colour is off the brand ramp on purpose: as `primary-800` these were near-cream in dark
mode, so the thing meant to be read last was as bright as the tagline, and amber stopped
meaning anything by being used twice. */}
{profile.keywords && profile.keywords.length > 0 && (
<Row className="max-w-3xl flex-wrap gap-2" data-testid="profile-keywords">
<Row className="max-w-3xl flex-wrap gap-x-6 gap-y-2.5" data-testid="profile-keywords">
{profile.keywords.map(capitalizePure).map((tag, i) => (
<span
key={i}
className="border-canvas-300/70 text-primary-800 font-dm-sans rounded-[3px] border uppercase"
style={{
padding: '6px 11px',
fontSize: '11px',
fontWeight: '400',
letterSpacing: '0.16em',
}}
>
<span key={i} className="text-ink-700 font-microcaps">
{tag.trim()}
</span>
))}

View File

@@ -39,6 +39,8 @@ import {getStars} from 'web/lib/supabase/stars'
import {BackButton} from '../back-button'
const signupMessage = 'Sign up to connect with them for free'
export function ProfileInfo(props: {
profile: Profile
user: User
@@ -209,12 +211,7 @@ export function ProfileInfo(props: {
<div className="from-canvas-50 absolute bottom-0 h-12 w-full bg-gradient-to-t to-transparent" />
</Col>
<Row className="gap-2">
<SignUpButton
text={t(
'profile.info.signup_to_see',
'Sign up to see their full profile or connect with them',
)}
/>
<SignUpButton text={t('profile.info.signup_to_see', signupMessage)} />
</Row>
</Col>
)}
@@ -567,14 +564,7 @@ function ProfileContent(props: {
)}
</Col>
)}
{!currentUser && (
<SignUpButton
text={t(
'profile.info.signup_to_see',
'Sign up to see their full profile or connect with them',
)}
/>
)}
{!currentUser && <SignUpButton text={t('profile.info.signup_to_see', signupMessage)} />}
</Col>
</div>
{/*<LikesDisplay*/}

View File

@@ -41,19 +41,19 @@ export default function ProfilePrimaryInfo(props: {
return (
<div
className={clsx(
'text-ink-500 flex flex-wrap items-center',
eyebrow && 'font-dm-sans uppercase',
)}
className={clsx('text-ink-500 flex flex-wrap items-center', eyebrow && 'font-microcaps')}
data-testid="profile-gender-location-height-inches"
style={eyebrow ? {fontSize: '11px', letterSpacing: '0.16em'} : {fontSize: '15px'}}
style={eyebrow ? undefined : {fontSize: '15px'}}
>
{parts.map((part, i) => (
<React.Fragment key={i}>
{/* `ink-500/50` rather than `ink-400`: the latter is the one neutral grey in a warm ramp
(#A0A0A0 light, #646464 dark) and read as a different, colder family than the text it
separates. Half-strength ink-500 stays warm and still steps back. */}
{i > 0 && (
<span
aria-hidden
className={clsx('text-ink-400 select-none', eyebrow ? 'px-2.5' : 'px-3.5')}
className={clsx('text-ink-500/50 select-none', eyebrow ? 'px-2.5' : 'px-3.5')}
>
/
</span>

View File

@@ -650,6 +650,18 @@ ol > li::marker {
font-size: 14px;
}
/* The small tracked caps that label things on the profile — the eyebrow over the name, the keyword
chips, the "Looking for" / "Status" labels. These had drifted to three near-identical variants
(11px/0.16em, 11px/0.16em, 10px/0.18em); a 1px and 0.02em difference is not enough to read as a
distinct level, only enough to look misaligned. One style, and colour carries the hierarchy. */
.font-microcaps {
font-family: 'DM Sans', sans-serif;
font-size: 11px;
font-weight: 500;
letter-spacing: 0.16em;
text-transform: uppercase;
}
input {
transition: background-color 0.2s ease-in-out;
}