Conversation
|
To view this pull requests documentation preview, visit the following URL: docs.page/invertase/docs.page~452 Documentation is deployed and generated using docs.page. |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
|
There was a problem hiding this comment.
Code Review
This pull request introduces significant updates to the design system, including the addition of new design token structures, UI best practices, and a comprehensive overhaul of the marketing homepage layout. The changes include new components for the homepage, updated styling for existing components, and improved accessibility and responsiveness. I have identified a bug in the rehype-inline-badges plugin where a comparison operator was used instead of an assignment, and a responsiveness issue in the TrustedBy component. Additionally, there are two new components, FeatureCell and FeaturesScrollStrip, that appear to be unused and should be removed if they are not intended for future use.
| function visitor(node: NodeWithChildren) { | ||
| function visitor(node: HastNode) { | ||
| if (!isElementWithVisited(node)) return; | ||
| node.visited === "true"; |
There was a problem hiding this comment.
This line uses a strict equality operator (===) instead of an assignment operator (=). This prevents the visited flag from being set, which likely breaks the logic in containsBadge (line 41) intended to prevent redundant processing of the same node.
| node.visited === "true"; | |
| node.visited = "true"; |
| className={cn( | ||
| "mt-8 grid w-full list-none sm:mt-10", | ||
| "px-20", | ||
| "grid-cols-1 gap-2 sm:grid-cols-2 sm:gap-3 lg:grid-cols-4", |
There was a problem hiding this comment.
The px-20 padding on this list applies to all screen sizes. On narrow mobile devices, this significantly restricts the available space for project cards, leading to aggressive truncation of project names. Consider using a smaller padding for mobile and increasing it at larger breakpoints.
| "grid-cols-1 gap-2 sm:grid-cols-2 sm:gap-3 lg:grid-cols-4", | |
| "px-4 sm:px-20", |
| "use client"; | ||
|
|
||
| import type { ComponentProps } from "react"; | ||
| import { useEffect, useState } from "react"; | ||
|
|
||
| import { cn } from "~/utils"; | ||
|
|
||
| function delay(ms: number) { | ||
| return new Promise<void>((resolve) => { | ||
| setTimeout(resolve, ms); | ||
| }); | ||
| } | ||
|
|
||
| export function FeatureCell({ | ||
| icon, | ||
| title, | ||
| description, | ||
| className, | ||
| tabIndex = 0, | ||
| ...rest | ||
| }: { | ||
| icon: React.ReactNode; | ||
| title: string; | ||
| description: string; | ||
| } & ComponentProps<"div">) { | ||
| const [active, setActive] = useState(false); | ||
| const [titleShown, setTitleShown] = useState(""); | ||
| const [descShown, setDescShown] = useState(""); | ||
| /** `null` until client reads hover / motion media (avoids clearing touch layout on first paint). */ | ||
| const [typewriterOff, setTypewriterOff] = useState<boolean | null>(null); | ||
|
|
||
| useEffect(() => { | ||
| const mqHover = window.matchMedia("(hover: none)"); | ||
| const mqMotion = window.matchMedia("(prefers-reduced-motion: reduce)"); | ||
| const sync = () => { | ||
| setTypewriterOff(mqHover.matches || mqMotion.matches); | ||
| }; | ||
| sync(); | ||
| mqHover.addEventListener("change", sync); | ||
| mqMotion.addEventListener("change", sync); | ||
| return () => { | ||
| mqHover.removeEventListener("change", sync); | ||
| mqMotion.removeEventListener("change", sync); | ||
| }; | ||
| }, []); | ||
|
|
||
| useEffect(() => { | ||
| if (typewriterOff === null) return; | ||
|
|
||
| if (typewriterOff) { | ||
| setTitleShown(title); | ||
| setDescShown(description); | ||
| return; | ||
| } | ||
|
|
||
| if (!active) { | ||
| setTitleShown(""); | ||
| setDescShown(""); | ||
| return; | ||
| } | ||
|
|
||
| let cancelled = false; | ||
|
|
||
| (async () => { | ||
| setTitleShown(""); | ||
| setDescShown(""); | ||
| for (let i = 1; i <= title.length; i++) { | ||
| if (cancelled) return; | ||
| await delay(18); | ||
| if (cancelled) return; | ||
| setTitleShown(title.slice(0, i)); | ||
| } | ||
| for (let i = 1; i <= description.length; i++) { | ||
| if (cancelled) return; | ||
| await delay(11); | ||
| if (cancelled) return; | ||
| setDescShown(description.slice(0, i)); | ||
| } | ||
| })(); | ||
|
|
||
| return () => { | ||
| cancelled = true; | ||
| }; | ||
| }, [active, typewriterOff, title, description]); | ||
|
|
||
| const typingTitle = titleShown.length < title.length; | ||
| const typingDesc = | ||
| titleShown.length >= title.length && descShown.length < description.length; | ||
| const showCaret = | ||
| active && typewriterOff === false && (typingTitle || typingDesc); | ||
|
|
||
| return ( | ||
| // biome-ignore lint/a11y/useSemanticElements: Marketing tile contains headings and overlay content; not representable as a single button or fieldset. | ||
| <div | ||
| {...rest} | ||
| role="group" | ||
| tabIndex={tabIndex} | ||
| aria-label={`${title}. ${description}`} | ||
| className={cn( | ||
| "feature-cell group relative z-10 flex aspect-square w-full min-w-0 flex-col items-center justify-center overflow-visible rounded-md border border-border", | ||
| "bg-background bg-gradient-to-b from-[#F1F2F3]/20 to-[#F8F8F8]/20 dark:from-[#2D2F39]/20 dark:to-[#16171D]/20", | ||
| "text-center", | ||
| "transition-[transform,box-shadow,background-color,background-image,border-color] duration-300 ease-out will-change-transform", | ||
| "hover:z-50 hover:scale-[1.18] hover:border-border hover:shadow-lg focus-within:z-50 focus-within:scale-[1.18] focus-within:border-border focus-within:shadow-lg", | ||
| "dark:hover:bg-[hsl(var(--color-design-black))] dark:focus-within:bg-[hsl(var(--color-design-black))]", | ||
| "motion-reduce:transform-none motion-reduce:hover:shadow-none", | ||
| "outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background", | ||
| className, | ||
| )} | ||
| onMouseEnter={() => setActive(true)} | ||
| onMouseLeave={() => setActive(false)} | ||
| onFocus={() => setActive(true)} | ||
| onBlur={() => setActive(false)} | ||
| > | ||
| <div className="feature-cell-content flex h-full w-full flex-col items-center justify-center p-4"> | ||
| <div className="feature-cell-icon-wrap origin-center shrink-0 opacity-100 transition-opacity duration-300 ease-out group-hover:opacity-0 group-focus-within:opacity-0"> | ||
| {icon} | ||
| </div> | ||
| </div> | ||
|
|
||
| <div | ||
| aria-hidden | ||
| className={cn( | ||
| "feature-cell-copy-overlay pointer-events-none absolute inset-0 z-[1] flex flex-col items-center justify-center rounded-md p-4", | ||
| "bg-background bg-gradient-to-b from-[#F1F2F3]/20 to-[#F8F8F8]/20 dark:from-[#2D2F39]/20 dark:to-[#16171D]/20", | ||
| "dark:group-hover:bg-[hsl(var(--color-design-black))] dark:group-focus-within:bg-[hsl(var(--color-design-black))]", | ||
| "opacity-0 transition-opacity duration-300 ease-out", | ||
| "group-hover:pointer-events-auto group-hover:opacity-100", | ||
| "group-focus-within:pointer-events-auto group-focus-within:opacity-100", | ||
| )} | ||
| > | ||
| <div className="feature-cell-copy-inner flex w-full flex-col items-center justify-center gap-1.5 overflow-visible px-0.5 py-1 text-center"> | ||
| <h3 className="relative w-full overflow-visible text-center font-heading text-sm font-medium leading-snug sm:text-base"> | ||
| <span className="invisible block w-full select-none" aria-hidden> | ||
| {title} | ||
| </span> | ||
| <span className="absolute left-0 top-0 block w-full text-foreground"> | ||
| <span className="inline text-center"> | ||
| {titleShown} | ||
| {showCaret && typingTitle ? ( | ||
| <span | ||
| aria-hidden | ||
| className="ml-px inline-block h-[1.05em] w-[2px] translate-y-[0.08em] animate-pulse bg-foreground align-baseline" | ||
| /> | ||
| ) : null} | ||
| </span> | ||
| </span> | ||
| </h3> | ||
| <p className="relative w-full overflow-visible text-center text-xs font-light leading-relaxed sm:text-[0.8125rem]"> | ||
| <span className="invisible block w-full select-none" aria-hidden> | ||
| {description} | ||
| </span> | ||
| <span className="absolute left-0 top-0 block w-full text-muted-foreground"> | ||
| <span className="inline text-center"> | ||
| {descShown} | ||
| {showCaret && typingDesc ? ( | ||
| <span | ||
| aria-hidden | ||
| className="ml-px inline-block h-[1.05em] w-[2px] translate-y-[0.08em] animate-pulse bg-muted-foreground align-baseline" | ||
| /> | ||
| ) : null} | ||
| </span> | ||
| </span> | ||
| </p> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } |
There was a problem hiding this comment.
| "use client"; | ||
|
|
||
| import { useCallback, useEffect, useRef, useState } from "react"; | ||
|
|
||
| import { useIsomorphicLayoutEffect } from "~/lib/use-isomorphic-layout-effect"; | ||
| import { cn } from "~/lib/utils"; | ||
|
|
||
| type ThumbState = { widthPct: number; leftPct: number }; | ||
|
|
||
| const MIN_THUMB_PCT = 10; | ||
|
|
||
| export function FeaturesScrollStrip({ | ||
| className, | ||
| children, | ||
| }: { | ||
| className?: string; | ||
| children: React.ReactNode; | ||
| }) { | ||
| const scrollRef = useRef<HTMLDivElement>(null); | ||
| const trackRef = useRef<HTMLDivElement>(null); | ||
| const [thumb, setThumb] = useState<ThumbState>({ | ||
| widthPct: 100, | ||
| leftPct: 0, | ||
| }); | ||
| const [overflow, setOverflow] = useState(false); | ||
|
|
||
| const updateThumb = useCallback(() => { | ||
| const el = scrollRef.current; | ||
| if (!el) return; | ||
| const { scrollLeft, scrollWidth, clientWidth } = el; | ||
| const maxScroll = scrollWidth - clientWidth; | ||
| const hasOverflow = maxScroll > 2; | ||
| setOverflow(hasOverflow); | ||
| if (!hasOverflow) { | ||
| setThumb({ widthPct: 100, leftPct: 0 }); | ||
| return; | ||
| } | ||
| const rawWidthPct = (clientWidth / scrollWidth) * 100; | ||
| const widthPct = Math.max(rawWidthPct, MIN_THUMB_PCT); | ||
| const maxLeft = 100 - widthPct; | ||
| const leftPct = maxScroll > 0 ? (scrollLeft / maxScroll) * maxLeft : 0; | ||
| setThumb({ widthPct, leftPct }); | ||
| }, []); | ||
|
|
||
| useIsomorphicLayoutEffect(() => { | ||
| updateThumb(); | ||
| }, [updateThumb]); | ||
|
|
||
| useEffect(() => { | ||
| const el = scrollRef.current; | ||
| if (!el) return; | ||
| updateThumb(); | ||
| el.addEventListener("scroll", updateThumb, { passive: true }); | ||
| const ro = new ResizeObserver(updateThumb); | ||
| ro.observe(el); | ||
| return () => { | ||
| el.removeEventListener("scroll", updateThumb); | ||
| ro.disconnect(); | ||
| }; | ||
| }, [updateThumb]); | ||
|
|
||
| const onTrackPointerDown = (e: React.PointerEvent<HTMLDivElement>) => { | ||
| if (e.button !== 0) return; | ||
| const scroll = scrollRef.current; | ||
| const track = trackRef.current; | ||
| if (!scroll || !track) return; | ||
| if ((e.target as HTMLElement).dataset.thumb === "true") return; | ||
| const rect = track.getBoundingClientRect(); | ||
| const x = e.clientX - rect.left; | ||
| const maxScroll = scroll.scrollWidth - scroll.clientWidth; | ||
| const ratio = rect.width > 0 ? Math.max(0, Math.min(1, x / rect.width)) : 0; | ||
| scroll.scrollLeft = ratio * maxScroll; | ||
| }; | ||
|
|
||
| const onThumbPointerDown = (e: React.PointerEvent<HTMLDivElement>) => { | ||
| e.stopPropagation(); | ||
| e.preventDefault(); | ||
| const scroll = scrollRef.current; | ||
| const track = trackRef.current; | ||
| if (!scroll || !track) return; | ||
| const startX = e.clientX; | ||
| const startScroll = scroll.scrollLeft; | ||
| const trackW = track.getBoundingClientRect().width; | ||
| const maxScroll = scroll.scrollWidth - scroll.clientWidth; | ||
|
|
||
| const onMove = (ev: PointerEvent) => { | ||
| const dx = ev.clientX - startX; | ||
| scroll.scrollLeft = | ||
| trackW > 0 ? startScroll + (dx / trackW) * maxScroll : startScroll; | ||
| }; | ||
| const onUp = () => { | ||
| window.removeEventListener("pointermove", onMove); | ||
| window.removeEventListener("pointerup", onUp); | ||
| }; | ||
| window.addEventListener("pointermove", onMove); | ||
| window.addEventListener("pointerup", onUp); | ||
| }; | ||
|
|
||
| return ( | ||
| <div className={cn("relative w-full", className)}> | ||
| <div | ||
| ref={scrollRef} | ||
| className={cn( | ||
| /* Horizontal inset so end cards aren’t flush with the strip edges; outer wrapper size unchanged */ | ||
| "marketing-features-scroll-native-hidden w-full py-0 px-4 sm:px-6", | ||
| /* Space below cards; extra when scrollbar track is shown */ | ||
| overflow ? "pb-5" : "pb-4", | ||
| )} | ||
| > | ||
| {children} | ||
| </div> | ||
| {overflow ? ( | ||
| <div | ||
| className="pointer-events-none absolute bottom-0 left-0 right-0 z-10" | ||
| aria-hidden | ||
| > | ||
| <div | ||
| ref={trackRef} | ||
| role="presentation" | ||
| className="pointer-events-auto relative h-1 w-full cursor-pointer rounded-none bg-muted" | ||
| onPointerDown={onTrackPointerDown} | ||
| > | ||
| <div | ||
| data-thumb="true" | ||
| className="absolute top-0 h-full cursor-grab rounded-none bg-primary active:cursor-grabbing" | ||
| style={{ | ||
| width: `${thumb.widthPct}%`, | ||
| left: `${thumb.leftPct}%`, | ||
| }} | ||
| onPointerDown={onThumbPointerDown} | ||
| /> | ||
| </div> | ||
| </div> | ||
| ) : null} | ||
| </div> | ||
| ); | ||
| } |
Layout update