mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-07-30 09:48:47 -04:00
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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*/}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user