From 060c9c7c281bb545411641babc258f77c50c11d8 Mon Sep 17 00:00:00 2001 From: o-p-o-p-o <204936872+o-p-o-p-o@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:41:41 +0100 Subject: [PATCH 1/8] feat(profile): restyle the personal space profile Sidebar - Spaces, Links and About lose their card borders and padding, separated by rules instead; a vertical rule in the tab bar's grey divides the rail from the main column. - Space rows match the browse sidebar's rows; the Editor label is plain grey text; "See all" is a secondary pill below the list. - Type tags are secondary pills; About's rows lose their dividers. Main column - The description moves out of the header into an About section at the top (still edited in the header). - Activity splits into Claims and Debates sections, each with a black "View all" pill in its header; two cards fit across without cropping. - Experience leads with the role, then the company, then that role's dates; Education leads with the school, then the course, then its dates. - Section titles use the medium title style; organisation logos are 50px. Shared - The Log in pill's classes move to the design system as PILL_BUTTON_CLASS_NAME, with a secondary variant; buttonClassNames is exported so links can wear button styles. - The cover takes an optional size; a profile's is 1200 x 300. - StickySideRail gains flushTop and divider options. Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/app/space/[id]/(space)/layout.tsx | 9 +- apps/web/core/wallet/wallet.tsx | 8 +- apps/web/design-system/button.tsx | 22 ++- .../editable-entity-cover-avatar-header.tsx | 28 +-- .../entity-page/entity-page-cover.tsx | 4 + .../entity-page-inline-description.tsx | 5 +- .../entity-page/entity-page-layout.ts | 2 + .../partials/entity-page/sticky-side-rail.tsx | 30 ++- .../partials/profile/organization-image.tsx | 4 +- .../profile/personal-space-profile.tsx | 38 ++-- .../profile/profile-activity-section.test.tsx | 23 ++- .../profile/profile-activity-section.tsx | 127 +++++-------- .../partials/profile/profile-bio-section.tsx | 32 ++++ .../web/partials/profile/profile-headline.tsx | 2 +- apps/web/partials/profile/profile-rail.tsx | 132 +++++++------ .../profile/profile-record-sections.tsx | 173 ++++++++++++------ 16 files changed, 394 insertions(+), 245 deletions(-) create mode 100644 apps/web/partials/profile/profile-bio-section.tsx diff --git a/apps/web/app/space/[id]/(space)/layout.tsx b/apps/web/app/space/[id]/(space)/layout.tsx index 7367be1d15..b7f1aecc36 100644 --- a/apps/web/app/space/[id]/(space)/layout.tsx +++ b/apps/web/app/space/[id]/(space)/layout.tsx @@ -23,7 +23,7 @@ import { Spacer } from '~/design-system/spacer'; import { EditableSpaceHeading } from '~/partials/entity-page/editable-space-header'; import { EntityPageCover } from '~/partials/entity-page/entity-page-cover'; import { EntityPageInlineDescription } from '~/partials/entity-page/entity-page-inline-description'; -import { ENTITY_PAGE_WITH_SIDEBAR_MAX_WIDTH } from '~/partials/entity-page/entity-page-layout'; +import { ENTITY_PAGE_WITH_SIDEBAR_MAX_WIDTH, PROFILE_COVER_SIZE } from '~/partials/entity-page/entity-page-layout'; import { PersonalProfileBioStarterMerge } from '~/partials/entity-page/personal-profile-bio-starter-merge'; import { PersonalProfileSuggestedCard } from '~/partials/entity-page/personal-profile-suggested-card'; import { PersonalProfileSuggestedTaskSync } from '~/partials/entity-page/personal-profile-suggested-task-sync'; @@ -131,6 +131,7 @@ export default async function Layout(props0: LayoutProps) { avatarUrl={props.avatarUrl} coverUrl={props.coverUrl} contentMaxWidth={isProfile ? ENTITY_PAGE_WITH_SIDEBAR_MAX_WIDTH : undefined} + coverSize={isProfile ? PROFILE_COVER_SIZE : undefined} /> {isProfile && } - + {/* * A profile renders none of this row. Types move to the rail's * About section, the vote pair into the action row beside Edit @@ -189,7 +190,9 @@ export default async function Layout(props0: LayoutProps) { /> -
+ {/* 40px above the tab bar on a profile, 24px elsewhere. The description's + own 20px bottom margin collapses into this one, so this is the gap. */} +
{typeIds.includes(SystemIds.PERSON_TYPE) ? ( diff --git a/apps/web/core/wallet/wallet.tsx b/apps/web/core/wallet/wallet.tsx index f6567053ea..ff7b1e0ace 100644 --- a/apps/web/core/wallet/wallet.tsx +++ b/apps/web/core/wallet/wallet.tsx @@ -5,7 +5,7 @@ import { createGeoWalletConfig, createMockConfig } from '@geogenesis/auth/wallet import * as React from 'react'; -import { Button } from '~/design-system/button'; +import { Button, PILL_BUTTON_CLASS_NAME } from '~/design-system/button'; import { trackPrivyAuth } from '../analytics'; import { Environment } from '../environment'; @@ -73,11 +73,7 @@ function PrivyConnectButton() { }; return ( - ); diff --git a/apps/web/design-system/button.tsx b/apps/web/design-system/button.tsx index 3b029da6cb..71ecab301b 100644 --- a/apps/web/design-system/button.tsx +++ b/apps/web/design-system/button.tsx @@ -16,7 +16,27 @@ type ButtonProps = React.ComponentPropsWithoutRef<'button'> & { small?: boolean; }; -const buttonClassNames = (className = '') => +/** The pill's shape and type, shared by both of its colourings below. */ +const PILL_BUTTON_SHAPE = + 'h-7 shrink-0 !gap-0 !rounded-full !px-2.5 !py-0 !text-[16px] !leading-[13px] font-normal tracking-[-0.35px] whitespace-nowrap !shadow-none'; + +/** + * The black pill: the navbar's Log in button, and the "View all" links on a profile. + * Layered over the `primary` variant, whose colours it overrides. + */ +export const PILL_BUTTON_CLASS_NAME = `${PILL_BUTTON_SHAPE} !border-transparent !bg-[#151515] !text-white hover:!bg-[#151515] focus-visible:!border-text focus-visible:!shadow-inner-text`; + +/** + * The same pill in the `secondary` variant's colours — white, a grey outline that + * darkens on hover. Layered over `secondary`, which supplies those colours. + */ +export const PILL_BUTTON_SECONDARY_CLASS_NAME = PILL_BUTTON_SHAPE; + +/** + * The button's classes on their own, for a link that should look like one. A + * ` - ); - })} -
- )} +
+
+

{kind.label}

+ + {kind.seeAllLabel} +
- {selected.isError && selected.rows.length === 0 ? ( + {kind.isError && kind.rows.length === 0 ? ( /* - * No retry here on purpose. This card is a summary; the tab its count - * links to holds the authoritative list and offers the retry, so a - * second control here would be a second thing to keep in step. + * No retry here on purpose. This section is a summary; the tab it links + * to holds the authoritative list and offers the retry, so a second + * control here would be a second thing to keep in step. */ -

Couldn’t load {selected.label.toLowerCase()}.

+

Couldn’t load {kind.label.toLowerCase()}.

) : ( - + )} - - - {selected.seeAllLabel} - -
); } @@ -183,11 +149,8 @@ function ActivityGallery({ {/* * `snap-x` so a flick lands on a card rather than between two. * - * The gap at either end is a spacer element rather than padding on the - * scroller: a scroll container's trailing padding is dropped by every - * browser that matters, so `p-4` gave 16px on the left and nothing on the - * right. Spacers are honoured on both sides, and `scroll-px` keeps a - * snapped card off the edge it lands against. + * No inset at either end: the first and last cards sit flush with the + * column's edges, in line with the heading above them. */} {/* `@container` on a wrapper rather than on the scroller itself: the container types imply `contain: inline-size`, and containing the @@ -197,9 +160,8 @@ function ActivityGallery({
- {shown.map(row => ( ))} -
@@ -322,11 +283,15 @@ function GalleryCard({ // be three times wider, so `80vw` there is not 80% of anything the reader // can see. The scroller establishes the container this measures — see // `ActivityGallery`. - 'w-[min(420px,80cqw)] shrink-0 snap-start', - // The lobby card brings its own outline; the feed's card does not, and - // draws a rule underneath itself to separate it from the next card - // *down* — which in a row is a line under nothing. - !isClaim && 'rounded-lg border border-grey-02 bg-white px-3 [&>*]:border-b-0' + 'shrink-0 snap-start', + // Two cards side by side, both whole: half the row less half the 24px + // gap. Below 640px half would be too narrow to read or watch, so a card + // takes most of the row and the next one peeks in. + 'w-[80cqw] @[640px]:w-[calc((100cqw-1.5rem)/2)]', + // No outline around a debate. The feed's card also draws a rule under + // itself to separate it from the next card *down* — which in a row is a + // line under nothing — so that goes too. + !isClaim && 'bg-white [&>*]:border-b-0 [&>*]:py-0' )} > {isClaim ? ( diff --git a/apps/web/partials/profile/profile-bio-section.tsx b/apps/web/partials/profile/profile-bio-section.tsx new file mode 100644 index 0000000000..e65457f05c --- /dev/null +++ b/apps/web/partials/profile/profile-bio-section.tsx @@ -0,0 +1,32 @@ +'use client'; + +import { SystemIds } from '@geoprotocol/geo-sdk/lite'; + +import { useValue } from '~/core/sync/use-store'; + +import { ClampedText } from '~/design-system/clamped-text'; + +/** + * The person's description, as a section of the profile rather than a line + * under the name — titled and set the way Experience and Education are. + * + * Read-only. It is still edited in the header, which is where its edit field is; + * the header shows it only while editing (`hideWhenReading`). + */ +export function ProfileBioSection({ spaceId, personEntityId }: { spaceId: string; personEntityId: string }) { + const description = useValue({ + selector: v => + v.entity.id === personEntityId && v.spaceId === spaceId && v.property.id === SystemIds.DESCRIPTION_PROPERTY, + })?.value; + + if (!description) return null; + + return ( +
+
+

About

+
+ +
+ ); +} diff --git a/apps/web/partials/profile/profile-headline.tsx b/apps/web/partials/profile/profile-headline.tsx index 983b13d2ec..e6bf7fcdeb 100644 --- a/apps/web/partials/profile/profile-headline.tsx +++ b/apps/web/partials/profile/profile-headline.tsx @@ -32,7 +32,7 @@ export function ProfileHeadline({ roles, spaceId }: Props) { // and `mb-5` is 20px less the description's own `-mt-3`, which pulls itself // up to sit tight under the name — right when it *is* under the name, wrong // with three roles in between. Both come out at 8px. -
    +
      {roles.map(role => (
    • + ); @@ -86,7 +93,10 @@ export function ProfileRailSections({ const positionsCount = isLoading && responses.total === null ? null : heldPositionsCount(responses, facts.positions); return ( -
      + // A rule between sections, 24px either side — the same divider the Explore + // and space rails draw (`SideRailSections`). Spacing lives on the sections + // rather than the rules, because any of them can be absent. +
      {facts.spaces.length > 0 && } {personEntityId ? ( @@ -111,15 +121,8 @@ export function ProfileRailSections({ } /** - * One card in the rail. - * - * Bordered cards rather than rule-separated sections: this rail holds three - * kinds of thing that have nothing to do with each other — a list of spaces, a - * set of handles, and a table of facts — and a rule between them says they are - * one document with three parts. - * - * `overflow-hidden` is what lets the system-data strip sit flush inside the - * bottom corners of the About card. + * One section in the rail: a titled header over its body, with no border and + * no inset — the title and content sit flush with the rail's edges. */ function RailCard({ title, @@ -135,12 +138,12 @@ function RailCard({ footer?: React.ReactNode; }) { return ( -
      -
      -

      {title}

      +
      +
      +

      {title}

      {action}
      -
      {children}
      +
      {children}
      {footer}
      ); @@ -150,38 +153,57 @@ function SpacesSection({ spaces }: { spaces: ReturnType[ const [showAll, setShowAll] = React.useState(false); const shown = showAll ? spaces : spaces.slice(0, 6); + // Only the rows on screen, so the collapsed list asks for six images rather than all of them. + const { spacesById } = useSpacesByIds(shown.map(space => space.id)); + const activeSpaceId = useSpaceId(); + return ( - 6 ? ( - - ) : null - } - > -
        - {shown.map(space => ( -
      • - - {/* Nine of the reference account's 33 have no name. A blank row in - a list of 33 reads as a loading failure. */} - {space.name ?? 'Untitled space'} - {space.isEditor && ( - Editor - )} - -
      • - ))} + + {/* Rows styled as the browse sidebar draws its spaces (`SpaceRowLink`), so + a space looks and responds the same wherever it is listed. */} +
          + {shown.map(space => { + // Nine of the reference account's 33 have no name. A blank row in a + // list of 33 reads as a loading failure. + const name = space.name ?? 'Untitled space'; + const image = spacesById.get(space.id)?.entity.image; + + return ( +
        • + + {image && image !== PLACEHOLDER_SPACE_IMAGE ? ( + + + + ) : ( + + {name.trim().slice(0, 1).toUpperCase() || '?'} + + )} + +

          {name}

          +
          + {space.isEditor && Editor} + +
        • + ); + })}
        + {spaces.length > 6 && ( + + )}
        ); } @@ -413,15 +435,17 @@ function AboutSection({ {types.length > 0 && ( - {/* `LinkableChip` is the relation pill every other surface draws — - bordered, text-coloured, border-text on hover. The bespoke blue - pill this replaced read as a link, which is the one thing a - relation chip is not. */} + {/* The secondary pill, as the Spaces "See all" button draws it — + white, grey outline, dark on hover. */} {types.map(type => ( - + {type.name ?? 'Untitled'} - + ))} @@ -468,7 +492,7 @@ function AboutSection({ function Row({ label, children }: { label: string; children: React.ReactNode }) { return ( -
        +
        {label}
        {children}
        diff --git a/apps/web/partials/profile/profile-record-sections.tsx b/apps/web/partials/profile/profile-record-sections.tsx index 71cf84ce72..740a2fc21f 100644 --- a/apps/web/partials/profile/profile-record-sections.tsx +++ b/apps/web/partials/profile/profile-record-sections.tsx @@ -4,7 +4,7 @@ import * as React from 'react'; import cx from 'classnames'; -import { formatDateRange, formatDuration, formatTotalDuration } from '~/core/profile/history-dates'; +import { formatDateRange, formatDuration } from '~/core/profile/history-dates'; import { type EducationCard, type EmploymentCard, @@ -101,12 +101,7 @@ export function ProfileRecordSection({ kind, cards, isOwner, onEdit, spaceId }: return (
        -

        - {copy.title} - {cards.length > 0 && ( - {cards.length} - )} -

        +

        {copy.title}

        {/* * A pen, not a plus. The section's own control should offer everything * that can be done to the section: a plus could only add, so removing a @@ -124,10 +119,10 @@ export function ProfileRecordSection({ kind, cards, isOwner, onEdit, spaceId }:
        ) : ( -
          +
            {shown.map(card => ( -
          • - +
          • +
          • ))}
          @@ -147,42 +142,78 @@ export function ProfileRecordSection({ kind, cards, isOwner, onEdit, spaceId }: } function OrganizationBlock({ + kind, card, isExpanded, spaceId, }: { + kind: Kind; card: HistoryCard; isExpanded: boolean; spaceId: string; }) { - // Time actually spent here, not the distance from the first start to the last - // end — a gap between two spells at one employer is not time served. - const duration = formatTotalDuration( - card.entries.map(entry => ({ start: entry.startDate, end: entry.endDate, isOpen: isOngoing(entry) })) + // The spine appears only where there is more than one entry. Someone with one + // course at one school sees a plain row and never learns there is a level below. + const hasRun = card.entries.length > 1; + + const organizationLink = (className: string) => ( + + {card.organization.name ?? 'Untitled'} + ); - // The spine appears only where there is more than one role. Someone with one - // job at one company sees a plain row and never learns there is a level below. - const hasRun = card.entries.length > 1; + // Experience leads with what the person does rather than where. Entries are + // sorted newest first, so the first is the current or most recent role: its + // title heads the card, the company sits under it, and its own dates follow + // straight after, with no company-wide total in between. Earlier roles at the + // same company hang off a spine below. + const [leadRole, ...earlierRoles] = card.entries; + if (kind === 'employment' && leadRole) { + return ( +
          + + +
          + + {leadRole.subject.name ?? 'Untitled'} + + {organizationLink('mt-0.5 block truncate text-smallTitle text-grey-04 hover:underline')} +
          + +
          + + {earlierRoles.length > 0 && ( +
            + {earlierRoles.map(entry => ( +
          • + +
          • + ))} +
          + )} +
          +
          + ); + } return ( -
          - +
          +
          - - {card.organization.name ?? 'Untitled'} - - {duration &&

          {duration}

          } + {/* The school, then each course with its own dates — no school-wide + total ahead of them. Laid out as Experience's heading is, with the two + lines swapped: school, course in grey, then the course's dates. */} + {organizationLink('block truncate text-smallTitle text-text hover:underline')} -
            +
              {card.entries.map(entry => (
            • - +
            • ))}
            @@ -191,8 +222,27 @@ function OrganizationBlock({ ); } -function EntryRow({ entry, isExpanded, spaceId }: { entry: HistoryEntry; isExpanded: boolean; spaceId: string }) { +function EntryRow({ + entry, + isExpanded, + spaceId, + hideTitle = false, + titleStyle = 'body', +}: { + entry: HistoryEntry; + isExpanded: boolean; + spaceId: string; + /** The card's heading already names this entry. */ + hideTitle?: boolean; + /** + * `heading` draws the title at the card heading's size in grey, 2px above the + * dates — the second line of an Experience heading, for a course under its school. + */ + titleStyle?: 'body' | 'heading'; +}) { const isOpen = isOngoing(entry); + // The +N chip opens this row's skills on its own, without expanding the whole section. + const [showAllSkills, setShowAllSkills] = React.useState(false); const subject = entry.subject.name ?? 'Untitled'; const dates = formatDateRange(entry.startDate, entry.endDate, isOpen); @@ -214,23 +264,32 @@ function EntryRow({ entry, isExpanded, spaceId }: { entry: HistoryEntry; isExpan {/* The degree and its fields are separate entities that read as one line — "Doctor of Philosophy, Finance" — so each is its own link rather than one link over the sentence. */} -

            - - {subject} - - {fields.map((field, index) => ( - - {index === 0 ? ', ' : ', '} - - {field.name ?? 'Untitled'} - - - ))} -

            - {meta &&

            {meta}

            } + {!hideTitle && ( +

            + + {subject} + + {fields.map((field, index) => ( + + {index === 0 ? ', ' : ', '} + + {field.name ?? 'Untitled'} + + + ))} +

            + )} + {meta && ( +

            {meta}

            + )} {entry.description && ( -
            +
            {/* Clamped on the card, whole once the section is expanded. Named, because a card of four employers otherwise offers eight buttons all called More. */} {isExpanded ? ( @@ -248,15 +307,26 @@ function EntryRow({ entry, isExpanded, spaceId }: { entry: HistoryEntry; isExpan )} {skills.length > 0 && ( -
              - {(isExpanded ? skills : skills.slice(0, 4)).map((skill, index) => ( +
                + {(isExpanded || showAllSkills ? skills : skills.slice(0, 4)).map((skill, index) => (
              • {skill.name ?? 'Untitled'}
              • ))} - {!isExpanded && skills.length > 4 &&
              • +{skills.length - 4}
              • } + {!isExpanded && !showAllSkills && skills.length > 4 && ( +
              • + +
              • + )}
              )}
            @@ -287,15 +357,12 @@ export function ProfileSkillsSection({ skills, spaceId }: { skills: NamedRef[]; return (
            -

            - Skills - {skills.length} -

            +

            Skills

            {/* The toggle sits inside the container, in the flow of the chips it controls — below the box it reads as a control over the section, which is a different and larger promise than "show the rest of these". */} -
              +
                {shown.map(skill => (
              • From 2a8a907386ee3c7d387bb60098f1e93730afe24a Mon Sep 17 00:00:00 2001 From: o-p-o-p-o <204936872+o-p-o-p-o@users.noreply.github.com> Date: Tue, 22 Sep 2026 10:05:28 +0100 Subject: [PATCH 2/8] chore(design-system): the black pill no longer backs profile View all links Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/design-system/button.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/design-system/button.tsx b/apps/web/design-system/button.tsx index 71ecab301b..7fc3459495 100644 --- a/apps/web/design-system/button.tsx +++ b/apps/web/design-system/button.tsx @@ -21,7 +21,7 @@ const PILL_BUTTON_SHAPE = 'h-7 shrink-0 !gap-0 !rounded-full !px-2.5 !py-0 !text-[16px] !leading-[13px] font-normal tracking-[-0.35px] whitespace-nowrap !shadow-none'; /** - * The black pill: the navbar's Log in button, and the "View all" links on a profile. + * The black pill: the navbar's Log in button. * Layered over the `primary` variant, whose colours it overrides. */ export const PILL_BUTTON_CLASS_NAME = `${PILL_BUTTON_SHAPE} !border-transparent !bg-[#151515] !text-white hover:!bg-[#151515] focus-visible:!border-text focus-visible:!shadow-inner-text`; From 363375d56ca100435cf69563ca8905be35fe52a0 Mon Sep 17 00:00:00 2001 From: o-p-o-p-o <204936872+o-p-o-p-o@users.noreply.github.com> Date: Tue, 22 Sep 2026 10:44:44 +0100 Subject: [PATCH 3/8] feat(profile): restyle Activity's header and tidy the sidebar Activity - Title stays; the Debates/Claims toggle sits below it as pills in the shared pill shape (black when selected, secondary otherwise). - "View all debates" / "View all claims" is a secondary pill at the right of that row, replacing the footer link; the gallery's arrows move beside it, using the data block galleries' Previous/Next buttons, disabled at the row's ends rather than hidden. - No outline, edge padding or divider; a 40px white fade marks the right edge while the row can scroll further. - The compact debate player's corners are 12px (16px in the feeds). Sidebar - The description moves back to the top of About, with its More toggle on a line of its own (`ClampedText` gains `togglePlacement="below"`). - Space system data is a closed-by-default accordion in the rows' own type, with the standard chevron and no background. - The rail sits 20px from the vertical divider, with that padding inside the scroller so the Spaces rows can align under their title and still bleed their hover background past it. Co-Authored-By: Claude Opus 5 (1M context) --- .../core/claims/browse/claim-page-view.tsx | 8 +- .../debates/browse/debate-feed-player.tsx | 10 +- apps/web/design-system/clamped-text.tsx | 34 ++- .../partials/entity-page/sticky-side-rail.tsx | 12 +- .../profile/personal-space-profile.tsx | 7 +- .../profile/profile-activity-section.test.tsx | 47 ++-- .../profile/profile-activity-section.tsx | 221 +++++++++--------- .../partials/profile/profile-bio-section.tsx | 32 --- apps/web/partials/profile/profile-rail.tsx | 58 +++-- 9 files changed, 236 insertions(+), 193 deletions(-) delete mode 100644 apps/web/partials/profile/profile-bio-section.tsx diff --git a/apps/web/core/claims/browse/claim-page-view.tsx b/apps/web/core/claims/browse/claim-page-view.tsx index cc70698fc5..8d73469d58 100644 --- a/apps/web/core/claims/browse/claim-page-view.tsx +++ b/apps/web/core/claims/browse/claim-page-view.tsx @@ -155,9 +155,7 @@ export function ClaimPageView({ }; // Matches profile record tabs: unknown/error stays reachable, while a settled zero disappears. const hasDebates = hasRecordToShow( - record.debatesLoading || record.debatesError || record.debatesCountUnavailable - ? undefined - : record.debatesTotal + record.debatesLoading || record.debatesError || record.debatesCountUnavailable ? undefined : record.debatesTotal ); const hasClaims = hasRecordToShow( record.claimsLoading || record.claimsError || record.claimsCountUnavailable ? undefined : record.claimsTotal @@ -347,7 +345,7 @@ function ClaimTabPanel({ isError: record.debatesError, isCountUnavailable: record.debatesCountUnavailable, href: hrefs.debates, - seeAllLabel: 'See all debates', + seeAllLabel: 'View all debates', onSeeAll: onSelectSystemTab ? () => onSelectSystemTab('debates') : undefined, }, { @@ -359,7 +357,7 @@ function ClaimTabPanel({ isError: record.claimsError, isCountUnavailable: record.claimsCountUnavailable, href: hrefs.claims, - seeAllLabel: 'See all claims', + seeAllLabel: 'View all claims', onSeeAll: onSelectSystemTab ? () => onSelectSystemTab('claims') : undefined, }, ]; diff --git a/apps/web/core/debates/browse/debate-feed-player.tsx b/apps/web/core/debates/browse/debate-feed-player.tsx index 143aa60370..f5034b82c7 100644 --- a/apps/web/core/debates/browse/debate-feed-player.tsx +++ b/apps/web/core/debates/browse/debate-feed-player.tsx @@ -36,12 +36,7 @@ type DebateFeedPlayerProps = { preload?: boolean; }; -export function DebateFeedPlayer({ - debate, - active, - preload = false, - reducedOverlays = false, -}: DebateFeedPlayerProps) { +export function DebateFeedPlayer({ debate, active, preload = false, reducedOverlays = false }: DebateFeedPlayerProps) { // Loading is deliberately wider than playing. `useDebatePlayback`'s flag gates only the URL // fetch and the transcript query — playback is driven by `active` in the effect below — so a // preloading card fetches without autoplaying off-screen. @@ -324,7 +319,8 @@ export function DebateFeedPlayer({ data-debate-autoplay-blocked={autoplayBlocked ? 'true' : 'false'} // No gap and one radius on the outside: the two tiles are a single surface in the Figma // frame, which is what lets the subtitle straddle the seam rather than sit inside one tile. - className="group relative flex flex-col overflow-hidden rounded-xl" + // 12px in the compact gallery (a profile's or claim's Activity), 16px in the feeds. + className={cx('group relative flex flex-col overflow-hidden', reducedOverlays ? 'rounded-lg' : 'rounded-xl')} > + } + className={cx(typeClassName, textClassName, clamp && LINE_CLAMP_CLASS[maxLines])} + > + {text} + + {showToggle && ( + + )} +
            + ); + } + return (
            -
            {children}
            +
            {children}
            ); diff --git a/apps/web/partials/profile/personal-space-profile.tsx b/apps/web/partials/profile/personal-space-profile.tsx index 3cb393aaa6..48f3207d36 100644 --- a/apps/web/partials/profile/personal-space-profile.tsx +++ b/apps/web/partials/profile/personal-space-profile.tsx @@ -15,7 +15,6 @@ import { heldPositionsCount, usePersonPositions, usePersonResponses } from '~/co import { EditRecordDialog } from './edit-record-dialog'; import { type ActivityKind, ProfileActivitySection } from './profile-activity-section'; -import { ProfileBioSection } from './profile-bio-section'; import { ProfileHeadline } from './profile-headline'; import { ProfileRecordSection, ProfileSkillsSection } from './profile-record-sections'; @@ -66,8 +65,6 @@ export function PersonalSpaceProfile({ spaceId, personEntityId }: Props) { * stale. It is one card tall either way, so leading with it costs the * history nothing. */} - - {/* A failed history read is not an empty account. Keep its own sections @@ -164,7 +161,7 @@ function ProfileActivity({ spaceId, personEntityId }: { spaceId: string; personE isCountUnavailable: isFactsError, isError: debates.isError, href: `/space/${spaceId}/debates`, - seeAllLabel: 'See all debates', + seeAllLabel: 'View all debates', }, { key: 'claims', @@ -179,7 +176,7 @@ function ProfileActivity({ spaceId, personEntityId }: { spaceId: string; personE isCountUnavailable: isFactsError && responses.isError, isError: positions.isError, href: `/space/${spaceId}/positions`, - seeAllLabel: 'See all claims', + seeAllLabel: 'View all claims', }, ]; diff --git a/apps/web/partials/profile/profile-activity-section.test.tsx b/apps/web/partials/profile/profile-activity-section.test.tsx index 98790a86b5..2df1b5b88f 100644 --- a/apps/web/partials/profile/profile-activity-section.test.tsx +++ b/apps/web/partials/profile/profile-activity-section.test.tsx @@ -72,7 +72,7 @@ const kind = (over: Partial[ total: 10, isLoading: false, href: '/space/s/debates', - seeAllLabel: 'See all debates', + seeAllLabel: 'View all debates', ...over, }); @@ -247,6 +247,16 @@ describe('ProfileActivitySection', () => { expect(screen.getByRole('region', { name: 'Loading activity' })).toHaveAttribute('aria-busy', 'true'); expect(screen.getByRole('heading', { name: 'Activity' })).toBeInTheDocument(); + // No tabs to draw until a kind arrives. + expect(screen.queryByRole('button', { name: /Debates|Claims/ })).not.toBeInTheDocument(); + }); + + it('titles the section Activity, with the kinds as pills beneath it', () => { + render(); + + expect(screen.getByRole('heading', { name: 'Activity' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Debates/ })).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByRole('button', { name: /Claims/ })).toHaveAttribute('aria-pressed', 'false'); }); it('shows a completed kind without waiting for the other kind', () => { @@ -260,9 +270,7 @@ describe('ProfileActivitySection', () => { it('keeps the first available kind selected when an earlier kind finishes later', () => { const claims = kind({ key: 'claims', label: 'Claims', rows: [row('c1')] }); - const { rerender } = render( - - ); + const { rerender } = render(); expect(screen.getByTestId('card')).toHaveTextContent('c1'); @@ -428,7 +436,7 @@ describe('ProfileActivitySection', () => { expect(cards.every(card => card.parentElement?.className.includes('w-[min(300px,84cqw)]'))).toBe(true); }); - it('offers left and right buttons to scroll one Activity card at a time', async () => { + it('offers left and right buttons in the header to scroll one Activity card at a time', async () => { render(); const scroller = document.querySelector('.overflow-x-auto') as HTMLElement; @@ -453,22 +461,29 @@ describe('ProfileActivitySection', () => { fireEvent.scroll(scroller); await act(async () => new Promise(resolve => requestAnimationFrame(() => resolve()))); - expect(screen.queryByRole('button', { name: 'Scroll activity left' })).toBeNull(); - const next = screen.getByRole('button', { name: 'Scroll activity right' }); + // Both arrows stay in the header once the row can scroll, so View all beside them never moves; + // the one pointing past an end is disabled instead. + expect(screen.getByRole('button', { name: 'Previous page' })).toBeDisabled(); + const next = screen.getByRole('button', { name: 'Next page' }); + expect(next).toBeEnabled(); + // More to the right, so the edge fades. + expect(document.querySelector('[data-activity-scroll-fade]')).not.toBeNull(); fireEvent.click(next); expect(scroller.scrollBy).toHaveBeenCalledWith({ left: 276, behavior: 'smooth' }); scroller.scrollLeft = 300; fireEvent.scroll(scroller); await act(async () => new Promise(resolve => requestAnimationFrame(() => resolve()))); - expect(screen.getByRole('button', { name: 'Scroll activity left' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Previous page' })).toBeEnabled(); // The final card is fully visible here even though the trailing spacer means the rail itself - // still has a few scrollable pixels left. Those pixels should not keep the arrow around. + // still has a few scrollable pixels left. Those pixels should not keep the arrow live. scroller.scrollLeft = 512; fireEvent.scroll(scroller); await act(async () => new Promise(resolve => requestAnimationFrame(() => resolve()))); - expect(screen.queryByRole('button', { name: 'Scroll activity right' })).toBeNull(); + expect(screen.getByRole('button', { name: 'Next page' })).toBeDisabled(); + // Nothing further right: no fade over the last card. + expect(document.querySelector('[data-activity-scroll-fade]')).toBeNull(); }); it('hands autoplay to the next visible debate when the current one scrolls out', async () => { @@ -555,34 +570,34 @@ describe('ProfileActivitySection', () => { const onSeeAll = vi.fn(); render(); - const seeAll = screen.getByRole('button', { name: 'See all debates' }); - expect(screen.queryByRole('link', { name: 'See all debates' })).not.toBeInTheDocument(); + const seeAll = screen.getByRole('button', { name: 'View all debates' }); + expect(screen.queryByRole('link', { name: 'View all debates' })).not.toBeInTheDocument(); fireEvent.click(seeAll); expect(onSeeAll).toHaveBeenCalledOnce(); }); - it('sends See all to the tab bar rather than the top of the page', () => { + it('sends View all to the tab bar rather than the top of the page', () => { render( ); // Without the fragment the reader lands at the top of the profile — a screenful of cover, // avatar, name, roles and bio — rather than on the list they clicked for. - expect(screen.getByRole('link', { name: /See all debates/ })).toHaveAttribute( + expect(screen.getByRole('link', { name: /View all debates/ })).toHaveAttribute( 'href', '/space/s/debates#space-tabs' ); fireEvent.click(screen.getByRole('button', { name: /Claims/ })); - expect(screen.getByRole('link', { name: /See all claims/ })).toHaveAttribute( + expect(screen.getByRole('link', { name: /View all claims/ })).toHaveAttribute( 'href', '/space/s/positions#space-tabs' ); diff --git a/apps/web/partials/profile/profile-activity-section.tsx b/apps/web/partials/profile/profile-activity-section.tsx index a034dcb8c8..c70b4f9342 100644 --- a/apps/web/partials/profile/profile-activity-section.tsx +++ b/apps/web/partials/profile/profile-activity-section.tsx @@ -13,18 +13,16 @@ import { ACTIVITY_GALLERY_CARD_LIMIT } from '~/core/profile/activity-gallery'; import type { ClaimResponse } from '~/core/profile/use-person-positions'; import { normId } from '~/core/utils/norm-id'; -import { ChevronRight } from '~/design-system/icons/chevron-right'; -import { RightArrowLongSmall } from '~/design-system/icons/right-arrow-long-small'; +import { PILL_BUTTON_CLASS_NAME, PILL_BUTTON_SECONDARY_CLASS_NAME, buttonClassNames } from '~/design-system/button'; import { PrefetchLink as Link } from '~/design-system/prefetch-link'; import { Skeleton } from '~/design-system/skeleton'; +import { NextButton, PreviousButton } from '~/design-system/table/table-pagination'; import { ExploreFeedCard } from '~/partials/explore/explore-feed-card'; import { withSpaceTabsAnchor } from '~/partials/space-page/space-tabs-anchor'; import { GalleryClaimCard } from './gallery-claim-card'; -const SEE_ALL_CLASS = - 'flex items-center justify-center gap-2 border-t border-divider py-3 text-metadataMedium text-grey-04 transition-colors hover:text-text'; const GALLERY_ACTIVATE_RATIO = 0.6; const GALLERY_DEACTIVATE_RATIO = 0.4; @@ -102,6 +100,9 @@ export function ProfileActivitySection({ kinds }: { kinds: ActivityKind[] }) { const selected = available.find(kind => kind.key === selectedKey) ?? available[0]; const { sectionRef, reserveRef, prepareSwitch } = useMobileActivityHeightReserve(selected?.key); + // The gallery measures whether its row can scroll; the arrows live in the header, so it reports + // up. Null while no gallery is mounted — a kind that failed to load has no row to step through. + const [navigation, setNavigation] = React.useState(null); const isLoading = kinds.some(kind => kind.isLoading); // Reserve the section while its first usable record is on the way. Once either kind resolves, @@ -118,57 +119,73 @@ export function ProfileActivitySection({ kinds }: { kinds: ActivityKind[] }) { ref={sectionRef} data-activity-section className={cx( - 'flex flex-col overflow-hidden rounded-lg border border-grey-02 bg-white', - // Not a card on a phone. A bordered panel holding bordered cards spends two gutters and two - // rules on saying "these belong together", which the heading already says — and on a 390px - // screen that is most of what a claim's buttons needed. The heading and the rule under it - // stay; the box around them goes, and the gallery below can reach the screen edge. - 'md:overflow-visible md:rounded-none md:border-0 md:bg-transparent' + // Not a card. A bordered panel holding bordered cards spends two gutters and two rules on + // saying "these belong together", which the heading already says. No box and no rule: the + // content sits flush with the column — on a phone the gallery can reach the screen edge. + 'flex flex-col' )} > - {/* The toggles sit to the right of the heading, and wrap below it rather - than squeezing into it on a narrow screen. */} -
            -

            Activity

            - - {/* Only when there is a choice to make. One pill on its own is a label - dressed up as a control. */} - {available.length > 1 && ( -
            - {available.map(kind => { - const isSelected = kind.key === selected.key; - - return ( - - ); - })} + + + ); + })} + + {/* + * The row's own controls, right of the tabs: step through the cards, then leave for the + * full tab. Both belong to the selected kind — see `navigation`. + */} +
            + {navigation && (navigation.left || navigation.right) ? ( + // The data block galleries' own arrows (`table-pagination`), 12px apart as they are + // there: dark when there is somewhere to go, grey at the row's end. + + navigation.scrollByCard(-1)} /> + navigation.scrollByCard(1)} /> + + ) : null} +
            - )} +
            {selected.isError && selected.rows.length === 0 ? ( @@ -177,7 +194,7 @@ export function ProfileActivitySection({ kinds }: { kinds: ActivityKind[] }) { * links to holds the authoritative list and offers the retry, so a * second control here would be a second thing to keep in step. */ -

            Couldn’t load {selected.label.toLowerCase()}.

            +

            Couldn’t load {selected.label.toLowerCase()}.

            ) : ( )} -
      {/* @@ -210,19 +227,15 @@ export function ProfileActivitySection({ kinds }: { kinds: ActivityKind[] }) { function ProfileActivitySkeleton() { return ( -
      -
      -

      Activity

      - +
      +
      +

      Activity

      +
      + + +
      -
      +
      @@ -233,17 +246,12 @@ function ProfileActivitySkeleton() { } function ActivitySeeAll({ kind }: { kind: ActivityKind }) { - const content = ( - <> - {kind.seeAllLabel} - - - ); + const className = buttonClassNames(PILL_BUTTON_SECONDARY_CLASS_NAME)({ variant: 'secondary' }); if (kind.onSeeAll) { return ( - ); } @@ -252,8 +260,8 @@ function ActivitySeeAll({ kind }: { kind: ActivityKind }) { // chrome. The fragment puts the tab row under the navbar instead. Side panels use `onSeeAll` // above because their tabs are selected in place and have no route fragment to follow. return ( - - {content} + + {kind.seeAllLabel} ); } @@ -440,14 +448,19 @@ function useMobileActivityHeightReserve(selectedKey: string | undefined) { return { sectionRef, reserveRef, prepareSwitch }; } +type GalleryNavigation = { left: boolean; right: boolean; scrollByCard: (direction: -1 | 1) => void }; + function ActivityGallery({ rows, responseByClaimId, personName, + onNavigationChange, }: { rows: ExploreFeedRow[]; responseByClaimId?: Record; personName?: string | null; + /** Where the header's arrows learn whether this row can move, and how to move it. */ + onNavigationChange?: (navigation: GalleryNavigation | null) => void; }) { const shown = React.useMemo(() => rows.slice(0, ACTIVITY_GALLERY_CARD_LIMIT), [rows]); @@ -466,6 +479,14 @@ function ActivityGallery({ scrollByCard, } = useActivityGallery(shown); + React.useEffect(() => { + onNavigationChange?.({ left: canScrollLeft, right: canScrollRight, scrollByCard }); + }, [canScrollLeft, canScrollRight, scrollByCard, onNavigationChange]); + + // Unmounted on a tab switch (it is keyed by kind) and when a kind fails: its row is gone, so the + // header should not offer to scroll it. + React.useEffect(() => () => onNavigationChange?.(null), [onNavigationChange]); + return ( // One at a time. Compact cards can leave several debates fully visible, so intersection alone // cannot choose. The first debate receives autoplay; clicking another player transfers the @@ -490,16 +511,14 @@ function ActivityGallery({ {/* * `snap-x` so a flick lands on a card rather than between two. * - * The gap at either end is a spacer element rather than padding on the scroller: a scroll - * container's trailing padding is dropped by every browser that matters, so `p-4` gave 16px - * on the left and nothing on the right. Spacers are honoured on both sides, and `scroll-px` - * keeps a snapped card off the edge it lands against. + * No inset at the start: the first card sits flush with the column, under the heading. On a + * phone the row bleeds to the screen edge, so a trailing spacer — not padding, which a + * scroll container drops at its far end — keeps the last card off it. */}
      - {shown.map(row => ( ))} - +
      - {canScrollLeft ? scrollByCard(-1)} /> : null} - {canScrollRight ? scrollByCard(1)} /> : null} + {/* + * A 40px white fade over the right edge while there is more to scroll: enough to say the + * row goes on, narrow enough to leave the cut-off card readable. It sits on the wrapper, + * not the scroller, so it stays put while the cards move under it, and it never takes a + * click meant for the card beneath. + */} + {canScrollRight ? ( +
      + ) : null}
      ); @@ -662,12 +692,9 @@ function useActivityGallery(rows: ExploreFeedRow[]) { [scrollerRef] ); - const requestPlayback = React.useCallback( - (debateId: string) => { - if (availableRef.current.has(normId(debateId))) setSelectedPlaybackId(debateId); - }, - [] - ); + const requestPlayback = React.useCallback((debateId: string) => { + if (availableRef.current.has(normId(debateId))) setSelectedPlaybackId(debateId); + }, []); const setPlaybackAvailable = React.useCallback((debateId: string, available: boolean) => { const id = normId(debateId); @@ -697,24 +724,6 @@ function useActivityGallery(rows: ExploreFeedRow[]) { }; } -function GalleryNavigationButton({ direction, onClick }: { direction: 'left' | 'right'; onClick: () => void }) { - return ( - - ); -} - /** * One card in the row. * diff --git a/apps/web/partials/profile/profile-bio-section.tsx b/apps/web/partials/profile/profile-bio-section.tsx deleted file mode 100644 index e65457f05c..0000000000 --- a/apps/web/partials/profile/profile-bio-section.tsx +++ /dev/null @@ -1,32 +0,0 @@ -'use client'; - -import { SystemIds } from '@geoprotocol/geo-sdk/lite'; - -import { useValue } from '~/core/sync/use-store'; - -import { ClampedText } from '~/design-system/clamped-text'; - -/** - * The person's description, as a section of the profile rather than a line - * under the name — titled and set the way Experience and Education are. - * - * Read-only. It is still edited in the header, which is where its edit field is; - * the header shows it only while editing (`hideWhenReading`). - */ -export function ProfileBioSection({ spaceId, personEntityId }: { spaceId: string; personEntityId: string }) { - const description = useValue({ - selector: v => - v.entity.id === personEntityId && v.spaceId === spaceId && v.property.id === SystemIds.DESCRIPTION_PROPERTY, - })?.value; - - if (!description) return null; - - return ( -
      -
      -

      About

      -
      - -
      - ); -} diff --git a/apps/web/partials/profile/profile-rail.tsx b/apps/web/partials/profile/profile-rail.tsx index 27321b583a..697159b99a 100644 --- a/apps/web/partials/profile/profile-rail.tsx +++ b/apps/web/partials/profile/profile-rail.tsx @@ -1,5 +1,6 @@ 'use client'; +import { SystemIds } from '@geoprotocol/geo-sdk/lite'; import * as Popover from '@radix-ui/react-popover'; import * as React from 'react'; @@ -22,6 +23,7 @@ import { type ProfileLink, profileLinks } from '~/core/profile/profile-links'; import type { ProfileRailFacts } from '~/core/profile/profile-rail-facts'; import { heldPositionsCount, usePersonResponses } from '~/core/profile/use-person-positions'; import { useEntitySchemaWithGroups } from '~/core/state/entity-page-store/entity-store'; +import { useValue } from '~/core/sync/use-store'; import { NavUtils } from '~/core/utils/utils'; import { @@ -31,9 +33,10 @@ import { SquareButton, buttonClassNames, } from '~/design-system/button'; +import { ClampedText } from '~/design-system/clamped-text'; import { FallbackImage } from '~/design-system/fallback-image'; +import { ChevronDownSmall } from '~/design-system/icons/chevron-down-small'; import { EditSmall } from '~/design-system/icons/edit-small'; -import { RightArrowLongSmall } from '~/design-system/icons/right-arrow-long-small'; import { RankingAggregatedSubmitterAvatars } from '~/partials/blocks/table/ranking-period-metadata'; import { StickySideRail } from '~/partials/entity-page/sticky-side-rail'; @@ -112,6 +115,7 @@ export function ProfileRailSections({ positionsCount={positionsCount} types={types} spaceId={spaceId} + personEntityId={personEntityId} systemEntityId={systemEntityId} address={address} spaceType={spaceType} @@ -160,8 +164,10 @@ function SpacesSection({ spaces }: { spaces: ReturnType[ return ( {/* Rows styled as the browse sidebar draws its spaces (`SpaceRowLink`), so - a space looks and responds the same wherever it is listed. */} -
        + a space looks and responds the same wherever it is listed. Pulled 10px + left — the row's own padding — so the icons line up under the title and + only the hover background reaches past it. */} +
          {shown.map(space => { // Nine of the reference account's 33 have no name. A blank row in a // list of 33 reads as a loading failure. @@ -396,6 +402,7 @@ function AboutSection({ positionsCount, types, spaceId, + personEntityId, systemEntityId, address, spaceType, @@ -408,11 +415,20 @@ function AboutSection({ positionsCount: number | null; types: ProfileRailProps['types']; spaceId: string; + personEntityId: string | null; systemEntityId: string; address: string | null; spaceType: ProfileRailProps['spaceType']; }) { const joined = formatJoined(facts.joinedAt); + + // The person's description, read here rather than under their name. It is still edited in the + // header, which is where its edit field is; the header shows it only while editing + // (`hideWhenReading`). + const description = useValue({ + selector: v => + v.entity.id === personEntityId && v.spaceId === spaceId && v.property.id === SystemIds.DESCRIPTION_PROPERTY, + })?.value; const elapsed = timeOnGeo(facts.joinedAt); return ( @@ -428,6 +444,18 @@ function AboutSection({ * and are the rows a returning reader scans for, so they sit last, next to * each other, where a set of numbers reads as a set. */} + {description && ( +
          + +
          + )} +
          {joined && } @@ -648,18 +676,17 @@ function SystemRecord({ spaceType: ProfileRailProps['spaceType']; }) { return ( - // A grey strip flush to the card's bottom edge, with an arrow that turns as - // it opens. Grey because it is a different register from the rows above it: - // those are facts about a person, these are ids for whoever is debugging - // the page. -
          - + // An accordion, closed by default, in the rows' own type: the label reads like + // "Positions" above it, and opening it lays the ids out as more rows of the + // same kind. The standard chevron, flipped when open. +
          + Space system data - - + + -
          +
          @@ -671,9 +698,8 @@ function SystemRecord({ function SystemRow({ label, value }: { label: string; value: string }) { return ( -
          -
          {label}
          -
          {value}
          -
          + + {value} + ); } From f131c162cd53ca9cae980a39b21e63ee8c0fd11c Mon Sep 17 00:00:00 2001 From: o-p-o-p-o <204936872+o-p-o-p-o@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:17:25 +0100 Subject: [PATCH 4/8] feat(profile): order the rail About, Links, Spaces; drop Types and the Activity fade Co-Authored-By: Claude Opus 5 (1M context) --- .../profile/profile-activity-section.test.tsx | 4 -- .../profile/profile-activity-section.tsx | 13 ----- apps/web/partials/profile/profile-rail.tsx | 51 ++++--------------- 3 files changed, 11 insertions(+), 57 deletions(-) diff --git a/apps/web/partials/profile/profile-activity-section.test.tsx b/apps/web/partials/profile/profile-activity-section.test.tsx index 2df1b5b88f..a39cd70580 100644 --- a/apps/web/partials/profile/profile-activity-section.test.tsx +++ b/apps/web/partials/profile/profile-activity-section.test.tsx @@ -466,8 +466,6 @@ describe('ProfileActivitySection', () => { expect(screen.getByRole('button', { name: 'Previous page' })).toBeDisabled(); const next = screen.getByRole('button', { name: 'Next page' }); expect(next).toBeEnabled(); - // More to the right, so the edge fades. - expect(document.querySelector('[data-activity-scroll-fade]')).not.toBeNull(); fireEvent.click(next); expect(scroller.scrollBy).toHaveBeenCalledWith({ left: 276, behavior: 'smooth' }); @@ -482,8 +480,6 @@ describe('ProfileActivitySection', () => { fireEvent.scroll(scroller); await act(async () => new Promise(resolve => requestAnimationFrame(() => resolve()))); expect(screen.getByRole('button', { name: 'Next page' })).toBeDisabled(); - // Nothing further right: no fade over the last card. - expect(document.querySelector('[data-activity-scroll-fade]')).toBeNull(); }); it('hands autoplay to the next visible debate when the current one scrolls out', async () => { diff --git a/apps/web/partials/profile/profile-activity-section.tsx b/apps/web/partials/profile/profile-activity-section.tsx index c70b4f9342..88c8d7382a 100644 --- a/apps/web/partials/profile/profile-activity-section.tsx +++ b/apps/web/partials/profile/profile-activity-section.tsx @@ -533,19 +533,6 @@ function ActivityGallery({
      - {/* - * A 40px white fade over the right edge while there is more to scroll: enough to say the - * row goes on, narrow enough to leave the cut-off card readable. It sits on the wrapper, - * not the scroller, so it stays put while the cards move under it, and it never takes a - * click meant for the card beneath. - */} - {canScrollRight ? ( -
      - ) : null}
      ); diff --git a/apps/web/partials/profile/profile-rail.tsx b/apps/web/partials/profile/profile-rail.tsx index 697159b99a..255b1d6886 100644 --- a/apps/web/partials/profile/profile-rail.tsx +++ b/apps/web/partials/profile/profile-rail.tsx @@ -26,13 +26,7 @@ import { useEntitySchemaWithGroups } from '~/core/state/entity-page-store/entity import { useValue } from '~/core/sync/use-store'; import { NavUtils } from '~/core/utils/utils'; -import { - Button, - PILL_BUTTON_SECONDARY_CLASS_NAME, - SmallButton, - SquareButton, - buttonClassNames, -} from '~/design-system/button'; +import { Button, PILL_BUTTON_SECONDARY_CLASS_NAME, SmallButton, SquareButton } from '~/design-system/button'; import { ClampedText } from '~/design-system/clamped-text'; import { FallbackImage } from '~/design-system/fallback-image'; import { ChevronDownSmall } from '~/design-system/icons/chevron-down-small'; @@ -56,9 +50,8 @@ export type ProfileRailProps = ProfileRailFacts & { /** * The facts a profile states about an account (GEO-2859). * - * Three sections, in the order a reader wants them: where this person works in - * the graph, how to reach them, then the facts — ending in the space's own - * record, folded away. + * Three sections: who this person is — ending in the space's own record, + * folded away — then how to reach them, then where they work in the graph. * * Rule-separated sections rather than bordered cards, which is how every other * rail in the app composes. @@ -82,7 +75,6 @@ export function ProfileRail(props: ProfileRailProps) { export function ProfileRailSections({ spaceId, personEntityId, - types, links, systemEntityId, address, @@ -100,26 +92,25 @@ export function ProfileRailSections({ // and space rails draw (`SideRailSections`). Spacing lives on the sections // rather than the rules, because any of them can be absent.
      - {facts.spaces.length > 0 && } - {personEntityId ? ( - - ) : ( - // No person to write to, so no card: a space with no topic entity has - // nowhere to put a link even for its owner. - links.length > 0 && - )} + {personEntityId ? ( + + ) : ( + // No person to write to, so no card: a space with no topic entity has + // nowhere to put a link even for its owner. + links.length > 0 && + )} + {facts.spaces.length > 0 && }
      ); } @@ -400,7 +391,6 @@ function AboutSection({ isLoading, isError, positionsCount, - types, spaceId, personEntityId, systemEntityId, @@ -413,7 +403,6 @@ function AboutSection({ isError: boolean; /** Positions actually held, or null while the vote table is still out. */ positionsCount: number | null; - types: ProfileRailProps['types']; spaceId: string; personEntityId: string | null; systemEntityId: string; @@ -461,24 +450,6 @@ function AboutSection({ - {types.length > 0 && ( - - {/* The secondary pill, as the Spaces "See all" button draws it — - white, grey outline, dark on hover. */} - - {types.map(type => ( - - {type.name ?? 'Untitled'} - - ))} - - - )} - {facts.verifiedBy.length > 0 && ( From d7473693a9a92311c3e13c32ac452cd74f8e650e Mon Sep 17 00:00:00 2001 From: o-p-o-p-o <204936872+o-p-o-p-o@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:18:06 +0100 Subject: [PATCH 5/8] style(profile): prettier Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/partials/profile/profile-activity-section.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/web/partials/profile/profile-activity-section.tsx b/apps/web/partials/profile/profile-activity-section.tsx index 88c8d7382a..db7f8b667e 100644 --- a/apps/web/partials/profile/profile-activity-section.tsx +++ b/apps/web/partials/profile/profile-activity-section.tsx @@ -532,7 +532,6 @@ function ActivityGallery({ ))}
      -
      ); From f80fa996027906ffdd0b4d6e18ceaa1ecf492a25 Mon Sep 17 00:00:00 2001 From: Nate <204936872+o-p-o-p-o@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:55:02 +0100 Subject: [PATCH 6/8] Potential fix for pull request finding 'Prevent mobile control row overflow' Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- apps/web/partials/profile/profile-activity-section.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/partials/profile/profile-activity-section.tsx b/apps/web/partials/profile/profile-activity-section.tsx index db7f8b667e..f933b25469 100644 --- a/apps/web/partials/profile/profile-activity-section.tsx +++ b/apps/web/partials/profile/profile-activity-section.tsx @@ -132,7 +132,7 @@ export function ProfileActivitySection({ kinds }: { kinds: ActivityKind[] }) { */}

      Activity

      -
      +className="flex flex-wrap items-center gap-2" {available.map(kind => { const isSelected = kind.key === selected.key; From 97ab6da80826b0859aeca9d300c86c2c786ad72e Mon Sep 17 00:00:00 2001 From: Nate <204936872+o-p-o-p-o@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:55:22 +0100 Subject: [PATCH 7/8] Potential fix for pull request finding 'Reserve gallery navigation buttons for stable header layout' Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- apps/web/partials/profile/profile-activity-section.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/partials/profile/profile-activity-section.tsx b/apps/web/partials/profile/profile-activity-section.tsx index f933b25469..2ed7453a66 100644 --- a/apps/web/partials/profile/profile-activity-section.tsx +++ b/apps/web/partials/profile/profile-activity-section.tsx @@ -175,12 +175,12 @@ className="flex flex-wrap items-center gap-2" * full tab. Both belong to the selected kind — see `navigation`. */}
      - {navigation && (navigation.left || navigation.right) ? ( + {selected.rows.length > 0 ? ( // The data block galleries' own arrows (`table-pagination`), 12px apart as they are // there: dark when there is somewhere to go, grey at the row's end. - navigation.scrollByCard(-1)} /> - navigation.scrollByCard(1)} /> + navigation?.scrollByCard(-1)} /> + navigation?.scrollByCard(1)} /> ) : null} From 8b563b486df2ecd234ecbd9d159ce0d76963931f Mon Sep 17 00:00:00 2001 From: o-p-o-p-o <204936872+o-p-o-p-o@users.noreply.github.com> Date: Tue, 22 Sep 2026 18:02:03 +0100 Subject: [PATCH 8/8] fix(profile): restore the Activity controls row's opening tag A suggested change replaced the whole
      line with a bare className, which stopped the file parsing and failed the build. Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/partials/profile/profile-activity-section.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/partials/profile/profile-activity-section.tsx b/apps/web/partials/profile/profile-activity-section.tsx index 2ed7453a66..bebf0b5d99 100644 --- a/apps/web/partials/profile/profile-activity-section.tsx +++ b/apps/web/partials/profile/profile-activity-section.tsx @@ -132,7 +132,7 @@ export function ProfileActivitySection({ kinds }: { kinds: ActivityKind[] }) { */}

      Activity

      -className="flex flex-wrap items-center gap-2" +
      {available.map(kind => { const isSelected = kind.key === selected.key;