diff --git a/README.md b/README.md index e5f1e37..54ca550 100644 --- a/README.md +++ b/README.md @@ -51,8 +51,20 @@ pnpm install pnpm dev ``` -The specimen app opens at `http://127.0.0.1:5173/`. Its assembled lab is at -`http://127.0.0.1:5173/lab`. +The site opens at `http://127.0.0.1:5173/` with a component showcase. +Documentation starts at `/docs/introduction`, setup instructions at +`/docs/installation`, and the full catalog at `/docs/components`. Each featured +component has a direct `/docs/` page with Preview, Install, and +React API tabs. The assembled five-state lab remains at `/lab`. + +The shared header searches guides, component names, descriptions, and states +(`Cmd/Ctrl+K`). Documentation has grouped navigation and a mobile menu; theme +and preview density preferences persist between pages. The homepage demos +use the same public component modules as the catalog, not private lookalikes. + +The homepage and documentation layout follow the structural reference of +[Spell UI](https://github.com/xxtomm/spell-ui), with OpenCoven's content, +semantic tokens, and familiar-native components. ## Consume the package diff --git a/apps/specimens/src/app.tsx b/apps/specimens/src/app.tsx index 84da0f2..8604cd3 100644 --- a/apps/specimens/src/app.tsx +++ b/apps/specimens/src/app.tsx @@ -1,756 +1,129 @@ +import { Button, SearchField, TooltipProvider } from "@opencoven/ui"; import { - ActivityItem, - AttachmentChip, - Badge, - BudgetPill, - Button, - Card, - CardContent, - CardFooter, - CardHeader, - CompletionPalette, - Composer, - ContextMeter, - FailureSurface, - MetricDisplay, - ModeSwitch, - PlanRow, - ResourceRow, - RunRail, - SearchField, - SendControl, - SessionHeader, - Tabs, - TabsContent, - TabsList, - TabsTrigger, - ToolMix, - TooltipProvider, - TranscriptTurn, - type ComposerMode, -} from "@opencoven/ui"; -import { ArrowRight, Moon, Sparkles, Sun } from "lucide-react"; -import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"; + ArrowLeft, + ArrowRight, + GitBranch, + Menu, + Moon, + Sun, + X, +} from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { CodeBlock } from "./code-block"; +import { Brand, Home, SiteFooter } from "./home"; +import { DensityControl, Lab, Library, SpecimenCard } from "./specimens"; +import { + groupOrder, + groupDetails, + matchesSpecimen, + useSpecimens, + type Density, +} from "./use-specimens"; -type Density = "default" | "compact"; type Scheme = "light" | "dark"; -type SpecimenGroup = "Composer" | "Run rail" | "Blocks"; - -type Specimen = { - id: string; - title: string; - group: SpecimenGroup; - primitive: string; - description: string; - states: string; - preview: ReactNode; -}; - -const groupOrder: SpecimenGroup[] = ["Composer", "Run rail", "Blocks"]; - -const groupDetails: Record< - SpecimenGroup, - { id: string; eyebrow: string; description: string } -> = { - Composer: { - id: "group-composer", - eyebrow: "Input layer", - description: "Intent, authority, attachments, and send readiness.", +const guides = [ + { + id: "introduction", + title: "Introduction", + description: "Building agent interfaces with OpenCoven UI.", }, - "Run rail": { - id: "group-run-rail", - eyebrow: "Evidence layer", - description: "Execution evidence, limits, resources, and failure states.", + { + id: "installation", + title: "Installation", + description: "Add OpenCoven components to your React project.", }, - Blocks: { - id: "group-blocks", - eyebrow: "Complete surfaces", - description: "Public components assembled into reusable agent workflows.", + { + id: "components", + title: "Components", + description: + "Explore the building blocks. Preview, install, and make them yours.", }, -}; - -function SpecimenCard({ - specimen, - index, -}: { - specimen: Specimen; - index: number; -}) { - const sourceKind = specimen.group === "Blocks" ? "blocks" : "components"; - const headingId = `${specimen.id}-title`; - const exportName = specimen.id - .split("-") - .map((part) => part[0]?.toUpperCase() + part.slice(1)) - .join(""); - const registryUrl = `https://ui.opencoven.ai/r/${specimen.id}.json`; - const packagePath = `@opencoven/ui/${sourceKind}/${specimen.id}`; +]; +function GettingStarted({ installation }: { installation: boolean }) { return ( -
-
-
- - {String(index + 1).padStart(2, "0")} - - {specimen.primitive} -
-

- {specimen.title} -

-

{specimen.description}

-
- - - Preview - Install - Usage - - -
{specimen.preview}
-
- -
-
-
- CLI - shadcn registry -
-
-                
-                  pnpm{" "}
-                  dlx{" "}
-                  shadcn@latest{" "}
-                  add{" "}
-                  {registryUrl}
-                
-              
-
-
-
- TypeScript - package API -
-
-                
-                  import{" "}
-                  {"{ "}
-                  {exportName}
-                  {" }"}{" "}
-                  from{" "}
-                  
-                    "{packagePath}"
-                  
-                  ;
-                
-              
-
-
-

- States - {specimen.states} -

-
- -

- Uses semantic tokens, visible focus, non-color state cues, logical - properties, and reduced-motion-safe feedback. Compact density is an - explicit prop, never a global compression shortcut. -

-
-
-
- ); -} - -function Library({ density, query }: { density: Density; query: string }) { - const [mode, setMode] = useState("do"); - const [message, setMessage] = useState("Review the changed files"); - - const specimens = useMemo( - () => [ - { - id: "mode-switch", - title: "Mode switch", - group: "Composer", - primitive: "native", - description: - "A pressed-state control for Chat, Do, and Plan authority.", - states: "default, selected, focus, disabled", - preview: ( - - ), - }, - { - id: "send-control", - title: "Send control", - group: "Composer", - primitive: "Base UI", - description: - "The surface's one filled action, with a stable stop state.", - states: "ready, running, disabled", - preview: ( -
- - -
- ), - }, - { - id: "completion-palette", - title: "Completion palette", - group: "Composer", - primitive: "Base UI Menu", - description: - "Keyboard-ready slash commands in a collision-aware overlay.", - states: "closed, open, focused, disabled", - preview: ( - Open slash commands} - onSelect={() => undefined} - commands={[ - { - id: "plan", - label: "/plan", - description: "Draft a plan before acting", - shortcut: "↵", - }, - { - id: "handoff", - label: "/handoff", - description: "Write a continuation handoff", - }, - { - id: "research", - label: "/research", - description: "Start a bounded research mission", - }, - ]} - /> - ), - }, - { - id: "attachment-chip", - title: "Attachment chip", - group: "Composer", - primitive: "native", - description: "A file attachment with explicit operational state.", - states: "ready, uploading, failed", - preview: ( -
- - - -
- ), - }, - { - id: "metric-display", - title: "Metric display", - group: "Run rail", - primitive: "native", - description: - "Comparable run figures in stable tabular numeric typography.", - states: "neutral, success, warning, information", - preview: ( - - - - - - ), - }, - { - id: "plan-row", - title: "Plan row", - group: "Run rail", - primitive: "native", - description: - "A task step whose icon, text treatment, and label carry state.", - states: "pending, active, complete, blocked", - preview: ( - - - - - - ), - }, - { - id: "activity-item", - title: "Activity item", - group: "Run rail", - primitive: "native", - description: - "One append-only tool event using the canonical class mapping.", - states: "read, write, exec, net, running", - preview: ( -
- - - - -
- ), - }, - { - id: "resource-row", - title: "Resource row", - group: "Run rail", - primitive: "native", - description: - "A direction-aware path row with operation and diff evidence.", - states: "modified, added, deleted, renamed", - preview: ( - - - - - ), - }, - { - id: "tool-mix", - title: "Tool mix", - group: "Run rail", - primitive: "native", - description: - "A readable summary with one fixed read/exec/write/net order.", - states: "populated, empty", - preview: ( - - ), - }, - { - id: "failure-surface", - title: "Failure surface", - group: "Run rail", - primitive: "Base UI Button", - description: - "A durable failure receipt with quiet output and explicit next moves.", - states: "failed, retried", - preview: ( - undefined }]} - /> - ), - }, - { - id: "context-meter", - title: "Context meter", - group: "Run rail", - primitive: "native progress", - description: - "Window consumption with a fixed threshold and stable numbers.", - states: "normal, warning, full", - preview: ( -
- - -
- ), - }, - { - id: "budget-pill", - title: "Budget pill", - group: "Run rail", - primitive: "native", - description: - "Spend against a limit with icon, text weight, and semantic tone.", - states: "normal, warning, over", - preview: ( -
- - - -
- ), - }, - { - id: "composer", - title: "Composer block", - group: "Blocks", - primitive: "composition", - description: - "The full intent-taking surface, built only from public modules.", - states: "empty, ready, running, disabled", - preview: ( - - ), - }, - { - id: "run-rail", - title: "Run rail block", - group: "Blocks", - primitive: "composition", - description: - "Metrics, activity, context, and budget as one operational report.", - states: "populated, loading, empty, error", - preview: ( - - ), - }, - { - id: "transcript-turn", - title: "Transcript turn", - group: "Blocks", - primitive: "composition", - description: - "Familiar identity and provenance lead an editorial response.", - states: "streaming, complete, with artifacts", - preview: ( - - Reply - Copy - 1.8K tokens - - } - > +
+ {!installation && ( + <> +
+

Interfaces for entrusted work

- The component source, registry item, and specimen now share one - implementation boundary. + OpenCoven UI is a collection of React components for agent + experiences: composing intent, following execution, and keeping + evidence in view. Every preview uses the same public modules you + install. +

+

+ Start with individual components or use complete blocks like the + Composer, Run rail, and Transcript turn. Familiar identity, + authority, and operational state stay explicit.

- - ), - }, - { - id: "session-header", - title: "Session header", - group: "Blocks", - primitive: "composition", - description: - "The session task, branch, execution state, and spend in one line.", - states: "pending, active, complete, blocked", - preview: ( - - ), - }, - ], - [density, message, mode], - ); - - const normalizedQuery = query.trim().toLowerCase(); - const filtered = specimens.filter((specimen) => - `${specimen.title} ${specimen.group} ${specimen.description}` - .toLowerCase() - .includes(normalizedQuery), - ); - - if (filtered.length === 0) { - return ( -
- -

No matching specimens

-

Try a component name, block, state, or operational concept.

-
- ); - } - - let specimenIndex = 0; - - return ( -
- {groupOrder.map((group) => { - const groupedSpecimens = filtered.filter( - (specimen) => specimen.group === group, - ); - - if (groupedSpecimens.length === 0) { - return null; - } - - const detail = groupDetails[group]; - - return ( -
-
-
-

- {detail.eyebrow} -

-

{group}

-

{detail.description}

-
- - {groupedSpecimens.length}{" "} - {groupedSpecimens.length === 1 ? "specimen" : "specimens"} - -
-
- {groupedSpecimens.map((specimen) => { - const currentIndex = specimenIndex; - specimenIndex += 1; - - return ( - - ); - })} -
- ); - })} -
- ); -} - -function Lab({ density }: { density: Density }) { - const [view, setView] = useState("composer"); - const [mode, setMode] = useState("do"); - const [message, setMessage] = useState( - "Ask Cody to review the changed files", - ); - - const views: Record = { - composer: ( -
- -

- I found two visual regressions in the specimen shell and kept the - package boundary intact. -

-
- -
- ), - messages: ( -
- - Reply - Copy - 1.2K tokens - - } - > -

- Model selection, linked context, and send readiness remain visible - without interrupting the writing flow. -

-
- -

- The same primitives can carry a different familiar identity without - changing their authority or accessibility contract. -

-
-
- ), - context: ( - - - - - - ), - actions: ( - - - - - ), - cards: ( -
- {[ - ["Pull request", "Recover attachment ingestion", "Checks 12 / 12"], - ["Proposal", "Merge #4764 · squash", "Awaiting your tap"], - ["Attachment", "Components-preview.png", "384 KB · added by Cody"], - ["Handoff", "Vercel deployment ledger", "7 sections"], - ].map(([kind, title, meta]) => ( - - - - {kind} - - - - {title} -

{meta}

-
- - Open in reader - -
- ))} -
- ), - }; - - return ( -
- - setView(String(next))}> -
- - {Object.keys(views).map((name) => ( - - {name} - - ))} - +
+

A familiar foundation

+

+ Built with TypeScript, Tailwind CSS 4, and Base UI. Semantic + tokens carry light and dark themes, lavender presence, tool + classes, and explicit cozy and compact densities. +

+

+ Keyboard access, visible focus, and reduced-motion feedback belong + to the components, not a separate demo layer. +

+
+ + )} +
+

+ {installation ? "Before you start" : "Bring it into your project"} +

+

+ Use a React 19 project with Tailwind CSS 4 and shadcn initialized. The + registry adds editable source to your project; the package provides + public imports from the same source tree. +

+

Install from the registry

+

+ Add the OpenCoven namespace to your existing{" "} + components.json registries: +

