From 5f2019408d9870585079bdfcef8f4fb540c37df8 Mon Sep 17 00:00:00 2001 From: myelinated-wackerow <263208946+myelinated-wackerow@users.noreply.github.com> Date: Fri, 3 Jul 2026 15:41:03 -0700 Subject: [PATCH 1/9] patch(ui): ui/grid cols 1 and asChild prop Co-Authored-By: wackerow <54227730+wackerow@users.noreply.github.com> --- src/components/ui/grid.tsx | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/src/components/ui/grid.tsx b/src/components/ui/grid.tsx index 89569b783dd..0b15485b7f7 100644 --- a/src/components/ui/grid.tsx +++ b/src/components/ui/grid.tsx @@ -1,5 +1,6 @@ import * as React from "react" import { cva, VariantProps } from "class-variance-authority" +import { Slot } from "@radix-ui/react-slot" import { cn } from "@/lib/utils/cn" @@ -21,6 +22,7 @@ import { cn } from "@/lib/utils/cn" const gridVariants = cva("grid gap-4", { variants: { columns: { + 1: "grid-cols-auto-1", 2: "grid-cols-auto-2", 3: "grid-cols-auto-3", 4: "grid-cols-auto-4", @@ -59,15 +61,19 @@ const gridVariants = cva("grid gap-4", { const Grid = React.forwardRef< HTMLDivElement, - React.HTMLAttributes & VariantProps ->(({ className, columns, size, fit, balanced, ...props }, ref) => ( -
-)) + React.HTMLAttributes & + VariantProps & { asChild?: boolean } +>(({ className, columns, size, fit, balanced, asChild, ...props }, ref) => { + const Comp = asChild ? Slot : "div" + return ( + + ) +}) Grid.displayName = "Grid" export { Grid } From b5e75a36a0a7d25a1c1f984b725d372ee0105329 Mon Sep 17 00:00:00 2001 From: myelinated-wackerow <263208946+myelinated-wackerow@users.noreply.github.com> Date: Fri, 3 Jul 2026 15:42:58 -0700 Subject: [PATCH 2/9] refactor(ui): ProductList component Co-Authored-By: wackerow <54227730+wackerow@users.noreply.github.com> --- app/[locale]/stablecoins/page.tsx | 24 +-- .../Content/ai-agents/AiAgentProductLists.tsx | 97 +++------- .../PredictionMarketLists.tsx | 70 ++----- .../Content/restaking/RestakingList.tsx | 44 ++--- src/components/ProductList/index.tsx | 183 +++++++++--------- 5 files changed, 166 insertions(+), 252 deletions(-) diff --git a/app/[locale]/stablecoins/page.tsx b/app/[locale]/stablecoins/page.tsx index 98df3fc4f0d..c36f12a1d35 100644 --- a/app/[locale]/stablecoins/page.tsx +++ b/app/[locale]/stablecoins/page.tsx @@ -22,7 +22,7 @@ import { CheckCircle } from "@/components/icons/CheckCircle" import { XCircle } from "@/components/icons/XCircle" import { Image } from "@/components/Image" import MainArticle from "@/components/MainArticle" -import ProductList from "@/components/ProductList" +import ProductList, { type ProductListContent } from "@/components/ProductList" import { StandaloneQuizWidget } from "@/components/Quiz/QuizWidget" import Tooltip from "@/components/Tooltip" import Translation from "@/components/Translation" @@ -371,50 +371,44 @@ async function Page(props: { params: Promise }) { }, ] - const toolsData = [ + const toolsData: ProductListContent[] = [ { title: "Stablecoins.wtf", description: t("page-stablecoins-tools-stablecoinswtf-description"), - link: "https://stablecoins.wtf", + href: "https://stablecoins.wtf", image: stablecoinsWtfImg, - alt: "", }, { title: "Stablepulse", description: t("page-stablecoins-tools-stablepulse-description"), - link: "https://www.stablepulse.org/", + href: "https://www.stablepulse.org/", image: stablePulseImg, - alt: "", className: "[&_img]:p-1", }, { title: "Stables.info", description: t("page-stablecoins-tools-stablesinfo-description"), - link: "https://stables.info/", + href: "https://stables.info/", image: stablesInfoImg, - alt: "", }, { title: "Dune Stablecoin Metrics", description: t("page-stablecoins-tools-dune-description"), - link: "https://dune.com/overview/stablecoin", + href: "https://dune.com/overview/stablecoin", image: duneImg, - alt: "", className: "dark:[&_img]:invert [&_img]:p-2", }, { title: "Visa Onchain Analytics", description: t("page-stablecoins-tools-visa-description"), - link: "https://visaonchainanalytics.com/", + href: "https://visaonchainanalytics.com/", image: visaImg, - alt: "", }, { title: "Stablewars", description: t("page-stablecoins-tools-stablewars-description"), - link: "https://stablewars.xyz/", + href: "https://stablewars.xyz/", image: stablesWarsImg, - alt: "", className: "[&_img]:p-1", }, ] @@ -855,12 +849,12 @@ async function Page(props: { params: Promise }) {

{t("page-stablecoins-tools-title")}

diff --git a/src/components/Content/ai-agents/AiAgentProductLists.tsx b/src/components/Content/ai-agents/AiAgentProductLists.tsx index add3a7bcaa1..62e5ef5020c 100644 --- a/src/components/Content/ai-agents/AiAgentProductLists.tsx +++ b/src/components/Content/ai-agents/AiAgentProductLists.tsx @@ -1,8 +1,9 @@ import { useTranslations } from "next-intl" import { Strong } from "@/components/IntlStringElements" -import ProductListComponent from "@/components/ProductList" -import { ButtonLink } from "@/components/ui/buttons/Button" +import ProductListComponent, { + type ProductListContent, +} from "@/components/ProductList" import InlineLink from "@/components/ui/Link" import aiagenttoolkit from "@/public/images/ai-agents/aiagenttoolkit.png" @@ -21,84 +22,50 @@ const AiAgentProductLists = ({ list }: { list: string }) => { "ai-agents": [ { title: t("luna-title"), - description: "", - image: luna, - contentItems: [ -

- {t.rich("luna-description-1", { - strong: Strong, - a: (chunks) => ( - - {chunks} - - ), - })} -

, -

{t("luna-description-2")}

, -
- - {t("chat-with-brand", { brand: t("luna-brand") })} - -
, + description: [ + t.rich("luna-description-1", { + strong: Strong, + a: (chunks) => ( + + {chunks} + + ), + }), + t("luna-description-2"), ], + image: luna, + href: "https://app.virtuals.io/virtuals/68", + ctaLabel: t("chat-with-brand", { brand: t("luna-brand") }), }, { title: t("aixbt-title"), - description: "", + description: [t("aixbt-description-1"), t("aixbt-description-2")], image: aixbt, - contentItems: [ -

{t("aixbt-description-1")}

, -

{t("aixbt-description-2")}

, -
- - {t("visit-brand", { brand: t("aixbt-brand") })} - -
, - ], + href: "https://x.com/aixbt_agent", + ctaLabel: t("visit-brand", { brand: t("aixbt-brand") }), }, { title: t("botto-title"), - description: "", + description: t("botto-description"), image: botto, - contentItems: [ -

{t("botto-description")}

, -
- - {t("visit-brand", { brand: t("botto-brand") })} - -
, - ], + href: "https://botto.com/", + ctaLabel: t("visit-brand", { brand: t("botto-brand") }), }, ], chat: [ { title: t("bankr-title"), - description: "", + description: t("bankr-description"), image: bankr, - contentItems: [ -

{t("bankr-description")}

, -
- - {t("bankr-button")} - -
, - ], + href: "https://bankr.bot/", + ctaLabel: t("bankr-button"), }, { title: t("heyanon-title"), - description: "", + description: t("heyanon-description"), image: heyanon, - contentItems: [ -

{t("heyanon-description")}

, -
- - {t("visit-brand", { brand: t("heyanon-brand") })} - -
, - ], + href: "https://heyanon.ai/", + ctaLabel: t("visit-brand", { brand: t("heyanon-brand") }), }, ], "dive-deeper": [ @@ -106,22 +73,22 @@ const AiAgentProductLists = ({ list }: { list: string }) => { title: t("aiagenttoolkit-title"), description: t("aiagenttoolkit-description"), image: aiagenttoolkit, - link: "https://www.aiagenttoolkit.xyz/t/frameworks", + href: "https://www.aiagenttoolkit.xyz/t/frameworks", }, { title: t("cookiefun-title"), description: t("cookiefun-description"), image: cookiefun, - link: "https://cookie.fun/", + href: "https://cookie.fun/", }, { title: t("clanker-title"), description: t("clanker-description"), image: clanker, - link: "https://farcaster.xyz/clanker", + href: "https://farcaster.xyz/clanker", }, ], - } + } satisfies Record return ( { const productListSets = [ { title: "Polymarket", - description: "", + description: "A popular forecasting market with real-time trading.", image: polymarket, - alt: "Polymarket logo", - contentItems: [ -

- A popular forecasting market with real-time trading. -

, -
- - Explore Polymarket - -
, - ], + href: "https://polymarket.com/", + ctaLabel: "Explore Polymarket", }, { title: "Augur", - description: "", + description: + "A fully decentralized prediction market protocol used for predicting price trends. Disclaimer: you will need some technical expertise to start using Augur.", image: auger, - alt: "Augur logo", - contentItems: [ -

- A fully decentralized prediction market protocol used for predicting - price trends. Disclaimer: you will need some technical expertise to - start using Augur. -

, -
- - Dive into Augur - -
, - ], + href: "https://github.com/AugurProject", + ctaLabel: "Dive into Augur", }, { title: "Kalshi", - description: "", + description: + "A CFTC-compliant platform using Ethereum for USDC deposits. (USA only)", image: kalshi, - alt: "Kalshi logo", - contentItems: [ -

- a CFTC-compliant platform using Ethereum for USDC deposits. (USA only) -

, -
- - Try Kalshi - -
, - ], + href: "https://kalshi.com/", + ctaLabel: "Try Kalshi", }, - ] + ] satisfies ProductListContent[] - return + return } export default PredictionMarketLists diff --git a/src/components/Content/restaking/RestakingList.tsx b/src/components/Content/restaking/RestakingList.tsx index 072c6b45471..1c55041d5c5 100644 --- a/src/components/Content/restaking/RestakingList.tsx +++ b/src/components/Content/restaking/RestakingList.tsx @@ -1,5 +1,6 @@ -import ProductListComponent from "@/components/ProductList" -import { ButtonLink } from "@/components/ui/buttons/Button" +import ProductListComponent, { + type ProductListContent, +} from "@/components/ProductList" import eigenLayer from "@/public/images/use-cases/eigen-layer-logo.png" import symbiotic from "@/public/images/use-cases/symbiotic-logo.png" @@ -8,40 +9,21 @@ export const RestakingList = () => { const productListSets = [ { title: "EigenLayer (Restaking Platform)", - description: "", + description: + "EigenLayer introduced the idea of restaking in 2023 and has grown to thousands of people restaking millions of ETH. Referred to as “Ethereum middleware”, it connects stakers, operators and AVSs.", image: eigenLayer, - alt: "eigen layer logo", - contentItems: [ -

- EigenLayer introduced the idea of restaking in 2023 and has grown to - thousands of people restaking millions of ETH. Referred to as - “Ethereum middleware”, it connects stakers, operators and AVSs. -

, -
- - Visit EigenLayer - -
, - ], + href: "https://eigenlayer.xyz/", + ctaLabel: "Visit EigenLayer", }, { title: "Symbiotic (Restaking platform)", - description: "", + description: + "Symbiotic is a permissionless restaking protocol that helps secure different blockchain networks by letting users “restake” their assets.", image: symbiotic, - alt: "Symbiotic logo", - contentItems: [ -

- Symbiotic is a permissionless restaking protocol that helps secure - different blockchain networks by letting users “restake” their assets. -

, -
- - Visit Symbiotic - -
, - ], + href: "https://symbiotic.fi/", + ctaLabel: "Visit Symbiotic", }, - ] + ] satisfies ProductListContent[] - return + return } diff --git a/src/components/ProductList/index.tsx b/src/components/ProductList/index.tsx index ce7b417db07..959467c9c9c 100644 --- a/src/components/ProductList/index.tsx +++ b/src/components/ProductList/index.tsx @@ -1,127 +1,136 @@ +import { useId } from "react" import type { ImageProps } from "next/image" import { Image } from "@/components/Image" import { ButtonLink } from "@/components/ui/buttons/Button" import { Flex } from "@/components/ui/flex" -import { List, ListItem } from "@/components/ui/list" +import { Grid } from "@/components/ui/grid" import { cn } from "@/lib/utils/cn" -type Content = { +type ProductListContentBase = { title: string - description: string - contentItems?: React.ReactNode[] - link?: string + description: React.ReactNode | React.ReactNode[] + href: string image?: ImageProps["src"] alt?: string id?: string className?: string } +export type ProductListContent = ProductListContentBase & { + /** Per-item CTA label; items without one fall back to `actionLabel` */ + ctaLabel?: string +} + +// `actionLabel` (the shared CTA fallback) may be omitted only when every +// content item supplies its own `ctaLabel`. export type ProductListProps = { - content: Content[] category?: string - actionLabel: string - /** Columns rendered from `md` up. Defaults to a single column. */ - columns?: 1 | 2 -} + columns?: 2 + as?: "h2" | "h4" +} & ( + | { + content: (ProductListContentBase & { ctaLabel: string })[] + actionLabel?: string + } + | { + content: ProductListContent[] + actionLabel: string + } +) const ProductList = ({ actionLabel, content, category, - columns = 1, + columns, + as, }: ProductListProps) => { - const CATEGORY_NAME = "category-name" - - // In a two-column grid the bottom row shouldn't draw a divider on `md+` (one - // item when the count is odd, two when it's even). On mobile the list is - // always a single column, so only the very last item drops its divider. - const lastRowSize = content.length % 2 === 0 ? 2 : 1 + const headingId = useId() + // Widen the union: TS can't call .map on a union of array types + const items: ProductListContent[] = content + const Heading = as || "h3" return ( -
+
{category && ( -

{category} -

+ )} - - {content.map( - ( - { - title, - description, - link, - image, - alt, - id, - contentItems, - className, - }, - idx - ) => { - const isLast = idx === content.length - 1 - const inLastGridRow = - columns === 2 && idx >= content.length - lastRowSize - - return ( - -
+
    + {items.map( + ( + { + title, + description, + href, + ctaLabel, + image, + alt = "", + id, + className, + }, + idx + ) => { + const descriptions = Array.isArray(description) + ? description + : [description] + return ( +
  • {image && ( {alt )} -
- -
-
{title}
-
{description}
- {contentItems && ( -
- {contentItems} -
- )} -
- {link && ( - - {actionLabel} - to {title} website + +
+

{title}

+ {descriptions.map((desc, idx) => ( +

+ {desc} +

+ ))} +
+ + {ctaLabel || ( + <> + {actionLabel} + to {title} website + + )} - )} -
-
- ) - } - )} -
+ + + ) + } + )} + +
) } From be357f43be182216f3d299182749b9f43c6ac114 Mon Sep 17 00:00:00 2001 From: myelinated-wackerow <263208946+myelinated-wackerow@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:56:00 -0700 Subject: [PATCH 3/9] refactor(ui): simplify ProductList API + layout Require a complete, self-descriptive ctaLabel per item and drop the shared actionLabel fallback and its synthesized aria-label, so the component is pure presentational -- no getTranslations, no async. Headings derive from a parentHeadingLevel prop, and the CTA now stacks below the content at every width instead of sitting inline on wide rows. Co-Authored-By: Claude Opus 4.8 Co-Authored-By: wackerow <54227730+wackerow@users.noreply.github.com> --- src/components/ProductList/index.tsx | 79 +++++++++++----------------- 1 file changed, 32 insertions(+), 47 deletions(-) diff --git a/src/components/ProductList/index.tsx b/src/components/ProductList/index.tsx index 959467c9c9c..32a0ff91c14 100644 --- a/src/components/ProductList/index.tsx +++ b/src/components/ProductList/index.tsx @@ -1,4 +1,3 @@ -import { useId } from "react" import type { ImageProps } from "next/image" import { Image } from "@/components/Image" @@ -8,59 +7,44 @@ import { Grid } from "@/components/ui/grid" import { cn } from "@/lib/utils/cn" -type ProductListContentBase = { +export type ProductListContent = { title: string description: React.ReactNode | React.ReactNode[] href: string + /** Complete, self-descriptive CTA label, e.g. "Visit EigenLayer" */ + ctaLabel: string image?: ImageProps["src"] alt?: string id?: string className?: string } -export type ProductListContent = ProductListContentBase & { - /** Per-item CTA label; items without one fall back to `actionLabel` */ - ctaLabel?: string -} - -// `actionLabel` (the shared CTA fallback) may be omitted only when every -// content item supplies its own `ctaLabel`. export type ProductListProps = { + content: ProductListContent[] category?: string columns?: 2 - as?: "h2" | "h4" -} & ( - | { - content: (ProductListContentBase & { ctaLabel: string })[] - actionLabel?: string - } - | { - content: ProductListContent[] - actionLabel: string - } -) + parentHeadingLevel?: 1 | 2 | 3 +} const ProductList = ({ - actionLabel, content, category, columns, - as, + parentHeadingLevel = 2, }: ProductListProps) => { - const headingId = useId() + const CategoryHeading = `h${parentHeadingLevel + 1}` as "h2" | "h3" | "h4" + const ProductHeading = `h${parentHeadingLevel + (category ? 2 : 1)}` as + | "h2" + | "h3" + | "h4" + | "h5" - // Widen the union: TS can't call .map on a union of array types - const items: ProductListContent[] = content - const Heading = as || "h3" return (
{category && ( - + {category} - + )} {/* Fold-independent dividers: every item draws a border-t; the 1px pull-up hides the top row's line under the heading's border (same @@ -69,11 +53,11 @@ const ProductList = ({ -
    - {items.map( +
      + {content.map( ( { title, @@ -94,7 +78,7 @@ const ProductList = ({
    • @@ -104,25 +88,26 @@ const ProductList = ({ alt={alt} width={80} height={80} - className="aspect-square h-20 rounded-3xl shadow-lg" + className="aspect-square h-20 rounded-xl shadow-lg" /> )} - -
      -

      {title}

      + +
      + + {title} + {descriptions.map((desc, idx) => ( -

      +

      {desc}

      ))}
      - - {ctaLabel || ( - <> - {actionLabel} - to {title} website - - )} + + {ctaLabel}
    • From e17c89a3b5d09c4a6e30f436a870aee7ccc203f5 Mon Sep 17 00:00:00 2001 From: myelinated-wackerow <263208946+myelinated-wackerow@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:56:04 -0700 Subject: [PATCH 4/9] refactor(content): i18n ProductList data + CTA Move hardcoded product titles, descriptions, and CTAs into their intl namespaces and render the lists from async Server Components via getTranslations. CTAs use a shared "Visit {brand}" skeleton that interpolates a translated brand key, rather than repeating the prefix across labels or injecting hardcoded English literals. Stablecoins tool titles become keys too; Dune and Visa keep a long title key separate from the short CTA brand key. Co-Authored-By: Claude Opus 4.8 Co-Authored-By: wackerow <54227730+wackerow@users.noreply.github.com> --- app/[locale]/stablecoins/page.tsx | 31 ++++++++++++++----- .../Content/ai-agents/AiAgentProductLists.tsx | 16 +++++----- .../PredictionMarketLists.tsx | 26 +++++++++------- .../Content/restaking/RestakingList.tsx | 20 ++++++------ src/intl/en/component-ai-agent-products.json | 4 ++- .../component-prediction-market-products.json | 11 +++++++ src/intl/en/component-restaking-products.json | 9 ++++++ src/intl/en/page-stablecoins.json | 11 +++++-- 8 files changed, 88 insertions(+), 40 deletions(-) create mode 100644 src/intl/en/component-prediction-market-products.json create mode 100644 src/intl/en/component-restaking-products.json diff --git a/app/[locale]/stablecoins/page.tsx b/app/[locale]/stablecoins/page.tsx index c36f12a1d35..ea0aafde1e1 100644 --- a/app/[locale]/stablecoins/page.tsx +++ b/app/[locale]/stablecoins/page.tsx @@ -373,43 +373,61 @@ async function Page(props: { params: Promise }) { const toolsData: ProductListContent[] = [ { - title: "Stablecoins.wtf", + title: t("page-stablecoins-tools-stablecoinswtf-title"), description: t("page-stablecoins-tools-stablecoinswtf-description"), href: "https://stablecoins.wtf", image: stablecoinsWtfImg, + ctaLabel: t("page-stablecoins-tools-visit-brand", { + brand: t("page-stablecoins-tools-stablecoinswtf-title"), + }), }, { - title: "Stablepulse", + title: t("page-stablecoins-tools-stablepulse-title"), description: t("page-stablecoins-tools-stablepulse-description"), href: "https://www.stablepulse.org/", image: stablePulseImg, className: "[&_img]:p-1", + ctaLabel: t("page-stablecoins-tools-visit-brand", { + brand: t("page-stablecoins-tools-stablepulse-title"), + }), }, { - title: "Stables.info", + title: t("page-stablecoins-tools-stablesinfo-title"), description: t("page-stablecoins-tools-stablesinfo-description"), href: "https://stables.info/", image: stablesInfoImg, + ctaLabel: t("page-stablecoins-tools-visit-brand", { + brand: t("page-stablecoins-tools-stablesinfo-title"), + }), }, { - title: "Dune Stablecoin Metrics", + title: t("page-stablecoins-tools-dune-title"), description: t("page-stablecoins-tools-dune-description"), href: "https://dune.com/overview/stablecoin", image: duneImg, className: "dark:[&_img]:invert [&_img]:p-2", + ctaLabel: t("page-stablecoins-tools-visit-brand", { + brand: t("page-stablecoins-tools-dune-brand"), + }), }, { - title: "Visa Onchain Analytics", + title: t("page-stablecoins-tools-visa-title"), description: t("page-stablecoins-tools-visa-description"), href: "https://visaonchainanalytics.com/", image: visaImg, + ctaLabel: t("page-stablecoins-tools-visit-brand", { + brand: t("page-stablecoins-tools-visa-brand"), + }), }, { - title: "Stablewars", + title: t("page-stablecoins-tools-stablewars-title"), description: t("page-stablecoins-tools-stablewars-description"), href: "https://stablewars.xyz/", image: stablesWarsImg, className: "[&_img]:p-1", + ctaLabel: t("page-stablecoins-tools-visit-brand", { + brand: t("page-stablecoins-tools-stablewars-title"), + }), }, ] @@ -852,7 +870,6 @@ async function Page(props: { params: Promise }) { category={t( "page-stablecoins-category-dashboard-and-education" )} - actionLabel={t("page-apps-ready-button")} content={toolsData} columns={2} /> diff --git a/src/components/Content/ai-agents/AiAgentProductLists.tsx b/src/components/Content/ai-agents/AiAgentProductLists.tsx index 62e5ef5020c..b6429047e26 100644 --- a/src/components/Content/ai-agents/AiAgentProductLists.tsx +++ b/src/components/Content/ai-agents/AiAgentProductLists.tsx @@ -1,4 +1,4 @@ -import { useTranslations } from "next-intl" +import { getTranslations } from "next-intl/server" import { Strong } from "@/components/IntlStringElements" import ProductListComponent, { @@ -15,8 +15,8 @@ import cookiefun from "@/public/images/ai-agents/cookiefun.png" import heyanon from "@/public/images/ai-agents/heyanon.png" import luna from "@/public/images/ai-agents/luna.png" -const AiAgentProductLists = ({ list }: { list: string }) => { - const t = useTranslations("component-ai-agent-products") +const AiAgentProductLists = async ({ list }: { list: string }) => { + const t = await getTranslations("component-ai-agent-products") const productListSets = { "ai-agents": [ @@ -74,28 +74,26 @@ const AiAgentProductLists = ({ list }: { list: string }) => { description: t("aiagenttoolkit-description"), image: aiagenttoolkit, href: "https://www.aiagenttoolkit.xyz/t/frameworks", + ctaLabel: t("visit-brand", { brand: t("aiagenttoolkit-brand") }), }, { title: t("cookiefun-title"), description: t("cookiefun-description"), image: cookiefun, href: "https://cookie.fun/", + ctaLabel: t("visit-brand", { brand: t("cookiefun-brand") }), }, { title: t("clanker-title"), description: t("clanker-description"), image: clanker, href: "https://farcaster.xyz/clanker", + ctaLabel: t("visit-brand", { brand: t("clanker-brand") }), }, ], } satisfies Record - return ( - - ) + return } export default AiAgentProductLists diff --git a/src/components/Content/prediction-markets/PredictionMarketLists.tsx b/src/components/Content/prediction-markets/PredictionMarketLists.tsx index a12afa4dce6..5245079298f 100644 --- a/src/components/Content/prediction-markets/PredictionMarketLists.tsx +++ b/src/components/Content/prediction-markets/PredictionMarketLists.tsx @@ -1,3 +1,5 @@ +import { getTranslations } from "next-intl/server" + import ProductListComponent, { type ProductListContent, } from "@/components/ProductList" @@ -6,30 +8,30 @@ import auger from "@/public/images/dapps/auger.png" import kalshi from "@/public/images/dapps/kalshi.png" import polymarket from "@/public/images/dapps/polymarket.png" -const PredictionMarketLists = () => { +const PredictionMarketLists = async () => { + const t = await getTranslations("component-prediction-market-products") + const productListSets = [ { - title: "Polymarket", - description: "A popular forecasting market with real-time trading.", + title: t("polymarket-title"), + description: t("polymarket-description"), image: polymarket, href: "https://polymarket.com/", - ctaLabel: "Explore Polymarket", + ctaLabel: t("polymarket-cta"), }, { - title: "Augur", - description: - "A fully decentralized prediction market protocol used for predicting price trends. Disclaimer: you will need some technical expertise to start using Augur.", + title: t("augur-title"), + description: t("augur-description"), image: auger, href: "https://github.com/AugurProject", - ctaLabel: "Dive into Augur", + ctaLabel: t("augur-cta"), }, { - title: "Kalshi", - description: - "A CFTC-compliant platform using Ethereum for USDC deposits. (USA only)", + title: t("kalshi-title"), + description: t("kalshi-description"), image: kalshi, href: "https://kalshi.com/", - ctaLabel: "Try Kalshi", + ctaLabel: t("kalshi-cta"), }, ] satisfies ProductListContent[] diff --git a/src/components/Content/restaking/RestakingList.tsx b/src/components/Content/restaking/RestakingList.tsx index 1c55041d5c5..3c45b079b30 100644 --- a/src/components/Content/restaking/RestakingList.tsx +++ b/src/components/Content/restaking/RestakingList.tsx @@ -1,3 +1,5 @@ +import { getTranslations } from "next-intl/server" + import ProductListComponent, { type ProductListContent, } from "@/components/ProductList" @@ -5,23 +7,23 @@ import ProductListComponent, { import eigenLayer from "@/public/images/use-cases/eigen-layer-logo.png" import symbiotic from "@/public/images/use-cases/symbiotic-logo.png" -export const RestakingList = () => { +export const RestakingList = async () => { + const t = await getTranslations("component-restaking-products") + const productListSets = [ { - title: "EigenLayer (Restaking Platform)", - description: - "EigenLayer introduced the idea of restaking in 2023 and has grown to thousands of people restaking millions of ETH. Referred to as “Ethereum middleware”, it connects stakers, operators and AVSs.", + title: t("eigenlayer-title"), + description: t("eigenlayer-description"), image: eigenLayer, href: "https://eigenlayer.xyz/", - ctaLabel: "Visit EigenLayer", + ctaLabel: t("visit-brand", { brand: t("eigenlayer-brand") }), }, { - title: "Symbiotic (Restaking platform)", - description: - "Symbiotic is a permissionless restaking protocol that helps secure different blockchain networks by letting users “restake” their assets.", + title: t("symbiotic-title"), + description: t("symbiotic-description"), image: symbiotic, href: "https://symbiotic.fi/", - ctaLabel: "Visit Symbiotic", + ctaLabel: t("visit-brand", { brand: t("symbiotic-brand") }), }, ] satisfies ProductListContent[] diff --git a/src/intl/en/component-ai-agent-products.json b/src/intl/en/component-ai-agent-products.json index 2d6d3d634b1..ba54b403abb 100644 --- a/src/intl/en/component-ai-agent-products.json +++ b/src/intl/en/component-ai-agent-products.json @@ -1,5 +1,5 @@ { - "action-label": "Go", + "aiagenttoolkit-brand": "Aiagenttoolkit.xyz", "aiagenttoolkit-description": "A curated list of AI agents, frameworks, launchpads & resources", "aiagenttoolkit-title": "Aiagenttoolkit.xyz", "aixbt-brand": "AIXBT", @@ -13,8 +13,10 @@ "botto-description": "Botto creates art and NFTs, with the community voting on its best work. Users formed a DAO that guides Botto’s artistic evolution while also earning token rewards for participation.", "botto-title": "Botto: Decentralized autonomous artist", "chat-with-brand": "Chat with {brand}", + "clanker-brand": "Clanker", "clanker-description": "AI that can create a token for you by tagging it on farcaster", "clanker-title": "Clanker", + "cookiefun-brand": "Cookie.fun", "cookiefun-description": "AI agents tracker", "cookiefun-title": "Cookie.fun", "heyanon-brand": "HeyAnon", diff --git a/src/intl/en/component-prediction-market-products.json b/src/intl/en/component-prediction-market-products.json new file mode 100644 index 00000000000..2b27fbabeb9 --- /dev/null +++ b/src/intl/en/component-prediction-market-products.json @@ -0,0 +1,11 @@ +{ + "augur-cta": "Dive into Augur", + "augur-description": "A fully decentralized prediction market protocol used for predicting price trends. Disclaimer: you will need some technical expertise to start using Augur.", + "augur-title": "Augur", + "kalshi-cta": "Try Kalshi", + "kalshi-description": "A CFTC-compliant platform using Ethereum for USDC deposits. (USA only)", + "kalshi-title": "Kalshi", + "polymarket-cta": "Explore Polymarket", + "polymarket-description": "A popular forecasting market with real-time trading.", + "polymarket-title": "Polymarket" +} diff --git a/src/intl/en/component-restaking-products.json b/src/intl/en/component-restaking-products.json new file mode 100644 index 00000000000..b45d2e0d0f0 --- /dev/null +++ b/src/intl/en/component-restaking-products.json @@ -0,0 +1,9 @@ +{ + "eigenlayer-brand": "EigenLayer", + "eigenlayer-description": "EigenLayer introduced the idea of restaking in 2023 and has grown to thousands of people restaking millions of ETH. Referred to as “Ethereum middleware”, it connects stakers, operators and AVSs.", + "eigenlayer-title": "EigenLayer (Restaking Platform)", + "symbiotic-brand": "Symbiotic", + "symbiotic-description": "Symbiotic is a permissionless restaking protocol that helps secure different blockchain networks by letting users “restake” their assets.", + "symbiotic-title": "Symbiotic (Restaking platform)", + "visit-brand": "Visit {brand}" +} diff --git a/src/intl/en/page-stablecoins.json b/src/intl/en/page-stablecoins.json index 57b8673d9e6..ae24b765827 100644 --- a/src/intl/en/page-stablecoins.json +++ b/src/intl/en/page-stablecoins.json @@ -123,8 +123,15 @@ "page-stablecoins-tools-dune-description": "Dashboard delivering real-time insights into stablecoin supply, liquidity, trading volume, and adoption across blockchains.", "page-stablecoins-tools-visa-description": "Dashboard visualizing the movement, supply, and usage of fiat-backed stablecoins across public blockchains.", "page-stablecoins-tools-stablewars-description": "Analytics leaderboard and dashboard, tracking balances, transfers, and rankings for stablecoins across multiple blockchains.", - - "page-apps-ready-button": "Go", + "page-stablecoins-tools-visit-brand": "Visit {brand}", + "page-stablecoins-tools-stablecoinswtf-title": "Stablecoins.wtf", + "page-stablecoins-tools-stablepulse-title": "Stablepulse", + "page-stablecoins-tools-stablesinfo-title": "Stables.info", + "page-stablecoins-tools-dune-title": "Dune Stablecoin Metrics", + "page-stablecoins-tools-dune-brand": "Dune", + "page-stablecoins-tools-visa-title": "Visa Onchain Analytics", + "page-stablecoins-tools-visa-brand": "Visa", + "page-stablecoins-tools-stablewars-title": "Stablewars", "pros": "Pros", "cons": "Cons", "example-projects": "Example projects", From f77a3ddcc7da1dd506542f41a41ed9132556163d Mon Sep 17 00:00:00 2001 From: myelinated-wackerow <263208946+myelinated-wackerow@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:56:08 -0700 Subject: [PATCH 5/9] docs(design-system): ProductList list + i18n Document the Grid-as-semantic-list pattern (asChild ul, list-none plus role=list, fold-independent dividers) and the bare ul/li legacy-style gotcha. Clarify the translation boundary -- getTranslations for Server Components, the custom useTranslation hook for genuine client components, never next-intl useTranslations directly -- and the rule to interpolate a translated value rather than a hardcoded literal. Co-Authored-By: Claude Opus 4.8 Co-Authored-By: wackerow <54227730+wackerow@users.noreply.github.com> --- .../design-system/references/components.md | 15 ++++++++++++- .../design-system/references/gotchas.md | 4 ++++ .../design-system/references/i18n-rtl.md | 22 ++++++++++++++----- .../references/server-vs-client.md | 12 +++++++--- 4 files changed, 44 insertions(+), 9 deletions(-) diff --git a/.claude/skills/design-system/references/components.md b/.claude/skills/design-system/references/components.md index 89a04c5b689..cba2351eae8 100644 --- a/.claude/skills/design-system/references/components.md +++ b/.claude/skills/design-system/references/components.md @@ -159,7 +159,20 @@ Responsive grid for laying out a collection of items (cards, tiles, badges). Ren - `size`: `small (7rem) | narrow (12rem) | base (18rem, default) | wide (22rem) | wider (26rem)`. The **min item width** (`--grid-item-min`) — the floor an item shrinks to before a column drops, and so the fold-aggressiveness lever. Pick by item shape: `small` for badges, `base` for standard content cards, `wide`/`wider` for horizontal items like callouts. Larger sizes wrap sooner; keep `columns` small enough that N items of the chosen width fit (min ≤ container/N). - `fit`: `boolean` (auto-fill mode only). Default keeps empty tracks (`auto-fill`); `fit` collapses them (`auto-fit`) so a partially-filled row stretches to fill the width. - `balanced`: `2 | 4`. A fixed, deterministic **breakpoint** reflow (overrides `columns` via `!important`; `size`/`fit` inert). `balanced={4}` → `4 → 2×2 → 1`, never an orphan 3-up row (which auto-fill produces); `balanced={2}` → `2 → 1` at `md`, a breakpoint-driven alternative to `columns={2}` (which folds by content width). Both fold `1 → 2` at `md`. Use with a **fixed set** of that many items (or a multiple). -- Applies `gap-4`. `className` is spread last, so override the gap (or `--grid-item-min` / `--grid-repeat`) per call site only when genuinely needed. +- `asChild`: contribute Grid's classes to the child element (Radix `Slot`) instead of rendering a `div` -- for when the grid must BE a semantic element, e.g. a `
        `. +- Applies `gap-4`. `className` is spread last, so override the gap (or `--grid-item-min` / `--grid-repeat`) per call site only when genuinely needed. **If you override the gap, mirror it in `--grid-gap`** (e.g. `gap-x-8 [--grid-gap:--spacing(8)]`): the `grid-cols-auto-*` fold math assumes the gutter via `var(--grid-gap, 1rem)`, and with a wider real gap the even-share term is computed against the wrong gutter -- a second column may **never** form. + +**Grid as a semantic list** (reference implementation: `ProductList`): + +```tsx + +
          ...
        +
        +``` + +- `m-0 list-none` reset the legacy global `ul` styles in `base.css` (start margin + `disc` markers; TODO-slated for removal but still live -- a bare `
          ` renders indented). Item `
        • `s likewise need `m-0` against the global `li` bottom margin. +- With markers removed (`list-none`), Safari/VoiceOver stops announcing list semantics -- `role="list"` restores them. +- **Divider lines between rows**: auto-fill folds by *content width*, so anything breakpoint- or width-conditional (`md:border-b-0`, a container query at the computed fold point) desyncs from the actual fold -- the fold constant moves whenever `size`/gap/`columns` change, and rounding at the boundary bites even when the math is right. The fold-independent pattern: **every** item draws a `border-t border-border`, and the list is pulled up 1px (`-mt-px`) so the top row's line is hidden -- under a heading's own `border-border` bottom border when one sits flush above, or cropped by `overflow-hidden` on the parent otherwise. Dividers then appear exactly between rows at any column count with nothing to keep in sync. Caveats: the overlap only disappears if the divider and the heading border share the same color token, and the crop wrapper must be `overflow-hidden`, not `overflow-clip` -- `clip` doesn't establish a BFC, so the negative margin collapses through and nothing gets cropped. Mind BFC side effects: a child heading's `mt-*` no longer collapses out of an `overflow-hidden` parent, so apply the crop only when needed (see `ProductList`: `overflow-hidden` only when no `category` heading renders). ### `Section` diff --git a/.claude/skills/design-system/references/gotchas.md b/.claude/skills/design-system/references/gotchas.md index b2c02cc156d..57b92cdda44 100644 --- a/.claude/skills/design-system/references/gotchas.md +++ b/.claude/skills/design-system/references/gotchas.md @@ -138,6 +138,10 @@ Use `useRtlFlip` only when you need to flip a non-chevron directional icon (a cu Configured in `base.css` lines 97-107. Persian fallback. Triggered by `:lang(ur)`, not `dir`. Don't override. +### Bare `
            `/`
          • ` inherit legacy global list styles + +`base.css` still ships global `ul`/`ol` rules (start margin + `disc` markers, TODO-slated for removal) and a global `li` bottom margin. A non-prose `
              ` (nav, card list, product list) needs `m-0 list-none` on the `ul` and `m-0` on the `li`s or it renders indented with stray spacing. And once `list-none` removes the markers, Safari/VoiceOver drops list semantics -- add `role="list"` to keep the list announced. See the "Grid as a semantic list" pattern in [components.md](components.md). + ## Heading Hierarchy ### One `

              ` per page diff --git a/.claude/skills/design-system/references/i18n-rtl.md b/.claude/skills/design-system/references/i18n-rtl.md index 29e5a6ea2d1..8bd8a6538ef 100644 --- a/.claude/skills/design-system/references/i18n-rtl.md +++ b/.claude/skills/design-system/references/i18n-rtl.md @@ -4,7 +4,11 @@ The site supports 25 languages, including **Arabic** and **Urdu** (RTL). Transla ## Translation -### Server Components (preferred) +**Never import `useTranslations` from `next-intl` directly.** There are exactly two sanctioned APIs: + +### Server Components (preferred): `await getTranslations` + +Any component that needs intl strings and has no genuine client-side requirement (state, effects, event handlers, browser APIs) should be an **async Server Component** using `getTranslations`. This includes small data/list components rendered from MDX -- they don't need to stay sync, and importing a `"use client"` leaf (e.g. `ButtonLink`) does NOT make the component itself client-side. ```tsx import { getLocale, getTranslations } from "next-intl/server" @@ -16,24 +20,32 @@ export default async function Page() { } ``` -### Client Components +### Client Components (only when genuinely client): `@/hooks/useTranslation` + +When a component is legitimately `"use client"`, use the project's custom hook -- not next-intl's `useTranslations`. It adds the `namespace:key` addressing scheme, a `common` default namespace, and missing-key fallback behavior the codebase relies on. ```tsx "use client" -import { useTranslations } from "next-intl" +import useTranslation from "@/hooks/useTranslation" export function Widget() { - const t = useTranslations("widget-namespace") - return

              {t("widget-description")} + const { t } = useTranslation("widget-namespace") + return

              {t("widget-description")}

              } ``` +Don't mark a file `"use client"` just to fetch strings -- see [server-vs-client.md](server-vs-client.md). + ### Never hard-code English If a string is rendered to a user, it must come from a translation key. The exception is dev-only debug UI (e.g., `AB/TestDebugPanel`) and developer-facing internal copy. If you find yourself wanting to hard-code, add the key to `src/intl/en/[namespace].json` and use `t()`. +### Interpolate a translated value, never a hard-coded literal + +A shared skeleton with an interpolated value -- `t("visit-brand", { brand: t("dune-brand") })` -- is the pattern to reach for: one `"Visit {brand}"` string instead of the `"Visit "` prefix repeated across N labels, and each brand is its own key the pipeline can transliterate or case-adjust per locale. The anti-pattern is passing a hard-coded English literal as the variable (`t("visit-brand", { brand: "Dune" })`): the `"Dune"` literal never enters an intl file, so it stays untranslated English no matter the locale. If a proper noun/brand is enumerable, give it a key and inject the translated value; reserve raw literals for genuinely dynamic runtime values (counts, dates, user input) that can't be keys. `ProductList` CTAs follow this -- see `AiAgentProductLists` and the stablecoins tools list. + ## RTL: Logical CSS Properties Always use logical equivalents instead of physical directions: diff --git a/.claude/skills/design-system/references/server-vs-client.md b/.claude/skills/design-system/references/server-vs-client.md index 8a7a51f4ff5..1fb5f06c29b 100644 --- a/.claude/skills/design-system/references/server-vs-client.md +++ b/.claude/skills/design-system/references/server-vs-client.md @@ -37,9 +37,14 @@ Primitives that handle their own client boundary: ## Translation Boundary -`useTranslations` (client) and `getTranslations` (server) serve the same purpose for content. **Don't mark a file `"use client"` just to use `useTranslations`.** Restructure: keep the parent server, let it call `getTranslations`, pass the translated strings down as props. +Two sanctioned APIs, chosen by where the component runs -- see [i18n-rtl.md](i18n-rtl.md): -The `t()` functions returned by these are *similar but not strictly equivalent* -- variable interpolation, ICU pluralization, and rich-text/htmr patterns can behave differently between server and client paths. If a change touches both, test both. Don't assume swapping `useTranslations` for `getTranslations` (or vice versa) is a no-op. +- **Server (preferred)**: make the component `async` and `await getTranslations("namespace")` from `next-intl/server`. A sync component that needs intl strings and has no client requirement should become an async Server Component -- there is no reason to reach for a hook. (`useTranslations` from `next-intl` does technically run server-side, but it's not used in this codebase -- don't introduce it.) +- **Client (only when genuinely `"use client"`)**: the custom `useTranslation` hook from `@/hooks/useTranslation` -- never `useTranslations` from `next-intl` directly. + +**Don't mark a file `"use client"` just to fetch strings.** Restructure: keep the parent server, let it call `getTranslations`, pass the translated strings down as props. + +The server and client `t()` functions are *similar but not strictly equivalent* -- variable interpolation, ICU pluralization, and rich-text/htmr patterns can behave differently between server and client paths. If a change touches both, test both. Don't assume swapping one for the other is a no-op. ### Callout is server-renderable (consolidation done) @@ -89,5 +94,6 @@ export default async function Page() { - **`useEffect` data fetching** in a component that could be a Server Component - **`"use client"` at the top of a page** when only a small subtree is interactive -- **`useTranslations` in a leaf component** instead of restructuring so the parent uses `getTranslations` +- **Translation hooks in a leaf component** instead of restructuring so the parent uses `getTranslations` +- **`import { useTranslations } from "next-intl"`** anywhere -- server components use `await getTranslations`; genuinely-client components use `@/hooks/useTranslation` - **`useColorModeValue`** (Chakra leftover) -- use Tailwind `dark:` variant instead From 3b12e33080cc7760a83d1521590c3068392850b8 Mon Sep 17 00:00:00 2001 From: myelinated-wackerow <263208946+myelinated-wackerow@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:32:52 -0700 Subject: [PATCH 6/9] refactor(ui): ProductList items as link cards Render each item as a ghost Card whose whole surface links to the product: href on the Card with a CardButtonFake as the sole action, so there are no nested links in the copy. Layout is a row -- a fixed-width logo thumbnail beside a content column. The CTA sits at the bottom of the column, full-width on narrow cards and fit-content once the card is wide enough (@container/card). Drops the old semantic-list + fold-independent-divider structure for the plain card-grid convention. Story updated to the current API. Co-Authored-By: Claude Opus 4.8 Co-Authored-By: wackerow <54227730+wackerow@users.noreply.github.com> --- .../ProductList/ProductList.stories.tsx | 65 ++++----- src/components/ProductList/index.tsx | 138 +++++++++--------- 2 files changed, 95 insertions(+), 108 deletions(-) diff --git a/src/components/ProductList/ProductList.stories.tsx b/src/components/ProductList/ProductList.stories.tsx index 59c2d374bfb..35e770fa2ac 100644 --- a/src/components/ProductList/ProductList.stories.tsx +++ b/src/components/ProductList/ProductList.stories.tsx @@ -1,6 +1,6 @@ import { Meta, StoryObj } from "@storybook/nextjs" -import ProductList from "." +import ProductList, { type ProductListContent } from "." const meta = { title: "Components / Content / ProductList", @@ -10,7 +10,7 @@ const meta = { docs: { description: { component: - "Vertical list of product / tool entries used on directory pages (wallets, dapps, dev tools). Each row shows an optional 66x66 thumbnail, a title, a description, optional content items, and an optional outlined CTA labelled by `actionLabel`. Pass `category` to render a heading above the list. Items are visually separated by dividers between rows.", + 'List of product / tool entries used on directory pages (wallets, dapps, dev tools). Each entry is a ghost `Card` whose whole surface links to the product: an optional logo thumbnail beside the title and description, and a required outlined CTA (`ctaLabel`, a complete self-descriptive label such as "Visit Uniswap") as the sole action. Pass `category` to render a heading above the list, and `columns={2}` to lay entries out in two columns when there\'s room.', }, }, }, @@ -27,14 +27,15 @@ export default meta type Story = StoryObj -const sampleContent = [ +const sampleContent: ProductListContent[] = [ { title: "Uniswap", description: "A decentralized exchange protocol that lets users swap tokens without intermediaries.", image: "/images/dapps/uni.png", alt: "Uniswap logo", - link: "https://uniswap.org", + href: "https://uniswap.org", + ctaLabel: "Visit Uniswap", }, { title: "Aave", @@ -42,7 +43,8 @@ const sampleContent = [ "An open-source, non-custodial protocol for earning interest on deposits and borrowing assets.", image: "/images/dapps/aave.png", alt: "Aave logo", - link: "https://aave.com", + href: "https://aave.com", + ctaLabel: "Visit Aave", }, { title: "Compound", @@ -50,76 +52,61 @@ const sampleContent = [ "An algorithmic, autonomous interest rate protocol for lending and borrowing crypto assets.", image: "/images/dapps/compound.png", alt: "Compound logo", - link: "https://compound.finance", + href: "https://compound.finance", + ctaLabel: "Visit Compound", }, ] export const Default: Story = { args: { - actionLabel: "Visit", content: sampleContent, }, } export const WithCategory: Story = { args: { - actionLabel: "Visit", category: "Decentralized exchanges", content: sampleContent, + columns: 2, }, } export const WithoutImages: Story = { args: { - actionLabel: "Open", - content: sampleContent.map(({ title, description, link }) => ({ - title, - description, - link, + content: sampleContent.map((item) => ({ + title: item.title, + description: item.description, + href: item.href, + ctaLabel: item.ctaLabel, })), }, } -export const WithoutLinks: Story = { +export const MultipleParagraphs: Story = { args: { - actionLabel: "Visit", - content: sampleContent.map(({ title, description, image, alt }) => ({ - title, - description, - image, - alt, - })), - }, -} - -export const WithContentItems: Story = { - args: { - actionLabel: "Visit", category: "Lending markets", content: [ { title: "Morpho", - description: + description: [ "Permissionless lending markets with isolated risk and curated vaults.", + "Isolated markets, audited contracts.", + ], image: "/images/dapps/morpho.png", alt: "Morpho logo", - link: "https://morpho.org", - contentItems: [ - Isolated markets, - Audited contracts, - ], + href: "https://morpho.org", + ctaLabel: "Visit Morpho", }, { title: "Spark", - description: + description: [ "Borrow and save against blue-chip collateral on Ethereum mainnet.", + "Predictable savings rates, backed by the DAI stablecoin.", + ], image: "/images/dapps/sparkfi.png", alt: "Spark logo", - link: "https://spark.fi", - contentItems: [ - Predictable savings rates, - Backed by the DAI stablecoin, - ], + href: "https://spark.fi", + ctaLabel: "Visit Spark", }, ], }, diff --git a/src/components/ProductList/index.tsx b/src/components/ProductList/index.tsx index 32a0ff91c14..2c041320dd3 100644 --- a/src/components/ProductList/index.tsx +++ b/src/components/ProductList/index.tsx @@ -1,8 +1,15 @@ import type { ImageProps } from "next/image" import { Image } from "@/components/Image" -import { ButtonLink } from "@/components/ui/buttons/Button" -import { Flex } from "@/components/ui/flex" +import { + Card, + CardBanner, + CardButtonFake, + CardContent, + CardFooter, + CardParagraph, + CardTitle, +} from "@/components/ui/card" import { Grid } from "@/components/ui/grid" import { cn } from "@/lib/utils/cn" @@ -40,81 +47,74 @@ const ProductList = ({ | "h5" return ( -
              +
              {category && ( {category} )} - {/* Fold-independent dividers: every item draws a border-t; the 1px - pull-up hides the top row's line under the heading's border (same - token), or overflow-hidden crops it when there's no heading. - Pattern notes: design-system skill. */} - -
                - {content.map( - ( - { - title, - description, - href, - ctaLabel, - image, - alt = "", - id, - className, - }, - idx - ) => { - const descriptions = Array.isArray(description) - ? description - : [description] - return ( -
              • - {image && ( - {alt} - )} - -
                - - {title} - - {descriptions.map((desc, idx) => ( -

                - {desc} -

                - ))} -
                - + {content.map( + ( + { + title, + description, + href, + ctaLabel, + image, + alt = "", + id, + className, + }, + idx + ) => { + const descriptions = Array.isArray(description) + ? description + : [description] + return ( + + {image && ( + + + {alt} + + + )} +
                + + + {title} + + {descriptions.map((desc, i) => ( + {desc} + ))} + + {/* Full-width CTA when the card is narrow; fit-content once + the card is wide enough (most visible at 1 column). */} + + {ctaLabel} - - -
              • - ) - } - )} -
              + + +
              + + ) + } + )}
              ) From dcb5a4c20e7d70328915d4718f6dce6d418e04e2 Mon Sep 17 00:00:00 2001 From: myelinated-wackerow <263208946+myelinated-wackerow@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:32:56 -0700 Subject: [PATCH 7/9] refactor(content): remove inline link from copy The whole ProductList card is now a single link, so a nested anchor in the copy is invalid HTML. Flatten Luna's TikTok link in its description to plain (bold) text. Co-Authored-By: Claude Opus 4.8 Co-Authored-By: wackerow <54227730+wackerow@users.noreply.github.com> --- src/components/Content/ai-agents/AiAgentProductLists.tsx | 6 ------ src/intl/en/component-ai-agent-products.json | 2 +- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/src/components/Content/ai-agents/AiAgentProductLists.tsx b/src/components/Content/ai-agents/AiAgentProductLists.tsx index b6429047e26..7176820a2c2 100644 --- a/src/components/Content/ai-agents/AiAgentProductLists.tsx +++ b/src/components/Content/ai-agents/AiAgentProductLists.tsx @@ -4,7 +4,6 @@ import { Strong } from "@/components/IntlStringElements" import ProductListComponent, { type ProductListContent, } from "@/components/ProductList" -import InlineLink from "@/components/ui/Link" import aiagenttoolkit from "@/public/images/ai-agents/aiagenttoolkit.png" import aixbt from "@/public/images/ai-agents/aixbt.png" @@ -25,11 +24,6 @@ const AiAgentProductLists = async ({ list }: { list: string }) => { description: [ t.rich("luna-description-1", { strong: Strong, - a: (chunks) => ( - - {chunks} - - ), }), t("luna-description-2"), ], diff --git a/src/intl/en/component-ai-agent-products.json b/src/intl/en/component-ai-agent-products.json index ba54b403abb..862bfd57fd8 100644 --- a/src/intl/en/component-ai-agent-products.json +++ b/src/intl/en/component-ai-agent-products.json @@ -23,7 +23,7 @@ "heyanon-description": "HeyAnon simplifies using a wallet with one-click swaps, asset bridging, and trading via a chat interface. This saves people a lot of time. It automates tasks, reduces transaction fees, and optimizes portfolios, making it easier to manage assets without requiring technical expertise.", "heyanon-title": "HeyAnon", "luna-brand": "Luna", - "luna-description-1": "Luna is a fully autonomous digital influencer & entertainer that blends music, pop culture, and AI tech. As a virtual idol Luna attracted over one million TikTok followers and performed live in a music festival.", + "luna-description-1": "Luna is a fully autonomous digital influencer & entertainer that blends music, pop culture, and AI tech. As a virtual idol Luna attracted over one million TikTok followers and performed live in a music festival.", "luna-description-2": "Luna engages with users through her own X account. You might receive an X reply if you tag her handle! Luna controls her own onchain wallet.", "luna-title": "Luna: The Virtual Influencer", "visit-brand": "Visit {brand}" From 8d07bc5f4d6291220d3280c480fb5b2ca2d1d9ab Mon Sep 17 00:00:00 2001 From: myelinated-wackerow <263208946+myelinated-wackerow@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:32:59 -0700 Subject: [PATCH 8/9] docs(design-system): ProductList is a card grid ProductList no longer uses the Grid-as-semantic-list + fold-independent-divider pattern, so remove that section from components.md and the now-dangling cross-reference from the bare-ul/li gotcha. Co-Authored-By: Claude Opus 4.8 Co-Authored-By: wackerow <54227730+wackerow@users.noreply.github.com> --- .../skills/design-system/references/components.md | 12 ------------ .claude/skills/design-system/references/gotchas.md | 2 +- 2 files changed, 1 insertion(+), 13 deletions(-) diff --git a/.claude/skills/design-system/references/components.md b/.claude/skills/design-system/references/components.md index c831dbacdbe..f27ca4bf538 100644 --- a/.claude/skills/design-system/references/components.md +++ b/.claude/skills/design-system/references/components.md @@ -163,18 +163,6 @@ Responsive grid for laying out a collection of items (cards, tiles, badges). Ren - `asChild`: contribute Grid's classes to the child element (Radix `Slot`) instead of rendering a `div` -- for when the grid must BE a semantic element, e.g. a `
                `. - Applies `gap-4`. `className` is spread last, so override the gap (or `--grid-item-min` / `--grid-repeat`) per call site only when genuinely needed. **If you override the gap, mirror it in `--grid-gap`** (e.g. `gap-x-8 [--grid-gap:--spacing(8)]`): the `grid-cols-auto-*` fold math assumes the gutter via `var(--grid-gap, 1rem)`, and with a wider real gap the even-share term is computed against the wrong gutter -- a second column may **never** form. -**Grid as a semantic list** (reference implementation: `ProductList`): - -```tsx - -
                  ...
                -
                -``` - -- `m-0 list-none` reset the legacy global `ul` styles in `base.css` (start margin + `disc` markers; TODO-slated for removal but still live -- a bare `
                  ` renders indented). Item `
                • `s likewise need `m-0` against the global `li` bottom margin. -- With markers removed (`list-none`), Safari/VoiceOver stops announcing list semantics -- `role="list"` restores them. -- **Divider lines between rows**: auto-fill folds by *content width*, so anything breakpoint- or width-conditional (`md:border-b-0`, a container query at the computed fold point) desyncs from the actual fold -- the fold constant moves whenever `size`/gap/`columns` change, and rounding at the boundary bites even when the math is right. The fold-independent pattern: **every** item draws a `border-t border-border`, and the list is pulled up 1px (`-mt-px`) so the top row's line is hidden -- under a heading's own `border-border` bottom border when one sits flush above, or cropped by `overflow-hidden` on the parent otherwise. Dividers then appear exactly between rows at any column count with nothing to keep in sync. Caveats: the overlap only disappears if the divider and the heading border share the same color token, and the crop wrapper must be `overflow-hidden`, not `overflow-clip` -- `clip` doesn't establish a BFC, so the negative margin collapses through and nothing gets cropped. Mind BFC side effects: a child heading's `mt-*` no longer collapses out of an `overflow-hidden` parent, so apply the crop only when needed (see `ProductList`: `overflow-hidden` only when no `category` heading renders). - ### `Section` ```tsx diff --git a/.claude/skills/design-system/references/gotchas.md b/.claude/skills/design-system/references/gotchas.md index 023c7639296..dfa257e365d 100644 --- a/.claude/skills/design-system/references/gotchas.md +++ b/.claude/skills/design-system/references/gotchas.md @@ -144,7 +144,7 @@ Configured in `base.css` lines 97-107. Persian fallback. Triggered by `:lang(ur) ### Bare `
                    `/`
                  • ` inherit legacy global list styles -`base.css` still ships global `ul`/`ol` rules (start margin + `disc` markers, TODO-slated for removal) and a global `li` bottom margin. A non-prose `
                      ` (nav, card list, product list) needs `m-0 list-none` on the `ul` and `m-0` on the `li`s or it renders indented with stray spacing. And once `list-none` removes the markers, Safari/VoiceOver drops list semantics -- add `role="list"` to keep the list announced. See the "Grid as a semantic list" pattern in [components.md](components.md). +`base.css` still ships global `ul`/`ol` rules (start margin + `disc` markers, TODO-slated for removal) and a global `li` bottom margin. A non-prose `
                        ` (nav menu, card list) needs `m-0 list-none` on the `ul` and `m-0` on the `li`s or it renders indented with stray spacing. And once `list-none` removes the markers, Safari/VoiceOver drops list semantics -- add `role="list"` to keep the list announced. ## Heading Hierarchy From 3fe1c29c09af65dbdf75e21281eff6a88ff9fa20 Mon Sep 17 00:00:00 2001 From: myelinated-wackerow <263208946+myelinated-wackerow@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:43:25 -0700 Subject: [PATCH 9/9] patch(ui): drop unused Grid asChild prop asChild was added to support a Grid-as-
                          semantic-list pattern that ProductList no longer uses, and nothing else consumes it. Remove the prop, its Slot import, and the doc bullet; the cols=1 support from the same original change stays. Co-Authored-By: Claude Opus 4.8 Co-Authored-By: wackerow <54227730+wackerow@users.noreply.github.com> --- .claude/skills/design-system/references/components.md | 1 - src/components/ui/grid.tsx | 9 +++------ 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/.claude/skills/design-system/references/components.md b/.claude/skills/design-system/references/components.md index f27ca4bf538..5f8fb5a0973 100644 --- a/.claude/skills/design-system/references/components.md +++ b/.claude/skills/design-system/references/components.md @@ -160,7 +160,6 @@ Responsive grid for laying out a collection of items (cards, tiles, badges). Ren - `size`: `small (7rem) | narrow (12rem) | base (18rem, default) | wide (22rem) | wider (26rem)`. The **min item width** (`--grid-item-min`) — the floor an item shrinks to before a column drops, and so the fold-aggressiveness lever. Pick by item shape: `small` for badges, `base` for standard content cards, `wide`/`wider` for horizontal items like callouts. Larger sizes wrap sooner; keep `columns` small enough that N items of the chosen width fit (min ≤ container/N). - `fit`: `boolean` (auto-fill mode only). Default keeps empty tracks (`auto-fill`); `fit` collapses them (`auto-fit`) so a partially-filled row stretches to fill the width. - `balanced`: `2 | 4`. A fixed, deterministic **breakpoint** reflow (overrides `columns` via `!important`; `size`/`fit` inert). `balanced={4}` → `4 → 2×2 → 1`, never an orphan 3-up row (which auto-fill produces); `balanced={2}` → `2 → 1` at `md`, a breakpoint-driven alternative to `columns={2}` (which folds by content width). Both fold `1 → 2` at `md`. Use with a **fixed set** of that many items (or a multiple). -- `asChild`: contribute Grid's classes to the child element (Radix `Slot`) instead of rendering a `div` -- for when the grid must BE a semantic element, e.g. a `
                            `. - Applies `gap-4`. `className` is spread last, so override the gap (or `--grid-item-min` / `--grid-repeat`) per call site only when genuinely needed. **If you override the gap, mirror it in `--grid-gap`** (e.g. `gap-x-8 [--grid-gap:--spacing(8)]`): the `grid-cols-auto-*` fold math assumes the gutter via `var(--grid-gap, 1rem)`, and with a wider real gap the even-share term is computed against the wrong gutter -- a second column may **never** form. ### `Section` diff --git a/src/components/ui/grid.tsx b/src/components/ui/grid.tsx index 0b15485b7f7..a8d61bafbd9 100644 --- a/src/components/ui/grid.tsx +++ b/src/components/ui/grid.tsx @@ -1,6 +1,5 @@ import * as React from "react" import { cva, VariantProps } from "class-variance-authority" -import { Slot } from "@radix-ui/react-slot" import { cn } from "@/lib/utils/cn" @@ -61,12 +60,10 @@ const gridVariants = cva("grid gap-4", { const Grid = React.forwardRef< HTMLDivElement, - React.HTMLAttributes & - VariantProps & { asChild?: boolean } ->(({ className, columns, size, fit, balanced, asChild, ...props }, ref) => { - const Comp = asChild ? Slot : "div" + React.HTMLAttributes & VariantProps +>(({ className, columns, size, fit, balanced, ...props }, ref) => { return ( -