fix: accessibility pass and miscellaneous UI polish across components

- Add `aria-hidden="true"`, `aria-label`, `aria-labelledby`, `aria-expanded`, `aria-controls`, and `role="region"` attributes throughout builder, detail, docs, and search components
- Fix `describeError` for `incompatible-peer` to show the module's human-readable label instead of the raw peer ID string
- Replace `IconLoader2` spinner ad-hoc pattern with a shared `Spinner` component; delete unused `popover.tsx`
- Add `IconExternalLink` indicator to deps table rows and make version column `tabular-nums`
- Fix scripts table to use `break-words whitespace-pre-wrap` on the command column so long commands wrap instead of overflowing
- Add `truncate overflow-hidden` to the file preview path header bar
- Capitalize "Environment Variables", "Required", "Optional" badge labels; add trailing period to empty-state copy
- Refactor `use-pointer-capability` and `use-time-ago` hooks for clarity; update chart legend, tooltip, and activity chart components
This commit is contained in:
2026-05-28 12:27:41 -04:00
parent 1e4e0269ba
commit dda6cb2e44
38 changed files with 413 additions and 320 deletions
@@ -1,6 +1,5 @@
import { themeToTreeStyles } from "@pierre/trees";
import { FileTree, useFileTree } from "@pierre/trees/react";
import { IconLoader2 } from "@tabler/icons-react";
import { useLocation, useNavigate } from "@tanstack/react-router";
import type { ReactNode } from "react";
import { useCallback, useEffect, useMemo, useRef } from "react";
@@ -9,6 +8,7 @@ import { FILE_TREE_ICONS } from "@/components/builder/file-tree-icons";
import { useTheme } from "@/components/theme-provider";
import { Card } from "@/components/ui/card";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import { Spinner } from "@/components/ui/spinner";
import { useGracePeriod } from "@/hooks/use-grace-period";
import { useMediaQuery } from "@/hooks/use-media-query";
import type { Preview } from "@/server/highlighter";
@@ -243,9 +243,9 @@ export function FilePreview({
<div
role="status"
aria-live="polite"
className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center bg-card/50 backdrop-blur-[1px] transition-opacity duration-150"
className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center bg-card/50 backdrop-blur-[1px]"
>
<IconLoader2 className="size-5 animate-spin text-muted-foreground" aria-hidden />
<Spinner className="size-5 text-muted-foreground" aria-hidden="true" />
<span className="sr-only">Loading</span>
</div>
) : null}
@@ -278,14 +278,14 @@ function PreviewPane({
if (!preview || !path) {
return (
<div className="flex h-full items-center justify-center px-6 py-10 text-sm text-muted-foreground">
Pick a file to preview
Pick a file to preview.
</div>
);
}
return (
<div className="flex h-full flex-col">
<div className="border-b border-border bg-muted/20 px-4 py-2 font-mono text-[11px] text-muted-foreground">
<div className="truncate overflow-hidden border-b border-border bg-muted/20 px-4 py-2 font-mono text-[11px] text-muted-foreground">
{path}
</div>
<div
@@ -110,7 +110,7 @@ function ModuleSection({
const result = resolveAdapter(m, { manifest: RESOLVER_MANIFEST, pending });
const disabled = !result.ok;
const selected = Boolean(selectedIds?.includes(m.id));
const reason = !result.ok ? describeError(result.error) : undefined;
const reason = !result.ok ? describeError(result.error, pending) : undefined;
return (
<ModuleCard
key={m.id}
@@ -197,13 +197,13 @@ const ModuleCard = memo(function ModuleCard({
/>
}
>
<IconInfoCircle className="size-3.5" aria-hidden />
<IconInfoCircle className="size-3.5" aria-hidden="true" />
</TooltipTrigger>
<TooltipContent sideOffset={8}>View details</TooltipContent>
</Tooltip>
</div>
{selected && (
<IconCheck className="mr-0.5 size-4 shrink-0 text-foreground" aria-hidden />
<IconCheck className="mr-0.5 size-4 shrink-0 text-foreground" aria-hidden="true" />
)}
</div>
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">{m.description}</p>
@@ -227,12 +227,17 @@ const ModuleCard = memo(function ModuleCard({
);
});
function describeError(error: ResolveError): string {
function describeError(
error: ResolveError,
pending: Partial<Record<CategoryId, ModuleMetadata>>,
): string {
switch (error.kind) {
case "missing-peer":
return `Pick a ${categoryLabel(error.category)} module first.`;
case "incompatible-peer":
return `Not compatible with ${error.peer} (${categoryLabel(error.category)}).`;
case "incompatible-peer": {
const peerLabel = pending[error.category]?.label ?? error.peer;
return `Not compatible with ${peerLabel} (${categoryLabel(error.category)}).`;
}
case "no-adapter":
return "Not compatible with your current stack.";
default:
@@ -47,15 +47,20 @@ export function AdapterSwitcher({
<div className="space-y-3">
{switchable.map(([slot, options]) => {
const active = resolvedPeers[slot];
const labelId = `adapter-switcher-${slot}`;
return (
<div key={slot} className="flex flex-wrap items-center gap-2">
<span className="w-20 shrink-0 text-[13px] font-medium tracking-tight text-muted-foreground">
<span
id={labelId}
className="w-20 shrink-0 text-[13px] font-medium tracking-tight text-muted-foreground"
>
{categoryLabel(slot)}
</span>
<ToggleGroup
variant="outline"
size="sm"
spacing={0}
aria-labelledby={labelId}
value={active ? [active] : []}
onValueChange={(value: string[]) => {
const next = value[0];
+20 -9
View File
@@ -1,4 +1,5 @@
import type { EnvVar } from "@stanza/registry";
import { IconExternalLink } from "@tabler/icons-react";
import { Section, SectionList } from "@/components/detail/section";
import { Badge } from "@/components/ui/badge";
@@ -16,12 +17,17 @@ export function DepsTable({ title, entries }: { title: string; entries: Record<s
href={`https://npmx.dev/package/${name}/v/${version}`}
target="_blank"
rel="noopener noreferrer"
aria-label={`${name} ${version} (opens in new tab)`}
className="group flex items-center justify-between gap-3 px-3 py-2 font-mono text-xs transition-colors hover:bg-muted/50"
>
<span className="truncate text-foreground group-hover:underline group-hover:underline-offset-1">
{name}
<span className="inline-flex min-w-0 items-center gap-1 text-foreground group-hover:underline group-hover:underline-offset-1">
<span className="truncate">{name}</span>
<IconExternalLink
className="size-3 shrink-0 text-muted-foreground/60"
aria-hidden="true"
/>
</span>
<span className="shrink-0 text-muted-foreground">{version}</span>
<span className="shrink-0 text-muted-foreground tabular-nums">{version}</span>
</a>
</li>
))}
@@ -41,10 +47,12 @@ export function ScriptsTable({ entries }: { entries: Record<string, string> }) {
<li
key={name}
translate="no"
className="flex items-center justify-between gap-3 px-3 py-2 font-mono text-xs"
className="flex items-start justify-between gap-3 px-3 py-2 font-mono text-xs"
>
<span className="truncate text-foreground">{name}</span>
<span className="shrink-0 text-muted-foreground">{command}</span>
<span className="shrink-0 text-foreground">{name}</span>
<span className="min-w-0 text-right break-words whitespace-pre-wrap text-muted-foreground">
{command}
</span>
</li>
))}
</SectionList>
@@ -56,16 +64,19 @@ export function EnvTable({ env }: { env: EnvVar[] }) {
if (env.length === 0) return null;
return (
<Section title="Environment variables" count={env.length}>
<Section title="Environment Variables" count={env.length}>
<SectionList>
{env.map((e) => (
<li key={e.name} className="grid gap-1 px-3 py-2.5 text-xs">
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono font-medium text-foreground">{e.name}</span>
<Badge variant={e.required ? "default" : "outline"}>
{e.required ? "required" : "optional"}
{e.required ? "Required" : "Optional"}
</Badge>
<span className="font-mono text-muted-foreground/70">= {e.example}</span>
<span className="font-mono text-muted-foreground/70">
{"= "}
{e.example}
</span>
</div>
{e.description && <p className="text-muted-foreground/80">{e.description}</p>}
</li>
@@ -37,26 +37,28 @@ function TemplateRow({
}) {
const [open, setOpen] = useState(false);
const hasPreview = Boolean(preview);
const previewId = `template-preview-${template.dest.replaceAll(/[^a-zA-Z0-9_-]/g, "-")}`;
return (
<li>
<button
type="button"
onClick={() => hasPreview && setOpen((o) => !o)}
disabled={!hasPreview}
className="flex w-full items-center justify-between gap-3 px-3 py-2 text-left font-mono text-xs transition-colors hover:bg-muted/40 disabled:cursor-default disabled:hover:bg-transparent"
aria-expanded={open}
className="flex w-full items-center justify-between gap-3 px-3 py-2 text-left font-mono text-xs transition-colors hover:bg-muted/40 disabled:cursor-default disabled:text-foreground/70 disabled:hover:bg-transparent"
aria-expanded={hasPreview ? open : undefined}
aria-controls={hasPreview ? previewId : undefined}
>
<span className="truncate text-foreground">{template.dest}</span>
<span className="shrink-0 text-[10px] tracking-wider text-muted-foreground/60 uppercase">
{scopeLabel(template.scope)}
</span>
</button>
{open && preview && <PreviewBlock preview={preview} />}
{open && preview && <PreviewBlock id={previewId} dest={template.dest} preview={preview} />}
</li>
);
}
function PreviewBlock({ preview }: { preview: Preview }) {
function PreviewBlock({ id, dest, preview }: { id: string; dest: string; preview: Preview }) {
const { resolvedTheme } = useTheme();
const inner = useMemo(
() => ({ __html: resolvedTheme === "dark" ? preview.dark : preview.light }),
@@ -64,6 +66,9 @@ function PreviewBlock({ preview }: { preview: Preview }) {
);
return (
<div
id={id}
role="region"
aria-label={`Preview of ${dest}`}
className="overflow-auto border-t border-border pt-4 text-xs leading-relaxed [&_pre]:bg-transparent! [&_pre]:p-0!"
dangerouslySetInnerHTML={inner}
/>
+37 -22
View File
@@ -3,6 +3,7 @@
import { IconCheck, IconChevronDown, IconCopy } from "@tabler/icons-react";
import * as React from "react";
import type { ReactElement, SVGProps } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { ButtonGroup } from "@/components/ui/button-group";
@@ -75,7 +76,14 @@ export function DocsActions({ markdownPath, pageUrl, className }: DocsActionsPro
<DropdownMenuItem
key={item.label}
className="cursor-pointer [&_svg]:text-muted-foreground"
render={<a href={href} target="_blank" rel="noopener noreferrer" />}
render={
<a
href={href}
target="_blank"
rel="noopener noreferrer"
aria-label={`${item.label} (opens in new tab)`}
/>
}
>
<Icon aria-hidden="true" data-icon="inline-start" />
<span className="text-[13px] leading-none">{item.label}</span>
@@ -108,31 +116,38 @@ function CopyMarkdownButton({ markdownPath }: { markdownPath: string }) {
setCopied(true);
timeoutRef.current = window.setTimeout(() => setCopied(false), 2000);
} catch {
// surface as a no-op; chevron menu still offers `View as Markdown`
toast.error("Couldnt copy page markdown.", {
description: "Try View as Markdown from the menu instead.",
});
}
}, [copied, markdownPath]);
return (
<Button
type="button"
variant="outline"
size="sm"
className={cn(
"shrink-0 gap-1.5 px-2.5! text-[13px] leading-none",
copied && "cursor-default",
)}
aria-label={copied ? "Copied" : "Copy Page"}
onClick={() => {
void handleClick();
}}
>
{copied ? (
<IconCheck aria-hidden="true" data-icon="inline-start" />
) : (
<IconCopy aria-hidden="true" data-icon="inline-start" />
)}
<span>{copied ? "Copied" : "Copy Page"}</span>
</Button>
<>
<Button
type="button"
variant="outline"
size="sm"
className={cn(
"shrink-0 gap-1.5 px-2.5! text-[13px] leading-none",
copied && "cursor-default",
)}
aria-label={copied ? "Copied" : "Copy Page"}
onClick={() => {
void handleClick();
}}
>
{copied ? (
<IconCheck aria-hidden="true" data-icon="inline-start" />
) : (
<IconCopy aria-hidden="true" data-icon="inline-start" />
)}
<span>{copied ? "Copied" : "Copy Page"}</span>
</Button>
<span aria-live="polite" className="sr-only">
{copied ? "Page markdown copied to clipboard" : ""}
</span>
</>
);
}
+11 -5
View File
@@ -22,8 +22,9 @@ function NavLink({ url, name, pathname }: { url: string; name: ReactNode; pathna
<Link
to="/docs/$"
params={{ _splat: toSplat(url) }}
aria-current={active ? "page" : undefined}
className={cn(
"block rounded px-2 py-1 text-muted-foreground transition-colors hover:text-foreground",
"block rounded px-2 py-1 text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring",
active && "bg-accent font-medium text-accent-foreground",
)}
>
@@ -83,17 +84,22 @@ export function DocsSidebar({ tree }: { tree: Root }) {
<details className="group my-4 md:hidden">
<summary className="flex cursor-pointer list-none items-center justify-between rounded-md border border-border px-3 py-1.5 text-sm font-medium transition-colors hover:bg-accent [&::-webkit-details-marker]:hidden">
Documentation
<IconChevronRight className="size-4 text-muted-foreground transition-transform group-open:rotate-90" />
<IconChevronRight
aria-hidden="true"
className="size-4 text-muted-foreground transition-transform duration-150 group-open:rotate-90 motion-reduce:transition-none"
/>
</summary>
<div className="mt-2 border-t border-border pt-2">{list}</div>
</details>
{/* Desktop: sticky left rail aligned to the page frame. */}
<aside className="hidden w-56 shrink-0 md:block">
{/* Desktop: sticky left rail aligned to the page frame. The mobile
* disclosure above intentionally has no landmark — the desktop nav
* is the canonical landmark, hidden via CSS on small screens. */}
<nav aria-label="Documentation" className="hidden w-56 shrink-0 md:block">
<div className="sticky top-14 max-h-[calc(100svh-3.5rem)] overflow-y-auto py-8 pr-4">
{list}
</div>
</aside>
</nav>
</>
);
}
+4 -3
View File
@@ -17,9 +17,10 @@ function TocList({ toc }: { toc: TOCItemType[] }) {
<li key={item.url}>
<a
href={item.url}
aria-current={isActive ? "location" : undefined}
style={{ paddingLeft: `${0.5 + Math.max(0, item.depth - 2) * 0.75}rem` }}
className={cn(
"block py-1 text-muted-foreground transition-colors hover:text-foreground",
"block py-1 text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring",
isActive && "font-medium text-foreground",
)}
>
@@ -36,7 +37,7 @@ export function DocsToc({ toc }: { toc: TOCItemType[] }) {
if (toc.length === 0) return null;
return (
<aside className="hidden w-56 shrink-0 xl:block">
<nav aria-label="On this page" className="hidden w-56 shrink-0 xl:block">
<div className="sticky top-14 max-h-[calc(100svh-3.5rem)] overflow-y-auto py-8">
<p className="mb-2 px-2 text-xs font-medium tracking-wide text-muted-foreground/70">
On this page
@@ -45,6 +46,6 @@ export function DocsToc({ toc }: { toc: TOCItemType[] }) {
<TocList toc={toc} />
</AnchorProvider>
</div>
</aside>
</nav>
);
}
@@ -3,6 +3,7 @@
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import { Spinner } from "@/components/ui/spinner";
import { cn } from "@/lib/utils";
// Format: { THEME_NAME: CSS_SELECTOR }
@@ -138,10 +139,14 @@ function LoadingIndicator({ isLoading }: { isLoading: boolean }) {
}
return (
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center">
<div
role="status"
aria-live="polite"
className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center"
>
<div className="flex items-center justify-center gap-2 rounded-md border bg-background px-2 py-0.5 text-sm text-primary">
<div className="h-3 w-3 animate-spin rounded-full border border-border border-t-primary" />
<span>Loading</span>
<Spinner className="size-3" aria-hidden="true" />
<span>Loading</span>
</div>
</div>
);
@@ -243,8 +248,12 @@ export function getPayloadConfigFromPayload(config: ChartConfig, payload: unknow
}
// Format values to percent for expanded charts
const percentFormatter = new Intl.NumberFormat(undefined, {
style: "percent",
maximumFractionDigits: 0,
});
function axisValueToPercentFormatter(value: number) {
return `${Math.round(value * 100).toFixed(0)}%`;
return percentFormatter.format(value);
}
// Pick the first string/number candidate as a series key, falling back to "value".
@@ -74,25 +74,36 @@ function ChartLegendContent({
// Get colors count for this item to determine gradient vs solid
const colorsCount = itemConfig ? getColorsCount(itemConfig) : 1;
const itemClassName = cn(
"flex items-center gap-1.5 transition-opacity motion-reduce:transition-none [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground",
!isSelected && "opacity-30",
);
const indicator =
itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<LegendIndicator variant={variant} dataKey={key} colorsCount={colorsCount} />
);
if (isClickable) {
return (
<button
key={key}
type="button"
aria-pressed={selected === key}
onClick={() => onSelectChange?.(selected === key ? null : key)}
className={cn(
itemClassName,
"cursor-pointer rounded-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring",
)}
>
{indicator}
{itemConfig?.label}
</button>
);
}
return (
<div
key={key}
className={cn(
"flex items-center gap-1.5 transition-opacity [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground",
!isSelected && "opacity-30",
isClickable && "cursor-pointer",
)}
onClick={() => {
if (!isClickable) return;
onSelectChange?.(selected === key ? null : key);
}}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<LegendIndicator variant={variant} dataKey={key} colorsCount={colorsCount} />
)}
<div key={key} className={itemClassName}>
{indicator}
{itemConfig?.label}
</div>
);
@@ -120,26 +131,44 @@ function LegendIndicator({
switch (variant) {
case "square":
return <div className="h-2 w-2 shrink-0" style={fillStyle} />;
return <div aria-hidden="true" className="h-2 w-2 shrink-0" style={fillStyle} />;
case "circle":
return <div className="h-2 w-2 shrink-0 rounded-full" style={fillStyle} />;
return <div aria-hidden="true" className="h-2 w-2 shrink-0 rounded-full" style={fillStyle} />;
case "circle-outline":
return <div className="h-2.5 w-2.5 shrink-0 rounded-full p-[1.5px]" style={outlineStyle} />;
return (
<div
aria-hidden="true"
className="h-2.5 w-2.5 shrink-0 rounded-full p-[1.5px]"
style={outlineStyle}
/>
);
case "vertical-bar":
return <div className="h-3 w-1 shrink-0 rounded-[2px]" style={fillStyle} />;
return (
<div aria-hidden="true" className="h-3 w-1 shrink-0 rounded-[2px]" style={fillStyle} />
);
case "horizontal-bar":
return <div className="h-1 w-3 shrink-0 rounded-[2px]" style={fillStyle} />;
return (
<div aria-hidden="true" className="h-1 w-3 shrink-0 rounded-[2px]" style={fillStyle} />
);
case "rounded-square-outline":
return <div className="h-2.5 w-2.5 shrink-0 rounded-[3px] p-[1.5px]" style={outlineStyle} />;
return (
<div
aria-hidden="true"
className="h-2.5 w-2.5 shrink-0 rounded-[3px] p-[1.5px]"
style={outlineStyle}
/>
);
case "rounded-square":
default:
return <div className="h-2 w-2 shrink-0 rounded-[2px]" style={fillStyle} />;
return (
<div aria-hidden="true" className="h-2 w-2 shrink-0 rounded-[2px]" style={fillStyle} />
);
}
}
@@ -79,7 +79,7 @@ function ChartTooltipContent({
if (!active || !payload?.length) {
// Empty tooltip - to prevent position getting 0.0 so it doesnt animate tooltip every time from 0.0 origin
return <span className="p-4" />;
return <span aria-hidden="true" className="p-4" />;
}
const nestLabel = payload.length === 1 && indicator !== "dot";
@@ -132,6 +132,7 @@ function ChartTooltipContent({
) : (
!hideIndicator && (
<div
aria-hidden="true"
className={cn("shrink-0 rounded-[2px]", {
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
+2 -2
View File
@@ -17,7 +17,7 @@ export function Footer() {
</a>
.
</p>
<nav className="flex items-center gap-5">
<nav aria-label="Footer" className="flex items-center gap-5">
<span>v{__APP_VERSION__ ?? "0.0.0"}</span>
<Link to="/docs/$" params={{ _splat: "" }} className="hover:text-foreground">
Docs
@@ -32,7 +32,7 @@ export function Footer() {
rel="noopener noreferrer"
>
GitHub
<IconExternalLink className="size-3" aria-hidden />
<IconExternalLink className="size-3" aria-hidden="true" />
</a>
</nav>
</div>
+1 -3
View File
@@ -16,7 +16,6 @@ export function Header() {
<div className="flex items-center gap-5">
<Link to="/" aria-label="Stanza" className="mr-1">
<Logo className="size-6" />
<span className="sr-only">Stanza</span>
</Link>
<Link
to="/docs/$"
@@ -47,8 +46,7 @@ export function Header() {
variant="outline"
size="icon"
>
<IconBrandGithub className="text-muted-foreground" aria-hidden />
<span className="sr-only">GitHub</span>
<IconBrandGithub className="text-muted-foreground" aria-hidden="true" />
</Button>
<ThemeToggle />
</div>
+1 -1
View File
@@ -10,7 +10,7 @@ export function Logo({ className, ...props }: SVGProps<SVGSVGElement>) {
width="1em"
height="1em"
viewBox="0 0 24 24"
aria-hidden
aria-hidden="true"
className={cn("size-5", className)}
{...props}
>
+2 -2
View File
@@ -5,7 +5,7 @@ import { cn } from "@/lib/utils";
function InlineSvg({ html, className }: { html: string; className: string }) {
const inner = useMemo(() => ({ __html: html }), [html]);
return <div aria-hidden className={className} dangerouslySetInnerHTML={inner} />;
return <div aria-hidden="true" className={className} dangerouslySetInnerHTML={inner} />;
}
/**
@@ -33,7 +33,7 @@ export function ModuleLogo({
if (!logo) {
return (
<div
aria-hidden
aria-hidden="true"
className={cn(
"flex shrink-0 items-center justify-center rounded-none border border-border bg-muted/40 font-semibold text-muted-foreground",
sizeClass,
@@ -13,7 +13,7 @@ import { isPackageManager, PACKAGE_MANAGERS, type PackageManager } from "@/lib/p
function NpmLogo(props: ComponentProps<"svg">) {
return (
<svg viewBox="0 0 32 32" aria-hidden {...props}>
<svg viewBox="0 0 32 32" aria-hidden="true" {...props}>
<path fill="#e53935" d="M4 4v24h24V4Zm20 20h-4V12h-4v12H8V8h16Z" />
</svg>
);
@@ -21,7 +21,7 @@ function NpmLogo(props: ComponentProps<"svg">) {
function PnpmLogo(props: ComponentProps<"svg">) {
return (
<svg viewBox="0 0 32 32" aria-hidden {...props}>
<svg viewBox="0 0 32 32" aria-hidden="true" {...props}>
<path
fill="#757575"
d="M2 22h8v8H2zm10 0h8v8h-8zm10 0h8v8h-8zM12 12h8v8h-8z"
@@ -34,7 +34,7 @@ function PnpmLogo(props: ComponentProps<"svg">) {
function BunLogo(props: ComponentProps<"svg">) {
return (
<svg viewBox="0 0 32 32" aria-hidden {...props}>
<svg viewBox="0 0 32 32" aria-hidden="true" {...props}>
<path
fill="#fff8e1"
d="M15.696 27.002a13.73 13.73 0 0 1-9.071-3.062a8.86 8.86 0 0 1-3.6-6.505c-.252-5.091 3.813-7.747 8.748-10.455c.28-.165.537-.322.793-.48a7.8 7.8 0 0 1 3.52-1.5a2 2 0 0 1 .695.118a14.8 14.8 0 0 1 2.95 1.576c.972.6 2.182 1.348 3.707 2.173a10.14 10.14 0 0 1 5.274 6.147A8.8 8.8 0 0 1 29 17.035a8.15 8.15 0 0 1-2.525 5.959a15.6 15.6 0 0 1-10.778 4.008Z"
@@ -72,13 +72,14 @@ export function PackageManagerSelect({
value: PackageManager;
onValueChange: (pm: PackageManager) => void;
}) {
const activeLabel = PACKAGE_MANAGERS.find((pm) => pm.id === value)?.label ?? value;
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger
render={
<Button variant="outline" aria-label="Package manager">
<Button variant="outline" aria-label={`Package manager: ${activeLabel}`}>
{PM_LOGOS[value]}
<IconChevronDown className="size-3 opacity-60" aria-hidden />
<IconChevronDown className="size-3 opacity-60" aria-hidden="true" />
</Button>
}
/>
+3 -3
View File
@@ -50,11 +50,11 @@ export function RouteErrorBoundary({ error }: ErrorComponentProps) {
variant="default"
size="sm"
>
<IconRefresh data-icon="inline-start" />
<IconRefresh aria-hidden="true" data-icon="inline-start" />
Try again
</Button>
<Button render={<Link to="/" />} nativeButton={false} variant="outline" size="sm">
<IconHome data-icon="inline-start" />
<IconHome aria-hidden="true" data-icon="inline-start" />
Return home
</Button>
</CenteredMessage>
@@ -74,7 +74,7 @@ export function RouteNotFoundBoundary() {
description="We couldnt find that page. It may have moved, or the URL might be wrong."
>
<Button render={<Link to="/" />} nativeButton={false} variant="outline" size="sm">
<IconHome data-icon="inline-start" />
<IconHome aria-hidden="true" data-icon="inline-start" />
Return home
</Button>
</CenteredMessage>
+53 -23
View File
@@ -217,7 +217,7 @@ export function SiteSearch({ registry, docs }: { registry: RegistryIndex; docs:
aria-label="Open search dialog"
className="gap-2 bg-background px-2 text-muted-foreground hover:bg-muted hover:text-foreground sm:min-w-[180px] sm:px-2.5"
>
<IconSearch data-icon="inline-start" aria-hidden />
<IconSearch data-icon="inline-start" aria-hidden="true" />
<span className="hidden flex-1 text-left text-[13px] font-normal sm:inline">Search</span>
<Kbd className="ml-2 hidden sm:inline">{hotkeyLabel}</Kbd>
</Button>
@@ -230,7 +230,7 @@ export function SiteSearch({ registry, docs }: { registry: RegistryIndex; docs:
</DialogDescription>
</DialogHeader>
<div className="flex items-center gap-2 border-b px-3">
<IconSearch className="size-4 shrink-0 opacity-50" aria-hidden />
<IconSearch className="size-4 shrink-0 opacity-50" aria-hidden="true" />
<input
autoFocus={!isTouchDevice}
type="search"
@@ -241,34 +241,62 @@ export function SiteSearch({ registry, docs }: { registry: RegistryIndex; docs:
aria-label="Search docs and modules"
autoComplete="off"
spellCheck={false}
inputMode="search"
enterKeyHint="search"
role="combobox"
aria-expanded
aria-controls="site-search-listbox"
aria-autocomplete="list"
aria-activedescendant={
flat.length > 0 ? `site-search-option-${activeIndex}` : undefined
}
className="h-10 w-full bg-transparent text-xs outline-none placeholder:text-muted-foreground"
/>
</div>
<div
ref={listRef}
id="site-search-listbox"
role="listbox"
aria-label="Search results"
className="max-h-72 overflow-x-hidden overflow-y-auto overscroll-contain p-1"
>
{flat.length === 0 ? (
<div className="py-6 text-center text-xs text-muted-foreground">No results.</div>
<div
role="status"
aria-live="polite"
className="py-6 text-center text-xs text-muted-foreground"
>
No results.
</div>
) : (
groups.map((group) => (
<div key={group.key} className="overflow-hidden">
<div className="px-2 py-1.5 text-xs text-muted-foreground">{group.label}</div>
{group.hits.map((hit) => {
const i = flatIndex++;
return (
<SearchRow
key={hitKey(hit, i)}
hit={hit}
index={i}
active={i === activeIndex}
onSelect={select}
onActivate={setActiveIndex}
/>
);
})}
</div>
))
groups.map((group) => {
const groupHeadingId = `site-search-group-${group.key}`;
return (
<div
key={group.key}
role="group"
aria-labelledby={groupHeadingId}
className="overflow-hidden"
>
<div id={groupHeadingId} className="px-2 py-1.5 text-xs text-muted-foreground">
{group.label}
</div>
{group.hits.map((hit) => {
const i = flatIndex++;
return (
<SearchRow
key={hitKey(hit, i)}
hit={hit}
index={i}
active={i === activeIndex}
onSelect={select}
onActivate={setActiveIndex}
/>
);
})}
</div>
);
})
)}
</div>
</DialogContent>
@@ -343,6 +371,9 @@ function SearchRow({
<button
type="button"
tabIndex={-1}
id={`site-search-option-${index}`}
role="option"
aria-selected={active}
data-index={index}
data-selected={active || undefined}
onClick={handleSelect}
@@ -369,7 +400,7 @@ function RowContent({ hit }: { hit: Hit }) {
return (
<>
<span
aria-hidden
aria-hidden="true"
className="flex size-5 shrink-0 items-center justify-center text-muted-foreground"
>
<IconBookmark className="size-3.5" />
@@ -379,7 +410,6 @@ function RowContent({ hit }: { hit: Hit }) {
{hit.item.excerptHtml ? (
<div
className="truncate text-[11px] text-muted-foreground [&_mark]:bg-yellow-200/60 [&_mark]:px-0.5 [&_mark]:text-foreground dark:[&_mark]:bg-yellow-500/30"
// oxlint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: hit.item.excerptHtml }}
/>
) : hit.item.description ? (
@@ -14,7 +14,7 @@ type ActivityChartProps = {
isLoading: boolean;
};
const dayLabelFormatter = new Intl.DateTimeFormat("en-US", {
const dayLabelFormatter = new Intl.DateTimeFormat(undefined, {
month: "short",
day: "numeric",
timeZone: "UTC",
@@ -49,19 +49,21 @@ export default function ActivityChart({ activity30d, isLoading }: ActivityChartP
}));
return (
<EvilAreaChart
data={data}
config={activityConfig}
className="aspect-auto h-24 w-full"
curveType="monotone"
animationType="left-to-right"
isLoading={isLoading}
>
<XAxis dataKey="day" hide />
<Tooltip cursor={false} />
<Area dataKey="count" variant="gradient" strokeVariant="solid">
<ActiveDot variant="colored-border" />
</Area>
</EvilAreaChart>
<div role="img" aria-label="CLI runs per day over the last 30 days">
<EvilAreaChart
data={data}
config={activityConfig}
className="aspect-auto h-24 w-full"
curveType="monotone"
animationType="left-to-right"
isLoading={isLoading}
>
<XAxis dataKey="day" hide />
<Tooltip cursor={false} />
<Area dataKey="count" variant="gradient" strokeVariant="solid">
<ActiveDot variant="colored-border" />
</Area>
</EvilAreaChart>
</div>
);
}
+11 -7
View File
@@ -45,7 +45,11 @@ export function BarList({ data, emptyMessage = "No data yet.", className }: BarL
if (data.length === 0) {
return (
<p className={cn("py-2 text-xs/relaxed text-muted-foreground/70", className)}>
<p
role="status"
aria-live="polite"
className={cn("py-2 text-xs/relaxed text-muted-foreground/70", className)}
>
{emptyMessage}
</p>
);
@@ -58,19 +62,19 @@ export function BarList({ data, emptyMessage = "No data yet.", className }: BarL
return (
<li key={entry.name} className="group/row relative h-7 overflow-hidden">
<div
aria-hidden
aria-hidden="true"
className={cn(
"absolute inset-y-0 left-0 bg-gradient-to-r from-muted via-muted/70 to-muted/30 transition-[width] duration-700 ease-out",
"absolute inset-y-0 left-0 w-full origin-left bg-gradient-to-r from-muted via-muted/70 to-muted/30 transition-transform duration-700 ease-out motion-reduce:transition-none",
"group-hover/row:from-foreground/20 group-hover/row:via-foreground/12 group-hover/row:to-foreground/5",
)}
style={{
width: mounted ? `${pct}%` : 0,
transform: `scaleX(${mounted ? pct / 100 : 0})`,
transitionDelay: `${Math.min(idx * 40, 240)}ms`,
}}
>
<div
aria-hidden
className="absolute inset-0 bg-[image:repeating-linear-gradient(135deg,currentColor_0_1px,transparent_1px_5px)] text-foreground opacity-[0.07] transition-opacity duration-300 group-hover/row:opacity-[0.14]"
aria-hidden="true"
className="absolute inset-0 bg-[image:repeating-linear-gradient(135deg,currentColor_0_1px,transparent_1px_5px)] text-foreground opacity-[0.07] transition-opacity duration-300 group-hover/row:opacity-[0.14] motion-reduce:transition-none"
/>
</div>
<div className="relative flex h-full items-center justify-between gap-3 px-2 text-xs">
@@ -79,7 +83,7 @@ export function BarList({ data, emptyMessage = "No data yet.", className }: BarL
{entry.href ? (
<a
href={entry.href}
className="truncate font-medium text-foreground after:absolute after:inset-0 hover:underline"
className="truncate font-medium text-foreground after:absolute after:inset-0 hover:underline focus-visible:underline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring"
>
{entry.name}
</a>
+11 -6
View File
@@ -20,24 +20,29 @@ export function ThemeToggle() {
<DropdownMenuTrigger
render={
<Button variant="outline" size="icon" aria-label="Toggle theme">
<IconSun className="scale-100 rotate-0 text-muted-foreground transition-transform dark:scale-0 dark:-rotate-90" />
<IconMoon className="absolute scale-0 rotate-90 text-muted-foreground transition-transform dark:scale-100 dark:rotate-0" />
<span className="sr-only">Toggle theme</span>
<IconSun
aria-hidden="true"
className="scale-100 rotate-0 text-muted-foreground transition-transform motion-reduce:transition-none dark:scale-0 dark:-rotate-90"
/>
<IconMoon
aria-hidden="true"
className="absolute scale-0 rotate-90 text-muted-foreground transition-transform motion-reduce:transition-none dark:scale-100 dark:rotate-0"
/>
</Button>
}
/>
<DropdownMenuContent align="end">
<DropdownMenuRadioGroup value={theme} onValueChange={setTheme}>
<DropdownMenuRadioItem value="light" closeOnClick className="cursor-pointer">
<IconSun className="text-muted-foreground" />
<IconSun aria-hidden="true" className="text-muted-foreground" />
Light
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="dark" closeOnClick className="cursor-pointer">
<IconMoon className="text-muted-foreground" />
<IconMoon aria-hidden="true" className="text-muted-foreground" />
Dark
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="system" closeOnClick className="cursor-pointer">
<IconDeviceLaptop className="text-muted-foreground" />
<IconDeviceLaptop aria-hidden="true" className="text-muted-foreground" />
System
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
+1 -1
View File
@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-none border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-none border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[background-color,color,border-color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
+24 -19
View File
@@ -69,25 +69,30 @@ function CopyButton({
);
return (
<Button
type="button"
variant={variant}
size={buttonSize}
disabled={disabled}
aria-label={copied ? copiedLabel : copyLabel}
className={cn("shrink-0", copied && "cursor-default", className)}
onClick={(event) => {
void handleClick(event);
}}
{...props}
>
{copied ? (
<IconCheck aria-hidden="true" data-icon={showLabel ? "inline-start" : undefined} />
) : (
<IconCopy aria-hidden="true" data-icon={showLabel ? "inline-start" : undefined} />
)}
{showLabel && <span>{copied ? copiedLabel : copyLabel}</span>}
</Button>
<>
<Button
type="button"
variant={variant}
size={buttonSize}
disabled={disabled}
aria-label={copied ? copiedLabel : copyLabel}
className={cn("shrink-0", copied && "cursor-default", className)}
onClick={(event) => {
void handleClick(event);
}}
{...props}
>
{copied ? (
<IconCheck aria-hidden="true" data-icon={showLabel ? "inline-start" : undefined} />
) : (
<IconCopy aria-hidden="true" data-icon={showLabel ? "inline-start" : undefined} />
)}
{showLabel && <span>{copied ? copiedLabel : copyLabel}</span>}
</Button>
<span aria-live="polite" className="sr-only">
{copied ? copiedLabel : ""}
</span>
</>
);
}
+4 -2
View File
@@ -4,6 +4,7 @@ function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
return (
<kbd
data-slot="kbd"
translate="no"
className={cn(
"pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-none bg-muted px-1 font-sans text-[11px] leading-5 font-medium text-muted-foreground select-none in-data-[slot=tooltip-content]:bg-background/20 in-data-[slot=tooltip-content]:text-background dark:in-data-[slot=tooltip-content]:bg-background/10 [&_svg:not([class*='size-'])]:size-3",
className,
@@ -13,10 +14,11 @@ function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
);
}
function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
function KbdGroup({ className, ...props }: React.ComponentProps<"span">) {
return (
<kbd
<span
data-slot="kbd-group"
translate="no"
className={cn("inline-flex items-center gap-1", className)}
{...props}
/>
-77
View File
@@ -1,77 +0,0 @@
"use client";
import { Popover as PopoverPrimitive } from "@base-ui/react/popover";
import * as React from "react";
import { cn } from "@/lib/utils";
function Popover({ ...props }: PopoverPrimitive.Root.Props) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
}
function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
}
function PopoverContent({
className,
align = "center",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
...props
}: PopoverPrimitive.Popup.Props &
Pick<PopoverPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset">) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<PopoverPrimitive.Popup
data-slot="popover-content"
className={cn(
"z-50 flex w-72 origin-(--transform-origin) flex-col gap-2.5 rounded-none bg-popover p-2.5 text-xs text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className,
)}
{...props}
/>
</PopoverPrimitive.Positioner>
</PopoverPrimitive.Portal>
);
}
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="popover-header"
className={cn("flex flex-col gap-1 text-xs", className)}
{...props}
/>
);
}
function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
return (
<PopoverPrimitive.Title
data-slot="popover-title"
className={cn("text-sm font-medium", className)}
{...props}
/>
);
}
function PopoverDescription({ className, ...props }: PopoverPrimitive.Description.Props) {
return (
<PopoverPrimitive.Description
data-slot="popover-description"
className={cn("text-xs/relaxed text-muted-foreground", className)}
{...props}
/>
);
}
export { Popover, PopoverContent, PopoverDescription, PopoverHeader, PopoverTitle, PopoverTrigger };
+1 -1
View File
@@ -29,7 +29,7 @@ function ResizableHandle({
<ResizablePrimitive.Separator
data-slot="resizable-handle"
className={cn(
"relative flex w-px items-center justify-center bg-border ring-offset-background after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-hidden aria-[orientation=horizontal]:h-px aria-[orientation=horizontal]:w-full aria-[orientation=horizontal]:after:left-0 aria-[orientation=horizontal]:after:h-1 aria-[orientation=horizontal]:after:w-full aria-[orientation=horizontal]:after:translate-x-0 aria-[orientation=horizontal]:after:-translate-y-1/2 [&[aria-orientation=horizontal]>div]:rotate-90",
"relative flex w-px touch-manipulation items-center justify-center bg-border ring-offset-background after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-hidden aria-[orientation=horizontal]:h-px aria-[orientation=horizontal]:w-full aria-[orientation=horizontal]:after:left-0 aria-[orientation=horizontal]:after:h-1 aria-[orientation=horizontal]:after:w-full aria-[orientation=horizontal]:after:translate-x-0 aria-[orientation=horizontal]:after:-translate-y-1/2 [&[aria-orientation=horizontal]>div]:rotate-90",
className,
)}
{...props}
+1 -1
View File
@@ -64,7 +64,7 @@ function ScrollBar({
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"m-1 flex opacity-0 transition-opacity delay-300 data-hovering:opacity-100 data-hovering:delay-0 data-hovering:duration-100 data-scrolling:opacity-100 data-scrolling:delay-0 data-scrolling:duration-100 data-[orientation=horizontal]:h-1 data-[orientation=horizontal]:flex-col data-[orientation=vertical]:w-1",
"m-1 flex opacity-0 transition-opacity delay-300 data-hovering:opacity-100 data-hovering:delay-0 data-hovering:duration-100 data-scrolling:opacity-100 data-scrolling:delay-0 data-scrolling:duration-100 data-[orientation=horizontal]:h-1 data-[orientation=horizontal]:flex-col data-[orientation=vertical]:w-1 motion-reduce:transition-none",
className,
)}
{...props}
+2 -1
View File
@@ -4,7 +4,8 @@ function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-muted", className)}
aria-hidden="true"
className={cn("animate-pulse rounded-md bg-muted motion-reduce:animate-none", className)}
{...props}
/>
);
+8 -6
View File
@@ -3,18 +3,20 @@ import {
IconInfoCircle,
IconAlertTriangle,
IconAlertOctagon,
IconLoader,
IconLoader2,
} from "@tabler/icons-react";
import { Toaster as Sonner, type ToasterProps } from "sonner";
import { useTheme } from "@/components/theme-provider";
const ICONS = {
success: <IconCircleCheck className="size-4" />,
info: <IconInfoCircle className="size-4" />,
warning: <IconAlertTriangle className="size-4" />,
error: <IconAlertOctagon className="size-4" />,
loading: <IconLoader className="size-4 animate-spin" />,
success: <IconCircleCheck aria-hidden="true" className="size-4" />,
info: <IconInfoCircle aria-hidden="true" className="size-4" />,
warning: <IconAlertTriangle aria-hidden="true" className="size-4" />,
error: <IconAlertOctagon aria-hidden="true" className="size-4" />,
loading: (
<IconLoader2 aria-hidden="true" className="size-4 animate-spin motion-reduce:animate-none" />
),
};
// CSS custom properties aren't representable in React.CSSProperties (csstype
+16
View File
@@ -0,0 +1,16 @@
import { IconLoader2 } from "@tabler/icons-react";
import { cn } from "@/lib/utils";
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
return (
<IconLoader2
role="status"
aria-label="Loading"
className={cn("size-4 animate-spin", className)}
{...props}
/>
);
}
export { Spinner };
+1 -1
View File
@@ -51,7 +51,7 @@ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
<TabsPrimitive.Tab
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-none border border-transparent px-1.5 py-0.5 text-xs font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start group-data-vertical/tabs:py-[calc(--spacing(1.25))] hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-none border border-transparent px-1.5 py-0.5 text-xs font-medium whitespace-nowrap text-foreground/60 transition-[background-color,color,border-color,box-shadow] group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start group-data-vertical/tabs:py-[calc(--spacing(1.25))] hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
+1 -1
View File
@@ -6,7 +6,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const toggleVariants = cva(
"group/toggle inline-flex items-center justify-center gap-1 rounded-none text-xs font-medium whitespace-nowrap transition-all outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted data-[state=on]:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group/toggle inline-flex items-center justify-center gap-1 rounded-none text-xs font-medium whitespace-nowrap transition-[background-color,color,border-color,box-shadow] outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted data-[state=on]:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
+12 -22
View File
@@ -1,6 +1,6 @@
"use client";
import * as React from "react";
import { useMediaQuery } from "@/hooks/use-media-query";
const HOVER_QUERY = "(hover: hover)";
const COARSE_POINTER_QUERY = "(pointer: coarse)";
@@ -11,29 +11,19 @@ type PointerCapability = {
isTouchDevice: boolean;
};
/**
* Pointer-capability flags derived from `(hover)` + `(pointer)` media queries.
* Defaults assume desktop (hover-capable, fine pointer) so SSR + first
* hydration don't flag desktop users as touch; touch devices read the real
* values on the first client render via `useSyncExternalStore`.
*/
export function usePointerCapability(): PointerCapability {
const supportsHover = useMediaQuery(HOVER_QUERY);
const isCoarsePointer = useMediaQuery(COARSE_POINTER_QUERY);
const supportsHover = useMediaQuery(HOVER_QUERY, true);
const isCoarsePointer = useMediaQuery(COARSE_POINTER_QUERY, false);
return {
supportsHover: supportsHover ?? false,
isCoarsePointer: isCoarsePointer ?? false,
isTouchDevice: supportsHover === false || isCoarsePointer === true,
supportsHover,
isCoarsePointer,
isTouchDevice: !supportsHover || isCoarsePointer,
};
}
function useMediaQuery(query: string) {
const [matches, setMatches] = React.useState<boolean>();
React.useEffect(() => {
const mediaQueryList = window.matchMedia(query);
const updateMatches = () => setMatches(mediaQueryList.matches);
updateMatches();
mediaQueryList.addEventListener("change", updateMatches);
return () => mediaQueryList.removeEventListener("change", updateMatches);
}, [query]);
return matches;
}
+9 -8
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
const relativeFormatter = new Intl.RelativeTimeFormat("en-US", { numeric: "auto" });
const relativeFormatter = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" });
const MINUTE = 60;
const HOUR = 60 * MINUTE;
@@ -13,7 +13,7 @@ function format(iso: string, now: Date): string {
if (Number.isNaN(date.getTime())) return iso;
const diffSec = Math.round((date.getTime() - now.getTime()) / 1000);
const abs = Math.abs(diffSec);
if (abs < MINUTE) return "just now";
if (abs < MINUTE) return relativeFormatter.format(0, "second");
if (abs < HOUR) return relativeFormatter.format(Math.round(diffSec / MINUTE), "minute");
if (abs < DAY) return relativeFormatter.format(Math.round(diffSec / HOUR), "hour");
if (abs < MONTH) return relativeFormatter.format(Math.round(diffSec / DAY), "day");
@@ -22,18 +22,19 @@ function format(iso: string, now: Date): string {
}
/**
* Reactive `Intl.RelativeTimeFormat` — re-renders on a fixed interval so the
* label stays fresh while the tab is open. SSR returns a value computed against
* server time; gate the rendered element on client-only state if exact
* second-level accuracy at hydration matters.
* Reactive `Intl.RelativeTimeFormat`. Defers reading `new Date()` to a
* post-mount effect so SSR + first hydration agree on a stable fallback (the
* raw iso), then we swap to the formatted relative label once the client clock
* is available. Re-renders on a fixed interval so the label stays fresh.
*/
export function useTimeAgo(iso: string, intervalMs = 30_000): string {
const [now, setNow] = useState(() => new Date());
const [now, setNow] = useState<Date | null>(null);
useEffect(() => {
setNow(new Date());
const id = setInterval(() => setNow(new Date()), intervalMs);
return () => clearInterval(id);
}, [intervalMs]);
return format(iso, now);
return now ? format(iso, now) : iso;
}
+4 -2
View File
@@ -36,9 +36,11 @@ const getDocLayout = createServerFn({ method: "GET" })
<article className="min-w-0 flex-1 pt-2 pb-8 md:pt-8">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<h1 className="text-3xl font-medium tracking-tight">{page.data.title}</h1>
<h1 className="scroll-mt-16 text-3xl font-medium tracking-tight text-balance">
{page.data.title}
</h1>
{page.data.description && (
<p className="mt-2 text-base leading-normal text-muted-foreground">
<p className="mt-2 text-base leading-normal text-pretty text-muted-foreground">
{page.data.description}
</p>
)}
+16 -8
View File
@@ -1,6 +1,6 @@
import type { CategoryId } from "@stanza/registry";
import { categoryLabel, KNOWN_CATEGORIES, PEER_CATEGORIES } from "@stanza/registry";
import { IconExternalLink } from "@tabler/icons-react";
import { IconArrowLeft, IconExternalLink } from "@tabler/icons-react";
import { Link, createFileRoute, notFound, useNavigate } from "@tanstack/react-router";
import { useCallback, useMemo } from "react";
@@ -104,9 +104,10 @@ function ModuleDetailPage() {
<div className="mb-6">
<Link
to="/"
className="text-xs text-muted-foreground transition-colors hover:text-foreground"
className="inline-flex items-center gap-1 text-xs text-muted-foreground transition-colors hover:text-foreground"
>
Back to builder
<IconArrowLeft className="size-3" aria-hidden="true" />
Back to builder
</Link>
</div>
@@ -114,23 +115,30 @@ function ModuleDetailPage() {
<ModuleLogo logo={module.logo} label={module.label} size="lg" />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-2xl font-medium tracking-tight">{module.label}</h1>
<h1 className="text-2xl font-medium tracking-tight text-balance" translate="no">
{module.label}
</h1>
<Badge variant="default">{categoryLabel(module.category)}</Badge>
</div>
<p className="mt-1.5 text-sm text-muted-foreground">{module.description}</p>
<p className="mt-1.5 text-sm text-pretty text-muted-foreground">{module.description}</p>
<div className="mt-3 flex flex-wrap gap-3 text-xs">
{module.homepage && (
<a
href={module.homepage}
target="_blank"
rel="noopener noreferrer"
aria-label="Website (opens in new tab)"
className="inline-flex items-center gap-1 text-muted-foreground transition-colors hover:text-foreground"
>
Website
<IconExternalLink className="size-3" aria-hidden />
<IconExternalLink className="size-3" aria-hidden="true" />
</a>
)}
{module.author && <span className="text-muted-foreground">by {module.author}</span>}
{module.author && (
<span className="text-muted-foreground">
by <span translate="no">{module.author}</span>
</span>
)}
</div>
</div>
</header>
@@ -150,7 +158,7 @@ function ModuleDetailPage() {
<div className="space-y-8">
<h2 className="sr-only">Module details</h2>
<DepsTable title="Dependencies" entries={effective.dependencies} />
<DepsTable title="Dev dependencies" entries={effective.devDependencies} />
<DepsTable title="Dev Dependencies" entries={effective.devDependencies} />
<EnvTable env={effective.env} />
<ScriptsTable entries={effective.scripts} />
<TemplatesList templates={templates} previews={previews} />
@@ -1,5 +1,6 @@
import type { CategoryId } from "@stanza/registry";
import { categoryLabel, isCategoryId } from "@stanza/registry";
import { IconArrowLeft } from "@tabler/icons-react";
import { Link, createFileRoute, notFound, useLoaderData } from "@tanstack/react-router";
import { useMemo } from "react";
@@ -61,23 +62,33 @@ function CategoryLandingPage() {
<div className="mb-6">
<Link
to="/"
className="text-xs text-muted-foreground transition-colors hover:text-foreground"
className="inline-flex items-center gap-1 text-xs text-muted-foreground transition-colors hover:text-foreground"
>
Back to builder
<IconArrowLeft className="size-3" aria-hidden="true" />
Back to builder
</Link>
</div>
<header>
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-2xl font-medium tracking-tight">{label}</h1>
<h1 className="text-2xl font-medium tracking-tight text-balance">{label}</h1>
</div>
<p className="mt-1.5 text-sm text-muted-foreground">{CATEGORY_BLURBS[category]}</p>
<p className="mt-1.5 text-sm text-pretty text-muted-foreground">
{CATEGORY_BLURBS[category]}
</p>
</header>
<Separator className="my-8" />
<h2 className="sr-only">Modules</h2>
{modules.length === 0 ? (
<p className="text-sm text-muted-foreground">No modules in this category yet.</p>
<p className="text-sm text-muted-foreground">
No {label.toLowerCase()} modules in the registry yet.{" "}
<Link to="/" className="underline underline-offset-1 hover:text-foreground">
Browse the builder
</Link>{" "}
for whats available.
</p>
) : (
<Section title="Modules" count={modules.length}>
<SectionList>
+18 -13
View File
@@ -25,8 +25,8 @@ export const Route = createFileRoute("/stats")({
component: StatsPage,
});
const numberFormatter = new Intl.NumberFormat("en-US");
const percentFormatter = new Intl.NumberFormat("en-US", {
const numberFormatter = new Intl.NumberFormat();
const percentFormatter = new Intl.NumberFormat(undefined, {
style: "percent",
maximumFractionDigits: 0,
});
@@ -34,7 +34,7 @@ const percentFormatter = new Intl.NumberFormat("en-US", {
function formatGeneratedAt(iso: string): string {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return iso;
return date.toLocaleString("en-US", {
return date.toLocaleString(undefined, {
dateStyle: "medium",
timeStyle: "short",
timeZone: "UTC",
@@ -113,7 +113,7 @@ function StatsPage() {
</CardHeader>
<CardContent>
<div className="font-mono text-4xl font-medium tracking-tight tabular-nums">
<NumberFlow value={stats?.projectsScaffolded ?? 0} locales="en-US" />
<NumberFlow value={stats?.projectsScaffolded ?? 0} />
</div>
</CardContent>
</Card>
@@ -125,7 +125,7 @@ function StatsPage() {
</CardHeader>
<CardContent>
<div className="font-mono text-4xl font-medium tracking-tight tabular-nums">
<NumberFlow value={stats?.modulesInstalled ?? 0} locales="en-US" />
<NumberFlow value={stats?.modulesInstalled ?? 0} />
</div>
</CardContent>
</Card>
@@ -135,20 +135,21 @@ function StatsPage() {
<Card className="gap-2 pb-2">
<CardHeader>
<CardTitle className="text-xs font-medium tracking-wider text-foreground/80 uppercase">
CLI runs <span className="text-muted-foreground">&middot; last 30 days</span>
CLI runs{" "}
<span className="text-muted-foreground">&middot; last&nbsp;30&nbsp;days</span>
</CardTitle>
<CardAction className="font-mono text-xs text-muted-foreground tabular-nums">
{stats ? (
<span className="font-mono text-xs text-muted-foreground tabular-nums">
{activitySum > 0
? `${numberFormatter.format(activitySum)} total`
? `${numberFormatter.format(activitySum)} total`
: "No runs yet."}
</span>
) : null}
</CardAction>
</CardHeader>
<CardContent>
<Suspense fallback={<div aria-hidden className="h-24 w-full" />}>
<Suspense fallback={<div aria-hidden="true" className="h-24 w-full" />}>
<ActivityChart activity30d={stats?.activity30d ?? []} isLoading={stats === null} />
</Suspense>
</CardContent>
@@ -156,7 +157,9 @@ function StatsPage() {
</section>
<section className="mb-8">
<h2 className="mb-4 text-lg font-medium tracking-tight">Popular modules by category</h2>
<h2 className="mb-4 text-lg font-medium tracking-tight text-balance">
Popular modules by category
</h2>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{KNOWN_CATEGORIES.map((category) => {
const entries = stats?.perCategory[category] ?? [];
@@ -195,7 +198,7 @@ function StatsPage() {
</section>
<section id="telemetry" className="scroll-mt-20">
<h2 className="mb-4 text-lg font-medium tracking-tight">Telemetry policy</h2>
<h2 className="mb-4 text-lg font-medium tracking-tight text-balance">Telemetry policy</h2>
<div className="grid gap-4 sm:grid-cols-2">
<Card size="sm">
<CardHeader className="pb-1">
@@ -224,7 +227,8 @@ function StatsPage() {
<li>Your IP address (PostHog ingest is server-proxied).</li>
<li>Any persistent identifier the process UUID is discarded on exit.</li>
<li>
Anything from CI runs (telemetry auto-skips when <code>CI</code> is set).
Anything from CI runs (telemetry auto-skips when <code translate="no">CI</code> is
set).
</li>
</ul>
</CardContent>
@@ -232,8 +236,9 @@ function StatsPage() {
</div>
<p className="mt-4 text-xs leading-5 text-muted-foreground">
<strong className="font-medium text-foreground">Opt out</strong> per-invocation with{" "}
<code>--no-telemetry</code>, or permanently with <code>STANZA_TELEMETRY=0</code> or{" "}
<code>DO_NOT_TRACK=1</code>. Learn more in the{" "}
<code translate="no">--no-telemetry</code>, or permanently with{" "}
<code translate="no">STANZA_TELEMETRY=0</code> or{" "}
<code translate="no">DO_NOT_TRACK=1</code>. Learn more in the{" "}
<Link
to="/docs/$"
params={{ _splat: "cli" }}