From d54fe93f3e3b5803aeb4766bdd8f9a34b5944b3b Mon Sep 17 00:00:00 2001 From: Barry Prendergast Date: Sat, 7 Nov 2026 13:36:27 +0100 Subject: [PATCH 1/6] Make home app cards a single stable link with a rounded focus ring The home page had three hand-rolled card variants. Replace them with one shared AppCard (large/medium/small). The link is now the stationary hit area and only the inner surface lifts on hover, which stops the cursor flickering at the card's bottom edge. Keyboard focus draws a 2px primary ring that follows the card's rounded corners. Co-Authored-By: Claude Opus 5.5 --- src/components/AppCard.tsx | 227 ++++++++++++++++++++++ src/routes/_header-layout.index.tsx | 289 +--------------------------- 2 files changed, 237 insertions(+), 279 deletions(-) create mode 100644 src/components/AppCard.tsx diff --git a/src/components/AppCard.tsx b/src/components/AppCard.tsx new file mode 100644 index 0000000..88535b3 --- /dev/null +++ b/src/components/AppCard.tsx @@ -0,0 +1,227 @@ +import type { ReactNode } from "react"; + +import * as stylex from "@stylexjs/stylex"; +import { Link as RouterLink } from "@tanstack/react-router"; +import { ChevronRight } from "lucide-react"; +import { useId } from "react"; + +import type { DirectoryListingCard } from "../integrations/tanstack-query/api-directory-listings.functions"; + +import { Avatar } from "../design-system/avatar"; +import { Card, CardImage } from "../design-system/card"; +import { Flex } from "../design-system/flex"; +import { + animationDuration, + animationTimingFunction, +} from "../design-system/theme/animations.stylex"; +import { primaryColor, uiColor } from "../design-system/theme/color.stylex"; +import { radius } from "../design-system/theme/radius.stylex"; +import { + gap, + horizontalSpace, + verticalSpace, +} from "../design-system/theme/semantic-spacing.stylex"; +import { shadow } from "../design-system/theme/shadow.stylex"; +import { Body, SmallBody } from "../design-system/typography"; +import { Text } from "../design-system/typography/text"; +import { getDirectoryListingSlug } from "../lib/directory-listing-slugs"; +import { getInitials } from "../lib/get-initials"; +import { getDirectoryListingHeroImageAlt } from "../lib/listing-copy"; +import { HeroImage } from "./HeroImage"; + +export type AppCardSize = "large" | "medium" | "small"; + +const styles = stylex.create({ + /** + * The link is the hit area. It never moves, so the pointer can't slip off + * its edge mid-hover (which made the cursor flicker between arrow and hand). + * All motion and the focus ring live on the surface inside it. + */ + link: { + WebkitTapHighlightColor: "transparent", + textDecoration: "none", + color: "inherit", + cursor: "pointer", + display: "block", + outlineStyle: "none", + height: "100%", + }, + surface: { + borderRadius: radius.lg, + cornerShape: "squircle", + boxShadow: { + default: shadow.sm, + [stylex.when.ancestor(":hover")]: shadow.lg, + }, + boxSizing: "border-box", + outlineColor: primaryColor.solid1, + outlineOffset: 2, + outlineStyle: { + default: "none", + [stylex.when.ancestor(":focus-visible")]: "solid", + }, + outlineWidth: 2, + transform: { + default: "none", + [stylex.when.ancestor(":hover")]: "translateY(-2px)", + }, + transitionDuration: animationDuration.slow, + transitionProperty: "transform, box-shadow", + transitionTimingFunction: animationTimingFunction.easeOut, + height: "100%", + }, + heroSurface: { + borderRadius: radius["3xl"], + boxShadow: "none", + transform: "none", + }, + content: { + gap: gap["4xl"], + boxSizing: "border-box", + flexGrow: 1, + paddingBottom: verticalSpace["4xl"], + paddingLeft: horizontalSpace["4xl"], + paddingRight: horizontalSpace["4xl"], + paddingTop: verticalSpace["4xl"], + }, + contentAfterImage: { + paddingTop: 0, + }, + identity: { + minWidth: 0, + }, + identityText: { + minWidth: 0, + }, + name: { + overflowWrap: "anywhere", + }, + tagline: { + flexGrow: 1, + }, + chevron: { + color: uiColor.text1, + flexShrink: 0, + transform: { + default: "none", + [stylex.when.ancestor(":hover")]: "translateX(2px)", + }, + transitionDuration: animationDuration.slow, + transitionProperty: "transform", + transitionTimingFunction: animationTimingFunction.easeOut, + }, +}); + +function getHandle(listing: DirectoryListingCard) { + return listing.productAccountHandle?.replace(/^@/, "") || "unknown"; +} + +/** + * The whole card is a single link. Keep everything inside it non-interactive + * (no buttons, no nested links) so there is exactly one click target. + */ +function AppCardLink({ + listing, + labelledBy, + describedBy, + children, +}: { + listing: DirectoryListingCard; + labelledBy?: string; + describedBy?: string; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + +export function AppCard({ + listing, + size, +}: { + listing: DirectoryListingCard; + size: AppCardSize; +}) { + const nameId = useId(); + const taglineId = useId(); + + if (size === "large" && listing.heroImageUrl) { + return ( + +
+ +
+
+ ); + } + + const showImage = size === "medium" && Boolean(listing.heroImageUrl); + + return ( + + + {showImage && listing.heroImageUrl ? ( + + ) : null} + + + + + + {listing.name} + + @{getHandle(listing)} + + + {size === "small" ? ( + + {listing.tagline} + + ) : ( + + {listing.tagline} + + )} + + + + + + + ); +} diff --git a/src/routes/_header-layout.index.tsx b/src/routes/_header-layout.index.tsx index dbb0a9c..b0e7a33 100644 --- a/src/routes/_header-layout.index.tsx +++ b/src/routes/_header-layout.index.tsx @@ -10,21 +10,17 @@ import { ChevronRight } from "lucide-react"; import type { DirectoryListingCard } from "../integrations/tanstack-query/api-directory-listings.functions"; +import { AppCard } from "../components/AppCard"; import { AppTagCard } from "../components/AppTagCard"; import { FeaturedListingGrid } from "../components/FeaturedListingGrid"; -import { HeroImage } from "../components/HeroImage"; import { Alert } from "../design-system/alert"; import { Avatar } from "../design-system/avatar"; import { Button } from "../design-system/button"; -import { Card, CardImage } from "../design-system/card"; import { Flex } from "../design-system/flex"; import { Grid } from "../design-system/grid"; import { Link } from "../design-system/link"; import { Page } from "../design-system/page"; -import { - animationDuration, - animationTimingFunction, -} from "../design-system/theme/animations.stylex"; +import { animationDuration } from "../design-system/theme/animations.stylex"; import { uiColor } from "../design-system/theme/color.stylex"; import { breakpoints } from "../design-system/theme/media-queries.stylex"; import { radius } from "../design-system/theme/radius.stylex"; @@ -34,17 +30,11 @@ import { verticalSpace, } from "../design-system/theme/semantic-spacing.stylex"; import { shadow } from "../design-system/theme/shadow.stylex"; -import { - Body, - Heading1, - Heading2, - SmallBody, -} from "../design-system/typography"; +import { Heading1, Heading2, SmallBody } from "../design-system/typography"; import { Text } from "../design-system/typography/text"; import { directoryListingApi } from "../integrations/tanstack-query/api-directory-listings.functions"; import { getDirectoryListingSlug } from "../lib/directory-listing-slugs"; import { getInitials } from "../lib/get-initials"; -import { getDirectoryListingHeroImageAlt } from "../lib/listing-copy"; import { buildRouteOgMeta } from "../lib/og-meta"; export const Route = createFileRoute("/_header-layout/")({ @@ -76,48 +66,6 @@ const styles = stylex.create({ headerDescription: { maxWidth: "41rem", }, - bentoLink: { - textDecoration: "none", - color: uiColor.text2, - display: "block", - position: "relative", - zIndex: 1, - }, - bentoLinkFeatured: { - aspectRatio: "16 / 9", - zIndex: 0, - }, - listingCardLink: { - borderRadius: radius.lg, - cornerShape: "squircle", - textDecoration: "none", - boxShadow: shadow.md, - display: "block", - transform: { - default: "none", - ":hover": "translateY(-2px)", - }, - transitionDuration: animationDuration.slow, - transitionProperty: "transform", - transitionTimingFunction: "ease-in-out", - height: "100%", - - ":hover::before": { - opacity: 1, - }, - "::before": { - inset: 0, - borderRadius: radius.lg, - cornerShape: "squircle", - boxShadow: shadow.lg, - content: "''", - opacity: 0, - position: "absolute", - transitionDuration: animationDuration.slow, - transitionProperty: "opacity", - transitionTimingFunction: "ease-in-out", - }, - }, claimBanner: { width: "100%", }, @@ -143,72 +91,10 @@ const styles = stylex.create({ flexDirection: "column", width: "100%", }, - promoCard: { - borderColor: uiColor.component2, - borderRadius: radius["lg"], - borderStyle: "solid", - borderWidth: 1, - boxShadow: shadow.none, - color: uiColor.text2, - transform: { - default: "none", - ":hover": "translateY(-2px)", - }, - transitionDuration: animationDuration.slow, - transitionProperty: "transform", - transitionTimingFunction: "ease-in-out", - height: "100%", - }, - promoCardShadow: { - borderRadius: radius.lg, - cornerShape: "squircle", - boxShadow: shadow.md, - position: "relative", - - ":hover::before": { - opacity: 1, - }, - "::before": { - inset: 0, - borderRadius: radius.lg, - cornerShape: "squircle", - boxShadow: shadow.lg, - content: "''", - opacity: 0, - position: "absolute", - transitionDuration: animationDuration.default, - transitionProperty: "opacity", - transitionTimingFunction: animationTimingFunction.linear, - }, - }, - promoCardBody: { - boxSizing: "border-box", - flexBasis: "0%", - flexGrow: "1", - flexShrink: "1", - justifyContent: "flex-start", - position: "relative", - zIndex: 1, - paddingBottom: verticalSpace["3xl"], - paddingLeft: horizontalSpace["3xl"], - paddingRight: horizontalSpace["3xl"], - paddingTop: verticalSpace["sm"], - }, - promoCardTagline: { - flexGrow: 1, - }, eyebrow: { letterSpacing: "0.16em", textTransform: "uppercase", }, - heroTitle: { - display: "block", - maxWidth: "18ch", - }, - heroDescription: { - margin: 0, - maxWidth: "32rem", - }, sectionHeader: { marginBottom: verticalSpace["3xl"], }, @@ -307,38 +193,11 @@ const styles = stylex.create({ [breakpoints.lg]: "repeat(3, minmax(0, 1fr))", }, }, - listingCard: { - boxShadow: shadow.none, - boxSizing: "border-box", - position: "relative", - height: "100%", - }, - listingCardContent: { - boxSizing: "border-box", - display: "flex", - flexDirection: "column", - height: "100%", - paddingBottom: verticalSpace["4xl"], - paddingLeft: horizontalSpace["4xl"], - paddingRight: horizontalSpace["4xl"], - paddingTop: verticalSpace["4xl"], - }, - listItemTagline: { - flexGrow: 1, - minWidth: 0, - }, exploreButton: { borderRadius: radius.full, cornerShape: "unset", cursor: "pointer", }, - heroImagePlaceholder: { - flexGrow: 1, - minHeight: 0, - }, - heroCardContent: { - height: "100%", - }, }); function HomePage() { @@ -408,13 +267,9 @@ function HomePage() { } isFeatured={(_, index) => index === 0} canFeature={(listing) => Boolean(listing.heroImageUrl)} - renderItem={(listing, { featured }) => - featured ? ( - - ) : ( - - ) - } + renderItem={(listing, { featured }) => ( + + )} /> @@ -452,7 +307,9 @@ function HomePage() { /> ))} - {promoListing ? : null} + {promoListing ? ( + + ) : null} @@ -465,7 +322,7 @@ function HomePage() { /> {data.fresh.map((listing) => ( - + ))} @@ -527,53 +384,6 @@ function SectionHeader({ eyebrow, title, to, search }: SectionHeaderProps) { ); } -function HeroCard({ listing }: { listing: DirectoryListingCard }) { - return ( - - {listing.heroImageUrl ? ( -
- -
- ) : ( - - - - - - - - {listing.name} - - - @ - {listing.productAccountHandle?.replace(/^@/, "") || - "unknown"} - - - - - {listing.tagline} - - - - - )} -
- ); -} - function PopularListItem({ listing, rank, @@ -609,85 +419,6 @@ function PopularListItem({ ); } -function PromoCard({ listing }: { listing: DirectoryListingCard }) { - return ( - - - {listing.heroImageUrl ? ( - - ) : null} - - - - - - {listing.name} - - - @{listing.productAccountHandle?.replace(/^@/, "") || "unknown"} - - - - - {listing.tagline} - - - - - - - - ); -} - -function ListingCard({ listing }: { listing: DirectoryListingCard }) { - return ( - - - - - - - - {listing.name} - - - @{listing.productAccountHandle?.replace(/^@/, "") || "unknown"} - - - - - {listing.tagline} - - - - - - - - ); -} - function StoreIcon({ listing, size, From 4e48194716377e090d132ae106fed65fb86d2853 Mon Sep 17 00:00:00 2001 From: Barry Prendergast Date: Sat, 7 Nov 2026 13:42:58 +0100 Subject: [PATCH 2/6] Show ratings and labels on app cards Medium and small cards now show up to two labels (ecosystem category, then editorial tags) with a "+N" overflow chip, plus a star rating or "No reviews yet". Labels stay on one line: only the first may truncate and later ones are width-capped, so short tags never collapse. The card link is described by tagline, labels and rating for screen readers. Co-Authored-By: Claude Opus 5.5 --- src/components/AppCard.tsx | 154 ++++++++++++++++++++++++++++++++- src/lib/listing-labels.test.ts | 36 ++++++++ src/lib/listing-labels.ts | 44 ++++++++++ 3 files changed, 231 insertions(+), 3 deletions(-) create mode 100644 src/lib/listing-labels.test.ts create mode 100644 src/lib/listing-labels.ts diff --git a/src/components/AppCard.tsx b/src/components/AppCard.tsx index 88535b3..c732507 100644 --- a/src/components/AppCard.tsx +++ b/src/components/AppCard.tsx @@ -4,12 +4,15 @@ import * as stylex from "@stylexjs/stylex"; import { Link as RouterLink } from "@tanstack/react-router"; import { ChevronRight } from "lucide-react"; import { useId } from "react"; +import { VisuallyHidden } from "react-aria"; import type { DirectoryListingCard } from "../integrations/tanstack-query/api-directory-listings.functions"; import { Avatar } from "../design-system/avatar"; +import { Badge } from "../design-system/badge"; import { Card, CardImage } from "../design-system/card"; import { Flex } from "../design-system/flex"; +import { StarRating } from "../design-system/star-rating"; import { animationDuration, animationTimingFunction, @@ -27,6 +30,7 @@ import { Text } from "../design-system/typography/text"; import { getDirectoryListingSlug } from "../lib/directory-listing-slugs"; import { getInitials } from "../lib/get-initials"; import { getDirectoryListingHeroImageAlt } from "../lib/listing-copy"; +import { getListingLabels } from "../lib/listing-labels"; import { HeroImage } from "./HeroImage"; export type AppCardSize = "large" | "medium" | "small"; @@ -45,6 +49,8 @@ const styles = stylex.create({ display: "block", outlineStyle: "none", height: "100%", + // Let grid/flex parents shrink the card; long labels truncate instead. + minWidth: 0, }, surface: { borderRadius: radius.lg, @@ -99,6 +105,42 @@ const styles = stylex.create({ tagline: { flexGrow: 1, }, + labels: { + gap: gap["sm"], + overflow: "hidden", + alignItems: "center", + display: "flex", + minWidth: 0, + }, + // Only the first label may shrink; later ones keep their natural width up + // to a cap, so a short tag like "Work" never collapses to "W…". + label: { + flexShrink: 0, + maxWidth: "10rem", + minWidth: 0, + }, + firstLabel: { + flexShrink: 1, + maxWidth: "none", + }, + labelEmoji: { + marginRight: "0.3em", + }, + labelText: { + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + minWidth: 0, + }, + moreLabel: { + flexShrink: 0, + }, + rating: { + gap: gap["md"], + alignItems: "center", + display: "flex", + minWidth: 0, + }, chevron: { color: uiColor.text1, flexShrink: 0, @@ -153,6 +195,8 @@ export function AppCard({ }) { const nameId = useId(); const taglineId = useId(); + const labelsId = useId(); + const ratingId = useId(); if (size === "large" && listing.heroImageUrl) { return ( @@ -174,7 +218,13 @@ export function AppCard({ 0 ? labelsId : null, + ratingId, + ] + .filter(Boolean) + .join(" ")} > {showImage && listing.heroImageUrl ? ( @@ -217,11 +267,109 @@ export function AppCard({ {listing.tagline} )} - - + + + + + + ); } + +const MAX_VISIBLE_LABELS = 2; + +/** + * Category + tag chips. Capped so they always fit on one line; overflow is + * summarised as "+N". Chips are plain labels, never links, so the card stays a + * single click target. + */ +export function AppCardLabels({ + id, + listing, +}: { + id?: string; + listing: Pick; +}) { + const labels = getListingLabels(listing); + if (labels.length === 0) return null; + + const visible = labels.slice(0, MAX_VISIBLE_LABELS); + const hiddenCount = labels.length - visible.length; + + return ( +
+ {visible.map((label, index) => ( + + + {label.emoji ? ( + + {label.emoji} + + ) : null} + {label.label} + + + ))} + {hiddenCount > 0 ? ( + + +{hiddenCount} + more + + ) : null} +
+ ); +} + +/** + * Stars plus the score and review count. Unrated apps show empty stars and + * "No reviews yet" so every card keeps the same footer shape. + */ +export function AppCardRating({ + id, + rating, + reviewCount, +}: { + id?: string; + rating: number | null; + reviewCount: number; +}) { + const hasReviews = rating != null && reviewCount > 0; + + return ( +
+ + + + {hasReviews ? ( + + Rated + {rating.toFixed(1)} + out of 5, ({reviewCount} + + {reviewCount === 1 ? " review" : " reviews"} + + ) + + ) : ( + No reviews yet + )} +
+ ); +} diff --git a/src/lib/listing-labels.test.ts b/src/lib/listing-labels.test.ts new file mode 100644 index 0000000..3bc95a2 --- /dev/null +++ b/src/lib/listing-labels.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; + +import { getListingLabels } from "./listing-labels"; + +describe("getListingLabels", () => { + it("puts the ecosystem category before editorial tags", () => { + const labels = getListingLabels({ + categorySlugs: ["apps/bluesky/client"], + appTags: ["social", "video"], + }); + + expect(labels.map((label) => label.kind)).toEqual([ + "category", + "tag", + "tag", + ]); + expect(labels[0]?.label).toBe("Bluesky › Client"); + expect(labels[1]?.label).toBe("Social"); + expect(labels[1]?.emoji).toBeTruthy(); + }); + + it("skips a top-level app's own apps/ category", () => { + const labels = getListingLabels({ + categorySlugs: ["apps/smoke-signal"], + appTags: ["events"], + }); + + expect(labels.map((label) => label.kind)).toEqual(["tag"]); + }); + + it("returns nothing when there is no category or tag", () => { + expect( + getListingLabels({ categorySlugs: ["apps/solo"], appTags: [] }), + ).toEqual([]); + }); +}); diff --git a/src/lib/listing-labels.ts b/src/lib/listing-labels.ts new file mode 100644 index 0000000..5e10db3 --- /dev/null +++ b/src/lib/listing-labels.ts @@ -0,0 +1,44 @@ +import { formatAppTagLabel } from "./app-tag-metadata"; +import { getAppTagEmoji } from "./app-tag-visuals"; +import { getBrowseCategoryLabel } from "./browse-listings"; + +export interface ListingLabel { + key: string; + kind: "category" | "tag"; + label: string; + emoji?: string; +} + +/** + * Display labels for an app, in the order browse shows them: the ecosystem + * category first, then editorial app tags. + * + * Top-level apps sit in their own `apps/` category, which adds nothing + * as a label, so only ecosystem tools and protocol categories are included. + */ +export function getListingLabels(listing: { + categorySlugs: Array; + appTags: Array; +}): Array { + const category = listing.categorySlugs.find( + (slug) => !(slug.startsWith("apps/") && slug.split("/").length === 2), + ); + + return [ + ...(category + ? [ + { + key: `category:${category}`, + kind: "category" as const, + label: getBrowseCategoryLabel(category), + }, + ] + : []), + ...listing.appTags.map((tag) => ({ + key: `tag:${tag}`, + kind: "tag" as const, + label: formatAppTagLabel(tag), + emoji: getAppTagEmoji(tag), + })), + ]; +} From dc2ce3c3c241087323f6a08072acc8aa876169a5 Mon Sep 17 00:00:00 2001 From: Barry Prendergast Date: Sat, 7 Nov 2026 13:55:23 +0100 Subject: [PATCH 3/6] Give the home hero card details and a third spotlight The large card is now a real card: image on top, then the same details as other cards (identity, tagline, labels, rating, chevron). On desktop the hero sits beside three stacked spotlights and its image grows to fill any spare height; on tablet the spotlights and the "Fresh apps" grid sit in a row of three under the hero. Cards adapt to their own width with a container query: narrow cards tighten padding, stack the icon above the name, shorten the rating and show one label plus "+N". The home page now asks for three spotlights. The admin hero screen gets a fourth slot, and a partial admin list is topped up from recent apps instead of being discarded. AspectRatioImage no longer applies its image style to the wrapper as well, which doubled the hover zoom. Co-Authored-By: Claude Opus 5.5 --- src/components/AppCard.tsx | 274 +++++++++++++----- src/design-system/aspect-ratio/index.tsx | 2 +- .../tanstack-query/api-admin.functions.ts | 2 +- .../api-directory-listings.functions.ts | 23 +- ...out._admin-layout.admin.home-page-hero.tsx | 7 +- src/routes/_header-layout.index.tsx | 76 ++++- 6 files changed, 282 insertions(+), 102 deletions(-) diff --git a/src/components/AppCard.tsx b/src/components/AppCard.tsx index c732507..443310c 100644 --- a/src/components/AppCard.tsx +++ b/src/components/AppCard.tsx @@ -29,12 +29,21 @@ import { Body, SmallBody } from "../design-system/typography"; import { Text } from "../design-system/typography/text"; import { getDirectoryListingSlug } from "../lib/directory-listing-slugs"; import { getInitials } from "../lib/get-initials"; -import { getDirectoryListingHeroImageAlt } from "../lib/listing-copy"; import { getListingLabels } from "../lib/listing-labels"; -import { HeroImage } from "./HeroImage"; +/** + * - large: hero slot. Image on top, full details underneath. + * - medium: promo slot. Same anatomy as large, in a smaller slot. + * - small: details only, no image. + */ export type AppCardSize = "large" | "medium" | "small"; +/** + * The card surface is a size container, so a card adapts to the width it is + * given (e.g. a row of three on a tablet) rather than to the viewport. + */ +const NARROW_CARD = "@container (max-width: 16rem)"; + const styles = stylex.create({ /** * The link is the hit area. It never moves, so the pointer can't slip off @@ -60,6 +69,7 @@ const styles = stylex.create({ [stylex.when.ancestor(":hover")]: shadow.lg, }, boxSizing: "border-box", + containerType: "inline-size", outlineColor: primaryColor.solid1, outlineOffset: 2, outlineStyle: { @@ -76,24 +86,64 @@ const styles = stylex.create({ transitionTimingFunction: animationTimingFunction.easeOut, height: "100%", }, - heroSurface: { - borderRadius: radius["3xl"], - boxShadow: "none", - transform: "none", + image: { + transform: { + default: "scale(1)", + [stylex.when.ancestor(":hover")]: "scale(1.02)", + }, + transitionDuration: animationDuration.verySlow, + transitionProperty: "transform", + transitionTimingFunction: animationTimingFunction.easeOut, }, content: { - gap: gap["4xl"], + gap: { + [NARROW_CARD]: gap["3xl"], + default: gap["4xl"], + }, boxSizing: "border-box", flexGrow: 1, - paddingBottom: verticalSpace["4xl"], - paddingLeft: horizontalSpace["4xl"], - paddingRight: horizontalSpace["4xl"], - paddingTop: verticalSpace["4xl"], + paddingBottom: { + [NARROW_CARD]: verticalSpace["3xl"], + default: verticalSpace["4xl"], + }, + paddingLeft: { + [NARROW_CARD]: horizontalSpace["3xl"], + default: horizontalSpace["4xl"], + }, + paddingRight: { + [NARROW_CARD]: horizontalSpace["3xl"], + default: horizontalSpace["4xl"], + }, + paddingTop: { + [NARROW_CARD]: verticalSpace["3xl"], + default: verticalSpace["4xl"], + }, }, contentAfterImage: { paddingTop: 0, }, + // A large card's image absorbs any spare height (e.g. when the hero sits + // beside a taller column of spotlights) so the card never has an empty band. + // It only ever grows past 16:9, cropping the sides, never shrinks below it. + imageFill: { + flexGrow: 1, + }, + noGrow: { + flexGrow: 0, + }, + // Narrow cards stack the icon above the name so long names keep the full + // card width instead of breaking mid-word beside the icon. identity: { + gap: gap["2xl"], + alignItems: { + [NARROW_CARD]: "flex-start", + default: "center", + }, + display: "flex", + flexDirection: { + [NARROW_CARD]: "column", + default: "row", + }, minWidth: 0, }, identityText: { @@ -102,8 +152,25 @@ const styles = stylex.create({ name: { overflowWrap: "anywhere", }, - tagline: { - flexGrow: 1, + // Spare height goes above the footer, so footers line up across a row. + // (Growing the tagline instead would reveal lines past its clamp.) + footer: { + marginTop: "auto", + }, + footerRow: { + gap: { + [NARROW_CARD]: gap["md"], + default: gap["xl"], + }, + alignItems: "center", + display: "flex", + justifyContent: "space-between", + }, + taglineClamped: { + overflow: "hidden", + WebkitBoxOrient: "vertical", + WebkitLineClamp: 3, + display: "-webkit-box", }, labels: { gap: gap["sm"], @@ -123,6 +190,30 @@ const styles = stylex.create({ flexShrink: 1, maxWidth: "none", }, + wideOnly: { + display: { + [NARROW_CARD]: "none", + default: "flex", + }, + }, + narrowOnly: { + display: { + [NARROW_CARD]: "flex", + default: "none", + }, + }, + wideOnlyInline: { + display: { + [NARROW_CARD]: "none", + default: "inline", + }, + }, + narrowOnlyInline: { + display: { + [NARROW_CARD]: "inline", + default: "none", + }, + }, labelEmoji: { marginRight: "0.3em", }, @@ -136,9 +227,14 @@ const styles = stylex.create({ flexShrink: 0, }, rating: { - gap: gap["md"], alignItems: "center", + columnGap: { + [NARROW_CARD]: gap["sm"], + default: gap["md"], + }, display: "flex", + flexWrap: "wrap", + rowGap: gap["xs"], minWidth: 0, }, chevron: { @@ -198,21 +294,10 @@ export function AppCard({ const labelsId = useId(); const ratingId = useId(); - if (size === "large" && listing.heroImageUrl) { - return ( - -
- -
-
- ); - } - - const showImage = size === "medium" && Boolean(listing.heroImageUrl); + const isSmall = size === "small"; + const imageUrl = isSmall ? null : listing.heroImageUrl; + const fillWithImage = size === "large" && Boolean(imageUrl); + const hasLabels = getListingLabels(listing).length > 0; return ( 0 ? labelsId : null, + hasLabels ? labelsId : null, ratingId, ] .filter(Boolean) .join(" ")} > - {showImage && listing.heroImageUrl ? ( - + {imageUrl ? ( + ) : null} - +
@@ -252,31 +347,30 @@ export function AppCard({ @{getHandle(listing)} - - {size === "small" ? ( - - {listing.tagline} - - ) : ( - + {isSmall ? ( + {listing.tagline} + + ) : ( + + {listing.tagline} )} - + - +
- +
@@ -284,12 +378,12 @@ export function AppCard({ ); } -const MAX_VISIBLE_LABELS = 2; - /** - * Category + tag chips. Capped so they always fit on one line; overflow is - * summarised as "+N". Chips are plain labels, never links, so the card stays a - * single click target. + * Category + tag chips, capped so they always fit on one line with the rest + * summarised as "+N": two chips normally, one in a narrow card. Chips are + * plain labels, never links, so the card stays a single click target. + * + * Screen readers get the full list as text; the chips are visual only. */ export function AppCardLabels({ id, @@ -301,32 +395,50 @@ export function AppCardLabels({ const labels = getListingLabels(listing); if (labels.length === 0) return null; - const visible = labels.slice(0, MAX_VISIBLE_LABELS); - const hiddenCount = labels.length - visible.length; + const visible = labels.slice(0, 2); + const moreWhenWide = labels.length - visible.length; + const moreWhenNarrow = labels.length - 1; return (
+ + {labels.map((label) => label.label).join(", ")} + {visible.map((label, index) => ( {label.emoji ? ( - - {label.emoji} - + {label.emoji} ) : null} {label.label} ))} - {hiddenCount > 0 ? ( - - +{hiddenCount} - more + {moreWhenWide > 0 ? ( + + +{moreWhenWide} + + ) : null} + {moreWhenNarrow > 0 ? ( + + +{moreWhenNarrow} ) : null}
@@ -335,7 +447,10 @@ export function AppCardLabels({ /** * Stars plus the score and review count. Unrated apps show empty stars and - * "No reviews yet" so every card keeps the same footer shape. + * "No reviews yet" so every card keeps the same footer shape. Narrow cards + * drop the numeric score (the stars already show it) and shorten the copy. + * + * Screen readers get one plain sentence; the visual parts are hidden. */ export function AppCardRating({ id, @@ -350,6 +465,11 @@ export function AppCardRating({ return (
+ + {hasReviews + ? `Rated ${rating.toFixed(1)} out of 5, ${String(reviewCount)} ${reviewCount === 1 ? "review" : "reviews"}` + : "No reviews yet"} + - {hasReviews ? ( - - Rated - {rating.toFixed(1)} - out of 5, ({reviewCount} - - {reviewCount === 1 ? " review" : " reviews"} - - ) - - ) : ( - No reviews yet - )} + + {hasReviews ? ( + <> + + {rating.toFixed(1)}{" "} + + ({reviewCount}) + + ) : ( + <> + No reviews yet + No reviews + + )} +
); } diff --git a/src/design-system/aspect-ratio/index.tsx b/src/design-system/aspect-ratio/index.tsx index e47d843..2b05a1b 100644 --- a/src/design-system/aspect-ratio/index.tsx +++ b/src/design-system/aspect-ratio/index.tsx @@ -66,7 +66,7 @@ export function AspectRatioImage({ ...props }: AspectRatioImageProps) { return ( -
+
{/* eslint-disable-next-line jsx-a11y/alt-text */} {children} diff --git a/src/integrations/tanstack-query/api-admin.functions.ts b/src/integrations/tanstack-query/api-admin.functions.ts index 5d37ca6..736f50d 100644 --- a/src/integrations/tanstack-query/api-admin.functions.ts +++ b/src/integrations/tanstack-query/api-admin.functions.ts @@ -33,7 +33,7 @@ import { z } from "zod"; import { dbMiddleware } from "./db-middleware"; -const HOME_HERO_SLOT_COUNT = 3; +const HOME_HERO_SLOT_COUNT = 4; const RECENT_REVIEWS_LIMIT = 200; const RECENT_LISTINGS_LIMIT = 200; const ADMIN_OVERVIEW_REVIEWS_PREVIEW = 6; diff --git a/src/integrations/tanstack-query/api-directory-listings.functions.ts b/src/integrations/tanstack-query/api-directory-listings.functions.ts index 72e5bef..c51aec0 100644 --- a/src/integrations/tanstack-query/api-directory-listings.functions.ts +++ b/src/integrations/tanstack-query/api-directory-listings.functions.ts @@ -1346,6 +1346,9 @@ function dedupeListings(rows: Array) { }); } +/** Small cards shown beside (desktop) or under (tablet) the home hero. */ +const HOME_SPOTLIGHT_COUNT = 3; + function requireCards( cards: Array, desiredCount: number, @@ -1358,16 +1361,22 @@ function requireCards( return cards.slice(0, desiredCount); } +/** + * Admin-picked hero rows first, in order, then recent apps to fill any empty + * slots. A partial admin list is kept rather than discarded, so adding a slot + * never throws away an existing configuration. + */ function resolveConfiguredHomeHeroRows(input: { configuredRows: Array; fallbackRows: Array; desiredCount: number; }) { - if (input.configuredRows.length >= input.desiredCount) { - return input.configuredRows.slice(0, input.desiredCount); - } + const configuredIds = new Set(input.configuredRows.map((row) => row.id)); - return input.fallbackRows.slice(0, input.desiredCount); + return [ + ...input.configuredRows, + ...input.fallbackRows.filter((row) => !configuredIds.has(row.id)), + ].slice(0, input.desiredCount); } function buildCategories(rows: Array, limit = 4) { @@ -2063,7 +2072,7 @@ const getHomePageData = createServerFn({ method: "GET" }) const heroRows = resolveConfiguredHomeHeroRows({ configuredRows: dedupeListings(configuredHomeHeroRows), fallbackRows: dedupedRecentAppRows, - desiredCount: 3, + desiredCount: 1 + HOME_SPOTLIGHT_COUNT, }); const featuredSource = heroRows[0] ?? fallbackFeaturedSource; @@ -2077,10 +2086,10 @@ const getHomePageData = createServerFn({ method: "GET" }) ? heroRows.slice(1) : dedupedRecentAppRows.filter((row) => row.id !== featuredSource.id); - const spotlightRows = remainingAppRows.slice(0, 2); + const spotlightRows = remainingAppRows.slice(0, HOME_SPOTLIGHT_COUNT); const spotlights = requireCards( spotlightRows.map((row) => toListingCard(row)), - 2, + HOME_SPOTLIGHT_COUNT, "homepage spotlights", ); diff --git a/src/routes/_header-layout._admin-layout.admin.home-page-hero.tsx b/src/routes/_header-layout._admin-layout.admin.home-page-hero.tsx index 80e08cb..c844af1 100644 --- a/src/routes/_header-layout._admin-layout.admin.home-page-hero.tsx +++ b/src/routes/_header-layout._admin-layout.admin.home-page-hero.tsx @@ -27,11 +27,12 @@ export const Route = createFileRoute( component: HomePageHeroPage, }); -const HOME_HERO_SLOT_COUNT = 3; +const HOME_HERO_SLOT_COUNT = 4; const HOME_HERO_SLOT_LABELS = [ "Featured app", "Spotlight app 1", "Spotlight app 2", + "Spotlight app 3", ] as const; const styles = stylex.create({ @@ -160,7 +161,9 @@ function HomePageHeroPage() { return; } if (new Set(listingIds).size !== listingIds.length) { - setHeroError("Please select three different apps."); + setHeroError( + "Please select a different app for each slot.", + ); return; } diff --git a/src/routes/_header-layout.index.tsx b/src/routes/_header-layout.index.tsx index b0e7a33..b5e3dc9 100644 --- a/src/routes/_header-layout.index.tsx +++ b/src/routes/_header-layout.index.tsx @@ -12,7 +12,6 @@ import type { DirectoryListingCard } from "../integrations/tanstack-query/api-di import { AppCard } from "../components/AppCard"; import { AppTagCard } from "../components/AppTagCard"; -import { FeaturedListingGrid } from "../components/FeaturedListingGrid"; import { Alert } from "../design-system/alert"; import { Avatar } from "../design-system/avatar"; import { Button } from "../design-system/button"; @@ -184,13 +183,41 @@ const styles = stylex.create({ flexShrink: "1", minWidth: 0, }, + // Phone: one column. Tablet: hero full width, spotlights as a row of three. + // Desktop: hero takes two columns, spotlights stack in the third. + heroGrid: { + gap: gap["2xl"], + display: "grid", + gridTemplateColumns: { + default: "minmax(0, 1fr)", + [breakpoints.sm]: "repeat(3, minmax(0, 1fr))", + }, + }, + heroItem: { + gridColumn: { + default: "auto", + [breakpoints.sm]: "1 / -1", + [breakpoints.xl]: "1 / span 2", + }, + gridRow: { + default: "auto", + [breakpoints.xl]: "1 / span 3", + }, + minWidth: 0, + }, + spotlightItem: { + gridColumn: { + default: "auto", + [breakpoints.xl]: "3", + }, + minWidth: 0, + }, newGrid: { gap: gap["lg"], display: "grid", gridTemplateColumns: { - default: "1fr", - [breakpoints.sm]: "repeat(2, minmax(0, 1fr))", - [breakpoints.lg]: "repeat(3, minmax(0, 1fr))", + default: "minmax(0, 1fr)", + [breakpoints.sm]: "repeat(3, minmax(0, 1fr))", }, }, exploreButton: { @@ -209,6 +236,10 @@ function HomePage() { directoryListingApi.getProductClaimEligibilityQueryOptions(), ); const promoListing = data.promo; + const [hero, ...spotlights] = putHeroImageFirst([ + data.featured, + ...data.spotlights, + ]); const showClaimBanner = claimEligibility.eligible && claimEligibility.listings.length > 0; @@ -260,17 +291,16 @@ function HomePage() {
- - index === 0 ? `featured-${listing.id}` : `spotlight-${listing.id}` - } - isFeatured={(_, index) => index === 0} - canFeature={(listing) => Boolean(listing.heroImageUrl)} - renderItem={(listing, { featured }) => ( - - )} - /> +
+
+ +
+ {spotlights.map((listing) => ( +
+ +
+ ))} +
@@ -435,3 +465,19 @@ function StoreIcon({ /> ); } + +/** + * The hero slot is the big image card, so make sure it gets a listing that + * has an image: swap the first spotlight with one if the featured app has + * none. If no listing has an image the hero renders without one. + */ +function putHeroImageFirst(listings: Array) { + if (listings[0]?.heroImageUrl) return listings; + + const withImage = listings.findIndex((listing) => listing.heroImageUrl); + if (withImage <= 0) return listings; + + const arranged = [...listings]; + [arranged[0], arranged[withImage]] = [arranged[withImage], arranged[0]]; + return arranged; +} From 219c5d4b86a02f8437a215e8592bc9f560251d29 Mon Sep 17 00:00:00 2001 From: Barry Prendergast Date: Sat, 7 Nov 2026 14:04:21 +0100 Subject: [PATCH 4/6] Make home and browse list rows a single link Add AppListRow with two variants sharing one look: a compact numbered row for the home "Trending" list and a detailed row for Browse. Each row is one stationary link (no Explore button, no nested filter-badge links), hovers with a tint and shadow, and draws the rounded focus ring. Spacing between rows belongs to the links, so there are no dead zones. Browse rows show every label (wrapping), the card rating format and a chevron. Both lists are now real lists; the trending list is an ordered list so screen readers announce position. Co-Authored-By: Claude Opus 5.5 --- src/components/AppCard.tsx | 33 ++- src/components/AppListRow.tsx | 362 +++++++++++++++++++++++ src/routes/_header-layout.apps.index.tsx | 209 ++----------- src/routes/_header-layout.index.tsx | 132 +-------- 4 files changed, 418 insertions(+), 318 deletions(-) create mode 100644 src/components/AppListRow.tsx diff --git a/src/components/AppCard.tsx b/src/components/AppCard.tsx index 443310c..a3d7883 100644 --- a/src/components/AppCard.tsx +++ b/src/components/AppCard.tsx @@ -179,6 +179,15 @@ const styles = stylex.create({ display: "flex", minWidth: 0, }, + labelsWrapped: { + overflow: "visible", + flexWrap: "wrap", + rowGap: gap["sm"], + }, + labelWrapped: { + maxWidth: "100%", + minWidth: 0, + }, // Only the first label may shrink; later ones keep their natural width up // to a cap, so a short tag like "Work" never collapses to "W…". label: { @@ -388,19 +397,25 @@ export function AppCard({ export function AppCardLabels({ id, listing, + showAll = false, }: { id?: string; listing: Pick; + /** Show every label, wrapping onto more lines (list rows have the room). */ + showAll?: boolean; }) { const labels = getListingLabels(listing); if (labels.length === 0) return null; - const visible = labels.slice(0, 2); + const visible = showAll ? labels : labels.slice(0, 2); const moreWhenWide = labels.length - visible.length; - const moreWhenNarrow = labels.length - 1; + const moreWhenNarrow = showAll ? 0 : labels.length - 1; return ( -
+
{labels.map((label) => label.label).join(", ")} @@ -410,10 +425,14 @@ export function AppCardLabels({ aria-hidden size="sm" variant={label.kind === "tag" ? "primary" : "default"} - style={[ - styles.label, - index === 0 ? styles.firstLabel : styles.wideOnly, - ]} + style={ + showAll + ? styles.labelWrapped + : [ + styles.label, + index === 0 ? styles.firstLabel : styles.wideOnly, + ] + } > {label.emoji ? ( diff --git a/src/components/AppListRow.tsx b/src/components/AppListRow.tsx new file mode 100644 index 0000000..4e300f7 --- /dev/null +++ b/src/components/AppListRow.tsx @@ -0,0 +1,362 @@ +import type { ReactNode } from "react"; + +import * as stylex from "@stylexjs/stylex"; +import { Link as RouterLink } from "@tanstack/react-router"; +import { ChevronRight, Heart } from "lucide-react"; +import { useId } from "react"; + +import type { DirectoryListingCard } from "../integrations/tanstack-query/api-directory-listings.functions"; +import type { BrowseListingCard } from "../lib/browse-listings"; + +import { Avatar } from "../design-system/avatar"; +import { Card } from "../design-system/card"; +import { Flex } from "../design-system/flex"; +import { + animationDuration, + animationTimingFunction, +} from "../design-system/theme/animations.stylex"; +import { primaryColor, uiColor } from "../design-system/theme/color.stylex"; +import { breakpoints } from "../design-system/theme/media-queries.stylex"; +import { radius } from "../design-system/theme/radius.stylex"; +import { + gap, + horizontalSpace, + verticalSpace, +} from "../design-system/theme/semantic-spacing.stylex"; +import { shadow } from "../design-system/theme/shadow.stylex"; +import { Body, SmallBody } from "../design-system/typography"; +import { Text } from "../design-system/typography/text"; +import { getDirectoryListingSlug } from "../lib/directory-listing-slugs"; +import { getInitials } from "../lib/get-initials"; +import { getListingLabels } from "../lib/listing-labels"; +import { AppCardLabels, AppCardRating } from "./AppCard"; + +/** + * List rows for apps. Two variants share one look and one interaction: + * - RankedAppRow: compact, numbered (home "Trending"). + * - DetailedAppRow: labels, rating, favorites, last update (Browse). + * + * Like AppCard, the whole row is a single link that never moves; the surface + * inside it tints on hover and draws the keyboard focus ring. + */ + +const styles = stylex.create({ + link: { + WebkitTapHighlightColor: "transparent", + textDecoration: "none", + color: "inherit", + cursor: "pointer", + display: "block", + outlineStyle: "none", + minWidth: 0, + }, + // Rows sit flush against each other. This padding is the visual gap between + // rows but still belongs to the link, so the pointer never falls into a dead + // zone (and flips back to an arrow) while moving down the list. + rankedLink: { + paddingBottom: verticalSpace.xs, + paddingTop: verticalSpace.xs, + }, + detailedLink: { + paddingBottom: verticalSpace.sm, + paddingTop: verticalSpace.sm, + }, + surface: { + borderRadius: radius.md, + cornerShape: "squircle", + backgroundColor: { + default: uiColor.bg, + [stylex.when.ancestor(":hover")]: uiColor.component1, + }, + boxShadow: { + default: shadow.none, + [stylex.when.ancestor(":hover")]: shadow.lg, + }, + outlineColor: primaryColor.solid1, + outlineOffset: 2, + outlineStyle: { + default: "none", + [stylex.when.ancestor(":focus-visible")]: "solid", + }, + outlineWidth: 2, + transitionDuration: animationDuration.slow, + transitionProperty: "background-color, box-shadow", + transitionTimingFunction: animationTimingFunction.easeOut, + }, + detailedSurface: { + borderRadius: radius.lg, + containIntrinsicSize: "auto 7rem", + boxShadow: { + default: shadow.sm, + [stylex.when.ancestor(":hover")]: shadow.lg, + }, + contentVisibility: "auto", + }, + rankedBody: { + gap: gap.xl, + alignItems: "center", + display: "flex", + paddingBottom: verticalSpace["2xl"], + paddingLeft: horizontalSpace["4xl"], + paddingRight: horizontalSpace["2xl"], + paddingTop: verticalSpace["2xl"], + }, + rank: { + minWidth: "1.25rem", + }, + rankedText: { + flexBasis: "0%", + flexGrow: 1, + flexShrink: 1, + minWidth: 0, + }, + detailedBody: { + gridTemplateAreas: { + default: '"avatar main chevron" "avatar stats chevron"', + [breakpoints.md]: '"avatar main stats chevron"', + }, + alignItems: "start", + columnGap: gap["3xl"], + display: "grid", + gridTemplateColumns: { + default: "auto minmax(0, 1fr) auto", + [breakpoints.md]: "auto minmax(0, 1fr) auto auto", + }, + rowGap: gap.md, + paddingBottom: verticalSpace["3xl"], + paddingLeft: horizontalSpace["3xl"], + paddingRight: horizontalSpace["2xl"], + paddingTop: verticalSpace["3xl"], + }, + avatarArea: { + gridColumnEnd: "avatar", + gridColumnStart: "avatar", + gridRowEnd: "avatar", + gridRowStart: "avatar", + }, + mainArea: { + gridColumnEnd: "main", + gridColumnStart: "main", + gridRowEnd: "main", + gridRowStart: "main", + minWidth: 0, + }, + nameLine: { + flexWrap: "wrap", + rowGap: gap.sm, + }, + statsArea: { + alignItems: { + default: "center", + [breakpoints.md]: "flex-end", + }, + columnGap: gap["2xl"], + display: "flex", + flexDirection: { + default: "row", + [breakpoints.md]: "column", + }, + flexWrap: "wrap", + gridColumnEnd: "stats", + gridColumnStart: "stats", + gridRowEnd: "stats", + gridRowStart: "stats", + rowGap: gap.sm, + whiteSpace: "nowrap", + }, + statLine: { + gap: gap.xs, + alignItems: "center", + color: uiColor.text1, + display: "inline-flex", + }, + chevronArea: { + alignSelf: "center", + gridColumnEnd: "chevron", + gridColumnStart: "chevron", + gridRowEnd: "chevron", + gridRowStart: "chevron", + }, + chevron: { + color: uiColor.text1, + display: "block", + flexShrink: 0, + transform: { + default: "none", + [stylex.when.ancestor(":hover")]: "translateX(2px)", + }, + transitionDuration: animationDuration.slow, + transitionProperty: "transform", + transitionTimingFunction: animationTimingFunction.easeOut, + }, +}); + +type RowListing = { + name: string; + slug?: string | null; +}; + +function AppRowLink({ + listing, + labelledBy, + describedBy, + style, + children, +}: { + listing: RowListing; + labelledBy: string; + describedBy: string; + style: stylex.StyleXStyles; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + +function RowChevron() { + return ; +} + +/** + * Compact numbered row. Render inside an `
    `: the list supplies the + * position to screen readers, so the visible number is decorative. + */ +export function RankedAppRow({ + listing, + rank, +}: { + listing: DirectoryListingCard; + rank: number; +}) { + const nameId = useId(); + const taglineId = useId(); + + return ( + +
    + + {rank} + + + + + {listing.name} + + + {listing.tagline} + + + +
    +
    + ); +} + +const relativeTimeFormat = new Intl.RelativeTimeFormat("en", { + numeric: "auto", +}); + +function formatUpdatedAgo(iso: string) { + const days = Math.round((Date.parse(iso) - Date.now()) / 86_400_000); + if (days > -30) return relativeTimeFormat.format(days, "day"); + if (days > -365) { + return relativeTimeFormat.format(Math.round(days / 30), "month"); + } + return relativeTimeFormat.format(Math.round(days / 365), "year"); +} + +/** Full-detail row for Browse: every label, rating, favorites, last update. */ +export function DetailedAppRow({ listing }: { listing: BrowseListingCard }) { + const nameId = useId(); + const taglineId = useId(); + const labelsId = useId(); + const ratingId = useId(); + const handle = listing.productAccountHandle?.replace(/^@/, ""); + + return ( + 0 ? labelsId : null, + ratingId, + ] + .filter(Boolean) + .join(" ")} + style={styles.detailedLink} + > + +
    +
    + +
    + + + + + {listing.name} + + {handle ? ( + @{handle} + ) : null} + + + {listing.tagline} + + + + +
    + + + + {`${listing.favoriteCount} ${listing.favoriteCount === 1 ? "favorite" : "favorites"}`} + + + Updated {formatUpdatedAgo(listing.updatedAt)} + +
    + +
    + +
    +
    +
    +
    + ); +} diff --git a/src/routes/_header-layout.apps.index.tsx b/src/routes/_header-layout.apps.index.tsx index 9a2fdab..afff258 100644 --- a/src/routes/_header-layout.apps.index.tsx +++ b/src/routes/_header-layout.apps.index.tsx @@ -1,27 +1,20 @@ import * as stylex from "@stylexjs/stylex"; import { useSuspenseQuery } from "@tanstack/react-query"; -import { - Link as RouterLink, - createFileRoute, - createLink, -} from "@tanstack/react-router"; -import { Heart, SearchX, SlidersHorizontal, X } from "lucide-react"; +import { createFileRoute } from "@tanstack/react-router"; +import { SearchX, SlidersHorizontal, X } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import type { BrowseFacet, BrowseFacets, BrowseFilters, - BrowseListingCard, BrowseSearch, BrowseSort, } from "../lib/browse-listings"; +import { DetailedAppRow } from "../components/AppListRow"; import { BrowseFilterPanel } from "../components/BrowseFilterPanel"; -import { Avatar } from "../design-system/avatar"; -import { Badge } from "../design-system/badge"; import { Button } from "../design-system/button"; -import { Card } from "../design-system/card"; import { Drawer, DrawerBody, @@ -40,41 +33,32 @@ import { Page } from "../design-system/page"; import { Pagination } from "../design-system/pagination"; import { SearchField } from "../design-system/search-field"; import { Select, SelectItem } from "../design-system/select"; -import { StarRating } from "../design-system/star-rating"; import { uiColor } from "../design-system/theme/color.stylex"; import { breakpoints } from "../design-system/theme/media-queries.stylex"; import { gap, - horizontalSpace, verticalSpace, } from "../design-system/theme/semantic-spacing.stylex"; import { Body, Heading1, SmallBody } from "../design-system/typography"; -import { Text } from "../design-system/typography/text"; import { directoryListingApi } from "../integrations/tanstack-query/api-directory-listings.functions"; import { formatAppTagLabel, getAppTagDescription, - getAppTagSlug, } from "../lib/app-tag-metadata"; -import { getAppTagEmoji } from "../lib/app-tag-visuals"; import { BROWSE_SORTS, BROWSE_SORT_LABELS, TOP_LEVEL_APPS_CATEGORY, clearBrowseFilters, countActiveBrowseFilters, - getBrowseCategoryLabel, getBrowseFilterValueLabel, getBrowseFilters, toggleBrowseFilter, validateBrowseSearch, } from "../lib/browse-listings"; import { getDirectoryCategoryOption } from "../lib/directory-categories"; -import { getInitials } from "../lib/get-initials"; import { buildAppTagOgImageUrl, buildRouteOgMeta } from "../lib/og-meta"; -const BadgeLink = createLink(Badge); - const QUERY_DEBOUNCE_MS = 250; const sortOptions = BROWSE_SORTS.map((id) => ({ @@ -211,65 +195,15 @@ const styles = stylex.create({ resultCount: { color: uiColor.text1, }, - row: { - contentVisibility: "auto", - }, - rowBody: { - alignItems: "flex-start", - columnGap: gap["3xl"], - display: "grid", - gridTemplateColumns: { - default: "auto minmax(0, 1fr)", - [breakpoints.md]: "auto minmax(0, 1fr) auto", - }, - rowGap: gap["md"], - paddingBottom: verticalSpace["3xl"], - paddingLeft: horizontalSpace["3xl"], - paddingRight: horizontalSpace["3xl"], - paddingTop: verticalSpace["3xl"], - }, - rowLink: { - textDecoration: { - default: "none", - ":hover": "underline", - }, - color: "inherit", - }, - rowMeta: { - flexWrap: "wrap", - rowGap: gap["sm"], - }, - badgeLink: { - textDecoration: "none", - }, - rowStats: { - alignItems: { - default: "center", - [breakpoints.md]: "flex-end", - }, - columnGap: gap["2xl"], - display: "flex", - flexDirection: { - default: "row", - [breakpoints.md]: "column", - }, - flexWrap: "wrap", - gridColumnEnd: { - default: "span 2", - [breakpoints.md]: "auto", - }, - gridColumnStart: { - default: "1", - [breakpoints.md]: "auto", - }, - rowGap: gap["sm"], - whiteSpace: "nowrap", - }, - statLine: { - gap: gap["xs"], - alignItems: "center", - color: uiColor.text1, - display: "inline-flex", + // Rows carry the spacing between them (see DetailedAppRow); pull the list + // out by that amount so the first and last rows line up as before. + results: { + padding: 0, + listStyle: "none", + marginBottom: `calc(${verticalSpace.sm} * -1)`, + marginLeft: 0, + marginRight: 0, + marginTop: `calc(${verticalSpace.sm} * -1)`, }, emptyState: { padding: verticalSpace["10xl"], @@ -391,11 +325,13 @@ function BrowseAppsPage() { {results.length > 0 ? ( - +
      {results.map((listing) => ( - +
    • + +
    • ))} - +
    ) : ( @@ -536,114 +472,3 @@ function ActiveFilterChips({
    ); } - -const relativeTimeFormat = new Intl.RelativeTimeFormat("en", { - numeric: "auto", -}); - -function formatUpdatedAgo(iso: string) { - const days = Math.round((Date.parse(iso) - Date.now()) / 86_400_000); - if (days > -30) return relativeTimeFormat.format(days, "day"); - if (days > -365) { - return relativeTimeFormat.format(Math.round(days / 30), "month"); - } - return relativeTimeFormat.format(Math.round(days / 365), "year"); -} - -function addFilter(search: BrowseSearch, facet: BrowseFacet, value: string) { - const filters = getBrowseFilters(search); - const selected = - facet === "categories" - ? filters.categories - : facet === "tags" - ? filters.tags - : []; - return selected.includes(value) - ? search - : toggleBrowseFilter(search, facet, value); -} - -function BrowseListingRow({ listing }: { listing: BrowseListingCard }) { - const productParams = { productId: listing.slug }; - const handle = listing.productAccountHandle?.replace(/^@/, ""); - // Top-level apps are their own `apps/` category, so only badge ecosystem tools and protocol. - const category = listing.categorySlugs.find( - (slug) => !(slug.startsWith("apps/") && slug.split("/").length === 2), - ); - - return ( - -
    - - - - - - - - - {listing.name} - - - {handle ? ( - @{handle} - ) : null} - - {listing.tagline} - - {category ? ( - - addFilter(prev, "categories", category) - } - size="sm" - style={styles.badgeLink} - > - {getBrowseCategoryLabel(category)} - - ) : null} - {listing.appTags.map((tag) => ( - - addFilter(prev, "tags", getAppTagSlug(tag)) - } - size="sm" - variant="primary" - style={styles.badgeLink} - > - {getAppTagEmoji(tag)} {formatAppTagLabel(tag)} - - ))} - - - -
    - - - - {`${listing.favoriteCount} ${listing.favoriteCount === 1 ? "favorite" : "favorites"}`} - - - Updated {formatUpdatedAgo(listing.updatedAt)} - -
    -
    -
    - ); -} diff --git a/src/routes/_header-layout.index.tsx b/src/routes/_header-layout.index.tsx index b5e3dc9..53cb943 100644 --- a/src/routes/_header-layout.index.tsx +++ b/src/routes/_header-layout.index.tsx @@ -1,7 +1,6 @@ import * as stylex from "@stylexjs/stylex"; import { useSuspenseQuery } from "@tanstack/react-query"; import { - Link as RouterLink, createFileRoute, createLink, useNavigate, @@ -11,15 +10,14 @@ import { ChevronRight } from "lucide-react"; import type { DirectoryListingCard } from "../integrations/tanstack-query/api-directory-listings.functions"; import { AppCard } from "../components/AppCard"; +import { RankedAppRow } from "../components/AppListRow"; import { AppTagCard } from "../components/AppTagCard"; import { Alert } from "../design-system/alert"; -import { Avatar } from "../design-system/avatar"; import { Button } from "../design-system/button"; import { Flex } from "../design-system/flex"; import { Grid } from "../design-system/grid"; import { Link } from "../design-system/link"; import { Page } from "../design-system/page"; -import { animationDuration } from "../design-system/theme/animations.stylex"; import { uiColor } from "../design-system/theme/color.stylex"; import { breakpoints } from "../design-system/theme/media-queries.stylex"; import { radius } from "../design-system/theme/radius.stylex"; @@ -28,12 +26,9 @@ import { horizontalSpace, verticalSpace, } from "../design-system/theme/semantic-spacing.stylex"; -import { shadow } from "../design-system/theme/shadow.stylex"; import { Heading1, Heading2, SmallBody } from "../design-system/typography"; import { Text } from "../design-system/typography/text"; import { directoryListingApi } from "../integrations/tanstack-query/api-directory-listings.functions"; -import { getDirectoryListingSlug } from "../lib/directory-listing-slugs"; -import { getInitials } from "../lib/get-initials"; import { buildRouteOgMeta } from "../lib/og-meta"; export const Route = createFileRoute("/_header-layout/")({ @@ -125,63 +120,21 @@ const styles = stylex.create({ [breakpoints.lg]: "1fr", }, }, + // Rows carry their own spacing (see RankedAppRow), so the vertical padding + // here is trimmed by that amount to keep the panel's inset unchanged. popularList: { - padding: verticalSpace["2xl"], + margin: 0, borderColor: uiColor.component2, borderRadius: radius.xl, borderStyle: "solid", borderWidth: 1, cornerShape: "squircle", + listStyle: "none", backgroundColor: uiColor.bg, - }, - listItem: { - borderRadius: radius.md, - gap: gap["xl"], - textDecoration: "none", - alignItems: "center", - backgroundColor: { - default: uiColor.bg, - ":hover": uiColor.component2, - }, - boxShadow: shadow.none, - color: uiColor.text2, - display: "flex", - position: "relative", - transitionDuration: animationDuration.slow, - transitionProperty: "background-color, z-index", - transitionTimingFunction: "ease-in-out", - zIndex: { - default: 0, - ":hover": 1, - }, - paddingBottom: verticalSpace["2xl"], - paddingLeft: horizontalSpace["4xl"], + paddingBottom: verticalSpace.lg, + paddingLeft: horizontalSpace["2xl"], paddingRight: horizontalSpace["2xl"], - paddingTop: verticalSpace["2xl"], - - ":hover::after": { - opacity: 1, - }, - "::after": { - inset: 0, - borderRadius: radius.md, - boxShadow: shadow.lg, - content: "''", - opacity: 0, - position: "absolute", - transitionDuration: animationDuration.slow, - transitionProperty: "opacity", - transitionTimingFunction: "ease-in-out", - }, - }, - rankNumber: { - minWidth: "1.25rem", - }, - listItemText: { - flexBasis: "0%", - flexGrow: "1", - flexShrink: "1", - minWidth: 0, + paddingTop: verticalSpace.lg, }, // Phone: one column. Tablet: hero full width, spotlights as a row of three. // Desktop: hero takes two columns, spotlights stack in the third. @@ -220,11 +173,6 @@ const styles = stylex.create({ [breakpoints.sm]: "repeat(3, minmax(0, 1fr))", }, }, - exploreButton: { - borderRadius: radius.full, - cornerShape: "unset", - cursor: "pointer", - }, }); function HomePage() { @@ -328,15 +276,13 @@ function HomePage() { promoListing ? null : styles.popularGridSingle, ]} > - +
      {data.popular.map((listing, index) => ( - +
    1. + +
    2. ))} - +
    {promoListing ? ( ) : null} @@ -414,58 +360,6 @@ function SectionHeader({ eyebrow, title, to, search }: SectionHeaderProps) { ); } -function PopularListItem({ - listing, - rank, -}: { - listing: DirectoryListingCard; - rank: number; -}) { - return ( - - - {rank} - - - - - {listing.name} - - {listing.tagline} - - - - ); -} - -function StoreIcon({ - listing, - size, -}: { - listing: DirectoryListingCard; - size: "lg" | "xl"; -}) { - return ( - - ); -} - /** * The hero slot is the big image card, so make sure it gets a listing that * has an image: swap the first spotlight with one if the featured app has From 6e94d73dcc33fb2537f0f62d744089e80ef886e1 Mon Sep 17 00:00:00 2001 From: Barry Prendergast Date: Sat, 7 Nov 2026 14:11:23 +0100 Subject: [PATCH 5/6] Use the shared app card on ecosystem and lexicon pages Replace EcosystemListingCard and the three copied lexicon listing cards with AppCard, and delete the now-unused EcosystemListingCard and FeaturedListingFallbackCard. FeaturedListingGrid follows the home hero rhythm: the large card spans two columns and three rows on desktop (full width on tablet) with three small cards beside it, so blocks are now seven apps instead of nine. The ecosystem page's first section shows seven apps so it fills evenly. Co-Authored-By: Claude Opus 5.5 --- src/components/EcosystemListingCard.tsx | 129 ------------------ .../FeaturedListingFallbackCard.tsx | 83 ----------- src/components/FeaturedListingGrid.tsx | 13 +- src/lib/featured-listing-grid.ts | 5 + .../_header-layout.apps.lexicon-set.tsx | 121 +--------------- src/routes/_header-layout.apps.lexicon.tsx | 122 +---------------- .../_header-layout.ecosystems.$app.index.tsx | 21 ++- ...products.$productId.lexicon-compatible.tsx | 120 +--------------- 8 files changed, 38 insertions(+), 576 deletions(-) delete mode 100644 src/components/EcosystemListingCard.tsx delete mode 100644 src/components/FeaturedListingFallbackCard.tsx create mode 100644 src/lib/featured-listing-grid.ts diff --git a/src/components/EcosystemListingCard.tsx b/src/components/EcosystemListingCard.tsx deleted file mode 100644 index b585503..0000000 --- a/src/components/EcosystemListingCard.tsx +++ /dev/null @@ -1,129 +0,0 @@ -import * as stylex from "@stylexjs/stylex"; -import { Link as RouterLink } from "@tanstack/react-router"; - -import type { DirectoryListingCard } from "../integrations/tanstack-query/api-directory-listings.functions"; - -import { Avatar } from "../design-system/avatar"; -import { Card } from "../design-system/card"; -import { Flex } from "../design-system/flex"; -import { StarRating } from "../design-system/star-rating"; -import { - gap, - horizontalSpace, - verticalSpace, -} from "../design-system/theme/semantic-spacing.stylex"; -import { Body, SmallBody } from "../design-system/typography"; -import { Text } from "../design-system/typography/text"; -import { getDirectoryListingSlug } from "../lib/directory-listing-slugs"; -import { getInitials } from "../lib/get-initials"; -import { getDirectoryListingHeroImageAlt } from "../lib/listing-copy"; -import { FeaturedListingFallbackCard } from "./FeaturedListingFallbackCard"; -import { HeroImage } from "./HeroImage"; - -const styles = stylex.create({ - listingLink: { - textDecoration: "none", - display: "block", - height: "100%", - }, - listingCard: { - contentVisibility: "auto", - height: "100%", - }, - listingCardBody: { - gap: gap["4xl"], - height: "100%", - paddingBottom: verticalSpace["4xl"], - paddingLeft: horizontalSpace["4xl"], - paddingRight: horizontalSpace["4xl"], - paddingTop: verticalSpace["4xl"], - }, - listingHeader: { - gap: gap["2xl"], - }, - listingInfo: { - flexBasis: "0%", - flexGrow: "1", - flexShrink: "1", - minWidth: 0, - }, - listingTagline: { - flexGrow: 1, - }, - listingFooter: { - alignItems: "center", - }, - ratingRow: { - flexWrap: "wrap", - }, -}); - -export function EcosystemListingCard({ - listing, - featured = false, -}: { - listing: DirectoryListingCard; - featured?: boolean; -}) { - return ( - - {featured && listing.heroImageUrl ? ( - - ) : featured ? ( - - ) : ( - - - - - - - {listing.name} - - - - @ - {listing.productAccountHandle?.replace(/^@/, "") || - "unknown"} - - - - - - - {listing.tagline} - - -
    - - - - - {listing.rating == null ? "—" : listing.rating.toFixed(1)} - - - - - - - )} - - ); -} diff --git a/src/components/FeaturedListingFallbackCard.tsx b/src/components/FeaturedListingFallbackCard.tsx deleted file mode 100644 index 06dd9dd..0000000 --- a/src/components/FeaturedListingFallbackCard.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import * as stylex from "@stylexjs/stylex"; - -import type { DirectoryListingCard } from "../integrations/tanstack-query/api-directory-listings.functions"; - -import { Avatar } from "../design-system/avatar"; -import { Card } from "../design-system/card"; -import { Flex } from "../design-system/flex"; -import { uiColor } from "../design-system/theme/color.stylex"; -import { radius } from "../design-system/theme/radius.stylex"; -import { - gap, - horizontalSpace, - verticalSpace, -} from "../design-system/theme/semantic-spacing.stylex"; -import { Body } from "../design-system/typography"; -import { Text } from "../design-system/typography/text"; -import { getInitials } from "../lib/get-initials"; - -const styles = stylex.create({ - card: { - borderRadius: radius["3xl"], - cornerShape: "squircle", - backgroundColor: uiColor.component1, - height: "100%", - }, - body: { - gap: gap["2xl"], - flexBasis: "0%", - flexGrow: "1", - flexShrink: "1", - height: "100%", - paddingBottom: verticalSpace["6xl"], - paddingLeft: horizontalSpace["6xl"], - paddingRight: horizontalSpace["6xl"], - paddingTop: verticalSpace["6xl"], - }, - description: { - flexBasis: "0%", - flexGrow: "1", - flexShrink: "1", - minHeight: 0, - }, - title: { - overflow: "hidden", - WebkitBoxOrient: "vertical", - WebkitLineClamp: 2, - display: "-webkit-box", - textOverflow: "ellipsis", - }, -}); - -/** - * Rendered inside a featured grid slot when the listing has no `heroImageUrl`. - * Keeps the oversized 16:9 tile from rendering empty by surfacing the - * listing's identity (name + tagline) instead. - */ -export function FeaturedListingFallbackCard({ - listing, -}: { - listing: Pick< - DirectoryListingCard, - "name" | "tagline" | "description" | "iconUrl" - >; -}) { - return ( - - - - - {listing.name} - - - {listing.tagline || listing.description} - - - - ); -} diff --git a/src/components/FeaturedListingGrid.tsx b/src/components/FeaturedListingGrid.tsx index 19b7778..e6b1a87 100644 --- a/src/components/FeaturedListingGrid.tsx +++ b/src/components/FeaturedListingGrid.tsx @@ -6,6 +6,7 @@ import * as stylex from "@stylexjs/stylex"; import { Grid } from "../design-system/grid"; import { breakpoints } from "../design-system/theme/media-queries.stylex"; import { gap } from "../design-system/theme/semantic-spacing.stylex"; +import { FEATURED_LISTING_BLOCK_SIZE } from "../lib/featured-listing-grid"; const styles = stylex.create({ grid: { @@ -21,16 +22,19 @@ const styles = stylex.create({ item: { minWidth: 0, }, + // Same rhythm as the home hero: on desktop the large card takes two columns + // and three rows, with three small cards stacked beside it; on tablet it + // spans the full width. Its image grows to fill whatever height that is. featuredItem: { gridColumn: { default: "auto", - [breakpoints.sm]: "span 2", + [breakpoints.sm]: "1 / -1", + [breakpoints.xl]: "span 2", }, gridRow: { default: "auto", - [breakpoints.sm]: "span 2", + [breakpoints.xl]: "span 3", }, - aspectRatio: 16 / 9, }, }); @@ -59,7 +63,8 @@ export function FeaturedListingGrid({ items, hasFeatured = true, getKey, - isFeatured = (_, index) => index % 9 === 0, + // One large card + three beside it + a row of three = seven per block. + isFeatured = (_, index) => index % FEATURED_LISTING_BLOCK_SIZE === 0, canFeature, renderItem, style, diff --git a/src/lib/featured-listing-grid.ts b/src/lib/featured-listing-grid.ts new file mode 100644 index 0000000..17c8f00 --- /dev/null +++ b/src/lib/featured-listing-grid.ts @@ -0,0 +1,5 @@ +/** + * Apps per block in a FeaturedListingGrid: one large card, three small cards + * stacked beside it, then a row of three. + */ +export const FEATURED_LISTING_BLOCK_SIZE = 7; diff --git a/src/routes/_header-layout.apps.lexicon-set.tsx b/src/routes/_header-layout.apps.lexicon-set.tsx index ed9feac..38045a0 100644 --- a/src/routes/_header-layout.apps.lexicon-set.tsx +++ b/src/routes/_header-layout.apps.lexicon-set.tsx @@ -8,18 +8,12 @@ import { redirect, useRouter, } from "@tanstack/react-router"; -import { StarRating } from "#/design-system/star-rating"; import { ChevronLeft } from "lucide-react"; -import type { DirectoryListingCard } from "../integrations/tanstack-query/api-directory-listings.functions"; - +import { AppCard } from "../components/AppCard"; import { AppTagHero } from "../components/AppTagHero"; -import { FeaturedListingFallbackCard } from "../components/FeaturedListingFallbackCard"; import { FeaturedListingGrid } from "../components/FeaturedListingGrid"; -import { HeroImage } from "../components/HeroImage"; -import { Avatar } from "../design-system/avatar"; import { Badge } from "../design-system/badge"; -import { Card } from "../design-system/card"; import { Flex } from "../design-system/flex"; import { Link } from "../design-system/link"; import { Page } from "../design-system/page"; @@ -27,16 +21,11 @@ import { Select, SelectItem } from "../design-system/select"; import { breakpoints } from "../design-system/theme/media-queries.stylex"; import { gap, - horizontalSpace, size, verticalSpace, } from "../design-system/theme/semantic-spacing.stylex"; -import { Body, SmallBody } from "../design-system/typography"; -import { Text } from "../design-system/typography/text"; +import { Body } from "../design-system/typography"; import { directoryListingApi } from "../integrations/tanstack-query/api-directory-listings.functions"; -import { getDirectoryListingSlug } from "../lib/directory-listing-slugs"; -import { getInitials } from "../lib/get-initials"; -import { getDirectoryListingHeroImageAlt } from "../lib/listing-copy"; import { formatLexiconClusterPageTitle, formatOAuthLexiconKeyClusterStyleHeadline, @@ -60,9 +49,6 @@ const styles = stylex.create({ [breakpoints.xl]: gap["7xl"], }, }, - listingTagline: { - flexGrow: 1, - }, page: { paddingBottom: verticalSpace["10xl"], paddingTop: verticalSpace["6xl"], @@ -77,41 +63,6 @@ const styles = stylex.create({ }, minWidth: "12rem", }, - listingLink: { - textDecoration: "none", - display: "block", - position: "relative", - zIndex: 1, - height: "100%", - }, - listingLinkFeatured: { - zIndex: 0, - }, - listingCard: { - boxSizing: "border-box", - height: "100%", - width: "100%", - }, - listingCardBody: { - gap: gap["4xl"], - position: "relative", - height: "100%", - paddingBottom: verticalSpace["xl"], - paddingLeft: horizontalSpace["xl"], - paddingRight: horizontalSpace["xl"], - paddingTop: verticalSpace["xl"], - }, - listingHeader: { - gap: gap["2xl"], - position: "relative", - zIndex: 1, - }, - listingInfo: { - flexBasis: "0%", - flexGrow: "1", - flexShrink: "1", - minWidth: 0, - }, emptyState: { gap: gap["lg"], maxWidth: "40rem", @@ -325,7 +276,7 @@ function AppsLexiconSetPage() { getKey={(listing) => `${gridKey}-${listing.id}`} canFeature={(listing) => Boolean(listing.heroImageUrl)} renderItem={(listing, { featured }) => ( - + )} /> ) : ( @@ -341,69 +292,3 @@ function AppsLexiconSetPage() { ); } - -function LexiconListingCard({ - listing, - featured = false, -}: { - listing: DirectoryListingCard; - featured?: boolean; -}) { - return ( - - {featured ? ( - listing.heroImageUrl ? ( - - ) : ( - - ) - ) : ( - - - - - - - {listing.name} - - - @ - {listing.productAccountHandle?.replace(/^@/, "") || "unknown"} - - - - - {listing.tagline} - - - - {listing.rating == null ? "—" : listing.rating.toFixed(1)} - - - - - - )} - - ); -} diff --git a/src/routes/_header-layout.apps.lexicon.tsx b/src/routes/_header-layout.apps.lexicon.tsx index f3dd6ee..a3eafd4 100644 --- a/src/routes/_header-layout.apps.lexicon.tsx +++ b/src/routes/_header-layout.apps.lexicon.tsx @@ -1,24 +1,17 @@ import * as stylex from "@stylexjs/stylex"; import { useSuspenseQuery } from "@tanstack/react-query"; import { - Link as RouterLink, createFileRoute, createLink, notFound, redirect, useRouter, } from "@tanstack/react-router"; -import { StarRating } from "#/design-system/star-rating"; import { ChevronLeft } from "lucide-react"; -import type { DirectoryListingCard } from "../integrations/tanstack-query/api-directory-listings.functions"; - +import { AppCard } from "../components/AppCard"; import { AppTagHero } from "../components/AppTagHero"; -import { FeaturedListingFallbackCard } from "../components/FeaturedListingFallbackCard"; import { FeaturedListingGrid } from "../components/FeaturedListingGrid"; -import { HeroImage } from "../components/HeroImage"; -import { Avatar } from "../design-system/avatar"; -import { Card } from "../design-system/card"; import { Flex } from "../design-system/flex"; import { Link } from "../design-system/link"; import { Page } from "../design-system/page"; @@ -26,15 +19,10 @@ import { Select, SelectItem } from "../design-system/select"; import { breakpoints } from "../design-system/theme/media-queries.stylex"; import { gap, - horizontalSpace, verticalSpace, } from "../design-system/theme/semantic-spacing.stylex"; -import { Body, SmallBody } from "../design-system/typography"; -import { Text } from "../design-system/typography/text"; +import { Body } from "../design-system/typography"; import { directoryListingApi } from "../integrations/tanstack-query/api-directory-listings.functions"; -import { getDirectoryListingSlug } from "../lib/directory-listing-slugs"; -import { getInitials } from "../lib/get-initials"; -import { getDirectoryListingHeroImageAlt } from "../lib/listing-copy"; import { formatOAuthLexiconKeyClusterStyleHeadline, oauthLexiconKeyKindLabel, @@ -57,9 +45,6 @@ const styles = stylex.create({ [breakpoints.xl]: gap["7xl"], }, }, - listingTagline: { - flexGrow: 1, - }, page: { paddingBottom: verticalSpace["10xl"], paddingTop: verticalSpace["6xl"], @@ -74,41 +59,6 @@ const styles = stylex.create({ }, minWidth: "12rem", }, - listingLink: { - textDecoration: "none", - display: "block", - position: "relative", - zIndex: 1, - height: "100%", - }, - listingLinkFeatured: { - zIndex: 0, - }, - listingCard: { - boxSizing: "border-box", - height: "100%", - width: "100%", - }, - listingCardBody: { - gap: gap["4xl"], - position: "relative", - height: "100%", - paddingBottom: verticalSpace["xl"], - paddingLeft: horizontalSpace["xl"], - paddingRight: horizontalSpace["xl"], - paddingTop: verticalSpace["xl"], - }, - listingHeader: { - gap: gap["2xl"], - position: "relative", - zIndex: 1, - }, - listingInfo: { - flexBasis: "0%", - flexGrow: "1", - flexShrink: "1", - minWidth: 0, - }, emptyState: { gap: gap["lg"], maxWidth: "40rem", @@ -248,7 +198,7 @@ function AppsLexiconPage() { getKey={(listing) => `${key}-${listing.id}`} canFeature={(listing) => Boolean(listing.heroImageUrl)} renderItem={(listing, { featured }) => ( - + )} /> ) : ( @@ -264,69 +214,3 @@ function AppsLexiconPage() { ); } - -function LexiconListingCard({ - listing, - featured = false, -}: { - listing: DirectoryListingCard; - featured?: boolean; -}) { - return ( - - {featured ? ( - listing.heroImageUrl ? ( - - ) : ( - - ) - ) : ( - - - - - - - {listing.name} - - - @ - {listing.productAccountHandle?.replace(/^@/, "") || "unknown"} - - - - - {listing.tagline} - - - - {listing.rating == null ? "—" : listing.rating.toFixed(1)} - - - - - - )} - - ); -} diff --git a/src/routes/_header-layout.ecosystems.$app.index.tsx b/src/routes/_header-layout.ecosystems.$app.index.tsx index 89b79d9..ffb7f73 100644 --- a/src/routes/_header-layout.ecosystems.$app.index.tsx +++ b/src/routes/_header-layout.ecosystems.$app.index.tsx @@ -5,8 +5,8 @@ import { ChevronLeft } from "lucide-react"; import type { DirectoryCategoryTreeNode } from "../lib/directory-categories"; +import { AppCard } from "../components/AppCard"; import { AppTagHero } from "../components/AppTagHero"; -import { EcosystemListingCard } from "../components/EcosystemListingCard"; import { FeaturedListingGrid } from "../components/FeaturedListingGrid"; import { Button } from "../design-system/button"; import { Flex } from "../design-system/flex"; @@ -29,6 +29,7 @@ import { getListingsForCategoryBranch, pickListingImageForCategoryBranch, } from "../lib/ecosystem-listings"; +import { FEATURED_LISTING_BLOCK_SIZE } from "../lib/featured-listing-grid"; import { buildRouteOgMeta } from "../lib/og-meta"; const ButtonLink = createLink(Button); @@ -145,12 +146,20 @@ function EcosystemIndexPage() { const categorySections = category.children .map((child) => ({ category: child, - listings: getListingsForCategoryBranch(child.id, listings).slice( + listings: getListingsForCategoryBranch(child.id, listings), + })) + .filter((section) => section.listings.length > 0) + .map((section, index) => ({ + ...section, + // The first section leads with a large card (one large + three beside + // it + a row of three); later sections are two plain rows of three. + listings: section.listings.slice( 0, - INITIAL_SECTION_LISTING_COUNT, + index === 0 + ? FEATURED_LISTING_BLOCK_SIZE + : INITIAL_SECTION_LISTING_COUNT, ), - })) - .filter((section) => section.listings.length > 0); + })); return ( @@ -247,7 +256,7 @@ function EcosystemCategorySection({ canFeature={(listing) => Boolean(listing.heroImageUrl)} style={styles.sectionGrid} renderItem={(listing, { featured }) => ( - + )} /> diff --git a/src/routes/_header-layout.products.$productId.lexicon-compatible.tsx b/src/routes/_header-layout.products.$productId.lexicon-compatible.tsx index f1f81ba..5625da7 100644 --- a/src/routes/_header-layout.products.$productId.lexicon-compatible.tsx +++ b/src/routes/_header-layout.products.$productId.lexicon-compatible.tsx @@ -8,18 +8,12 @@ import { redirect, useRouter, } from "@tanstack/react-router"; -import { StarRating } from "#/design-system/star-rating"; import { ChevronLeft } from "lucide-react"; -import type { DirectoryListingCard } from "../integrations/tanstack-query/api-directory-listings.functions"; - +import { AppCard } from "../components/AppCard"; import { AppTagHero } from "../components/AppTagHero"; -import { FeaturedListingFallbackCard } from "../components/FeaturedListingFallbackCard"; import { FeaturedListingGrid } from "../components/FeaturedListingGrid"; -import { HeroImage } from "../components/HeroImage"; -import { Avatar } from "../design-system/avatar"; import { Badge } from "../design-system/badge"; -import { Card } from "../design-system/card"; import { Flex } from "../design-system/flex"; import { Link } from "../design-system/link"; import { Page } from "../design-system/page"; @@ -27,19 +21,15 @@ import { Select, SelectItem } from "../design-system/select"; import { breakpoints } from "../design-system/theme/media-queries.stylex"; import { gap, - horizontalSpace, size, verticalSpace, } from "../design-system/theme/semantic-spacing.stylex"; -import { Body, SmallBody } from "../design-system/typography"; -import { Text } from "../design-system/typography/text"; +import { Body } from "../design-system/typography"; import { directoryListingApi } from "../integrations/tanstack-query/api-directory-listings.functions"; import { getDirectoryListingSlug, getLegacyDirectoryListingId, } from "../lib/directory-listing-slugs"; -import { getInitials } from "../lib/get-initials"; -import { getDirectoryListingHeroImageAlt } from "../lib/listing-copy"; import { formatOAuthLexiconKeyClusterStyleHeadline, oauthLexiconKeyKindLabel, @@ -64,9 +54,6 @@ const styles = stylex.create({ [breakpoints.xl]: gap["7xl"], }, }, - listingTagline: { - flexGrow: 1, - }, page: { paddingBottom: verticalSpace["10xl"], paddingTop: verticalSpace["6xl"], @@ -81,41 +68,6 @@ const styles = stylex.create({ }, minWidth: "12rem", }, - listingLink: { - textDecoration: "none", - display: "block", - position: "relative", - zIndex: 1, - height: "100%", - }, - listingLinkFeatured: { - zIndex: 0, - }, - listingCard: { - boxSizing: "border-box", - height: "100%", - width: "100%", - }, - listingCardBody: { - gap: gap["4xl"], - position: "relative", - height: "100%", - paddingBottom: verticalSpace["xl"], - paddingLeft: horizontalSpace["xl"], - paddingRight: horizontalSpace["xl"], - paddingTop: verticalSpace["xl"], - }, - listingHeader: { - gap: gap["2xl"], - position: "relative", - zIndex: 1, - }, - listingInfo: { - flexBasis: "0%", - flexGrow: "1", - flexShrink: "1", - minWidth: 0, - }, emptyState: { gap: gap["lg"], maxWidth: "40rem", @@ -329,7 +281,7 @@ function LexiconCompatibleAppsPage() { getKey={(listing) => listing.id} canFeature={(listing) => Boolean(listing.heroImageUrl)} renderItem={(listing, { featured }) => ( - + )} /> ) : ( @@ -374,69 +326,3 @@ function LexiconMatchBadge({ ); } - -function CompatibleAppListingCard({ - listing, - featured = false, -}: { - listing: DirectoryListingCard; - featured?: boolean; -}) { - return ( - - {featured ? ( - listing.heroImageUrl ? ( - - ) : ( - - ) - ) : ( - - - - - - - {listing.name} - - - @ - {listing.productAccountHandle?.replace(/^@/, "") || "unknown"} - - - - - {listing.tagline} - - - - {listing.rating == null ? "—" : listing.rating.toFixed(1)} - - - - - - )} - - ); -} From 51a10316bd2706a29d0a1ee944f5450b7fdec957 Mon Sep 17 00:00:00 2001 From: Barry Prendergast Date: Sat, 7 Nov 2026 14:19:58 +0100 Subject: [PATCH 6/6] Stop pagination overflowing narrow phone screens The pagination lists kept the browser's default list indent and margins, which pushed the control 4-19px past the edge of 360-375px viewports and made the Browse page scroll sideways. Reset them. Co-Authored-By: Claude Opus 5.5 --- src/design-system/pagination/index.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/design-system/pagination/index.tsx b/src/design-system/pagination/index.tsx index 0de4d80..9c6b304 100644 --- a/src/design-system/pagination/index.tsx +++ b/src/design-system/pagination/index.tsx @@ -19,11 +19,19 @@ import { gap } from "../theme/semantic-spacing.stylex"; const styles = stylex.create({ list: { + margin: 0, + padding: 0, gap: gap["2xl"], alignItems: "center", containerType: "inline-size", display: "flex", }, + // Without this the page numbers keep the browser's default list indent, + // which pushed the control past the edge of narrow phone screens. + pageList: { + margin: 0, + padding: 0, + }, pages: { flexGrow: 1, }, @@ -118,7 +126,7 @@ export function Pagination({
  1. -
      +
        {visiblePages[0] !== 1 && (