From e78bb58f71e3eda956082dbd4c63ca67c1bff45e Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 2 May 2026 10:38:27 +0900 Subject: [PATCH 01/23] Add HoverCard solid-ui primitive Wraps Kobalte's HoverCard for the new web-next stack, mirroring the existing Tooltip primitive in components/ui/. The default content surface follows DESIGN.md (rounded-lg, bg-popover, single elevation shadow-md) and uses the motion-safe: prefix on the open/close animation classes so reduced-motion users get a static fade. This is the first step toward profile hover cards (issue #90). https://github.com/hackers-pub/hackerspub/issues/90 Assisted-by: Claude Code:claude-opus-4-7 Assisted-by: Codex:gpt-5.5 --- web-next/src/components/ui/hover-card.tsx | 43 +++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 web-next/src/components/ui/hover-card.tsx diff --git a/web-next/src/components/ui/hover-card.tsx b/web-next/src/components/ui/hover-card.tsx new file mode 100644 index 000000000..4ae8fba4c --- /dev/null +++ b/web-next/src/components/ui/hover-card.tsx @@ -0,0 +1,43 @@ +import type { ValidComponent } from "solid-js"; +import { type Component, splitProps } from "solid-js"; + +import * as HoverCardPrimitive from "@kobalte/core/hover-card"; +import type { PolymorphicProps } from "@kobalte/core/polymorphic"; + +import { cn } from "~/lib/utils.ts"; + +const HoverCardTrigger = HoverCardPrimitive.Trigger; + +const HoverCard: Component = (props) => { + return ( + + ); +}; + +type HoverCardContentProps = + & HoverCardPrimitive.HoverCardContentProps + & { class?: string | undefined }; + +const HoverCardContent = ( + props: PolymorphicProps>, +) => { + const [local, others] = splitProps(props as HoverCardContentProps, ["class"]); + return ( + + + + ); +}; + +export { HoverCard, HoverCardContent, HoverCardTrigger }; From e8bc77a69fe19eb92517f07478eee20226e6f008 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 2 May 2026 10:48:42 +0900 Subject: [PATCH 02/23] Add ActorHoverCard scaffolding Three new pieces, not yet wired into any surface: - ActorPreviewCard: the body shown inside a hover card. New ActorPreviewCard_actor fragment selects name, handle, avatar, bio, follow counts, plus the FollowButton fragment. Layout follows DESIGN.md (achromatic surface, single elevation, hairline borders) and matches the X reference in spirit: avatar + name + follow on top, clamped bio, counts row. - ActorPreviewSkeleton: matched-proportions placeholder shown while the actor query is in flight. - ActorHoverCardLoader: lazy actor fetch via the established loadQuery + createPreloadedQuery pattern, scoped behind an ErrorBoundary so a failing GraphQL/network call collapses to the same "Could not load profile." fallback as a null actorByHandle. - ActorHoverCard: thin glue wrapping a child in a HoverCardTrigger with role="presentation" + tabIndex={-1} (drops Kobalte Link.Root's phantom tab stop) and class="inline-flex" so Popper has a real anchor rect. Defers loader mount until open. https://github.com/hackers-pub/hackerspub/issues/90 Assisted-by: Claude Code:claude-opus-4-7 Assisted-by: Codex:gpt-5.5 --- web-next/src/components/ActorHoverCard.tsx | 34 ++++ .../src/components/ActorHoverCardLoader.tsx | 51 ++++++ web-next/src/components/ActorPreviewCard.tsx | 149 ++++++++++++++++++ .../src/components/ActorPreviewSkeleton.tsx | 21 +++ 4 files changed, 255 insertions(+) create mode 100644 web-next/src/components/ActorHoverCard.tsx create mode 100644 web-next/src/components/ActorHoverCardLoader.tsx create mode 100644 web-next/src/components/ActorPreviewCard.tsx create mode 100644 web-next/src/components/ActorPreviewSkeleton.tsx diff --git a/web-next/src/components/ActorHoverCard.tsx b/web-next/src/components/ActorHoverCard.tsx new file mode 100644 index 000000000..698cecacb --- /dev/null +++ b/web-next/src/components/ActorHoverCard.tsx @@ -0,0 +1,34 @@ +import { createSignal, type JSX, Show } from "solid-js"; +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "~/components/ui/hover-card.tsx"; +import { ActorHoverCardLoader } from "./ActorHoverCardLoader.tsx"; + +export interface ActorHoverCardProps { + /** Canonical fediverse handle (e.g., `@user@host`). */ + handle: string; + children: JSX.Element; +} + +export function ActorHoverCard(props: ActorHoverCardProps) { + const [open, setOpen] = createSignal(false); + return ( + + + {props.children} + + + + + + + + ); +} diff --git a/web-next/src/components/ActorHoverCardLoader.tsx b/web-next/src/components/ActorHoverCardLoader.tsx new file mode 100644 index 000000000..e2cefb015 --- /dev/null +++ b/web-next/src/components/ActorHoverCardLoader.tsx @@ -0,0 +1,51 @@ +import { graphql } from "relay-runtime"; +import { ErrorBoundary, Show } from "solid-js"; +import { + createPreloadedQuery, + loadQuery, + useRelayEnvironment, +} from "solid-relay"; +import { useLingui } from "~/lib/i18n/macro.d.ts"; +import type { ActorHoverCardLoaderQuery } from "./__generated__/ActorHoverCardLoaderQuery.graphql.ts"; +import { ActorPreviewCard } from "./ActorPreviewCard.tsx"; +import { ActorPreviewSkeleton } from "./ActorPreviewSkeleton.tsx"; + +const actorHoverCardLoaderQuery = graphql` + query ActorHoverCardLoaderQuery($handle: String!) { + actorByHandle(handle: $handle, allowLocalHandle: true) { + ...ActorPreviewCard_actor + } + } +`; + +export interface ActorHoverCardLoaderProps { + handle: string; +} + +export function ActorHoverCardLoader(props: ActorHoverCardLoaderProps) { + const { t } = useLingui(); + const env = useRelayEnvironment(); + + const data = createPreloadedQuery( + actorHoverCardLoaderQuery, + () => loadQuery(env(), actorHoverCardLoaderQuery, { handle: props.handle }), + ); + + const unavailable = () => ( +
+ {t`Could not load profile.`} +
+ ); + + return ( + + }> + {(loaded) => ( + + {(actor) => } + + )} + + + ); +} diff --git a/web-next/src/components/ActorPreviewCard.tsx b/web-next/src/components/ActorPreviewCard.tsx new file mode 100644 index 000000000..8fdf10c6d --- /dev/null +++ b/web-next/src/components/ActorPreviewCard.tsx @@ -0,0 +1,149 @@ +import { graphql } from "relay-runtime"; +import { Show } from "solid-js"; +import { createFragment } from "solid-relay"; +import { + Avatar, + AvatarFallback, + AvatarImage, +} from "~/components/ui/avatar.tsx"; +import { msg, plural, useLingui } from "~/lib/i18n/macro.d.ts"; +import type { ActorPreviewCard_actor$key } from "./__generated__/ActorPreviewCard_actor.graphql.ts"; +import { FollowButton } from "./FollowButton.tsx"; + +export interface ActorPreviewCardProps { + $actor: ActorPreviewCard_actor$key; +} + +export function ActorPreviewCard(props: ActorPreviewCardProps) { + const { t, i18n } = useLingui(); + const actor = createFragment( + graphql` + fragment ActorPreviewCard_actor on Actor { + id + name + username + handle + avatarUrl + avatarInitials + bio + local + url + iri + followsViewer + followeesCount: followees { + totalCount + } + followersCount: followers { + totalCount + } + ...FollowButton_actor + } + `, + () => props.$actor, + ); + + return ( + + {(a) => { + const profileHref = () => + a().local ? `/@${a().username}` : a().url ?? a().iri; + const profileTarget = () => (a().local ? undefined : "_blank"); + return ( +
+ + + + ); + }} + + ); +} diff --git a/web-next/src/components/ActorPreviewSkeleton.tsx b/web-next/src/components/ActorPreviewSkeleton.tsx new file mode 100644 index 000000000..83d985a1b --- /dev/null +++ b/web-next/src/components/ActorPreviewSkeleton.tsx @@ -0,0 +1,21 @@ +import { Skeleton } from "~/components/ui/skeleton.tsx"; + +export function ActorPreviewSkeleton() { + return ( +
+
+ +
+ + +
+
+
+ + + +
+ +
+ ); +} From e0133f34800a7ef4eac79556897e4250a854c53f Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 2 May 2026 10:58:45 +0900 Subject: [PATCH 03/23] Wire ActorHoverCard onto avatar and name surfaces Wraps avatar and display-name links across the timeline, profile cards, notifications, and shares so hovering them shows the actor preview (avatar, name, handle, bio, follow counts, follow button). The trigger wrapper is a non-focusable inline-flex span; ActorHoverCard now accepts an optional `class` prop so call sites that wrap a fixed- size avatar in a flex container pass `shrink-0` for the wrapper itself. NotificationMessage's actor stack moves its z-index/transition utility classes from each `` to the `ActorHoverCard` wrapper so the parent's `-space-x-*` and the per-item `hover:z-10` keep working with the wrapper as the direct flex child. Surfaces wired: PostAvatar, NoteHeader display-name, ArticleCard avatar + name, QuotedNoteCard avatar + name, SmallProfileCard avatar + name, NotificationActor link, NotificationMessage avatar stack, PostSharer "shared by" link, QuestionCard shared-actor link, and the inline display-name link inside QuestionCardContent. https://github.com/hackers-pub/hackerspub/issues/90 Assisted-by: Claude Code:claude-opus-4-7 Assisted-by: Codex:gpt-5.5 --- web-next/src/components/ActorHoverCard.tsx | 9 +++- web-next/src/components/ArticleCard.tsx | 53 +++++++++++-------- web-next/src/components/NoteHeader.tsx | 19 ++++--- web-next/src/components/NotificationActor.tsx | 45 ++++++++-------- web-next/src/components/PostAvatar.tsx | 21 ++++---- web-next/src/components/PostSharer.tsx | 23 ++++---- web-next/src/components/QuestionCard.tsx | 41 +++++++------- web-next/src/components/QuotedNoteCard.tsx | 41 +++++++------- web-next/src/components/SmallProfileCard.tsx | 37 +++++++------ .../notification/NotificationMessage.tsx | 25 +++++---- 10 files changed, 182 insertions(+), 132 deletions(-) diff --git a/web-next/src/components/ActorHoverCard.tsx b/web-next/src/components/ActorHoverCard.tsx index 698cecacb..e63a0eb52 100644 --- a/web-next/src/components/ActorHoverCard.tsx +++ b/web-next/src/components/ActorHoverCard.tsx @@ -4,11 +4,18 @@ import { HoverCardContent, HoverCardTrigger, } from "~/components/ui/hover-card.tsx"; +import { cn } from "~/lib/utils.ts"; import { ActorHoverCardLoader } from "./ActorHoverCardLoader.tsx"; export interface ActorHoverCardProps { /** Canonical fediverse handle (e.g., `@user@host`). */ handle: string; + /** + * Extra classes for the trigger wrapper. Append `shrink-0` when wrapping a + * fixed-size avatar in a flex container so the wrapper itself does not + * collapse. + */ + class?: string; children: JSX.Element; } @@ -18,7 +25,7 @@ export function ActorHoverCard(props: ActorHoverCardProps) { diff --git a/web-next/src/components/ArticleCard.tsx b/web-next/src/components/ArticleCard.tsx index af44c13a9..77c7d9b93 100644 --- a/web-next/src/components/ArticleCard.tsx +++ b/web-next/src/components/ArticleCard.tsx @@ -11,6 +11,7 @@ import { ArticleCard_article$key, } from "./__generated__/ArticleCard_article.graphql.ts"; import { ArticleCardInternal_article$key } from "./__generated__/ArticleCardInternal_article.graphql.ts"; +import { ActorHoverCard } from "./ActorHoverCard.tsx"; import { ArticleControls } from "./ArticleControls.tsx"; import { InternalLink } from "./InternalLink.tsx"; import { PostActionMenu } from "./PostActionMenu.tsx"; @@ -144,30 +145,40 @@ function ArticleCardInternal(props: ArticleCardInternalProps) { {(article) => ( <>
- - - - - {article().actor.avatarInitials} - - - + + + + + + {article().actor.avatarInitials} + + + +
- + + + (
- + + + {" "} ( {(firstActor) => ( - - - {firstActor().handle} - - } - > - {(name) => ( - - {" "} - - ({firstActor().handle}) + + + + {firstActor().handle} - - )} - - + } + > + {(name) => ( + + {" "} + + ({firstActor().handle}) + + + )} + + + )} )} diff --git a/web-next/src/components/PostAvatar.tsx b/web-next/src/components/PostAvatar.tsx index cc510735f..d628594c6 100644 --- a/web-next/src/components/PostAvatar.tsx +++ b/web-next/src/components/PostAvatar.tsx @@ -2,6 +2,7 @@ import { graphql } from "relay-runtime"; import { Show } from "solid-js"; import { createFragment } from "solid-relay"; import { PostAvatar_actor$key } from "./__generated__/PostAvatar_actor.graphql.ts"; +import { ActorHoverCard } from "./ActorHoverCard.tsx"; import { InternalLink } from "./InternalLink.tsx"; import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar.tsx"; @@ -28,15 +29,17 @@ export function PostAvatar(props: PostAvatarProps) { return ( {(a) => ( - - - - {a().avatarInitials} - - + + + + + {a().avatarInitials} + + + )} ); diff --git a/web-next/src/components/PostSharer.tsx b/web-next/src/components/PostSharer.tsx index 804181afa..ca4582e7d 100644 --- a/web-next/src/components/PostSharer.tsx +++ b/web-next/src/components/PostSharer.tsx @@ -3,6 +3,7 @@ import { Show } from "solid-js"; import { createFragment } from "solid-relay"; import { useLingui } from "~/lib/i18n/macro.d.ts"; import { PostSharer_post$key } from "./__generated__/PostSharer_post.graphql.ts"; +import { ActorHoverCard } from "./ActorHoverCard.tsx"; import { Timestamp } from "./Timestamp.tsx"; import { Trans } from "./Trans.tsx"; @@ -36,16 +37,18 @@ export function PostSharer(props: PostSharerProps) { message={t`${"SHARER"} shared ${"RELATIVE_TIME"}`} values={{ SHARER: () => ( - - {post().actor.name} - + + + {post().actor.name} + + ), RELATIVE_TIME: () => , }} diff --git a/web-next/src/components/QuestionCard.tsx b/web-next/src/components/QuestionCard.tsx index 9df3a16a0..89177ffb4 100644 --- a/web-next/src/components/QuestionCard.tsx +++ b/web-next/src/components/QuestionCard.tsx @@ -21,6 +21,7 @@ import { msg, plural, useLingui } from "~/lib/i18n/macro.d.ts"; import type { QuestionCard_question$key } from "./__generated__/QuestionCard_question.graphql.ts"; import type { QuestionCardContent_question$key } from "./__generated__/QuestionCardContent_question.graphql.ts"; import type { QuestionCard_voteOnPoll_Mutation } from "./__generated__/QuestionCard_voteOnPoll_Mutation.graphql.ts"; +import { ActorHoverCard } from "./ActorHoverCard.tsx"; import { InternalLink } from "./InternalLink.tsx"; import { QuestionActionMenu } from "./PostActionMenu.tsx"; import { PostAvatar } from "./PostAvatar.tsx"; @@ -78,16 +79,18 @@ export function QuestionCard(props: QuestionCardProps) { message={t`${"SHARER"} shared ${"RELATIVE_TIME"}`} values={{ SHARER: () => ( - - {q().actor.name} - + + + {q().actor.name} + + ), RELATIVE_TIME: () => , }} @@ -219,14 +222,16 @@ function QuestionCardContent(props: QuestionCardContentProps) {
- + + + {" "}