mirror of
https://github.com/jakejarvis/stanza.git
synced 2026-07-16 18:05:58 -04:00
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:
@@ -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];
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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("Couldn’t 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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 couldn’t 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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,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}
|
||||
/>
|
||||
|
||||
@@ -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 };
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
@@ -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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 what’s available.
|
||||
</p>
|
||||
) : (
|
||||
<Section title="Modules" count={modules.length}>
|
||||
<SectionList>
|
||||
|
||||
@@ -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">· last 30 days</span>
|
||||
CLI runs{" "}
|
||||
<span className="text-muted-foreground">· last 30 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" }}
|
||||
|
||||
Reference in New Issue
Block a user