+ {`{ + "registries": { + "@opencoven": "https://ui.opencoven.ai/r/{name}.json" + } +}`} + + pnpm dlx shadcn@latest add @opencoven/composer + +

Or use the package

+ pnpm add @opencoven/ui + {`import "@opencoven/ui/globals.css"; +import { Composer } from "@opencoven/ui/blocks/composer";`} +

+ Import the theme once at your application entry point. Each component + page includes its exact registry URL and React import path. +

+
+
+

Build your first surface

+

+ Explore individual previews or see how the pieces fit together in the + five-state lab. +

+ - {Object.entries(views).map(([name, content]) => ( - -
{content}
-
- ))} - -
- ); -} - -function DensityControl({ - density, - onDensityChange, -}: { - density: Density; - onDensityChange: (density: Density) => void; -}) { - return ( -
- - +
); } -function App() { +export function App() { const [scheme, setScheme] = useState(() => localStorage.getItem("coven-ui:scheme") === "light" ? "light" : "dark", ); @@ -760,10 +133,69 @@ function App() { : "default", ); const [query, setQuery] = useState(""); + const [searchOpen, setSearchOpen] = useState(false); + const [menuOpen, setMenuOpen] = useState(false); const searchRef = useRef(null); - const topbarRef = useRef(null); - const normalizedPath = window.location.pathname.replace(/\/+$/, "") || "/"; - const isLab = normalizedPath === "/lab"; + const menuButtonRef = useRef(null); + const headerRef = useRef(null); + const searchAreaRef = useRef(null); + const path = window.location.pathname.replace(/\/+$/, "") || "/"; + const isHome = path === "/"; + const isLab = path === "/lab"; + const pageId = + path === "/docs" + ? "introduction" + : path.startsWith("/docs/") + ? path.slice(6) + : ""; + const specimens = useSpecimens(density); + const specimen = specimens.find((item) => item.id === pageId); + const nextSpecimen = specimen + ? specimens[specimens.indexOf(specimen) + 1] + : undefined; + const guide = guides.find((item) => item.id === pageId); + const title = isHome + ? "OpenCoven UI" + : isLab + ? "Assembled lab" + : (specimen?.title ?? guide?.title ?? "Page not found"); + const results = [ + ...guides + .filter((item) => + `${item.title} ${item.description}` + .toLowerCase() + .includes(query.trim().toLowerCase()), + ) + .map((item) => ({ ...item, category: "Getting started" })), + ...specimens + .filter((item) => matchesSpecimen(item, query)) + .map((item) => ({ ...item, category: item.group })), + ]; + const toc = isLab + ? [{ id: "assembled-lab", title: "Workbench" }] + : specimen + ? [ + { id: specimen.id, title: "Preview & installation" }, + { id: "states", title: "States" }, + ] + : pageId === "components" + ? groupOrder + .filter((group) => + specimens.some( + (item) => item.group === group && matchesSpecimen(item, query), + ), + ) + .map((group) => ({ id: groupDetails[group].id, title: group })) + : [ + ...(pageId === "introduction" + ? [ + { id: "overview", title: "Overview" }, + { id: "foundation", title: "Foundation" }, + ] + : []), + { id: "installation", title: "Installation" }, + { id: "next", title: "Next steps" }, + ]; useEffect(() => { document.documentElement.classList.toggle("dark", scheme === "dark"); @@ -772,176 +204,340 @@ function App() { localStorage.setItem("coven-ui:density", density); }, [density, scheme]); + useEffect(() => { + document.title = isHome ? title : `${title} - OpenCoven UI`; + }, [isHome, title]); + useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") { event.preventDefault(); + setSearchOpen(true); searchRef.current?.focus(); } + if (event.key === "Escape") { + if (searchAreaRef.current?.contains(document.activeElement)) { + event.preventDefault(); + } + if (document.activeElement?.closest(".site-search-results")) { + searchRef.current?.focus(); + } + if ( + document.activeElement?.closest("#site-navigation") && + menuButtonRef.current?.getClientRects().length + ) { + menuButtonRef.current.focus(); + } + setSearchOpen(false); + setMenuOpen(false); + } + }; + const onPointerDown = (event: PointerEvent) => { + if ( + event.target instanceof Node && + !searchAreaRef.current?.contains(event.target) + ) + setSearchOpen(false); }; - window.addEventListener("keydown", onKeyDown); - return () => window.removeEventListener("keydown", onKeyDown); + document.addEventListener("pointerdown", onPointerDown); + return () => { + window.removeEventListener("keydown", onKeyDown); + document.removeEventListener("pointerdown", onPointerDown); + }; }, []); useEffect(() => { - const topbar = topbarRef.current; - if (!topbar) return; - - const updateTopbarHeight = () => { + const header = headerRef.current; + if (!header) return; + const update = () => document.documentElement.style.setProperty( "--specimen-topbar-height", - `${Math.ceil(topbar.getBoundingClientRect().height)}px`, + `${Math.ceil(header.getBoundingClientRect().height)}px`, ); - }; - const observer = new ResizeObserver(updateTopbarHeight); - - updateTopbarHeight(); - observer.observe(topbar); - - return () => { - observer.disconnect(); - document.documentElement.style.removeProperty("--specimen-topbar-height"); - }; + const observer = new ResizeObserver(update); + update(); + observer.observe(header); + return () => observer.disconnect(); }, []); return ( - Skip to specimens + Skip to content -
-
- - - - OpenCoven UI - Reference lab - - -
- {isLab ? ( - - ) : ( - + )} + {isLab ? ( + + ) : specimen ? ( + <> + +
+

States

+

{specimen.states}.

+

+ Use semantic tokens and explicit state labels. Compact + density is opt-in; keyboard focus and reduced-motion + feedback remain visible. +

+
+
+ + + {nextSpecimen && ( + + {nextSpecimen.title} + )} +
+ + ) : pageId === "components" ? ( + + ) : guide ? ( + + ) : ( + + Browse components + )} + + {(guide || specimen || isLab) && ( + )} - - + + )} +
); } - -export { App }; diff --git a/apps/specimens/src/code-block.tsx b/apps/specimens/src/code-block.tsx new file mode 100644 index 0000000..cf083d9 --- /dev/null +++ b/apps/specimens/src/code-block.tsx @@ -0,0 +1,72 @@ +import { Button } from "@opencoven/ui"; +import { Check, Copy } from "lucide-react"; +import { useEffect, useState } from "react"; + +export function CopyCodeButton({ + text, + label, +}: { + text: string; + label: string; +}) { + const [status, setStatus] = useState<"idle" | "copied" | "error">("idle"); + + useEffect(() => { + if (status !== "copied") return; + const timeout = window.setTimeout(() => setStatus("idle"), 2000); + return () => window.clearTimeout(timeout); + }, [status]); + + const copy = async () => { + try { + await navigator.clipboard.writeText(text); + setStatus("copied"); + } catch { + setStatus("error"); + } + }; + + return ( +
+ + + {status === "error" + ? "Could not copy. Select and copy the code manually." + : status === "copied" + ? "Copied to clipboard." + : ""} + +
+ ); +} + +export function CodeBlock({ + children, + label, +}: { + children: string; + label: string; +}) { + return ( +
+
+ {label} + +
+
+        {children}
+      
+
+ ); +} diff --git a/apps/specimens/src/home.tsx b/apps/specimens/src/home.tsx new file mode 100644 index 0000000..cc2139e --- /dev/null +++ b/apps/specimens/src/home.tsx @@ -0,0 +1,144 @@ +import { ArrowRight, Blocks, GitBranch, Sparkles } from "lucide-react"; +import type { Specimen } from "./use-specimens"; + +const featured = [ + "composer", + "mode-switch", + "budget-pill", + "activity-item", + "run-rail", + "context-meter", + "transcript-turn", +]; + +export function Brand() { + return ( + + + ); +} + +export function Home({ specimens }: { specimens: Specimen[] }) { + return ( +
+
+ + Open source. Yours to build with. +

+ Thoughtful UI components +
+ for agent experiences. +

+

+ Composers, execution evidence, and familiar-native building blocks. + Copy the source. Make it your own. +

+ +

+ React Tailwind CSS{" "} + Base UI{" "} + shadcn registry +

+
+
+ {featured.map((id) => { + const specimen = specimens.find((item) => item.id === id); + if (!specimen) throw new Error(`Missing featured specimen: ${id}`); + return ( + + ); + })} +
+

+ Real components, interactive previews. No agent runs or external + actions. +

+
+ ); +} + +export function SiteFooter() { + return ( + + ); +} diff --git a/apps/specimens/src/main.tsx b/apps/specimens/src/main.tsx index ba2fdcb..ac119da 100644 --- a/apps/specimens/src/main.tsx +++ b/apps/specimens/src/main.tsx @@ -4,22 +4,9 @@ import { createRoot } from "react-dom/client"; import "@opencoven/ui/globals.css"; import "./specimens.css"; import "./specimens-fixes.css"; +import "./site.css"; import { App } from "./app"; -const normalizedPath = window.location.pathname.replace(/\/+$/, "") || "/"; - -if (normalizedPath !== "/") { - window.addEventListener( - "keydown", - (event) => { - if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") { - event.stopImmediatePropagation(); - } - }, - { capture: true }, - ); -} - const root = document.getElementById("root"); if (!root) { diff --git a/apps/specimens/src/site.css b/apps/specimens/src/site.css new file mode 100644 index 0000000..0162f7a --- /dev/null +++ b/apps/specimens/src/site.css @@ -0,0 +1,840 @@ +.site-header { + position: sticky; + inset-block-start: 0; + z-index: 50; + border-block-end: 1px solid var(--border); + background: var(--background); +} + +.site-header__inner { + display: flex; + align-items: center; + gap: 1.75rem; + min-height: 3.5rem; + max-width: 96rem; + margin-inline: auto; + padding: 0.5rem 1.5rem; +} + +.site-brand { + display: inline-flex; + align-items: center; + flex-shrink: 0; + gap: 0.5rem; + color: var(--foreground); + font-size: 0.9375rem; + font-weight: 600; + letter-spacing: -0.03em; + text-decoration: none; + white-space: nowrap; +} + +.site-brand > svg { + width: 1.5rem; + height: 1.5rem; + color: var(--presence); +} + +.site-brand__suffix { + font-weight: 400; + color: var(--muted-foreground); +} + +.site-links, +.site-actions { + display: flex; + align-items: center; + gap: 1.5rem; +} + +.site-links a, +.docs-toc a, +.site-footer a:not(.site-brand) { + color: var(--muted-foreground); + font-size: 0.8125rem; + text-decoration: none; +} + +.site-links a:hover, +.site-links a[aria-current], +.docs-toc a:hover, +.site-footer a:hover { + color: var(--foreground); +} + +.site-actions { + min-width: 0; + margin-inline-start: auto; + gap: 0.5rem; +} + +.site-icon-link { + display: inline-grid; + width: 2rem; + height: 2rem; + flex-shrink: 0; + place-items: center; + color: var(--muted-foreground); + border-radius: var(--radius-2); +} + +.site-icon-link svg { + width: 1.125rem; + height: 1.125rem; +} + +.site-search { + position: relative; + min-width: 0; + width: clamp(12rem, 22vw, 18rem); + margin-inline-end: 0.5rem; +} + +.site-search input { + height: 2rem; + font-size: 0.75rem; + background: var(--card); +} + +.site-search-results { + position: absolute; + inset-block-start: calc(100% + 0.75rem); + inset-inline-end: 0; + width: min(24rem, calc(100vw - 2rem)); + max-height: min(30rem, 65dvh); + overflow-y: auto; + border: 1px solid var(--border); + border-radius: var(--radius-3); + background: var(--popover); + padding: 0.5rem; + box-shadow: var(--elevation-3); +} + +.site-search-results > p { + margin: 0; + padding: 0.5rem; + font-size: 0.75rem; + color: var(--muted-foreground); +} + +.site-search-results a { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.2rem 1rem; + border-radius: var(--radius-2); + padding: 0.6rem; + font-size: 0.8125rem; + text-decoration: none; +} + +.site-search-results a:hover, +.site-search-results a:focus-visible { + background: var(--accent); +} + +.site-search-results small { + grid-column: 1; + color: var(--muted-foreground); +} + +.site-search-results svg { + grid-column: 2; + grid-row: 1 / 3; + width: 1rem; + align-self: center; +} + +.site-menu-toggle { + display: none; +} + +.home { + width: 100%; + padding: 0 1.5rem 4rem; +} + +.home-hero { + display: flex; + flex-direction: column; + align-items: center; + max-width: 45rem; + margin-inline: auto; + padding-block: 3.5rem 3.75rem; + text-align: center; +} + +.home-announcement { + display: inline-flex; + align-items: center; + gap: 0.5rem; + color: var(--muted-foreground); + font-size: 0.75rem; + text-decoration: none; +} + +.home-announcement svg { + width: 0.875rem; + height: 0.875rem; +} + +.home-hero h1 { + margin: 1.5rem 0 0; + font-size: clamp(2rem, 3.6vw, 3.25rem); + font-weight: 550; + letter-spacing: -0.055em; + line-height: 1.12; + text-wrap: balance; + max-width: 100%; + overflow-wrap: anywhere; +} + +.home-hero > p:not(.home-stack) { + max-width: 34rem; + margin: 1.25rem 0 0; + color: var(--muted-foreground); + font-size: 1.0625rem; + line-height: 1.6; + text-wrap: pretty; +} + +.home-actions { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 0.75rem; + margin-block-start: 1.75rem; + max-width: 100%; +} + +.home-button { + display: inline-flex; + min-height: 2.75rem; + align-items: center; + justify-content: center; + gap: 0.5rem; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--card); + padding: 0.625rem 1.125rem; + color: var(--foreground); + font-size: 0.875rem; + font-weight: 550; + text-decoration: none; + transition: border-color var(--motion-fast) var(--motion-ease); + min-width: 0; + overflow-wrap: anywhere; +} + +.home-button--primary { + background: var(--primary); + color: var(--primary-foreground); + border-color: var(--primary); +} + +.home-button:hover { + border-color: var(--presence); +} + +.home-button svg { + width: 1rem; + height: 1rem; +} + +.home-stack { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 0.6rem; + margin: 1.5rem 0 0; + color: var(--muted-foreground); + font-size: 0.6875rem; +} + +.home-stack span { + color: var(--border); +} + +.home-showcase { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 1rem; + max-width: 87.5rem; + margin-inline: auto; +} + +.showcase-tile { + display: flex; + flex-direction: column; + min-width: 0; + border: 1px solid var(--border); + border-radius: var(--radius-4); + background: var(--background); + padding: 1rem; +} + +.showcase-tile__stage { + display: flex; + flex: 1; + flex-direction: column; + align-items: center; + justify-content: center; + min-width: 0; + min-height: 10.5rem; + gap: 1.75rem; + padding: 1rem 0 1.75rem; +} + +.showcase-tile__stage > * { + max-width: 100%; + min-width: 0; +} + +.showcase-tile__stage > [data-slot="composer"], +.showcase-tile__stage > [data-slot="run-rail"], +.showcase-tile__stage > [data-slot="transcript-turn"] { + width: 100%; +} + +.showcase-tile [data-slot="context-meter"] > :first-child { + flex-wrap: wrap; + gap: 0.25rem 0.75rem; +} + +.showcase-tile--composer { + grid-column: span 2; + grid-row: span 2; + background: color-mix(in srgb, var(--presence) 4%, var(--background)); + padding-inline: clamp(1rem, 2.25vw, 2rem); +} + +.showcase-tile--composer .showcase-tile__stage { + padding-block: 2rem; +} + +.showcase-tile--run-rail { + grid-column: 4; + grid-row: 2 / span 2; +} + +.showcase-tile--transcript-turn { + grid-column: span 2; +} + +.showcase-intro { + text-align: center; +} + +.showcase-intro__mark { + display: inline-grid; + width: 2.75rem; + height: 2.75rem; + place-items: center; + border-radius: var(--radius-3); + border: 1px solid color-mix(in srgb, var(--presence) 32%, var(--border)); + background: color-mix(in srgb, var(--presence) 8%, var(--background)); + color: var(--presence); +} + +.showcase-intro h2 { + margin: 1rem 0 0; + font-family: var(--font-editorial); + font-size: clamp(1.5rem, 2.2vw, 2rem); + font-weight: 400; + letter-spacing: -0.04em; +} + +.showcase-intro p { + margin: 0.5rem 0 0; + color: var(--muted-foreground); + font-size: 0.8125rem; + line-height: 1.5; +} + +.showcase-tile__link { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 1.5rem; + color: var(--muted-foreground); + font-size: 0.75rem; + text-decoration: none; +} + +.showcase-tile__link svg { + width: 0.875rem; + height: 0.875rem; +} + +.showcase-tile__link:hover { + color: var(--foreground); +} + +.home-caption { + margin: 1.5rem 0 0; + color: var(--muted-foreground); + font-size: 0.6875rem; + text-align: center; +} + +.site-layout { + display: grid; + grid-template-columns: 15rem minmax(0, 1fr); + max-width: 96rem; + margin-inline: auto; +} + +.site-layout--home { + display: block; + max-width: none; +} + +.site-layout--home .docs-sidebar { + display: none; +} + +.docs-sidebar { + position: sticky; + inset-block-start: var(--specimen-topbar-height); + height: calc(100dvh - var(--specimen-topbar-height)); + overflow-y: auto; + padding: 2rem 1rem 1.5rem 1.5rem; + scrollbar-width: thin; +} + +.docs-nav-group { + display: grid; + gap: 0.125rem; + margin-block-end: 1.5rem; +} + +.docs-nav-group h2 { + margin: 0 0 0.5rem; + padding-inline: 0.75rem; + font-size: 0.75rem; + font-weight: 600; +} + +.docs-nav-group a { + min-width: 0; + border-radius: var(--radius-2); + padding: 0.45rem 0.75rem; + color: var(--muted-foreground); + font-size: 0.8125rem; + text-decoration: none; + overflow-wrap: anywhere; +} + +.docs-nav-group a:hover, +.docs-nav-group a[aria-current] { + background: var(--muted); + color: var(--foreground); +} + +.docs-sidebar__foot { + display: grid; + gap: 0.5rem; + border-block-start: 1px solid var(--border); + padding: 1rem 0.75rem 0; + color: var(--muted-foreground); + font-size: 0.6875rem; +} + +.docs-main { + display: grid; + grid-template-columns: minmax(0, 1fr) 10rem; + align-items: start; + gap: 2.5rem; + min-width: 0; + padding: 2.5rem 2rem 5rem; +} + +.docs-content { + width: 100%; + max-width: 64rem; + min-width: 0; + margin-inline: auto; +} + +.docs-heading { + margin-block-end: 2rem; +} + +.docs-breadcrumb { + display: flex; + flex-wrap: wrap; + gap: 0.6rem; + margin: 0 0 1.25rem; + font-size: 0.75rem; + color: var(--muted-foreground); +} + +.docs-heading h1 { + margin: 0; + font-size: clamp(1.875rem, 3vw, 2.25rem); + line-height: 1.2; + font-weight: 600; + letter-spacing: -0.04em; + overflow-wrap: anywhere; +} + +.docs-heading > p:last-child { + max-width: 42rem; + margin: 0.75rem 0 0; + color: var(--muted-foreground); + font-size: 0.9375rem; + line-height: 1.65; +} + +.docs-preview-toolbar { + display: flex; + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; + gap: 0.75rem; + margin-block-end: 1rem; + color: var(--muted-foreground); + font-size: 0.75rem; +} + +.docs-toc { + position: sticky; + inset-block-start: calc(var(--specimen-topbar-height) + 2.5rem); + display: grid; + gap: 0.875rem; + font-size: 0.75rem; +} + +.docs-toc h2 { + margin: 0; + font-size: inherit; + font-weight: 550; +} + +.docs-toc a { + display: inline-flex; + align-items: center; + gap: 0.5rem; + font-size: inherit; +} + +.docs-toc a:last-child { + margin-block-start: 0.75rem; +} + +.docs-toc svg { + width: 0.875rem; + height: 0.875rem; +} + +.docs-prose { + font-size: 0.875rem; + line-height: 1.8; + overflow-wrap: anywhere; +} + +.docs-prose section { + margin-block-end: 2.5rem; +} + +.docs-prose h2 { + margin: 2rem 0 0.75rem; + font-size: 1.25rem; + font-weight: 600; + letter-spacing: -0.025em; +} + +.docs-prose h3 { + margin: 1.75rem 0 0.75rem; + font-size: 1rem; + font-weight: 600; +} + +.docs-prose p { + margin: 0.75rem 0; + color: var(--muted-foreground); +} + +.docs-code { + min-width: 0; + margin-block: 1rem; + border: 1px solid var(--border); + border-radius: var(--radius-3); + background: var(--card); +} + +.docs-code__header { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + border-block-end: 1px solid var(--border); + padding: 0.5rem 0.75rem; + color: var(--muted-foreground); + font-size: 0.6875rem; +} + +.docs-code pre { + overflow-x: auto; + margin: 0; + padding: 1rem; + font-size: 0.75rem; +} + +.copy-code { + display: grid; + flex-shrink: 0; + justify-items: end; + min-width: 0; +} + +.copy-code .copy-code__error { + max-width: 24ch; + color: var(--destructive); + font-size: 0.75rem; + font-weight: 400; + letter-spacing: normal; + line-height: 1.5; + text-transform: none; +} + +.specimen-code-snippet__header > div:first-child { + display: grid; + gap: 0.25rem; +} + +.specimen-code-snippet__header { + flex-wrap: wrap; +} + +.docs-next { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 1rem; + margin-block-start: 2rem; + border-block-start: 1px solid var(--border); + padding-block-start: 1.5rem; +} + +.docs-next a { + display: inline-flex; + align-items: center; + gap: 0.5rem; + font-size: 0.8125rem; + text-decoration: none; +} + +.docs-next svg { + width: 1rem; + height: 1rem; +} + +.docs-content + > .specimen-card + > [data-slot="tabs"] + > [data-slot="tabs-content"] + > .specimen-stage { + min-height: 22rem; +} + +.specimen-card__title a { + text-decoration: none; +} + +.specimen-card__title a:hover { + text-decoration: underline; + text-underline-offset: 0.2em; +} + +.site-footer { + border-block-start: 1px solid var(--border); +} + +.site-footer__inner { + display: grid; + grid-template-columns: 2fr 1fr 1fr 1.5fr; + gap: 2rem; + max-width: 87.5rem; + margin-inline: auto; + padding: 3.5rem 1.5rem 1.5rem; +} + +.site-footer h2 { + margin: 0 0 1rem; + font-size: 0.8125rem; + font-weight: 550; +} + +.site-footer p { + color: var(--muted-foreground); + font-size: 0.8125rem; + line-height: 1.7; +} + +.site-footer nav { + display: flex; + flex-direction: column; + align-items: start; + gap: 0.625rem; +} + +.site-footer nav h2 { + margin-block-end: 0.375rem; +} + +.site-footer__bottom { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + grid-column: 1 / -1; + gap: 0.5rem; + margin: 1.5rem 0 0; + font-size: 0.6875rem !important; +} + +@media (max-width: 80rem) { + .docs-main { + grid-template-columns: minmax(0, 1fr); + } + .docs-toc { + display: none; + } +} + +@media (max-width: 68rem) { + .site-header__inner { + gap: 1rem; + } + .site-links { + gap: 1rem; + } + .home-showcase { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .showcase-tile--run-rail { + grid-column: auto; + grid-row: span 2; + } + .showcase-tile--transcript-turn { + grid-column: span 2; + } + .site-layout { + grid-template-columns: 13rem minmax(0, 1fr); + } + .docs-sidebar { + padding-inline: 1rem; + } + .docs-main { + padding-inline: 1.5rem; + } + .specimen-grid { + grid-template-columns: minmax(0, 1fr); + } +} + +@media (max-width: 48rem) { + .site-header__inner { + flex-wrap: wrap; + gap: 0.5rem; + padding-inline: 1rem; + } + .site-menu-toggle { + display: inline-flex; + flex-shrink: 0; + } + .site-links { + display: none; + } + .site-actions { + flex: 1 0 100%; + flex-wrap: wrap; + } + .site-search { + flex: 1 1 12rem; + width: auto; + margin-inline-end: 0; + } + .site-search-results { + inset-inline: 0 auto; + } + .site-layout { + display: block; + } + .docs-sidebar { + display: none; + position: static; + height: auto; + max-height: 65dvh; + border-block-end: 1px solid var(--border); + padding: 1rem; + } + .site-layout[data-menu-open="true"] .docs-sidebar { + display: block; + } + .docs-sidebar > nav { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 9rem), 1fr)); + gap: 1rem; + } + .docs-main { + padding: 1.5rem 1rem 3rem; + } + .docs-heading { + margin-block-end: 1.5rem; + } + .home { + padding: 0 1rem 2.5rem; + } + .home-hero { + padding-block: 2.5rem; + } + .home-hero > p:not(.home-stack) { + font-size: 0.9375rem; + } + .home-showcase { + grid-template-columns: minmax(0, 1fr); + } + .showcase-tile--composer, + .showcase-tile--run-rail, + .showcase-tile--transcript-turn { + grid-column: auto; + grid-row: auto; + } + .showcase-tile__stage { + min-height: 12rem; + } + .site-footer__inner { + grid-template-columns: repeat(auto-fit, minmax(min(100%, 9rem), 1fr)); + padding: 2.5rem 1rem 1.5rem; + } + .site-footer__inner > * { + min-width: 0; + overflow-wrap: anywhere; + } + .site-footer__brand, + .site-footer__note { + grid-column: 1 / -1; + } +} + +@media (max-width: 24rem) { + .docs-sidebar > nav { + grid-template-columns: minmax(0, 1fr); + } + .home-hero h1 { + font-size: 1.875rem; + } + .home-actions { + gap: 0.5rem; + } + .home-button { + padding-inline: 0.875rem; + } +} + +@media (prefers-reduced-motion: reduce) { + .home-button { + transition: none; + } +} diff --git a/apps/specimens/src/specimens-fixes.css b/apps/specimens/src/specimens-fixes.css index 4894d88..a2059f5 100644 --- a/apps/specimens/src/specimens-fixes.css +++ b/apps/specimens/src/specimens-fixes.css @@ -5,116 +5,7 @@ html { min-width: 320px; } -body:not(:has(#group-composer)) .specimen-rail a[href="#group-composer"], -body:not(:has(#group-run-rail)) .specimen-rail a[href="#group-run-rail"], -body:not(:has(#group-blocks)) .specimen-rail a[href="#group-blocks"] { - display: none; -} - -@media (max-width: 68rem) { - .specimen-topbar__inner { - display: flex; - flex-wrap: wrap; - } - - .surface-switcher { - margin-inline-start: auto; - } - - .specimen-topbar__actions { - display: flex; - flex: 1 0 100%; - flex-wrap: wrap; - } - - .specimen-search { - width: auto; - max-width: none; - min-width: 7rem; - flex: 1 1 10rem; - } - - .specimen-shell { - grid-template-columns: minmax(0, 1fr); - } -} - @media (max-width: 48rem) { - .specimen-shell { - box-sizing: border-box; - width: 100%; - min-width: 0; - max-width: 100vw; - margin-inline: 0; - } - - .specimen-main__inner { - box-sizing: border-box; - width: 100%; - min-width: 0; - max-width: 100%; - } - - .specimen-hero, - .specimen-hero__copy, - .specimen-stats { - min-width: 0; - max-width: 100%; - } - - .specimen-stats { - grid-template-columns: repeat(3, minmax(0, 1fr)); - } - - .specimen-stats div { - min-width: 0; - } - - .specimen-stats :where(dt, dd) { - overflow-wrap: anywhere; - } - - .specimen-rail { - box-sizing: border-box; - width: 100%; - min-width: 0; - max-width: 100%; - } - - .specimen-rail > *, - .specimen-rail__context, - .specimen-rail__nav, - .specimen-rail__package { - box-sizing: border-box; - min-width: 0; - max-width: 100%; - } - - .specimen-rail__context, - .specimen-rail__package, - .specimen-rail__package code { - overflow-wrap: anywhere; - word-break: break-word; - } - - .specimen-rail__nav { - display: grid; - width: 100%; - min-width: 0; - grid-template-columns: repeat(auto-fit, minmax(min(100%, 5.5rem), 1fr)); - } - - .specimen-rail__nav:has(a:only-child) { - grid-template-columns: minmax(0, 1fr); - } - - .specimen-rail__nav a { - min-width: 0; - justify-content: center; - gap: 0.375rem; - padding-inline: 0.375rem; - } - .specimen-grid { grid-template-columns: minmax(0, 1fr); } @@ -141,6 +32,7 @@ body:not(:has(#group-blocks)) .specimen-rail a[href="#group-blocks"] { max-width: 100%; min-height: 2.75rem; grid-template-columns: repeat(3, minmax(5.25rem, 1fr)); + justify-content: start; gap: 0; margin: 0 !important; overflow-x: auto; @@ -153,7 +45,7 @@ body:not(:has(#group-blocks)) .specimen-rail a[href="#group-blocks"] { > [data-slot="tabs"] > [data-slot="tabs-list"] > [data-slot="tabs-trigger"] { - min-width: 0; + min-width: 5.25rem; max-width: 100%; min-height: 2.75rem; padding: 0.625rem 0.5rem; @@ -184,6 +76,11 @@ body:not(:has(#group-blocks)) .specimen-rail a[href="#group-blocks"] { padding: 1rem; } + .specimen-card__meta { + flex-wrap: wrap; + gap: 0.5rem; + } + .specimen-card__title { margin-block-start: 0.875rem; } @@ -193,19 +90,22 @@ body:not(:has(#group-blocks)) .specimen-rail a[href="#group-blocks"] { line-height: 1.5; } - .specimen-stage { + .specimen-stage, + .assembled-lab__stage { box-sizing: border-box; width: 100%; min-width: 0; min-height: 0; max-width: 100%; align-content: start; + grid-template-columns: minmax(0, 1fr); justify-items: stretch; border-block-start: 0; padding: 1rem; } - .specimen-stage > * { + .specimen-stage > *, + .assembled-lab__stage > * { box-sizing: border-box; width: 100%; min-width: 0; @@ -213,16 +113,56 @@ body:not(:has(#group-blocks)) .specimen-rail a[href="#group-blocks"] { } .specimen-stage *, - .specimen-documentation * { + .specimen-documentation *, + .showcase-tile__stage *, + .assembled-lab__stage * { min-width: 0; max-width: 100%; } .specimen-stage :where(p, span, strong, small, code), - .specimen-documentation :where(p, span, strong, small, code) { + .specimen-documentation :where(p, span, strong, small, code), + .showcase-tile__stage :where(p, span, strong, small, code), + .assembled-lab__stage :where(p, span, strong, small, code) { overflow-wrap: anywhere; } + :is(.specimen-stage, .showcase-tile__stage, .assembled-lab__stage) + [data-slot="mode-switch"] { + flex-wrap: wrap; + } + + :is(.specimen-stage, .showcase-tile__stage) + [data-slot="card"]:has(> [data-slot="metric-display"]) { + grid-template-columns: repeat(auto-fit, minmax(min(100%, 6rem), 1fr)); + } + + :is(.specimen-stage, .showcase-tile__stage) [data-slot="metric-display"] > *, + .specimen-stage [data-slot="failure-surface"] strong { + overflow: visible; + white-space: normal; + text-overflow: clip; + } + + .specimen-stage [data-slot="failure-surface"] > [data-slot="card-header"] { + grid-template-columns: minmax(0, 1fr); + justify-items: start; + } + + .assembled-lab__stage button { + height: auto; + white-space: normal; + } + + #completion-palette .specimen-stage button { + height: auto; + white-space: normal; + } + + .assembled-lab__tabs [data-slot="tabs-trigger"] { + min-height: 2.75rem; + } + .specimen-documentation { box-sizing: border-box; width: 100%; @@ -280,29 +220,6 @@ body:not(:has(#group-blocks)) .specimen-rail a[href="#group-blocks"] { } @media (max-width: 20rem) { - .specimen-topbar__inner { - gap: 0.375rem; - padding-block: 0.375rem; - } - - .specimen-main__inner { - padding-block-start: 0.75rem; - } - - .specimen-hero { - gap: 0.75rem; - margin-block-end: 1.25rem; - padding-block-end: 0.75rem; - } - - .specimen-hero h1 { - font-size: 2rem; - } - - .specimen-hero__copy > p:last-child { - margin-block-start: 0.625rem; - } - .catalog-group__header { gap: 0.375rem; } diff --git a/apps/specimens/src/specimens.css b/apps/specimens/src/specimens.css index 19353e6..e21ee76 100644 --- a/apps/specimens/src/specimens.css +++ b/apps/specimens/src/specimens.css @@ -27,71 +27,6 @@ body { translate: 0; } -.specimen-topbar { - position: sticky; - inset-block-start: 0; - z-index: 50; - border-block-end: 1px solid var(--border); - background: color-mix(in srgb, var(--background) 92%, transparent); - backdrop-filter: blur(18px); -} - -.specimen-topbar__inner { - display: flex; - flex-wrap: wrap; - min-height: 3.75rem; - max-width: 96rem; - align-items: center; - gap: 0.75rem; - margin-inline: auto; - padding-inline: clamp(1rem, 3vw, 1.5rem); -} - -.specimen-brand { - display: inline-flex; - align-items: center; - gap: 0.75rem; - min-width: max-content; - color: var(--foreground); - text-decoration: none; -} - -.specimen-brand__mark { - display: grid; - width: 2rem; - height: 2rem; - flex: none; - place-items: center; - border: 1px solid color-mix(in srgb, var(--presence) 42%, var(--border)); - border-radius: var(--radius-2); - background: color-mix(in srgb, var(--presence) 12%, var(--card)); - color: var(--presence); -} - -.specimen-brand__mark svg { - width: 1rem; - height: 1rem; -} - -.specimen-brand > span:last-child { - display: grid; - font-size: 0.875rem; - font-weight: 750; - letter-spacing: -0.015em; - line-height: 1.1; -} - -.specimen-brand small { - margin-block-start: 0.2rem; - color: var(--muted-foreground); - font-family: var(--font-numeric); - font-size: 0.625rem; - font-weight: 600; - letter-spacing: 0.08em; - text-transform: uppercase; -} - -.surface-switcher, .density-control { display: inline-flex; align-items: center; @@ -102,7 +37,6 @@ body { background: var(--card); } -.surface-switcher a, .density-control button { border: 0; border-radius: calc(var(--radius-2) - 2px); @@ -116,216 +50,17 @@ body { white-space: nowrap; } -.surface-switcher a:hover, .density-control button:hover { background: var(--muted); color: var(--foreground); } -.surface-switcher a[aria-current="page"], .density-control button[aria-pressed="true"] { background: var(--foreground); color: var(--background); box-shadow: var(--elevation-1); } -.specimen-topbar__actions { - display: flex; - flex: 1 1 34rem; - flex-wrap: wrap; - min-width: 0; - align-items: center; - justify-content: flex-end; - gap: 0.5rem; -} - -.specimen-search { - width: min(20rem, 34vw); - max-width: 20rem; - flex: 1 1 16rem; -} - -.scheme-control { - flex: none; - white-space: nowrap; -} - -.scheme-control svg { - width: 0.875rem; - height: 0.875rem; -} - -.specimen-shell { - display: grid; - max-width: 96rem; - min-height: calc(100vh - var(--specimen-topbar-height)); - grid-template-columns: 13.5rem minmax(0, 1fr); - margin-inline: auto; -} - -.specimen-rail { - position: sticky; - inset-block-start: var(--specimen-topbar-height); - align-self: start; - height: calc(100vh - var(--specimen-topbar-height)); - overflow-y: auto; - border-inline-end: 1px solid var(--border); - background: color-mix(in srgb, var(--card) 72%, var(--background)); - padding: 1.5rem 0.875rem; - scrollbar-width: thin; -} - -.specimen-rail__context { - padding-inline: 0.625rem; -} - -.specimen-rail__context h2 { - margin: 0.4rem 0 0; - font-size: 1rem; - font-weight: 750; - letter-spacing: -0.015em; -} - -.specimen-rail__context p:last-child { - margin: 0.625rem 0 0; - color: var(--muted-foreground); - font-size: 0.75rem; - line-height: 1.55; -} - -.specimen-kicker { - margin: 0; - color: var(--presence); - font-size: 0.625rem; - font-weight: 750; - letter-spacing: 0.14em; - text-transform: uppercase; -} - -.specimen-rail__nav { - display: grid; - gap: 0.25rem; - margin-block-start: 1.25rem; -} - -.specimen-rail__nav a { - display: flex; - align-items: center; - justify-content: space-between; - gap: 0.75rem; - border-radius: var(--radius-2); - color: var(--muted-foreground); - padding: 0.5rem 0.625rem; - font-size: 0.8125rem; - font-weight: 650; - text-decoration: none; -} - -.specimen-rail__nav a:hover { - background: var(--muted); - color: var(--foreground); -} - -.specimen-rail__nav small { - color: color-mix(in srgb, var(--muted-foreground) 76%, transparent); - font-size: 0.625rem; -} - -.specimen-rail__package { - margin-block-start: 1.5rem; - border-block-start: 1px solid var(--border); - padding: 1rem 0.625rem 0; -} - -.specimen-rail__package code { - display: block; - overflow: hidden; - margin-block-start: 0.5rem; - color: var(--foreground); - font-size: 0.75rem; - text-overflow: ellipsis; - white-space: nowrap; -} - -.specimen-rail__package p { - margin: 0.625rem 0 0; - color: var(--muted-foreground); - font-size: 0.7rem; - line-height: 1.5; -} - -.specimen-main { - min-width: 0; -} - -.specimen-main__inner { - width: min(100%, 82.5rem); - margin-inline: auto; - padding: clamp(1.5rem, 3vw, 2.75rem) clamp(1rem, 3vw, 2.5rem) 5rem; -} - -.specimen-hero { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(15rem, 20rem); - align-items: center; - gap: 1.5rem; - margin-block-end: clamp(2rem, 4vw, 3rem); - border-block-end: 1px solid var(--border); - padding-block-end: clamp(1.5rem, 3vw, 2rem); -} - -.specimen-hero__copy { - max-width: 52rem; -} - -.specimen-hero h1 { - max-width: 19ch; - margin: 0.5rem 0 0; - font-family: var(--font-editorial); - font-size: clamp(2.4rem, 4.6vw, 4.25rem); - font-weight: 500; - letter-spacing: -0.04em; - line-height: 1; - color: var(--foreground); - text-wrap: balance; -} - -.specimen-hero__copy > p:last-child { - max-width: 68ch; - margin: 0.875rem 0 0; - color: var(--muted-foreground); - font-size: 0.875rem; - line-height: 1.6; -} - -.specimen-stats { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 0.5rem; - margin: 0; -} - -.specimen-stats div { - min-width: 0; - border-inline-start: 1px solid var(--border); - padding-inline-start: 0.875rem; -} - -.specimen-stats dt { - color: var(--muted-foreground); - font-size: 0.625rem; - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; -} - -.specimen-stats dd { - margin: 0.25rem 0 0; - color: var(--foreground); - font-size: 1.25rem; - font-weight: 650; -} - .catalog { display: grid; gap: clamp(2.75rem, 5vw, 4.5rem); @@ -677,83 +412,7 @@ body { margin-inline: auto; } -@media (max-width: 68rem) { - .specimen-topbar__inner { - grid-template-columns: auto 1fr; - padding-block: 0.75rem; - } - - .surface-switcher { - justify-self: end; - } - - .specimen-topbar__actions { - display: grid; - width: 100%; - grid-column: 1 / -1; - grid-template-columns: minmax(0, 1fr) auto auto; - } - - .specimen-search { - width: 100%; - } - - .specimen-shell { - min-height: 0; - grid-template-columns: 1fr; - } - - .specimen-rail { - position: static; - display: block; - height: auto; - overflow-x: auto; - border-inline-end: 0; - border-block-end: 1px solid var(--border); - padding: 0.5rem clamp(1rem, 4vw, 2rem); - } - - .specimen-rail__context, - .specimen-rail__package { - display: none; - } - - .specimen-rail__nav { - display: flex; - width: max-content; - min-width: 100%; - gap: 0.375rem; - margin: 0; - } - - .specimen-rail__nav a { - min-width: 9rem; - flex: 1 0 auto; - border: 1px solid transparent; - background: color-mix(in srgb, var(--card) 72%, var(--background)); - } - - .specimen-main__inner { - padding-block-start: clamp(1.5rem, 4vw, 2.5rem); - } -} - @media (max-width: 48rem) { - .specimen-hero { - grid-template-columns: 1fr; - align-items: start; - } - - .specimen-stats { - width: 100%; - } - - .specimen-hero { - gap: 1.25rem; - margin-block-end: 2rem; - padding-block-end: 1.25rem; - } - .catalog-group__header { align-items: start; } @@ -768,38 +427,6 @@ body { } @media (max-width: 36rem) { - .specimen-topbar__inner { - gap: 0.75rem; - } - - .specimen-brand small { - display: none; - } - - .specimen-topbar__actions { - grid-template-columns: minmax(0, 1fr) auto auto; - } - - .specimen-search { - grid-column: auto; - } - - .scheme-control span { - display: none; - } - - .specimen-main__inner { - padding-inline: 1rem; - } - - .specimen-hero { - margin-block-end: 2rem; - } - - .specimen-hero h1 { - font-size: clamp(2.25rem, 11vw, 3.25rem); - } - .catalog { gap: 3rem; } @@ -828,61 +455,11 @@ body { } @media (max-width: 24.375rem) { - html { - scroll-padding-top: 1rem; - } - - .specimen-topbar { - position: static; - } - - .specimen-brand > span:last-child { - display: none; - } - - .specimen-brand__mark { - width: 2rem; - height: 2rem; - } - - .surface-switcher a, .density-control button { padding-inline: 0.45rem; font-size: 0.6875rem; } - .specimen-topbar__actions { - gap: 0.375rem; - } - - .specimen-hero h1 { - font-size: 2rem; - } - - .specimen-main__inner { - padding-block-start: 16px; - padding-inline: 16px; - } - - .specimen-hero { - gap: 1rem; - margin-block-end: 1.5rem; - } - - .specimen-hero__copy > p:last-child { - font-size: 0.8125rem; - line-height: 1.5; - } - - .specimen-stats div { - min-width: 0; - padding-inline-start: 0.625rem; - } - - .specimen-stats dt { - font-size: 0.55rem; - } - .specimen-stage { min-height: 14rem; padding: 0.875rem; @@ -905,7 +482,6 @@ body { } @media (forced-colors: active) { - .surface-switcher a[aria-current="page"], .density-control button[aria-pressed="true"] { border: 1px solid Highlight; } diff --git a/apps/specimens/src/specimens.tsx b/apps/specimens/src/specimens.tsx new file mode 100644 index 0000000..df6b8cc --- /dev/null +++ b/apps/specimens/src/specimens.tsx @@ -0,0 +1,390 @@ +import { + Badge, + Button, + Card, + CardContent, + CardFooter, + CardHeader, + Composer, + ResourceRow, + SessionHeader, + Tabs, + TabsContent, + TabsList, + TabsTrigger, + TranscriptTurn, + type ComposerMode, +} from "@opencoven/ui"; +import { ArrowRight, Sparkles } from "lucide-react"; +import { type ReactNode, useState } from "react"; +import { CopyCodeButton } from "./code-block"; +import { + groupOrder, + groupDetails, + matchesSpecimen, + type Density, + type Specimen, +} from "./use-specimens"; + +export function SpecimenCard({ + specimen, + detail = false, +}: { + specimen: Specimen; + detail?: boolean; +}) { + const sourceKind = specimen.group === "Blocks" ? "blocks" : "components"; + const headingId = `${specimen.id}-title`; + const exportName = specimen.id + .split("-") + .map((part) => part[0]?.toUpperCase() + part.slice(1)) + .join(""); + const registryUrl = `https://ui.opencoven.ai/r/${specimen.id}.json`; + const packagePath = `@opencoven/ui/${sourceKind}/${specimen.id}`; + + return ( +
+ {!detail && ( +
+
+ {specimen.group} + {specimen.primitive} +
+

+ {specimen.title} +

+

{specimen.description}

+
+ )} + + + Preview + Install + React API + + +
{specimen.preview}
+
+ +
+
+
+
+ CLI + shadcn registry +
+ +
+
+                
+                  pnpm{" "}
+                  dlx{" "}
+                  shadcn@latest{" "}
+                  add{" "}
+                  {registryUrl}
+                
+              
+
+
+

+ States + {specimen.states} +

+

+ Set up your project before adding + components. +

+
+ +
+
+
+ TypeScript + package API +
+ +
+
+              
+                import{" "}
+                {"{ "}
+                {exportName}
+                {" }"}{" "}
+                from{" "}
+                "{packagePath}"
+                ;
+              
+            
+
+

+ Uses semantic tokens, visible focus, non-color state cues, logical + properties, and reduced-motion-safe feedback. Compact density is an + explicit prop, never a global compression shortcut. +

+
+
+
+ ); +} + +export function Library({ + specimens, + query, +}: { + specimens: Specimen[]; + query: string; +}) { + const filtered = specimens.filter((specimen) => + matchesSpecimen(specimen, query), + ); + + if (filtered.length === 0) { + return ( +
+ +

No matching specimens

+

Try a component name, block, state, or operational concept.

+
+ ); + } + + return ( +
+ {groupOrder.map((group) => { + const groupedSpecimens = filtered.filter( + (specimen) => specimen.group === group, + ); + + if (groupedSpecimens.length === 0) { + return null; + } + + const detail = groupDetails[group]; + + return ( +
+
+
+

+ {detail.eyebrow} +

+

{group}

+

{detail.description}

+
+ + {groupedSpecimens.length}{" "} + {groupedSpecimens.length === 1 ? "specimen" : "specimens"} + +
+
+ {groupedSpecimens.map((specimen) => { + return ; + })} +
+
+ ); + })} +
+ ); +} + +export function Lab({ density }: { density: Density }) { + const [view, setView] = useState("composer"); + const [mode, setMode] = useState("do"); + const [message, setMessage] = useState( + "Ask Cody to review the changed files", + ); + + const views: Record = { + composer: ( +
+ +

+ I found two visual regressions in the specimen shell and kept the + package boundary intact. +

+
+ +
+ ), + messages: ( +
+ + Reply + Copy + 1.2K tokens + + } + > +

+ Model selection, linked context, and send readiness remain visible + without interrupting the writing flow. +

+
+ +

+ The same primitives can carry a different familiar identity without + changing their authority or accessibility contract. +

+
+
+ ), + context: ( + + + + + + ), + actions: ( + + + + + ), + cards: ( +
+ {[ + ["Pull request", "Recover attachment ingestion", "Checks 12 / 12"], + ["Proposal", "Merge #4764 · squash", "Awaiting your tap"], + ["Attachment", "Components-preview.png", "384 KB · added by Cody"], + ["Handoff", "Vercel deployment ledger", "7 sections"], + ].map(([kind, title, meta]) => ( + + + + {kind} + + + + {title} +

{meta}

+
+ + Open in reader + +
+ ))} +
+ ), + }; + + return ( +
+ + setView(String(next))}> +
+ + {Object.keys(views).map((name) => ( + + {name} + + ))} + +
+ {Object.entries(views).map(([name, content]) => ( + +
{content}
+
+ ))} +
+
+ ); +} + +export function DensityControl({ + density, + onDensityChange, +}: { + density: Density; + onDensityChange: (density: Density) => void; +}) { + return ( +
+ + +
+ ); +} diff --git a/apps/specimens/src/use-specimens.tsx b/apps/specimens/src/use-specimens.tsx new file mode 100644 index 0000000..e5ad79a --- /dev/null +++ b/apps/specimens/src/use-specimens.tsx @@ -0,0 +1,388 @@ +import { + ActivityItem, + AttachmentChip, + BudgetPill, + Button, + Card, + CompletionPalette, + Composer, + ContextMeter, + FailureSurface, + MetricDisplay, + ModeSwitch, + PlanRow, + ResourceRow, + RunRail, + SendControl, + SessionHeader, + ToolMix, + TranscriptTurn, + type ComposerMode, +} from "@opencoven/ui"; +import { type ReactNode, useMemo, useState } from "react"; + +export type Density = "default" | "compact"; +type SpecimenGroup = "Composer" | "Run rail" | "Blocks"; +export type Specimen = { + id: string; + title: string; + group: SpecimenGroup; + primitive: string; + description: string; + states: string; + preview: ReactNode; +}; + +export const groupOrder: SpecimenGroup[] = ["Composer", "Run rail", "Blocks"]; +export const groupDetails: Record< + SpecimenGroup, + { id: string; eyebrow: string; description: string } +> = { + Composer: { + id: "group-composer", + eyebrow: "Input layer", + description: "Intent, authority, attachments, and send readiness.", + }, + "Run rail": { + id: "group-run-rail", + eyebrow: "Evidence layer", + description: "Execution evidence, limits, resources, and failure states.", + }, + Blocks: { + id: "group-blocks", + eyebrow: "Complete surfaces", + description: "Public components assembled into reusable agent workflows.", + }, +}; + +export function matchesSpecimen(specimen: Specimen, query: string) { + return `${specimen.title} ${specimen.group} ${specimen.description} ${specimen.states}` + .toLowerCase() + .includes(query.trim().toLowerCase()); +} + +export function useSpecimens(density: Density) { + const [mode, setMode] = useState("do"); + const [message, setMessage] = useState("Review the changed files"); + + return useMemo( + () => [ + { + id: "mode-switch", + title: "Mode switch", + group: "Composer", + primitive: "native", + description: + "A pressed-state control for Chat, Do, and Plan authority.", + states: "default, selected, focus, disabled", + preview: ( + + ), + }, + { + id: "send-control", + title: "Send control", + group: "Composer", + primitive: "Base UI", + description: + "The surface's one filled action, with a stable stop state.", + states: "ready, running, disabled", + preview: ( +
+ + +
+ ), + }, + { + id: "completion-palette", + title: "Completion palette", + group: "Composer", + primitive: "Base UI Menu", + description: + "Keyboard-ready slash commands in a collision-aware overlay.", + states: "closed, open, focused, disabled", + preview: ( + Open slash commands} + onSelect={() => undefined} + commands={[ + { + id: "plan", + label: "/plan", + description: "Draft a plan before acting", + shortcut: "↵", + }, + { + id: "handoff", + label: "/handoff", + description: "Write a continuation handoff", + }, + { + id: "research", + label: "/research", + description: "Start a bounded research mission", + }, + ]} + /> + ), + }, + { + id: "attachment-chip", + title: "Attachment chip", + group: "Composer", + primitive: "native", + description: "A file attachment with explicit operational state.", + states: "ready, uploading, failed", + preview: ( +
+ + + +
+ ), + }, + { + id: "metric-display", + title: "Metric display", + group: "Run rail", + primitive: "native", + description: + "Comparable run figures in stable tabular numeric typography.", + states: "neutral, success, warning, information", + preview: ( + + + + + + ), + }, + { + id: "plan-row", + title: "Plan row", + group: "Run rail", + primitive: "native", + description: + "A task step whose icon, text treatment, and label carry state.", + states: "pending, active, complete, blocked", + preview: ( + + + + + + ), + }, + { + id: "activity-item", + title: "Activity item", + group: "Run rail", + primitive: "native", + description: + "One append-only tool event using the canonical class mapping.", + states: "read, write, exec, net, running", + preview: ( +
+ + + + +
+ ), + }, + { + id: "resource-row", + title: "Resource row", + group: "Run rail", + primitive: "native", + description: + "A direction-aware path row with operation and diff evidence.", + states: "modified, added, deleted, renamed", + preview: ( + + + + + ), + }, + { + id: "tool-mix", + title: "Tool mix", + group: "Run rail", + primitive: "native", + description: + "A readable summary with one fixed read/exec/write/net order.", + states: "populated, empty", + preview: ( + + ), + }, + { + id: "failure-surface", + title: "Failure surface", + group: "Run rail", + primitive: "Base UI Button", + description: + "A durable failure receipt with quiet output and explicit next moves.", + states: "failed, retried", + preview: ( + undefined }]} + /> + ), + }, + { + id: "context-meter", + title: "Context meter", + group: "Run rail", + primitive: "native progress", + description: + "Window consumption with a fixed threshold and stable numbers.", + states: "normal, warning, full", + preview: ( +
+ + +
+ ), + }, + { + id: "budget-pill", + title: "Budget pill", + group: "Run rail", + primitive: "native", + description: + "Spend against a limit with icon, text weight, and semantic tone.", + states: "normal, warning, over", + preview: ( +
+ + + +
+ ), + }, + { + id: "composer", + title: "Composer block", + group: "Blocks", + primitive: "composition", + description: + "The full intent-taking surface, built only from public modules.", + states: "empty, ready, running, disabled", + preview: ( + + ), + }, + { + id: "run-rail", + title: "Run rail block", + group: "Blocks", + primitive: "composition", + description: + "Metrics, activity, context, and budget as one operational report.", + states: "populated, loading, empty, error", + preview: ( + + ), + }, + { + id: "transcript-turn", + title: "Transcript turn", + group: "Blocks", + primitive: "composition", + description: + "Familiar identity and provenance lead an editorial response.", + states: "streaming, complete, with artifacts", + preview: ( + + Reply + Copy + 1.8K tokens + + } + > +

+ The component source, registry item, and specimen now share one + implementation boundary. +

+
+ ), + }, + { + id: "session-header", + title: "Session header", + group: "Blocks", + primitive: "composition", + description: + "The session task, branch, execution state, and spend in one line.", + states: "pending, active, complete, blocked", + preview: ( + + ), + }, + ], + [density, message, mode], + ); +} diff --git a/apps/specimens/tsconfig.tsbuildinfo b/apps/specimens/tsconfig.tsbuildinfo index 24d9281..ce5303a 100644 --- a/apps/specimens/tsconfig.tsbuildinfo +++ b/apps/specimens/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/main.tsx","./vite.config.ts"],"version":"6.0.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/code-block.tsx","./src/home.tsx","./src/main.tsx","./src/specimens.tsx","./src/use-specimens.tsx","./vite.config.ts"],"version":"6.0.3"} \ No newline at end of file diff --git a/packages/ui/tests/specimen-site.test.tsx b/packages/ui/tests/specimen-site.test.tsx new file mode 100644 index 0000000..ec3b68d --- /dev/null +++ b/packages/ui/tests/specimen-site.test.tsx @@ -0,0 +1,271 @@ +import { fireEvent, render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { axe } from "vitest-axe"; + +import { App } from "../../../apps/specimens/src/app"; + +beforeAll(() => { + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + disconnect() {} + unobserve() {} + }, + ); +}); + +beforeEach(() => { + window.history.replaceState(null, "", "/"); + localStorage.clear(); +}); + +afterAll(() => vi.unstubAllGlobals()); + +describe("OpenCoven library site", () => { + it("provides the showcase home and real component destinations", () => { + render(); + const main = screen.getByRole("main"); + expect(within(main).getByRole("heading", { level: 1 })).toHaveTextContent( + "Thoughtful UI components", + ); + expect( + within(main).getByRole("link", { name: "Get started" }), + ).toHaveAttribute("href", "/docs/introduction"); + expect( + within(main).getByRole("link", { name: "Components" }), + ).toHaveAttribute("href", "/docs/components"); + expect(main.querySelectorAll(".showcase-tile")).toHaveLength(7); + expect( + within(main).getByRole("link", { name: "Composer" }), + ).toHaveAttribute("href", "/docs/composer"); + }); + + it("retains all sixteen specimens in the component catalog", () => { + window.history.replaceState(null, "", "/docs/components"); + render(); + expect( + screen.getByRole("main").querySelectorAll(".specimen-card"), + ).toHaveLength(16); + expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent( + "Components", + ); + }); + + it("separates direct-page preview, registry install, and React imports", async () => { + const user = userEvent.setup(); + window.history.replaceState(null, "", "/docs/session-header/"); + render(); + expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent( + "Session header", + ); + await user.click(screen.getByRole("tab", { name: "Install" })); + const install = screen.getByRole("tabpanel", { name: "Install" }); + expect(install).toHaveTextContent( + "pnpm dlx shadcn@latest add https://ui.opencoven.ai/r/session-header.json", + ); + expect(install).not.toHaveTextContent("import {"); + await user.click(screen.getByRole("tab", { name: "React API" })); + expect( + screen.getByRole("tabpanel", { name: "React API" }), + ).toHaveTextContent( + 'import { SessionHeader } from "@opencoven/ui/blocks/session-header";', + ); + }); + + it("copies the exact registry command and package import from a component page", async () => { + const user = userEvent.setup(); + window.history.replaceState(null, "", "/docs/mode-switch"); + render(); + const write = vi.spyOn(navigator.clipboard, "writeText"); + await user.click(screen.getByRole("tab", { name: "Install" })); + await user.click( + screen.getByRole("button", { name: "Copy Mode switch install command" }), + ); + expect(write).toHaveBeenLastCalledWith( + "pnpm dlx shadcn@latest add https://ui.opencoven.ai/r/mode-switch.json", + ); + await user.click(screen.getByRole("tab", { name: "React API" })); + await user.click( + screen.getByRole("button", { name: "Copy Mode switch React import" }), + ); + expect(write).toHaveBeenLastCalledWith( + 'import { ModeSwitch } from "@opencoven/ui/components/mode-switch";', + ); + write.mockRestore(); + }); + + it("opens keyboard search on docs, matches states, and restores focus on Escape", async () => { + const user = userEvent.setup(); + window.history.replaceState(null, "", "/docs/introduction"); + render(); + fireEvent.keyDown(window, { key: "k", ctrlKey: true }); + const search = screen.getByRole("searchbox", { + name: "Search documentation", + }); + expect(search).toHaveFocus(); + await user.type(search, "uploading"); + const results = screen.getByRole("navigation", { name: "Search results" }); + expect(within(results).getAllByRole("link")).toHaveLength(1); + const result = within(results).getByRole("link", { + name: /Attachment chip/, + }); + expect(result).toHaveAttribute("href", "/docs/attachment-chip"); + await user.keyboard("{ArrowDown}"); + expect(result).toHaveFocus(); + await user.keyboard("{Escape}"); + expect(search).toHaveFocus(); + expect( + screen.queryByRole("navigation", { name: "Search results" }), + ).not.toBeInTheDocument(); + }); + + it("reports empty search results without fabricating a destination", async () => { + const user = userEvent.setup(); + render(); + await user.type(screen.getByRole("searchbox"), "no-such-component"); + expect(screen.getByRole("status")).toHaveTextContent("0 results"); + expect( + screen.queryByRole("navigation", { name: "Search results" }), + ).not.toBeInTheDocument(); + }); + + it("prevents native search-field Escape from clearing and reopening results", async () => { + const user = userEvent.setup(); + render(); + const search = screen.getByRole("searchbox"); + await user.type(search, "budget"); + const escape = new KeyboardEvent("keydown", { + key: "Escape", + bubbles: true, + cancelable: true, + }); + fireEvent(search, escape); + expect(escape.defaultPrevented).toBe(true); + expect(search).toHaveValue("budget"); + expect( + screen.queryByRole("navigation", { name: "Search results" }), + ).not.toBeInTheDocument(); + }); + + it("moves through search results with arrow keys, Home, and End", async () => { + const user = userEvent.setup(); + render(); + const search = screen.getByRole("searchbox"); + await user.click(search); + const links = within( + screen.getByRole("navigation", { name: "Search results" }), + ).getAllByRole("link"); + await user.keyboard("{ArrowDown}"); + expect(links[0]).toHaveFocus(); + await user.keyboard("{ArrowDown}"); + expect(links[1]).toHaveFocus(); + await user.keyboard("{End}"); + expect(links.at(-1)).toHaveFocus(); + await user.keyboard("{ArrowDown}"); + expect(links.at(-1)).toHaveFocus(); + await user.keyboard("{Home}{ArrowUp}"); + expect(search).toHaveFocus(); + await user.keyboard("{Escape}"); + expect( + screen.queryByRole("navigation", { name: "Search results" }), + ).not.toBeInTheDocument(); + }); + + it("keeps catalog section links consistent with filtered components", async () => { + const user = userEvent.setup(); + window.history.replaceState(null, "", "/docs/components"); + render(); + await user.type(screen.getByRole("searchbox"), "uploading"); + const toc = screen.getByRole("navigation", { name: "On this page" }); + const anchors = within(toc) + .getAllByRole("link") + .filter((link) => link.getAttribute("href")?.startsWith("#")); + expect(anchors).toHaveLength(1); + for (const anchor of anchors) { + expect( + document.querySelector(anchor.getAttribute("href")!), + ).toBeInTheDocument(); + } + }); + + it("retains all five assembled lab views", async () => { + const user = userEvent.setup(); + window.history.replaceState(null, "", "/lab"); + render(); + for (const name of [ + "composer", + "messages", + "context", + "actions", + "cards", + ]) { + await user.click(screen.getByRole("tab", { name })); + expect(screen.getByRole("tabpanel", { name })).toBeVisible(); + } + }); + + it("persists scheme and density and exposes the mobile navigation state", async () => { + const user = userEvent.setup(); + window.history.replaceState(null, "", "/docs/composer"); + render(); + await user.click(screen.getByRole("button", { name: "Use light scheme" })); + await user.click(screen.getByRole("button", { name: "Compact" })); + expect(localStorage.getItem("coven-ui:scheme")).toBe("light"); + expect(localStorage.getItem("coven-ui:density")).toBe("compact"); + expect(document.documentElement).toHaveAttribute("data-density", "compact"); + await user.click(screen.getByRole("button", { name: "Open navigation" })); + expect( + screen.getByRole("button", { name: "Close navigation" }), + ).toHaveAttribute("aria-expanded", "true"); + await user.keyboard("{Escape}"); + expect( + screen.getByRole("button", { name: "Open navigation" }), + ).toHaveAttribute("aria-expanded", "false"); + }); + + it("copies setup commands and reports a clipboard failure explicitly", async () => { + const user = userEvent.setup(); + window.history.replaceState(null, "", "/docs/installation"); + render(); + const write = vi.spyOn(navigator.clipboard, "writeText"); + await user.click( + screen.getByRole("button", { name: "Copy Registry install" }), + ); + expect(write).toHaveBeenCalledWith( + "pnpm dlx shadcn@latest add @opencoven/composer", + ); + write.mockRejectedValueOnce( + new DOMException("Clipboard access denied", "NotAllowedError"), + ); + await user.click( + screen.getByRole("button", { name: "Copy Package install" }), + ); + expect( + screen.getByText("Could not copy. Select and copy the code manually."), + ).toBeVisible(); + write.mockRestore(); + }); + + it("keeps unknown routes distinct from the library home", () => { + window.history.replaceState(null, "", "/docs/not-a-component"); + render(); + expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent( + "Page not found", + ); + expect( + within(screen.getByRole("main")).getByRole("link", { + name: "Browse components", + }), + ).toHaveAttribute("href", "/docs/components"); + }); + + it("has no automated accessibility violations with search open", async () => { + const user = userEvent.setup(); + window.history.replaceState(null, "", "/docs/composer"); + const { container } = render(); + await user.click(screen.getByRole("searchbox")); + const results = await axe(container); + expect(results.violations).toHaveLength(0); + }); +}); diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json index 102e02b..b3ac73c 100644 --- a/packages/ui/tsconfig.json +++ b/packages/ui/tsconfig.json @@ -2,6 +2,7 @@ "extends": "../../tsconfig.json", "compilerOptions": { "paths": { + "@opencoven/ui": ["./src/index.ts"], "@opencoven/ui/*": ["./src/*"] }, "types": ["vitest/globals", "@testing-library/jest-dom"] diff --git a/scripts/mobile-quality-review.mjs b/scripts/mobile-quality-review.mjs index 9e8c360..2762ca7 100644 --- a/scripts/mobile-quality-review.mjs +++ b/scripts/mobile-quality-review.mjs @@ -1,688 +1,91 @@ -import { spawn } from "node:child_process"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; import path from "node:path"; - -const chromePath = process.env.CHROME_PATH; -const baseUrl = process.env.BASE_URL ?? "http://127.0.0.1:4173"; -const outputDir = path.resolve( - process.env.MOBILE_OUTPUT_DIR ?? "artifacts/mobile-quality", -); -const port = Number(process.env.MOBILE_CHROME_PORT ?? 9233); -const cases = [ - { - name: "mobile-320-dark-cozy", - width: 320, - scheme: "dark", - density: "default", - }, - { - name: "mobile-375-light-compact", - width: 375, - scheme: "light", - density: "compact", - }, - { - name: "mobile-390-dark-cozy", - width: 390, - scheme: "dark", - density: "default", - }, - { - name: "mobile-430-light-cozy", - width: 430, - scheme: "light", - density: "default", - }, - { - name: "mobile-390-dark-rtl", - width: 390, - scheme: "dark", - density: "compact", - rtl: true, - }, - { - name: "mobile-390-dark-text-200", - width: 390, - scheme: "dark", - density: "default", - textScale: 2, - }, - { - name: "mobile-320-dark-text-200", - width: 320, - scheme: "dark", - density: "default", - textScale: 2, - }, - { - name: "mobile-430-dark-text-200", - width: 430, - scheme: "dark", - density: "default", - textScale: 2, - }, -]; - -if (!chromePath) throw new Error("CHROME_PATH is required"); - -const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); -const waitForPageTarget = async (url, timeoutMs = 30_000) => { - const deadline = Date.now() + timeoutMs; - let lastError; - - while (Date.now() < deadline) { - try { - const response = await fetch(url); - if (response.ok) { - const targets = await response.json(); - const page = targets.find((entry) => entry.type === "page"); - if (page?.webSocketDebuggerUrl) return page; - lastError = new Error("Chrome has not exposed a page target"); - } else { - lastError = new Error(`${response.status} ${response.statusText}`); - } - } catch (error) { - lastError = error; - } - await sleep(150); - } - - throw new Error(`Chrome page target unavailable: ${lastError}`); -}; -const profile = await mkdtemp(path.join(tmpdir(), "opencoven-mobile-quality-")); -await mkdir(outputDir, { recursive: true }); - -const chromeOutput = []; -const chrome = spawn( - chromePath, - [ - "--headless=new", - "--no-sandbox", - "--disable-dev-shm-usage", - "--disable-gpu", - "--hide-scrollbars", - "--no-first-run", - "--no-default-browser-check", - "--disable-background-networking", - "--disable-component-update", - `--remote-debugging-port=${port}`, - `--user-data-dir=${profile}`, - "about:blank", - ], - { - stdio: ["ignore", "pipe", "pipe"], - }, -); - -chrome.stdout.on("data", (chunk) => chromeOutput.push(String(chunk))); -chrome.stderr.on("data", (chunk) => chromeOutput.push(String(chunk))); - -let socket; -try { - const target = await waitForPageTarget(`http://127.0.0.1:${port}/json/list`); - - socket = new WebSocket(target.webSocketDebuggerUrl); - await new Promise((resolve, reject) => { - socket.addEventListener("open", resolve, { once: true }); - socket.addEventListener("error", reject, { once: true }); - }); - - let id = 0; - const pending = new Map(); - const listeners = new Map(); - socket.addEventListener("message", (event) => { - const message = JSON.parse(String(event.data)); - if (message.id) { - const waiter = pending.get(message.id); - if (!waiter) return; - pending.delete(message.id); - clearTimeout(waiter.timeout); - if (message.error) waiter.reject(new Error(message.error.message)); - else waiter.resolve(message.result ?? {}); - return; - } - - for (const handler of listeners.get(message.method) ?? []) { - handler(message.params ?? {}); - } - }); - - const rejectPending = (error) => { - for (const waiter of pending.values()) { - clearTimeout(waiter.timeout); - waiter.reject(error); - } - pending.clear(); - }; - - socket.addEventListener( - "close", - () => rejectPending(new Error("Chrome DevTools connection closed")), - { once: true }, - ); - socket.addEventListener( - "error", - () => rejectPending(new Error("Chrome DevTools connection failed")), - { once: true }, - ); - - const on = (method, handler) => { - const handlers = listeners.get(method) ?? []; - handlers.push(handler); - listeners.set(method, handlers); - - return () => { - listeners.set( - method, - (listeners.get(method) ?? []).filter( - (candidate) => candidate !== handler, - ), - ); - }; - }; - - const send = (method, params = {}) => - new Promise((resolve, reject) => { - if (socket.readyState !== WebSocket.OPEN) { - reject(new Error("Chrome DevTools connection is not open")); - return; - } - - const requestId = ++id; - const timeout = setTimeout(() => { - pending.delete(requestId); - reject(new Error(`${method} timed out`)); - }, 15_000); - pending.set(requestId, { resolve, reject, timeout }); - socket.send(JSON.stringify({ id: requestId, method, params })); - }); - - const waitForEvent = (method, timeoutMs = 15_000) => - new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - removeListener(); - reject(new Error(`Timed out waiting for ${method}`)); - }, timeoutMs); - const removeListener = on(method, (params) => { - clearTimeout(timeout); - removeListener(); - resolve(params); - }); - }); - - const evaluate = async (expression) => { - const result = await send("Runtime.evaluate", { - expression, - awaitPromise: true, - returnByValue: true, - }); - - if (result.exceptionDetails) { - throw new Error( - result.exceptionDetails.exception?.description ?? - result.exceptionDetails.text, - ); - } - - return result.result?.value; - }; - - const waitForRender = async (selector, timeoutMs = 15_000) => { - const deadline = Date.now() + timeoutMs; - - while (Date.now() < deadline) { - if ( - await evaluate( - `Boolean(document.querySelector(${JSON.stringify(selector)}))`, - ) - ) { - return; - } - await sleep(100); - } - - throw new Error(`Timed out waiting for ${selector}`); - }; - - const navigate = async (url) => { - const loaded = waitForEvent("Page.loadEventFired"); - const response = await send("Page.navigate", { url }); - if (response.errorText) { - throw new Error(`Navigation failed: ${response.errorText}`); - } - await loaded; - await waitForRender("#specimen-main"); - }; - - await send("Page.enable"); - await send("Runtime.enable"); - await send("Emulation.setEmulatedMedia", { - features: [{ name: "prefers-reduced-motion", value: "reduce" }], - }); - - const results = []; - - for (const scenario of cases) { - await send("Emulation.setDeviceMetricsOverride", { - width: scenario.width, - height: 900, - deviceScaleFactor: 1, - mobile: true, - screenWidth: scenario.width, - screenHeight: 900, - }); - - await navigate(new URL("/", baseUrl).href); - await evaluate(`(() => { - localStorage.setItem("coven-ui:scheme", ${JSON.stringify(scenario.scheme)}); - localStorage.setItem("coven-ui:density", ${JSON.stringify(scenario.density)}); - })()`); - await navigate(new URL("/", baseUrl).href); - - await evaluate(`(() => { - document.documentElement.dir = ${JSON.stringify( - scenario.rtl ? "rtl" : "ltr", - )}; - document.documentElement.style.fontSize = ${JSON.stringify( - scenario.textScale ? `${scenario.textScale * 100}%` : "", - )}; - })()`); - - const measurement = await evaluate(`(async () => { - await document.fonts.ready; - await new Promise((resolve) => - requestAnimationFrame(() => requestAnimationFrame(resolve)), - ); - - const root = document.documentElement; - const topbar = document.querySelector(".specimen-topbar"); - const rail = document.querySelector(".specimen-rail"); - const railLinks = [ - ...document.querySelectorAll(".specimen-rail__nav a"), - ]; - const topbarControls = [ - ...document.querySelectorAll( - ".specimen-brand, .surface-switcher, .specimen-search, .specimen-search input, .specimen-search kbd, .density-control, .scheme-control", - ), - ]; - const firstCard = document.querySelector(".specimen-card"); - const cards = [...document.querySelectorAll(".specimen-card")]; - const stages = [...document.querySelectorAll(".specimen-stage")]; - const cardTabRoots = cards - .map((card) => card.querySelector(':scope > [data-slot="tabs"]')) - .filter(Boolean); - const cardLists = cardTabRoots - .map((tabs) => tabs.querySelector(':scope > [data-slot="tabs-list"]')) - .filter(Boolean); - const activePanels = cardTabRoots - .map((tabs) => tabs.querySelector(':scope > [data-slot="tabs-content"]')) - .filter(Boolean); - const transcript = document.querySelector( - '#transcript-turn [data-slot="transcript-turn"]', - ); - const session = document.querySelector( - '#session-header [data-slot="session-header"]', - ); - const sessionTitle = session?.querySelector("strong"); - const clipped = (element) => - element ? Math.max(0, element.scrollWidth - element.clientWidth) : 0; - const rect = (element) => element?.getBoundingClientRect(); - const tabHeights = cardLists.flatMap((list) => - [...list.querySelectorAll('[role="tab"]')].map( - (tab) => rect(tab)?.height ?? 0, - ), - ); - const tabTargets = cardLists.flatMap((list) => [ - ...list.querySelectorAll('[role="tab"]'), - ]); - const label = (element) => - element - ? [ - element.tagName.toLowerCase(), - element.id ? "#" + element.id : "", - ...[...element.classList] - .slice(0, 3) - .map((name) => "." + name), - ].join("") - : null; - const overflowingElements = [...document.querySelectorAll("body *")] - .map((element) => { - const bounds = rect(element); - const overflow = bounds - ? Math.max(0, -bounds.left, bounds.right - root.clientWidth) - : 0; - return { - label: label(element), - parent: label(element.parentElement), - left: bounds?.left ?? null, - right: bounds?.right ?? null, - width: bounds?.width ?? null, - overflow, - parentOverflowX: element.parentElement - ? getComputedStyle(element.parentElement).overflowX - : null, - }; - }) - .filter(({ overflow }) => overflow > 1) - .sort((left, right) => right.overflow - left.overflow) - .slice(0, 20); - const initialScrollY = scrollY; - document.querySelector(".skip-link")?.click(); - await new Promise((resolve) => - requestAnimationFrame(() => requestAnimationFrame(resolve)), - ); - const skipMainBounds = rect(document.querySelector("#specimen-main")); - const skipTopbarBounds = rect(topbar); - const skipNavigation = { - clearance: skipMainBounds - ? skipMainBounds.top - Math.max(0, skipTopbarBounds?.bottom ?? 0) - : null, - scrollY, - mainTop: skipMainBounds?.top ?? null, - topbarBottom: skipTopbarBounds?.bottom ?? null, - topbarHeight: skipTopbarBounds?.height ?? null, - topbarPosition: topbar ? getComputedStyle(topbar).position : null, - scrollPaddingTop: getComputedStyle(root).scrollPaddingTop, - }; - scrollTo(0, initialScrollY); - await new Promise((resolve) => requestAnimationFrame(resolve)); - const railLink = document.querySelector(".specimen-rail__nav a"); - const railTarget = railLink - ? document.querySelector(railLink.getAttribute("href")) - : null; - railLink?.click(); - await new Promise((resolve) => - requestAnimationFrame(() => requestAnimationFrame(resolve)), - ); - const railTargetClearance = (() => { - const targetBounds = rect(railTarget); - const topbarBounds = rect(topbar); - return targetBounds - ? targetBounds.top - Math.max(0, topbarBounds?.bottom ?? 0) - : null; - })(); - scrollTo(0, initialScrollY); - await new Promise((resolve) => requestAnimationFrame(resolve)); - - return { - viewport: root.clientWidth, - documentOverflow: Math.max(0, root.scrollWidth - root.clientWidth), - chromeBottom: Math.max( - rect(topbar)?.bottom ?? 0, - rect(rail)?.bottom ?? 0, - ), - firstCardTop: rect(firstCard)?.top ?? null, - railNavOverflow: clipped( - document.querySelector(".specimen-rail__nav"), - ), - maxRailLinkContentOverflow: Math.max( - 0, - ...railLinks.map(clipped), - ), - maxRailLinkViewportOverflow: Math.max( - 0, - ...railLinks.map((link) => { - const bounds = rect(link); - return bounds - ? Math.max(0, -bounds.left, bounds.right - root.clientWidth) - : 0; - }), - ), - maxTopbarControlViewportOverflow: Math.max( - 0, - ...topbarControls.map((control) => { - const bounds = rect(control); - return bounds - ? Math.max(0, -bounds.left, bounds.right - root.clientWidth) - : 0; - }), - ), - cardCount: cards.length, - tabRootCount: cardTabRoots.length, - tabListCount: cardLists.length, - activePanelCount: activePanels.length, - tabTargetCount: tabHeights.length, - maxCardOverflow: Math.max(0, ...cards.map(clipped)), - maxStageOverflow: Math.max(0, ...stages.map(clipped)), - maxTabRootOverflow: Math.max(0, ...cardTabRoots.map(clipped)), - maxTabListOverflow: Math.max(0, ...cardLists.map(clipped)), - maxTabTargetContentOverflow: Math.max( - 0, - ...tabTargets.map(clipped), - ), - minTabHeight: tabHeights.length > 0 ? Math.min(...tabHeights) : null, - stackedTabs: cardLists.every((list, index) => { - const listRect = rect(list); - const panelRect = rect(activePanels[index]); - return listRect && panelRect && panelRect.top >= listRect.bottom - 1; - }), - fullWidthTabs: cardLists.every((list, index) => { - const listRect = rect(list); - const rootRect = rect(cardTabRoots[index]); - return ( - listRect && - rootRect && - Math.abs(listRect.width - rootRect.width) <= 1 - ); - }), - transcriptOverflow: clipped(transcript), - sessionOverflow: clipped(session), - sessionTitleEllipsized: sessionTitle - ? getComputedStyle(sessionTitle).textOverflow === "ellipsis" - : null, - direction: root.dir, - reducedMotion: matchMedia("(prefers-reduced-motion: reduce)").matches, - skipNavigation, - railTargetClearance, - overflowingElements, - }; - })()`); - - const failures = []; - if (measurement.cardCount !== 16) { - failures.push(`expected 16 cards, got ${measurement.cardCount}`); - } - if (measurement.tabRootCount !== 16) { - failures.push( - `expected 16 card tab roots, got ${measurement.tabRootCount}`, - ); - } - if (measurement.tabListCount !== 16) { - failures.push( - `expected 16 card tab lists, got ${measurement.tabListCount}`, - ); - } - if (measurement.activePanelCount !== 16) { - failures.push( - `expected 16 active card panels, got ${measurement.activePanelCount}`, - ); - } - if (measurement.tabTargetCount !== 48) { - failures.push( - `expected 48 card tab targets, got ${measurement.tabTargetCount}`, - ); - } - if (measurement.documentOverflow > 1) { - failures.push( - `document overflow ${measurement.documentOverflow}px: ${measurement.overflowingElements - .map(({ label, overflow }) => `${label} (${overflow}px)`) - .join(", ")}`, - ); - } - if (measurement.maxRailLinkViewportOverflow > 1) { - failures.push( - `rail link viewport overflow ${measurement.maxRailLinkViewportOverflow}px`, - ); - } - if (measurement.railNavOverflow > 1) { - failures.push( - `rail navigation overflow ${measurement.railNavOverflow}px`, - ); - } - if (measurement.maxRailLinkContentOverflow > 1) { - failures.push( - `rail link content overflow ${measurement.maxRailLinkContentOverflow}px`, - ); - } - if (measurement.maxTopbarControlViewportOverflow > 1) { - failures.push( - `topbar control viewport overflow ${measurement.maxTopbarControlViewportOverflow}px`, - ); - } - if ( - measurement.skipNavigation.clearance === null || - measurement.skipNavigation.clearance < 0 - ) { - failures.push( - `skip target clearance ${measurement.skipNavigation.clearance ?? "missing"}px`, - ); - } - if ( - measurement.railTargetClearance === null || - measurement.railTargetClearance < 0 || - measurement.railTargetClearance > 64 - ) { - failures.push( - `rail target clearance ${measurement.railTargetClearance ?? "missing"}px`, - ); - } - if (!scenario.textScale && measurement.chromeBottom > 200) { - failures.push( - `mobile shell chrome ends at ${measurement.chromeBottom}px`, - ); - } - if ( - !scenario.textScale && - (measurement.firstCardTop === null || measurement.firstCardTop > 640) - ) { - failures.push( - `first card starts at ${measurement.firstCardTop ?? "missing"}px`, - ); - } - if (measurement.maxCardOverflow > 1) { - failures.push(`card overflow ${measurement.maxCardOverflow}px`); - } - if (measurement.maxStageOverflow > 1) { - failures.push(`stage overflow ${measurement.maxStageOverflow}px`); - } - if (measurement.maxTabRootOverflow > 1) { - failures.push(`tab-root overflow ${measurement.maxTabRootOverflow}px`); - } - if (!scenario.textScale && measurement.maxTabListOverflow > 1) { - failures.push(`tab-list overflow ${measurement.maxTabListOverflow}px`); - } - if (measurement.maxTabTargetContentOverflow > 1) { - failures.push( - `tab target content overflow ${measurement.maxTabTargetContentOverflow}px`, - ); - } - if (measurement.minTabHeight === null || measurement.minTabHeight < 44) { - failures.push( - `tab target ${measurement.minTabHeight ?? "missing"}px < 44px`, - ); - } - if (!measurement.stackedTabs) { - failures.push("card tabs are not stacked above their active panels"); - } - if (!measurement.fullWidthTabs) { - failures.push("card tab lists do not consume the mobile content width"); - } - if (measurement.transcriptOverflow > 1) { - failures.push(`transcript overflow ${measurement.transcriptOverflow}px`); - } - if (measurement.sessionOverflow > 1) { - failures.push(`session header overflow ${measurement.sessionOverflow}px`); - } - if (measurement.sessionTitleEllipsized) { - failures.push("session title is ellipsized on mobile"); - } - if (!measurement.reducedMotion) { - failures.push("reduced-motion media query was not active"); - } - if (scenario.rtl && measurement.direction !== "rtl") { - failures.push("RTL direction was not applied"); - } - - const image = await send("Page.captureScreenshot", { - format: "png", - fromSurface: true, - captureBeyondViewport: false, - }); - const screenshot = `${scenario.name}.png`; - await writeFile( - path.join(outputDir, screenshot), - Buffer.from(image.data, "base64"), - ); - results.push({ ...scenario, measurement, failures, screenshot }); - } - - const summary = { - generatedAt: new Date().toISOString(), - passed: results.every((entry) => entry.failures.length === 0), - results, - }; - await writeFile( - path.join(outputDir, "summary.json"), - `${JSON.stringify(summary, null, 2)}\n`, - ); - await writeFile( - path.join(outputDir, "README.md"), - [ - "# OpenCoven UI mobile quality review", - "", - `Result: **${summary.passed ? "PASS" : "FAIL"}**`, - "", - "| Scenario | Width | Document overflow | Stage overflow | Min tab target | Result |", - "|---|---:|---:|---:|---:|---|", - ...results.map( - (entry) => - `| ${entry.name} | ${entry.width}px | ${entry.measurement.documentOverflow}px | ${entry.measurement.maxStageOverflow}px | ${entry.measurement.minTabHeight}px | ${entry.failures.length ? entry.failures.join("; ") : "PASS"} |`, - ), - "", - ].join("\n"), - ); - - if (!summary.passed) { - throw new Error( - results - .flatMap((entry) => - entry.failures.map((failure) => `${entry.name}: ${failure}`), - ) - .join("\n"), - ); - } -} catch (error) { - await writeFile( - path.join(outputDir, "chrome.log"), - `${chromeOutput.join("")}\n`, - ); - throw error; -} finally { - socket?.close(); - chrome.kill(); - await new Promise((resolve) => { - if (chrome.exitCode !== null) { - resolve(); - return; - } - const timeout = setTimeout(resolve, 2000); - chrome.once("exit", () => { - clearTimeout(timeout); - resolve(); - }); - }); - let profileRemoved = false; - for (let attempt = 0; attempt < 5; attempt += 1) { - try { - await rm(profile, { recursive: true, force: true }); - profileRemoved = true; - break; - } catch { - await sleep(100 * (attempt + 1)); - } - } - if (!profileRemoved) { - console.warn(`Unable to remove temporary Chrome profile: ${profile}`); - } -} +import { runReview } from "./visual-review.mjs"; + +await runReview({ + name: "mobile quality review", + baseUrl: process.env.BASE_URL ?? "http://127.0.0.1:4173", + chromePath: process.env.CHROME_PATH, + outputDir: path.resolve( + process.env.MOBILE_OUTPUT_DIR ?? "artifacts/mobile-quality", + ), + port: Number(process.env.MOBILE_CHROME_PORT ?? 9233), + scenarios: [ + { name: "home-320-dark", pathname: "/", width: 320 }, + { + name: "home-320-text-200", + pathname: "/", + width: 320, + textScale: 2, + captureMenu: true, + captureHomeDetails: true, + }, + { + name: "home-390-text-200", + pathname: "/", + width: 390, + textScale: 2, + captureMenu: true, + }, + { + name: "home-430-light-rtl", + pathname: "/", + width: 430, + scheme: "light", + rtl: true, + }, + { + name: "docs-introduction-320-text-200", + pathname: "/docs/introduction", + width: 320, + textScale: 2, + }, + { + name: "docs-installation-430-text-200", + pathname: "/docs/installation", + width: 430, + textScale: 2, + }, + { name: "catalog-320-dark", pathname: "/docs/components", width: 320 }, + { + name: "catalog-375-light-compact", + pathname: "/docs/components", + width: 375, + scheme: "light", + density: "compact", + }, + { + name: "catalog-390-dark-rtl", + pathname: "/docs/components", + width: 390, + density: "compact", + rtl: true, + }, + { + name: "catalog-320-text-200", + pathname: "/docs/components", + width: 320, + textScale: 2, + }, + { + name: "catalog-390-text-200", + pathname: "/docs/components", + width: 390, + textScale: 2, + }, + { + name: "catalog-430-text-200", + pathname: "/docs/components", + width: 430, + textScale: 2, + }, + { + name: "component-390-text-200", + pathname: "/docs/session-header", + width: 390, + textScale: 2, + captureCode: true, + }, + { name: "lab-320-dark", pathname: "/lab", width: 320 }, + { name: "lab-430-text-200", pathname: "/lab", width: 430, textScale: 2 }, + ].map((scenario) => ({ ...scenario, mobile: true })), +}); diff --git a/scripts/verify-contracts.mjs b/scripts/verify-contracts.mjs index c1fb5a8..8a04a3f 100644 --- a/scripts/verify-contracts.mjs +++ b/scripts/verify-contracts.mjs @@ -11,6 +11,12 @@ const [ specimenCss, specimenFixes, specimenApp, + specimenCards, + specimenDefinitions, + specimenHome, + siteCss, + specimenMain, + registryJson, button, tooltip, menu, @@ -26,6 +32,12 @@ const [ read("apps/specimens/src/specimens.css"), read("apps/specimens/src/specimens-fixes.css"), read("apps/specimens/src/app.tsx"), + read("apps/specimens/src/specimens.tsx"), + read("apps/specimens/src/use-specimens.tsx"), + read("apps/specimens/src/home.tsx"), + read("apps/specimens/src/site.css"), + read("apps/specimens/src/main.tsx"), + read("registry.json"), read("packages/ui/src/components/ui/button.tsx"), read("packages/ui/src/components/ui/tooltip.tsx"), read("packages/ui/src/components/ui/dropdown-menu.tsx"), @@ -40,11 +52,36 @@ const config = JSON.parse(componentsJson); const manifest = JSON.parse(packageJson); const portable = JSON.parse(portableJson); const vectors = JSON.parse(vectorsJson); -const specimenStyles = `${specimenCss}\n${specimenFixes}`; -const specimenAt68 = specimenCss.slice( - specimenCss.indexOf("@media (max-width: 68rem)"), - specimenCss.indexOf("@media (max-width: 48rem)"), -); +const specimenStyles = `${specimenCss}\n${specimenFixes}\n${siteCss}`; +const siteSources = `${specimenApp}\n${specimenCards}\n${specimenHome}`; +const registry = JSON.parse(registryJson); +const componentIds = [ + "mode-switch", + "send-control", + "completion-palette", + "attachment-chip", + "metric-display", + "plan-row", + "activity-item", + "resource-row", + "tool-mix", + "failure-surface", + "context-meter", + "budget-pill", + "composer", + "run-rail", + "transcript-turn", + "session-header", +]; +const featuredIds = [ + "composer", + "mode-switch", + "budget-pill", + "activity-item", + "run-rail", + "context-meter", + "transcript-turn", +]; const assertions = [ ["style is base-nova", config.style === "base-nova"], ["base color is zinc", config.tailwind.baseColor === "zinc"], @@ -103,43 +140,90 @@ const assertions = [ ), ], [ - "specimen shell has stable landmarks", - specimenApp.includes('className="specimen-topbar"') && - specimenApp.includes('className="specimen-rail"') && + "site shell has stable landmarks and a focusable skip target", + specimenApp.includes('className="site-header"') && + specimenApp.includes('className="docs-sidebar"') && + specimenApp.includes('id="site-navigation"') && specimenApp.includes('id="specimen-main"') && - specimenApp.includes('className="skip-link"'), + specimenApp.includes('className="skip-link"') && + specimenApp.includes('href="#specimen-main"') && + specimenApp.includes("tabIndex={-1}") && + specimenHome.includes("tabIndex={-1}"), + ], + [ + "home is a distinct curated entry point", + specimenApp.includes('const isHome = path === "/"') && + specimenApp.includes("") && + specimenHome.includes('className="home-hero"') && + featuredIds.every((id) => specimenHome.includes(`"${id}"`)) && + specimenHome.includes("specimen.preview") && + specimenHome.includes("href={`/docs/${id}`}") && + specimenHome.includes('href="/docs/introduction"') && + specimenHome.includes('href="/docs/components"') && + specimenApp.includes(""), + ], + [ + "docs, individual specimens and assembled lab have real routes", + specimenApp.includes('path.startsWith("/docs/")') && + specimenApp.includes('id: "introduction"') && + specimenApp.includes('id: "installation"') && + specimenApp.includes('pageId === "components"') && + specimenApp.includes("") && + specimenApp.includes('