diff --git a/README.md b/README.md index f39de46..2639ceb 100644 --- a/README.md +++ b/README.md @@ -23,10 +23,10 @@ quietly drift back into copied HTML. | Icons | Lucide | | Distribution | `@opencoven/ui` package and shadcn registry | -Zinc supplies scaffold structure only. Colors, display/reading/mono font roles, +Zinc supplies scaffold structure only. The package's colors and display/reading/mono font roles, and the 4/8/12/16/24/32/48/64/96px spacing scale map to the pinned canonical -brand profile. Fonts use its local/system fallback stacks; no font files or -third-party font requests are bundled. Components consume semantic tokens such +brand profile. Package fonts use its local/system fallback stacks without +bundled font files or third-party font requests. Components consume semantic tokens such as `background`, `card`, `presence`, `success`, and the canonical `tool-read`/`tool-write`/`tool-exec`/`tool-net` mappings. @@ -69,15 +69,55 @@ direct navigation, while the selected entry follows page scrolling. Library and Lab use one fixed compact layout, without density controls or density labels. Public component density props remain available to consumers. -The library presents one specimen per row, with bounded live surfaces, -responsive block layouts, and a compact installation strip. It keeps live previews above syntax-highlighted install commands, +The app uses the [docs UI](https://docs.opencoven.ai/docs/guide/getting-started) +neutral backgrounds (`#121212` dark, `#f5f5f5` light), with Cave's **Coven** +default lavender accents, Inter for documentation headings and interface text, +and JetBrains Mono for code and numeric labels. EB Garamond remains available +through the Cave editorial token but is not preloaded for documentation pages. +The Cave reference is +[`foundations.css` at `47afd756`](https://github.com/OpenCoven/coven-cave/blob/47afd75644ad9e28136f480b4185409541adb68f/src/styles/globals/foundations.css). +Color conveys interaction or semantic state, not catalog categories. +Dark is the initial scheme; saved light preferences are respected. +`coven-theme.css` scopes these aliases to the app, including portaled menus. +The official mark, shared spacing scale, and public package/registry tokens stay +unchanged. + +The three Latin variable fonts are self-hosted, with no runtime font requests +to third parties. Their SIL Open Font License 1.1 files are included alongside +each font in `public/fonts/`. + +Component headings link directly to their specimens. Search announces the +result count and provides a clear action beside the query; Escape clears the +query while the search field is focused. +The slim desktop header centers search between balanced side columns, with +navigation on the left and an icon-only theme action on the right. Below +1088px, search occupies its own centered row with touch-sized controls. + +The presentation follows [shadcn's component documentation](https://ui.shadcn.com/docs/components/button): +sans-serif headings, quiet grouped navigation, a unified neutral shell, and +text-only Preview/Source tabs above a bordered canvas. The OpenCoven mark, +original column widths, complete catalog, and stateful demos remain intact. + +The library uses a compact documentation header and one specimen per row, with +a single bounded preview canvas rather than nested outer cards. Small controls +retain their intrinsic width on mobile; composed surfaces use the available +space. Install/Import tabs and their code panels use the full content width +beneath the preview. It keeps live previews above syntax-highlighted install commands, imports, and full component source, with copy controls and explicit clipboard-failure feedback. The shared code renderer supports Bash, TypeScript/TSX, and CSS. Each specimen has Preview/Source tabs in a fixed-size preview area, so opening source does not change the card or page height. Preview or Escape returns to the same component state; source scrolls inside that area. Supported states live in a separate, always-visible footer rather -than a tab. The Lab +than a tab. + +Related specimens expose **Open in Lab** links to their matching scene. +Scene URLs such as `/lab#run-rail` are shareable; scene changes replace the +current fragment without adding history entries. Browser hash history preserves +mounted drafts, and unknown scene fragments normalize to Composer. +**View in library** returns to the relevant specimen. + +The Lab fits the viewport and offers six scenes through tabs, previous/next controls, and touch swipes. Only the active scene scrolls when space is limited or text is enlarged; the page and navigation stay fixed. Demo actions are local and diff --git a/apps/specimens/index.html b/apps/specimens/index.html index 3d576d3..670933f 100644 --- a/apps/specimens/index.html +++ b/apps/specimens/index.html @@ -1,14 +1,41 @@ - + + + - + + OpenCoven UI — Built for agents. Made for humans. diff --git a/apps/specimens/src/app.tsx b/apps/specimens/src/app.tsx index 1a15c94..df60ffd 100644 --- a/apps/specimens/src/app.tsx +++ b/apps/specimens/src/app.tsx @@ -3,7 +3,6 @@ import { ActivityItem, AttachmentChip, - Badge, BudgetPill, Button, Card, @@ -23,13 +22,14 @@ import { TranscriptTurn, type ComposerMode, } from "@opencoven/ui"; -import { Box, Braces, Layers3, Moon, Sparkles, Sun } from "lucide-react"; +import { ArrowUpRight, Link2, Moon, SearchX, Sun, X } from "lucide-react"; import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"; import { ComposerDemo, RunRailDemo } from "./block-demos"; import { CodeSnippet } from "./code-snippet"; import { ComponentPreview } from "./component-preview"; import { Lab } from "./lab"; +import { labScenes } from "./lab-scenes"; import { CompletionPaletteExample, FailureSurfaceExample, @@ -44,7 +44,6 @@ type Specimen = { id: string; title: string; group: SpecimenGroup; - primitive: string; description: string; states: string; preview: ReactNode; @@ -64,26 +63,21 @@ const componentSources = import.meta.glob( { query: "?raw", import: "default", eager: true }, ); -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.", - }, -}; +const groupDetails: Record = + { + Composer: { + id: "group-composer", + description: "Intent, authority, attachments, and send readiness.", + }, + "Run rail": { + id: "group-run-rail", + description: "Execution evidence, limits, resources, and failure states.", + }, + Blocks: { + id: "group-blocks", + description: "Public components assembled into reusable agent workflows.", + }, + }; function SpecimenCard({ specimen }: { specimen: Specimen }) { const sourceKind = specimen.group === "Blocks" ? "blocks" : "components"; @@ -95,6 +89,7 @@ function SpecimenCard({ specimen }: { specimen: Specimen }) { const registryUrl = `https://ui.opencoven.ai/r/${specimen.id}.json`; const packagePath = `@opencoven/ui/${sourceKind}/${specimen.id}`; const importCode = `import { ${exportName} } from "${packagePath}";`; + const labScene = labScenes.find((scene) => scene.source === specimen.id); const source = componentSources[ `../../../packages/ui/src/${sourceKind}/${specimen.id}.tsx` @@ -110,15 +105,25 @@ function SpecimenCard({ specimen }: { specimen: Specimen }) { >
+

+ + {specimen.title} + +

- {sourceKind === "blocks" ? : } {sourceKind === "blocks" ? "Block" : "Component"} - {specimen.primitive}
-

- {specimen.title} -

+ {labScene ? ( + + Open in Lab + ) : null}

{specimen.description}

- CLI - React API + Install + Import
- - + Supported states
    {specimen.states.split(", ").map((state) => (
  • {state}
  • @@ -175,7 +178,6 @@ function useSpecimens() { 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", @@ -187,7 +189,6 @@ function useSpecimens() { 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", @@ -197,7 +198,6 @@ function useSpecimens() { 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", @@ -207,7 +207,6 @@ function useSpecimens() { id: "attachment-chip", title: "Attachment chip", group: "Composer", - primitive: "native", description: "A file attachment with explicit operational state.", states: "ready, uploading, failed", preview: ( @@ -226,7 +225,6 @@ function useSpecimens() { 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", @@ -242,7 +240,6 @@ function useSpecimens() { 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", @@ -262,7 +259,6 @@ function useSpecimens() { 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", @@ -279,7 +275,6 @@ function useSpecimens() { 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", @@ -304,7 +299,6 @@ function useSpecimens() { 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", @@ -323,7 +317,6 @@ function useSpecimens() { 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", @@ -333,7 +326,6 @@ function useSpecimens() { 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", @@ -348,7 +340,6 @@ function useSpecimens() { 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", @@ -364,7 +355,6 @@ function useSpecimens() { 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", @@ -374,7 +364,6 @@ function useSpecimens() { 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", @@ -384,7 +373,6 @@ function useSpecimens() { id: "transcript-turn", title: "Transcript turn", group: "Blocks", - primitive: "composition", description: "Familiar identity and provenance lead an editorial response.", states: "streaming, complete, with artifacts", @@ -414,7 +402,6 @@ function useSpecimens() { 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", @@ -435,18 +422,22 @@ function useSpecimens() { function Library({ specimens }: { specimens: Specimen[] }) { if (specimens.length === 0) { return ( -
    +
    -

    No matching specimens

    -

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

    +

    No matches found

    +

    Try a component like Composer, or a state like running.

    ); } return ( -
    +
    {groupOrder.map((group) => { const groupedSpecimens = specimens.filter( (specimen) => specimen.group === group, @@ -462,16 +453,9 @@ function Library({ specimens }: { specimens: Specimen[] }) {
    -

    - {detail.eyebrow} -

    {group}

    {detail.description}

    - - {groupedSpecimens.length}{" "} - {groupedSpecimens.length === 1 ? "specimen" : "specimens"} -
    {groupedSpecimens.map((specimen) => ( @@ -601,10 +585,7 @@ function CatalogNavigation({ data-group={group} key={group} > -

    - {group} - {items.length} -

    +

    {group}

      {items.map((specimen) => (
    • @@ -655,7 +636,6 @@ function CatalogNavigation({
      Install @opencoven/ui -

      Semantic source, package exports, and registry remain aligned.

      ); @@ -668,25 +648,40 @@ function App() { const [query, setQuery] = useState(""); const searchRef = useRef(null); const topbarRef = useRef(null); + const mainRef = useRef(null); const normalizedPath = window.location.pathname.replace(/\/+$/, "") || "/"; const isLab = normalizedPath === "/lab"; const specimens = useSpecimens(); + const normalizedQuery = query.trim().toLowerCase(); + const isSearching = normalizedQuery.length > 0; const filteredSpecimens = useMemo(() => { - const normalizedQuery = query.trim().toLowerCase(); return specimens.filter((specimen) => `${specimen.title} ${specimen.group} ${specimen.description} ${specimen.states}` .toLowerCase() .includes(normalizedQuery), ); - }, [query, specimens]); + }, [normalizedQuery, specimens]); const activeId = useCatalogLocation(filteredSpecimens, !isLab); const activeGroup = filteredSpecimens.find( (specimen) => specimen.id === activeId, )?.group; + function clearSearch() { + setQuery(""); + searchRef.current?.focus(); + } + useEffect(() => { document.documentElement.classList.toggle("dark", scheme === "dark"); document.documentElement.dataset.density = density; + document + .querySelector('meta[name="theme-color"]') + ?.setAttribute( + "content", + getComputedStyle(document.documentElement) + .getPropertyValue("--oc-bg") + .trim(), + ); try { localStorage.setItem("coven-ui:scheme", scheme); localStorage.removeItem("coven-ui:density"); @@ -731,47 +726,85 @@ function App() { return (
      - + { + if (isLab) { + event.preventDefault(); + mainRef.current?.focus({ preventScroll: true }); + } + }} + > Skip to specimens
      - - - -
      - {!isLab ? ( + +
      + {!isLab ? ( +
      setQuery(event.target.value)} + onKeyDown={(event) => { + if ( + event.key === "Escape" && + !event.nativeEvent.isComposing && + query + ) { + event.preventDefault(); + clearSearch(); + } + }} + aria-label="Search components" + aria-controls="component-catalog" + aria-keyshortcuts="Control+K Meta+K" placeholder="Search components…" - shortcut="⌘K" - className="specimen-search" + shortcut={query ? undefined : "⌘K"} /> - ) : null} + {query ? ( + + ) : null} +
      + ) : null} +
      @@ -793,7 +825,12 @@ function App() { /> ) : null} -
      +
      -

      - {isLab - ? "The component workbench" - : "Sixteen public building blocks"} -

      -

      - {isLab ? ( - <> - A little room to experiment. - - ) : ( - <> - Agent surfaces. -
      - Made tangible. - - )} -

      +

      {isLab ? "Component lab" : "Component library"}

      {isLab - ? "Six scenes. Real components. One focused canvas." - : "Try the interaction, then take the source. Composable inputs, execution evidence, and complete agent workflows."} + ? `${labScenes.length} interactive scenes, built from the library.` + : `${specimens.length} components and blocks. Preview, inspect, and reuse.`}

      -
      -
      -
      {isLab ? "Views" : "Specimens"}
      -
      {isLab ? "06" : "16"}
      -
      -
      -
      Schemes
      -
      02
      -
      -
      {isLab ? ( ) : ( - + <> +
      +

      + {isSearching + ? `${filteredSpecimens.length} ${filteredSpecimens.length === 1 ? "result" : "results"}` + : `All ${specimens.length} components`} +

      +
      + + )}
      diff --git a/apps/specimens/src/component-preview.tsx b/apps/specimens/src/component-preview.tsx index 853f3aa..27d5696 100644 --- a/apps/specimens/src/component-preview.tsx +++ b/apps/specimens/src/component-preview.tsx @@ -1,5 +1,4 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@opencoven/ui"; -import { Braces, Eye } from "lucide-react"; import { type ReactNode, useRef, useState } from "react"; import { CodeSnippet } from "./code-snippet"; @@ -35,16 +34,15 @@ function ComponentPreview({ className="specimen-preview__tabs" > - - - + Source
      {/* Keep the live component's dimensions and state behind its source. */} diff --git a/apps/specimens/src/coven-theme.css b/apps/specimens/src/coven-theme.css new file mode 100644 index 0000000..aa7188e --- /dev/null +++ b/apps/specimens/src/coven-theme.css @@ -0,0 +1,114 @@ +@font-face { + font-family: "Inter"; + font-style: normal; + font-weight: 100 900; + font-display: swap; + src: url("/fonts/inter/inter-latin.woff2") format("woff2"); +} + +@font-face { + font-family: "EB Garamond"; + font-style: normal; + font-weight: 400 700; + font-display: swap; + src: url("/fonts/eb-garamond/eb-garamond-latin.woff2") format("woff2"); +} + +@font-face { + font-family: "JetBrains Mono"; + font-style: normal; + font-weight: 400 700; + font-display: swap; + src: url("/fonts/jetbrains-mono/jetbrains-mono-latin.woff2") format("woff2"); +} + +/* Cave's Coven default typography/accents, with the docs UI's neutral surfaces. */ +:root[data-presentation="coven"] { + --oc-bg: #f5f5f5; + --oc-surface-1: #f1f1f1; + --oc-surface-2: #ededed; + --oc-surface-3: #e3e3e3; + --coven-panel: var(--oc-surface-1); + --oc-text: #0a0a0a; + --oc-text-muted: oklch(0.42 0.009 291); + --oc-text-subtle: color-mix(in oklch, var(--oc-text) 76%, transparent); + --oc-border: color-mix(in oklch, var(--oc-text) 12%, transparent); + --oc-border-strong: color-mix(in oklch, var(--oc-text) 60%, transparent); + --oc-presence: #6859ac; + --oc-on-presence: var(--oc-bg); + --oc-action: var(--oc-presence); + --oc-action-hover: color-mix(in oklch, var(--oc-presence) 88%, black); + --oc-on-action: var(--oc-on-presence); + --oc-focus: var(--oc-presence); + /* Strengthen Cave's light-mode state ink for small labels on docs panels. */ + --oc-success-text: color-mix( + in oklch, + oklch(0.52 0.16 158) 88%, + var(--oc-text) + ); + --oc-pending-text: color-mix( + in oklch, + oklch(0.58 0.16 78) 80%, + var(--oc-text) + ); + --oc-blocked-text: oklch(0.52 0.2 24); + --oc-evidence-text: var(--oc-presence); + --oc-success-bg: color-mix( + in oklch, + var(--oc-success-text) 8%, + var(--oc-surface-1) + ); + --oc-pending-bg: color-mix( + in oklch, + var(--oc-pending-text) 8%, + var(--oc-surface-1) + ); + --oc-blocked-bg: color-mix( + in oklch, + var(--oc-blocked-text) 8%, + var(--oc-surface-1) + ); + --oc-evidence-bg: color-mix( + in oklch, + var(--oc-presence) 8%, + var(--oc-surface-1) + ); + --oc-font-display: Inter, "SF Pro Display", system-ui, sans-serif; + --oc-font-reading: Inter, "SF Pro Text", system-ui, sans-serif; + --oc-font-mono: + "JetBrains Mono", "SFMono-Regular", ui-monospace, Menlo, Monaco, Consolas, + monospace; + --oc-leading-heading: 1.05; + --oc-shadow-1: none; + --oc-shadow-2: 0 8px 24px + color-mix(in oklch, oklch(0.45 0.01 291) 40%, transparent); + + --font-editorial: "EB Garamond", "Iowan Old Style", Georgia, serif; + --popover: #fafafa; + --accent: var(--oc-surface-2); + --input: var(--oc-border-strong); + --tool-read: var(--oc-evidence-text); + --tool-write: var(--oc-success-text); + --tool-exec: var(--oc-pending-text); + --tool-net: var(--oc-blocked-text); + --overlay: color-mix(in oklch, var(--oc-bg) 96%, transparent); +} + +:root[data-presentation="coven"].dark { + --oc-bg: #121212; + --oc-surface-1: #191919; + --oc-surface-2: #212121; + --oc-surface-3: #2c2c2c; + --oc-text: #ebebeb; + --oc-text-muted: oklch(0.66 0.01 291); + --oc-text-subtle: color-mix(in oklch, var(--oc-text) 72%, transparent); + --oc-border-strong: color-mix(in oklch, var(--oc-text) 48%, transparent); + --oc-presence: #9386d0; + --oc-on-presence: oklch(0.16 0.008 291); + --oc-action-hover: color-mix(in oklch, var(--oc-presence) 88%, white); + --oc-success-text: oklch(0.78 0.14 158); + --oc-pending-text: oklch(0.83 0.13 78); + --oc-blocked-text: oklch(0.74 0.18 24); + --oc-shadow-2: 0 8px 24px rgb(0 0 0 / 40%); + --popover: #1e1e1e; +} diff --git a/apps/specimens/src/lab-scenes.ts b/apps/specimens/src/lab-scenes.ts new file mode 100644 index 0000000..9a557a0 --- /dev/null +++ b/apps/specimens/src/lab-scenes.ts @@ -0,0 +1,53 @@ +import { + Activity, + FileCode2, + Layers3, + MessageSquare, + PenLine, + Wand2, +} from "lucide-react"; + +export const labScenes = [ + { + id: "composer", + title: "Composer", + subtitle: "From intent to action", + icon: PenLine, + source: "composer", + }, + { + id: "run-rail", + title: "Run rail", + subtitle: "Every step, accounted for", + icon: Activity, + source: "run-rail", + }, + { + id: "messages", + title: "Messages", + subtitle: "A familiar voice, with evidence", + icon: MessageSquare, + source: "transcript-turn", + }, + { + id: "context", + title: "Context", + subtitle: "The right files, within reach", + icon: FileCode2, + source: "resource-row", + }, + { + id: "actions", + title: "Actions", + subtitle: "Small controls, clear intent", + icon: Wand2, + source: "completion-palette", + }, + { + id: "cards", + title: "Cards", + subtitle: "Outcomes you can open", + icon: Layers3, + source: "session-header", + }, +] as const; diff --git a/apps/specimens/src/lab.css b/apps/specimens/src/lab.css index f75764b..3fc9853 100644 --- a/apps/specimens/src/lab.css +++ b/apps/specimens/src/lab.css @@ -11,6 +11,11 @@ flex: none; } +.specimen-app--lab .specimen-topbar__inner { + grid-template-columns: minmax(0, 1fr) auto; + grid-template-areas: "leading actions"; +} + .specimen-app--lab .specimen-shell { display: block; flex: 1; @@ -44,31 +49,19 @@ .specimen-app--lab .specimen-hero h1 { max-width: none; - font-size: clamp(1.6rem, 3vw, 2.5rem); + font-size: clamp(1.5rem, 3vw, 1.75rem); } -.specimen-app--lab .specimen-hero__copy > p:last-child, -.specimen-app--lab .specimen-stats { +.specimen-app--lab .specimen-hero__copy > p:last-child { display: none; } .assembled-lab { - --scene-color: var(--presence); display: flex; flex-direction: column; min-width: 0; min-height: 0; - border: 1px solid color-mix(in srgb, var(--scene-color) 28%, var(--border)); -} - -.assembled-lab[data-tone="information"] { - --scene-color: var(--information); -} -.assembled-lab[data-tone="success"] { - --scene-color: var(--success); -} -.assembled-lab[data-tone="warning"] { - --scene-color: var(--warning); + border: 1px solid var(--border); } .lab-scene-header { @@ -95,10 +88,10 @@ height: 2.5rem; flex: none; place-items: center; - border: 1px solid color-mix(in srgb, var(--scene-color) 28%, var(--border)); + border: 1px solid var(--border); border-radius: var(--radius-2); - background: color-mix(in srgb, var(--scene-color) 8%, var(--card)); - color: var(--scene-color); + background: var(--muted); + color: var(--foreground); } .lab-scene-icon svg { @@ -107,9 +100,9 @@ } .lab-scene-heading h2 { margin: 0; - color: var(--scene-color); + color: var(--foreground); font-size: 1rem; - font-weight: 700; + font-weight: 500; } .lab-scene-heading p { margin: 0.2rem 0 0; @@ -165,7 +158,7 @@ min-height: 100%; align-content: safe center; padding: clamp(1rem, 3vh, 2.5rem) clamp(1rem, 4vw, 4rem); - background: var(--oc-surface-2); + background: var(--background); } .lab-scene-surface { @@ -179,7 +172,7 @@ } .lab-scene-surface [data-slot="composer"] { - box-shadow: var(--elevation-3); + box-shadow: none; } .lab-scene-surface .demo-status { text-align: center; @@ -200,7 +193,7 @@ overflow: visible; border-block: 1px solid var(--border); padding: 0.625rem; - background: var(--card); + background: var(--background); } .assembled-lab .assembled-lab__tabs { @@ -230,10 +223,14 @@ height: 1.125rem; } .assembled-lab__tabs [role="tab"][data-active] { - border-color: color-mix(in srgb, var(--scene-color) 35%, var(--border)); - background: color-mix(in srgb, var(--scene-color) 10%, var(--card)); - color: var(--scene-color); - box-shadow: inset 0 -2px 0 var(--scene-color); + border-color: transparent; + background: var(--muted); + color: var(--foreground); + box-shadow: none; +} + +.assembled-lab__tabs [role="tab"][data-active] svg { + color: var(--foreground); } .lab-footer { @@ -252,7 +249,7 @@ display: inline-flex; align-items: center; gap: 0.375rem; - color: var(--scene-color); + color: var(--muted-foreground); text-decoration: none; } .lab-footer a:hover { @@ -401,6 +398,10 @@ margin-block-start: 0.5rem; } + .lab-message-stack .specimen-command { + padding-block: 0.5rem; + } + .lab-scene-surface [data-slot="run-rail"] { gap: 0.75rem; } @@ -417,35 +418,18 @@ margin: 0; font-size: 1.5rem; } - - .specimen-app--lab .specimen-hero .specimen-kicker { - display: none; - } } @media (max-width: 68rem) { .specimen-app--lab .specimen-topbar__inner { - flex-wrap: nowrap; - padding-block: 0.5rem; - gap: 0.5rem; - } - .specimen-app--lab .specimen-topbar__actions { - flex: 0 1 auto; - width: auto; + padding-block: 0.25rem; } .specimen-app--lab .specimen-brand > span:last-child { display: none; } - .specimen-app--lab .surface-switcher { - margin-inline-start: 0; - margin-inline-end: auto; - } } @media (max-width: 48rem) { - .specimen-app--lab .scheme-control span { - display: none; - } .specimen-app--lab .specimen-hero { padding-block-end: 0.75rem; } diff --git a/apps/specimens/src/lab.tsx b/apps/specimens/src/lab.tsx index f467df4..4a6dad8 100644 --- a/apps/specimens/src/lab.tsx +++ b/apps/specimens/src/lab.tsx @@ -9,74 +9,33 @@ import { TabsTrigger, TranscriptTurn, } from "@opencoven/ui"; -import { - Activity, - ArrowLeft, - ArrowRight, - FileCode2, - Layers3, - MessageSquare, - PenLine, - Wand2, -} from "lucide-react"; -import { useRef, useState } from "react"; +import { ArrowLeft, ArrowRight, FileCode2, Wand2 } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; import { ComposerDemo, RunRailDemo } from "./block-demos"; import { CodeSnippet, HighlightedCode } from "./code-snippet"; +import { labScenes as scenes } from "./lab-scenes"; -const scenes = [ - { - id: "composer", - title: "Composer", - subtitle: "From intent to action", - icon: PenLine, - source: "composer", - tone: "presence", - }, - { - id: "run-rail", - title: "Run rail", - subtitle: "Every step, accounted for", - icon: Activity, - source: "run-rail", - tone: "information", - }, - { - id: "messages", - title: "Messages", - subtitle: "A familiar voice, with evidence", - icon: MessageSquare, - source: "transcript-turn", - tone: "presence", - }, - { - id: "context", - title: "Context", - subtitle: "The right files, within reach", - icon: FileCode2, - source: "resource-row", - tone: "success", - }, - { - id: "actions", - title: "Actions", - subtitle: "Small controls, clear intent", - icon: Wand2, - source: "completion-palette", - tone: "warning", - }, - { - id: "cards", - title: "Cards", - subtitle: "Outcomes you can open", - icon: Layers3, - source: "session-header", - tone: "information", - }, -] as const; +function sceneIndexFromLocation() { + return Math.max( + 0, + scenes.findIndex((scene) => `#${scene.id}` === window.location.hash), + ); +} + +function replaceSceneHash(index: number) { + const hash = `#${scenes[index]!.id}`; + if (window.location.hash !== hash) { + window.history.replaceState( + window.history.state, + "", + `${window.location.pathname}${window.location.search}${hash}`, + ); + } +} function Lab({ density }: { density: "default" | "compact" }) { - const [activeIndex, setActiveIndex] = useState(0); + const [activeIndex, setActiveIndex] = useState(sceneIndexFromLocation); const [actionStatus, setActionStatus] = useState( "Choose an action to try its local preview.", ); @@ -88,6 +47,18 @@ function Lab({ density }: { density: "default" | "compact" }) { const navigate = (offset: number) => setActiveIndex((index) => (index + offset + scenes.length) % scenes.length); + useEffect(() => { + const onHashChange = () => { + const index = sceneIndexFromLocation(); + setActiveIndex(index); + replaceSceneHash(index); + }; + window.addEventListener("hashchange", onHashChange); + return () => window.removeEventListener("hashchange", onHashChange); + }, []); + + useEffect(() => replaceSceneHash(activeIndex), [activeIndex]); + const views = { composer: , "run-rail": , @@ -251,7 +222,6 @@ function Lab({ density }: { density: "default" | "compact" }) { id="assembled-lab" aria-label="Component scenes" aria-roledescription="carousel" - data-tone={scene.tone} >
      @@ -266,7 +236,8 @@ function Lab({ density }: { density: "default" | "compact" }) {
      {scene.title}, scene - {String(activeIndex + 1).padStart(2, "0")} / 06 + {String(activeIndex + 1).padStart(2, "0")} /{" "} + {String(scenes.length).padStart(2, "0")}
diff --git a/apps/specimens/src/main.tsx b/apps/specimens/src/main.tsx index dbd0dfe..79b400a 100644 --- a/apps/specimens/src/main.tsx +++ b/apps/specimens/src/main.tsx @@ -2,6 +2,7 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import "@opencoven/ui/globals.css"; +import "./coven-theme.css"; import "./specimens.css"; import "./specimens-fixes.css"; import "./lab.css"; diff --git a/apps/specimens/src/specimens-fixes.css b/apps/specimens/src/specimens-fixes.css index 4b704de..2364bf0 100644 --- a/apps/specimens/src/specimens-fixes.css +++ b/apps/specimens/src/specimens-fixes.css @@ -1,28 +1,4 @@ /* Responsive guards for the catalog and its source panels. */ -@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; - } -} - @media (max-width: 48rem) { html { scroll-padding-top: 1rem; @@ -44,14 +20,11 @@ .specimen-hero, .specimen-hero__copy, - .specimen-stats, - .specimen-stats div, .specimen-rail > * { min-width: 0; max-width: 100%; } - .specimen-stats :where(dt, dd), .specimen-rail__package { overflow-wrap: anywhere; } @@ -72,8 +45,7 @@ grid-template-columns: repeat(2, minmax(5.25rem, 1fr)); gap: 0; overflow-x: auto; - border-block-end: 1px solid var(--border); - padding: 0 0.75rem; + padding: 0; scrollbar-width: thin; } @@ -92,18 +64,18 @@ .specimen-card__header { min-height: 0; - padding: 16px; + padding: 0 0 16px; overflow-wrap: anywhere; } .specimen-preview { - margin-inline: 16px; + margin-inline: 0; } .specimen-stage { min-width: 0; min-height: 10rem; - justify-items: stretch; + justify-items: center; padding: 16px; } @@ -121,7 +93,6 @@ .specimen-stage > * { box-sizing: border-box; - width: 100%; min-width: 0; max-width: 100%; } @@ -140,7 +111,7 @@ .specimen-documentation { min-width: 0; max-width: 100%; - padding: 0.75rem 1rem 1rem; + padding: 0.75rem 0 0; } } @@ -161,16 +132,12 @@ } .specimen-hero h1 { - font-size: 2rem; + font-size: 1.5rem; } .catalog-group__header { gap: 0.375rem; } - - .catalog-group__count { - display: none; - } } @media (prefers-reduced-motion: reduce) { diff --git a/apps/specimens/src/specimens.css b/apps/specimens/src/specimens.css index 93dbcf1..54d59b0 100644 --- a/apps/specimens/src/specimens.css +++ b/apps/specimens/src/specimens.css @@ -1,6 +1,6 @@ html { min-width: 320px; - --specimen-topbar-height: 3.75rem; + --specimen-topbar-height: 3.25rem; scroll-padding-top: calc(var(--specimen-topbar-height) + 1rem); } @@ -34,21 +34,33 @@ body { 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); + background: var(--background); } .specimen-topbar__inner { - display: flex; - flex-wrap: wrap; - min-height: 3.75rem; + display: grid; + min-height: 3.25rem; max-width: 1680px; + grid-template-columns: minmax(0, 1fr) minmax(0, min(22rem, 40vw)) minmax( + 0, + 1fr + ); + grid-template-areas: "leading search actions"; align-items: center; gap: 0.75rem; margin-inline: auto; padding-inline: clamp(1rem, 3vw, 1.5rem); } +.specimen-topbar__leading { + display: flex; + grid-area: leading; + min-width: 0; + flex-wrap: wrap; + align-items: center; + gap: var(--oc-space-3); +} + .specimen-brand { display: inline-flex; align-items: center; @@ -60,47 +72,39 @@ body { .specimen-brand__mark { display: grid; - width: 2rem; - height: 2rem; + width: 1.5rem; + height: 1.5rem; flex: none; place-items: center; color: var(--foreground); } .specimen-brand__mark > span { - width: 1.875rem; - height: 1.875rem; + width: 1.5rem; + height: 1.5rem; background: currentColor; mask: url("/opencoven-mark.svg") center / contain no-repeat; } .specimen-brand > span:last-child { - display: grid; font-size: 0.875rem; - font-weight: 750; + font-weight: 600; letter-spacing: -0.015em; - line-height: 1.1; + line-height: 1.5; font-family: var(--font-display); } -.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 { display: inline-flex; + min-width: 0; + max-width: 100%; + flex-wrap: wrap; align-items: center; gap: 0.125rem; - padding: 0.1875rem; - border: 1px solid var(--border); + padding: 0; + border: 0; border-radius: var(--radius-2); - background: var(--card); + background: transparent; } .surface-switcher a { @@ -108,9 +112,9 @@ body { border-radius: calc(var(--radius-2) - 2px); background: transparent; color: var(--muted-foreground); - padding: 0.375rem 0.75rem; - font-size: 0.75rem; - font-weight: 700; + padding: 0.375rem 0.5rem; + font-size: 0.8125rem; + font-weight: 500; line-height: 1.25rem; text-decoration: none; white-space: nowrap; @@ -122,15 +126,14 @@ body { } .surface-switcher a[aria-current="page"] { - background: var(--foreground); - color: var(--background); - box-shadow: var(--elevation-1); + background: transparent; + color: var(--foreground); + font-weight: 600; } .specimen-topbar__actions { display: flex; - flex: 1 1 34rem; - flex-wrap: wrap; + grid-area: actions; min-width: 0; align-items: center; justify-content: flex-end; @@ -138,13 +141,29 @@ body { } .specimen-search { - width: min(20rem, 34vw); - max-width: 20rem; - flex: 1 1 16rem; + position: relative; + grid-area: search; + width: 100%; + max-width: 22rem; + min-width: 0; + justify-self: center; +} + +.specimen-search input { + height: 2.25rem; + font-size: 0.8125rem; +} + +.specimen-search input::-webkit-search-cancel-button { + -webkit-appearance: none; } .scheme-control { flex: none; + width: 2.25rem; + height: 2.25rem; + min-height: 2.25rem; + padding: 0; white-space: nowrap; } @@ -167,19 +186,19 @@ body { 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)); + border-inline-end: 0; + background: var(--background); padding: 28px 16px 40px 20px; scrollbar-width: thin; } .specimen-kicker { margin: 0; - color: var(--presence); - font-size: 0.625rem; - font-weight: 750; - letter-spacing: 0.14em; - text-transform: uppercase; + color: var(--muted-foreground); + font-family: var(--font-display); + font-size: 0.75rem; + font-weight: 500; + letter-spacing: 0; } .specimen-rail__nav { @@ -188,29 +207,18 @@ body { } .specimen-rail__group { - --nav-color: var(--presence); min-width: 0; } -.specimen-rail__group[data-group="Run rail"] { - --nav-color: var(--information); -} - -.specimen-rail__group[data-group="Blocks"] { - --nav-color: var(--success); -} - .specimen-rail__group h2 { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; margin: 0 12px 8px; - color: var(--nav-color); - font-size: 0.625rem; - font-weight: 650; - letter-spacing: 0.1em; - text-transform: uppercase; + color: var(--foreground); + font-size: 0.875rem; + font-weight: 500; } .specimen-rail__group ul { @@ -221,12 +229,12 @@ body { .specimen-rail__nav a { display: block; - border-inline-start: 2px solid transparent; - border-radius: 0 var(--radius-1) var(--radius-1) 0; + border: 0; + border-radius: var(--radius-2); color: var(--muted-foreground); padding: 6px 12px; - font-size: 0.84375rem; - font-weight: 450; + font-size: 0.875rem; + font-weight: 400; line-height: 1.4; text-decoration: none; overflow-wrap: anywhere; @@ -238,15 +246,9 @@ body { } .specimen-rail__nav a[aria-current] { - border-inline-start-color: var(--nav-color); - background: color-mix(in srgb, var(--nav-color) 9%, var(--card)); + background: var(--muted); color: var(--foreground); - font-weight: 650; -} - -.specimen-rail__nav small { - color: color-mix(in srgb, var(--muted-foreground) 76%, transparent); - font-size: 0.625rem; + font-weight: 500; } .specimen-mobile-nav { @@ -288,9 +290,8 @@ body { } .specimen-toc a { - border-inline-start: 1px solid var(--border); color: var(--muted-foreground); - padding: 6px 12px; + padding: 6px 0; font-size: 0.75rem; text-decoration: none; overflow-wrap: anywhere; @@ -298,8 +299,7 @@ body { .specimen-toc a:hover, .specimen-toc a[aria-current] { - border-inline-start-color: var(--presence); - color: var(--presence); + color: var(--foreground); } .specimen-rail__package { @@ -318,13 +318,6 @@ body { 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; } @@ -336,126 +329,98 @@ body { } [data-density="compact"] .specimen-main__inner { - padding-block-start: var(--oc-space-8); + padding-block-start: var(--oc-space-6); } .specimen-hero { - display: flex; - flex-wrap: wrap; - 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); + display: block; + margin-block-end: var(--oc-space-6); + border: 0; + padding: 0; } .specimen-hero__copy { min-width: 0; - flex: 1 1 24rem; } .specimen-hero h1 { - max-width: 19ch; + max-width: 22ch; overflow-wrap: anywhere; - margin: 0.5rem 0 0; + margin: 0; font-family: var(--font-display); - font-size: var(--oc-type-3); - font-weight: 700; - letter-spacing: var(--oc-tracking-heading); - line-height: var(--oc-leading-heading); + font-size: 1.75rem; + font-weight: 600; + letter-spacing: -0.025em; + line-height: 1.2; color: var(--foreground); text-wrap: balance; } -.specimen-hero h1 em { - color: var(--presence); - font-style: normal; -} - -.specimen-stats div:nth-child(2) dd { - color: var(--information); -} - .specimen-hero__copy > p:last-child { max-width: var(--oc-reading-measure); - margin: var(--oc-space-4) 0 0; + margin: var(--oc-space-2) 0 0; color: var(--muted-foreground); font-size: var(--oc-type--1); line-height: var(--oc-leading-reading); } -.specimen-stats { +.catalog { display: grid; - flex: 0 1 16rem; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 0.5rem; - margin: 0; + gap: clamp(2.75rem, 5vw, 4.5rem); } -.specimen-stats div { - min-width: 0; - border-inline-start: 1px solid var(--border); - padding-inline-start: 0.875rem; +.catalog-results { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: var(--oc-space-2) var(--oc-space-4); } -.specimen-stats dt { - color: var(--muted-foreground); - font-size: 0.625rem; - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; +.catalog-results[data-active] { + margin-block-end: var(--oc-space-4); } -.specimen-stats dd { - margin: 0.25rem 0 0; - color: var(--foreground); - font-size: 1.25rem; - font-weight: 650; +.catalog-results__count { + margin: 0; + color: var(--muted-foreground); + font-size: var(--oc-type--1); } -.catalog { - display: grid; - gap: clamp(2.75rem, 5vw, 4.5rem); +.catalog-clear-search { + position: absolute; + inset-inline-end: 0.125rem; + inset-block-start: 50%; + width: 2rem; + height: 2rem; + min-height: 2rem; + padding: 0; + transform: translateY(-50%); } .catalog-group { - --section-color: var(--presence); scroll-margin-block-start: 0; } -#group-run-rail { - --section-color: var(--information); -} - -#group-blocks { - --section-color: var(--success); -} - .catalog-group__header { display: flex; align-items: end; justify-content: space-between; gap: 2rem; - margin-block-end: 0.875rem; -} - -.catalog-group__eyebrow { - margin: 0; - color: var(--section-color); - font-size: 0.625rem; - font-weight: 750; - letter-spacing: 0.14em; - text-transform: uppercase; + margin-block-end: var(--oc-space-6); + border-block-end: 1px solid var(--border); + padding-block-end: var(--oc-space-4); } .catalog-group__header h2 { max-width: 34rem; - margin: 0.35rem 0 0; - font-size: clamp(1.25rem, 2vw, 1.6rem); - font-weight: 700; + margin: 0; + font-family: var(--font-display); + font-size: 1.25rem; + font-weight: 600; letter-spacing: -0.025em; line-height: 1.2; - color: var(--section-color); + color: var(--foreground); text-wrap: balance; } @@ -463,18 +428,10 @@ body { max-width: 52ch; margin: 0.35rem 0 0; color: var(--muted-foreground); - font-size: 0.75rem; + font-size: 0.875rem; line-height: 1.5; } -.catalog-group__count { - flex: none; - color: var(--muted-foreground); - font-size: 0.6875rem; - letter-spacing: 0.04em; - text-transform: uppercase; -} - .specimen-grid { display: grid; grid-template-columns: minmax(0, 1fr); @@ -485,66 +442,113 @@ body { display: flex; flex-direction: column; min-width: 0; - overflow: hidden; + overflow: visible; scroll-margin-block-start: 0; - border: 1px solid var(--border); - border-radius: var(--radius-4); - background: var(--card); + border: 0; + background: transparent; color: var(--card-foreground); - box-shadow: var(--elevation-1); - transition: - border-color var(--motion-standard) var(--motion-ease), - box-shadow var(--motion-standard) var(--motion-ease), - translate var(--motion-standard) var(--motion-ease); -} - -.specimen-card:hover, -.specimen-card:focus-within { - border-color: color-mix(in srgb, var(--section-color) 48%, var(--border)); - box-shadow: var(--elevation-2); } .specimen-card__header { - min-height: 8.25rem; - padding: var(--oc-space-6) var(--oc-space-6) var(--oc-space-4); + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: var(--oc-space-2) var(--oc-space-3); + min-height: 0; + padding: 0 0 var(--oc-space-4); } .specimen-card__meta { display: flex; flex-wrap: wrap; - align-items: center; - justify-content: space-between; - gap: 1rem; + min-width: 0; + align-items: baseline; + gap: var(--oc-space-2) var(--oc-space-3); } .specimen-card__kind { display: inline-flex; align-items: center; gap: 0.45rem; - color: var(--section-color); - font-size: 0.625rem; - font-weight: 700; - letter-spacing: 0.12em; - text-transform: uppercase; + color: var(--muted-foreground); + font-size: 0.6875rem; + font-weight: 500; } -.specimen-card__kind svg { +.specimen-card__title { + min-width: 0; + max-width: 100%; + margin: 0; + color: var(--foreground); + font-family: var(--font-display); + font-size: 1.25rem; + font-weight: 600; + letter-spacing: var(--oc-tracking-heading); +} + +.specimen-lab-link { + display: inline-flex; + min-height: 2.5rem; + align-items: center; + justify-self: end; + gap: var(--oc-space-1); + border-radius: var(--radius-1); + color: var(--muted-foreground); + font-size: 0.75rem; + text-decoration: none; +} + +.specimen-lab-link svg { width: 0.875rem; height: 0.875rem; + flex: none; } -.specimen-card__title { - margin: 0.75rem 0 0; - color: color-mix(in srgb, var(--section-color) 55%, var(--foreground)); - font-family: var(--font-display); - font-size: var(--oc-type-1); - font-weight: 650; - letter-spacing: var(--oc-tracking-heading); +.specimen-lab-link:hover, +.specimen-lab-link:focus-visible { + color: var(--foreground); + text-decoration: underline; + text-underline-offset: 0.2em; +} + +.specimen-permalink { + display: inline-flex; + max-width: 100%; + align-items: baseline; + gap: var(--oc-space-2); + border-radius: var(--radius-1); + color: inherit; + text-decoration: none; +} + +.specimen-permalink span { + min-width: 0; + overflow-wrap: anywhere; +} + +.specimen-permalink svg { + width: 0.65em; + height: 0.65em; + flex: none; + color: var(--muted-foreground); +} + +.specimen-permalink:hover span, +.specimen-permalink:focus-visible span { + text-decoration: underline; + text-decoration-thickness: 1px; + text-underline-offset: 0.2em; +} + +.specimen-permalink:hover svg, +.specimen-permalink:focus-visible svg { + color: var(--foreground); } .specimen-card__description { - max-width: 46ch; - margin: var(--oc-space-2) 0 0; + grid-column: 1 / -1; + max-width: var(--oc-reading-measure); + margin: 0; color: var(--muted-foreground); font-size: var(--oc-type--1); line-height: var(--oc-leading-reading); @@ -556,28 +560,25 @@ body { flex: 1; flex-direction: column; min-width: 0; - margin: 0 var(--oc-space-4) var(--oc-space-4); - overflow: hidden; - border: 1px solid color-mix(in srgb, var(--section-color) 24%, var(--border)); - border-radius: var(--radius-2); - background: var(--oc-surface-2); - box-shadow: inset 0 1px 0 - color-mix(in srgb, var(--section-color) 18%, transparent); + margin: 0; + overflow: visible; + border: 0; + background: transparent; } .specimen-preview__tabs { flex: 1; min-width: 0; - gap: 0; + gap: var(--oc-space-3); } .specimen-view-tabs { width: 100%; - gap: var(--oc-space-1); + gap: var(--oc-space-4); border-block-end: 1px solid var(--border); border-radius: 0; - background: var(--card); - padding: var(--oc-space-2); + background: transparent; + padding: 0; justify-content: flex-start; overflow-x: auto; scrollbar-width: thin; @@ -588,20 +589,23 @@ body { min-width: 0; min-height: 2.75rem; gap: var(--oc-space-2); - padding-inline: var(--oc-space-4); - font-size: 0.8125rem; + padding-inline: var(--oc-space-2); + font-size: 0.875rem; + font-weight: 500; + color: var(--muted-foreground); + border: 0; + border-radius: 0; } .specimen-view-tabs [role="tab"][data-active] { - border-color: var(--border); - background: var(--oc-surface-2); - color: var(--presence); - box-shadow: inset 0 -2px 0 var(--presence); + background: transparent; + color: var(--foreground); + box-shadow: none; } -.specimen-view-tabs svg { - width: 0.875rem; - height: 0.875rem; +.specimen-view-tabs [role="tab"][data-active]::after { + inset-block-end: 0; + background: var(--foreground); } .specimen-view-tabs [role="tab"]:focus-visible { @@ -613,6 +617,10 @@ body { display: grid; flex: 1; min-width: 0; + overflow: hidden; + border: 1px solid var(--border); + border-radius: var(--radius-2); + background: var(--background); } .specimen-live-panel { @@ -647,6 +655,8 @@ body { flex: 1; min-height: 0; max-height: none; + border: 0; + background: var(--card); overscroll-behavior: contain; } @@ -657,7 +667,7 @@ body { .specimen-stage { display: grid; flex: 1; - min-height: 14rem; + min-height: 10rem; align-content: center; justify-items: center; padding: var(--oc-space-6); @@ -676,7 +686,7 @@ body { .demo-status { margin: 0.75rem 0 0; color: var(--muted-foreground); - font-size: 0.6875rem; + font-size: 0.75rem; line-height: 1.5; } @@ -700,10 +710,10 @@ body { .specimen-documentation { display: grid; - min-height: 7.5rem; + min-height: 0; align-content: start; gap: var(--oc-space-3); - padding: var(--oc-space-3) var(--oc-space-4) var(--oc-space-4); + padding: var(--oc-space-3) 0 0; font-size: 0.8125rem; line-height: 1.6; } @@ -711,8 +721,7 @@ body { .specimen-states { display: grid; gap: var(--oc-space-2); - border-block-start: 1px solid var(--border); - padding: var(--oc-space-4); + padding: var(--oc-space-3) 0 0; font-size: 0.6875rem; } @@ -721,70 +730,67 @@ body { align-items: center; gap: var(--oc-space-2); color: var(--muted-foreground); - font-weight: 600; + font-weight: 500; } -.specimen-states svg { - width: 0.875rem; - height: 0.875rem; -} .specimen-states ul { display: flex; flex-wrap: wrap; - gap: var(--oc-space-2); + gap: var(--oc-space-3); margin: 0; padding: 0; list-style: none; } .specimen-states li { - border: 1px solid var(--border); - border-radius: var(--radius-1); - background: var(--oc-surface-2); + border: 0; + background: transparent; color: var(--oc-text-muted); - padding: var(--oc-space-1) var(--oc-space-2); + padding: 0; } .specimen-card > [data-slot="tabs"] { flex: none; gap: 0; - border-block-start: 1px solid var(--border); - background: color-mix(in srgb, var(--background) 70%, var(--card)); + margin-block-start: var(--oc-space-4); + background: transparent; } .specimen-code-tabs { + width: 100%; + justify-content: flex-start; + gap: var(--oc-space-4); margin: 0; - padding: 0.375rem 0.75rem; + border-block-end: 1px solid var(--border); + padding: 0; } -@media (min-width: 64rem) { - .specimen-card__header { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(0, 1.25fr); - align-items: center; - column-gap: var(--oc-space-8); - row-gap: var(--oc-space-4); - padding: var(--oc-space-6); - } +.specimen-code-tabs [role="tab"] { + flex: none; +} - .specimen-card__meta { - grid-column: 1 / -1; - } +.specimen-documentation .specimen-code-snippet { + gap: 0; + overflow: hidden; + border: 1px solid var(--border); + border-radius: var(--radius-2); + background: var(--card); +} - .specimen-card__title, - .specimen-card__description { - margin: 0; - } +.specimen-documentation .specimen-code-snippet__header { + border-block-end: 1px solid var(--border); + padding-inline: var(--oc-space-3); +} - .specimen-card__description { - max-width: 60ch; - justify-self: end; - } +.specimen-documentation .specimen-command { + border: 0; + background: var(--card); +} - .specimen-preview { - margin-inline: var(--oc-space-6); - margin-block-end: var(--oc-space-6); - } +.specimen-documentation .code-copy-error { + padding: 0 var(--oc-space-3) var(--oc-space-3); +} +@media (min-width: 64rem) { .specimen-stage { padding: var(--oc-space-8); } @@ -802,44 +808,26 @@ body { grid-column: 1 / -1; } - .specimen-card > [data-slot="tabs"] { - display: grid; - grid-template-columns: auto minmax(0, 1fr); - align-items: start; - gap: var(--oc-space-6); - padding-inline: var(--oc-space-6); - } - - .specimen-code-tabs { - margin-block-start: var(--oc-space-4); - padding-inline: 0; - } - - .specimen-documentation { - min-height: 0; - padding: var(--oc-space-4) 0; - } - .specimen-states { display: flex; flex-wrap: wrap; align-items: center; gap: var(--oc-space-4); - padding-inline: var(--oc-space-6); } } .specimen-code-tabs [role="tab"] { min-height: 2.75rem; - font-size: 0.6875rem; + font-size: 0.875rem; } .specimen-code-tabs [role="tab"][data-active] { - color: var(--section-color); + color: var(--foreground); } .specimen-code-tabs [role="tab"][data-active]::after { - background: var(--section-color); + inset-block-end: 0; + background: var(--foreground); } .specimen-documentation p { @@ -863,10 +851,8 @@ body { align-items: center; gap: 0.4rem; color: var(--muted-foreground); - font-size: 0.6rem; - font-weight: 750; - letter-spacing: 0.08em; - text-transform: uppercase; + font-size: 0.6875rem; + font-weight: 500; } .specimen-code-snippet__header svg { @@ -876,7 +862,7 @@ body { .specimen-code-snippet__header button { min-height: 2.75rem; - font-size: 0.625rem; + font-size: 0.75rem; } .code-copy-error { @@ -896,7 +882,7 @@ body { background: var(--background); color: var(--foreground); padding: 0.75rem; - font-size: 0.7rem; + font-size: 0.75rem; line-height: 1.6; white-space: pre-wrap; word-break: break-word; @@ -966,9 +952,9 @@ p > .hljs { } .specimen-install-meta span { - color: var(--presence); + color: var(--muted-foreground); font-size: 0.625rem; - font-weight: 750; + font-weight: 500; letter-spacing: 0.08em; text-transform: uppercase; } @@ -993,12 +979,12 @@ p > .hljs { .catalog-empty { display: grid; - min-height: 24rem; + min-height: min(20rem, 320px); place-items: center; align-content: center; border: 1px dashed var(--border); border-radius: var(--radius-3); - padding: 2rem; + padding: min(var(--oc-space-8), 32px); text-align: center; } @@ -1033,9 +1019,9 @@ p > .hljs { scroll-margin-block-start: 0; border: 1px solid var(--border); border-radius: var(--radius-3); - background: var(--card); + background: var(--background); color: var(--card-foreground); - box-shadow: var(--elevation-2); + box-shadow: none; } .assembled-lab__nav { @@ -1091,23 +1077,48 @@ p > .hljs { @media (max-width: 68rem) { .specimen-topbar__inner { - grid-template-columns: auto 1fr; - padding-block: 0.75rem; + grid-template-columns: minmax(0, 1fr) auto; + grid-template-areas: "leading actions" "search search"; + gap: var(--oc-space-1) var(--oc-space-3); + padding-block: var(--oc-space-1) var(--oc-space-2); } - .surface-switcher { - justify-self: end; + .specimen-topbar__leading { + min-height: 2.75rem; } - .specimen-topbar__actions { - display: grid; - width: 100%; - grid-column: 1 / -1; - grid-template-columns: minmax(0, 1fr) auto; + .specimen-search input { + height: 2.75rem; + } + + .scheme-control { + width: 2.75rem; + height: 2.75rem; + min-height: 2.75rem; + } + + .catalog-clear-search { + width: 2.5rem; + height: 2.5rem; + min-height: 2.5rem; + } +} + +@media (pointer: coarse) and (min-width: 68.01rem) { + .specimen-search input, + .scheme-control { + height: 2.75rem; + min-height: 2.75rem; + } + + .scheme-control { + width: 2.75rem; } - .specimen-search { - width: 100%; + .catalog-clear-search { + width: 2.5rem; + height: 2.5rem; + min-height: 2.5rem; } } @@ -1153,18 +1164,8 @@ p > .hljs { @media (max-width: 48rem) { .specimen-hero { - align-items: start; - } - - .specimen-stats { - flex-basis: 100%; - width: 100%; - } - - .specimen-hero { - gap: 1.25rem; - margin-block-end: 2rem; - padding-block-end: 1.25rem; + margin-block-end: 1.5rem; + padding-block-end: 0; } .catalog-group__header { @@ -1181,23 +1182,7 @@ p > .hljs { } @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; - } - - .specimen-search { - grid-column: auto; - } - - .scheme-control span { + .specimen-brand > span:last-child { display: none; } @@ -1206,11 +1191,7 @@ p > .hljs { } .specimen-hero { - margin-block-end: 2rem; - } - - .specimen-hero h1 { - font-size: clamp(2.25rem, 11vw, 3.25rem); + margin-block-end: 1.5rem; } .catalog { @@ -1222,10 +1203,6 @@ p > .hljs { gap: 0.75rem; } - .catalog-group__count { - justify-self: start; - } - .specimen-stage, .assembled-lab__stage { padding: 1rem; @@ -1249,28 +1226,6 @@ p > .hljs { position: static; } - .specimen-brand > span:last-child { - display: none; - } - - .specimen-brand__mark { - width: 2rem; - height: 2rem; - } - - .surface-switcher a { - 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; @@ -1286,17 +1241,7 @@ p > .hljs { 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; } } diff --git a/packages/ui/tests/catalog-navigation.test.tsx b/packages/ui/tests/catalog-navigation.test.tsx index b869ba2..a5398fa 100644 --- a/packages/ui/tests/catalog-navigation.test.tsx +++ b/packages/ui/tests/catalog-navigation.test.tsx @@ -31,6 +31,126 @@ describe("catalog navigation", () => { window.history.replaceState(null, "", "/"); }); + it("defaults to dark and retains an explicitly chosen light scheme", () => { + const { unmount } = render(); + expect(document.documentElement).toHaveClass("dark"); + fireEvent.click(screen.getByRole("button", { name: "Use light scheme" })); + expect(document.documentElement).not.toHaveClass("dark"); + expect(localStorage.getItem("coven-ui:scheme")).toBe("light"); + unmount(); + render(); + expect(document.documentElement).not.toHaveClass("dark"); + expect( + screen.getByRole("button", { name: "Use dark scheme" }), + ).toBeVisible(); + }); + + it("keeps the catalog header free of decorative stats and framework badges", () => { + const { container } = render(); + expect(container.querySelector(".specimen-hero dl")).toBeNull(); + expect( + container.querySelector(".specimen-card__meta [data-slot=badge]"), + ).toBeNull(); + expect(container.querySelectorAll(".specimen-card")).toHaveLength(16); + }); + + it("keeps documentation headings free of repeated decorative labels", () => { + const { container } = render(); + expect(screen.queryByText("Reference lab")).toBeNull(); + expect(screen.queryByText("Input layer")).toBeNull(); + expect(screen.queryByText("Evidence layer")).toBeNull(); + expect(screen.queryByText("Complete surfaces")).toBeNull(); + expect(container.querySelector(".catalog-group__count")).toBeNull(); + expect(container.querySelector(".specimen-rail__nav small")).toBeNull(); + expect(container.querySelectorAll(".catalog-group h2")).toHaveLength(3); + }); + + it.each([ + ["/", "Component library"], + ["/lab", "Component lab"], + ])("uses a task-focused page title on %s", (path, title) => { + window.history.replaceState(null, "", path); + render(); + expect( + screen.getByRole("heading", { level: 1, name: title }), + ).toBeVisible(); + }); + + it("names the installation and import actions accurately", () => { + render(); + const specimen = within( + screen.getByRole("article", { name: "Mode switch" }), + ); + expect(specimen.getByRole("tab", { name: "Install" })).toBeVisible(); + fireEvent.click(specimen.getByRole("tab", { name: "Import" })); + expect(specimen.getByLabelText("Import Mode switch")).toHaveTextContent( + 'import { ModeSwitch } from "@opencoven/ui/components/mode-switch";', + ); + expect(specimen.queryByRole("tab", { name: "React API" })).toBeNull(); + }); + + it("keeps search clearing beside the query and restores input focus", () => { + render(); + const input = screen.getByRole("searchbox", { name: "Search components" }); + fireEvent.change(input, { target: { value: "metric" } }); + const search = within( + screen.getByRole("search", { name: "Component search" }), + ); + fireEvent.click(search.getByRole("button", { name: "Clear search" })); + expect(input).toHaveValue(""); + expect(input).toHaveFocus(); + expect(document.querySelectorAll(".specimen-card")).toHaveLength(16); + expect(search.queryByRole("button", { name: "Clear search" })).toBeNull(); + }); + + it("places search between navigation and an accessible icon-only theme action", () => { + render(); + const search = screen.getByRole("search", { name: "Component search" }); + expect(search.previousElementSibling).toHaveClass( + "specimen-topbar__leading", + ); + expect(search.nextElementSibling).toHaveClass("specimen-topbar__actions"); + const theme = screen.getByRole("button", { name: "Use light scheme" }); + expect(theme.textContent).toBe(""); + expect(theme).toHaveAttribute("title", "Use light scheme"); + fireEvent.click(theme); + expect( + screen.getByRole("button", { name: "Use dark scheme" }), + ).toBeVisible(); + }); + + it("links each assembled example to its matching Lab scene", () => { + render(); + for (const [name, scene] of [ + ["Composer block", "composer"], + ["Run rail block", "run-rail"], + ["Transcript turn", "messages"], + ["Resource row", "context"], + ["Completion palette", "actions"], + ["Session header", "cards"], + ]) { + const specimen = within(screen.getByRole("article", { name })); + expect( + specimen.getByRole("link", { name: "Open in Lab" }), + ).toHaveAttribute("href", `/lab#${scene}`); + } + expect(screen.getAllByRole("link", { name: "Open in Lab" })).toHaveLength( + 6, + ); + }); + + it("keeps the linked Lab scene when skipping to its main content", () => { + window.history.replaceState(null, "", "/lab#run-rail"); + render(); + fireEvent.click(screen.getByRole("link", { name: "Skip to specimens" })); + expect(screen.getByRole("main")).toHaveFocus(); + expect(window.location.hash).toBe("#run-rail"); + expect(screen.getByRole("tab", { name: "Run rail" })).toHaveAttribute( + "aria-selected", + "true", + ); + }); + it.each([ ["/", "default"], ["/", "compact"], @@ -109,11 +229,67 @@ describe("catalog navigation", () => { fireEvent.change(screen.getByPlaceholderText("Search components…"), { target: { value: "no-such-specimen" }, }); - expect(screen.getByText("No matching specimens")).toBeVisible(); + expect(screen.getByText("No matches found")).toBeVisible(); expect(within(navigation).getAllByRole("link")).toHaveLength(1); expect(screen.getAllByRole("option")).toHaveLength(1); }); + it("announces search results and restores the inventory and focus when cleared", () => { + const { container } = render(); + const search = screen.getByPlaceholderText("Search components…"); + const status = screen.getByRole("status", { name: "Search results" }); + expect(status).toHaveTextContent("All 16 components"); + fireEvent.change(search, { target: { value: "Completion palette" } }); + expect(status).toHaveTextContent("1 result"); + fireEvent.change(search, { target: { value: "no-such-component" } }); + expect(status).toHaveTextContent("0 results"); + expect(screen.getByText("No matches found")).toBeVisible(); + fireEvent.click(screen.getByRole("button", { name: "Clear search" })); + expect(search).toHaveValue(""); + expect(search).toHaveFocus(); + expect(container.querySelectorAll(".specimen-card")).toHaveLength(16); + expect(status).toHaveTextContent("All 16 components"); + expect( + screen.queryByRole("button", { name: "Clear search" }), + ).not.toBeInTheDocument(); + }); + + it("clears search with Escape while retaining keyboard focus", () => { + render(); + const search = screen.getByPlaceholderText("Search components…"); + act(() => search.focus()); + fireEvent.change(search, { target: { value: "Context meter" } }); + fireEvent.keyDown(search, { key: "Escape" }); + expect(search).toHaveValue(""); + expect(search).toHaveFocus(); + expect( + screen.getByRole("status", { name: "Search results" }), + ).toHaveTextContent("All 16 components"); + }); + + it("does not clear an in-progress text composition with Escape", () => { + render(); + const search = screen.getByPlaceholderText("Search components…"); + fireEvent.change(search, { target: { value: "Context" } }); + fireEvent.keyDown(search, { key: "Escape", isComposing: true }); + expect(search).toHaveValue("Context"); + }); + + it("provides a permalink from every component heading without changing its name", () => { + const { container } = render(); + const articles = [ + ...container.querySelectorAll(".specimen-card"), + ]; + expect(articles).toHaveLength(16); + for (const article of articles) { + const heading = within(article).getByRole("heading", { level: 3 }); + expect( + within(heading).getByRole("link", { name: heading.textContent! }), + ).toHaveAttribute("href", `#${article.id}`); + } + expect(screen.getByRole("article", { name: "Mode switch" })).toBeVisible(); + }); + it("clears a stale catalog fragment when filtering removes its target", () => { window.history.replaceState({ retained: true }, "", "/?navigation=keep"); render(); diff --git a/packages/ui/tests/specimens.test.tsx b/packages/ui/tests/specimens.test.tsx index 35934a2..a97abc4 100644 --- a/packages/ui/tests/specimens.test.tsx +++ b/packages/ui/tests/specimens.test.tsx @@ -11,6 +11,74 @@ import { Lab } from "../../../apps/specimens/src/lab"; import { ComponentPreview } from "../../../apps/specimens/src/component-preview"; describe("specimen source and scenes", () => { + beforeEach(() => window.history.replaceState(null, "", "/lab")); + afterEach(() => window.history.replaceState(null, "", "/")); + + it("opens a linked Lab scene and exposes its library return path", () => { + window.history.replaceState( + { from: "library" }, + "", + "/lab?view=demo#run-rail", + ); + render(); + expect(screen.getByRole("tab", { name: "Run rail" })).toHaveAttribute( + "aria-selected", + "true", + ); + expect( + screen.getByRole("link", { name: "View in library" }), + ).toHaveAttribute("href", "/#run-rail"); + expect(window.location.search).toBe("?view=demo"); + expect(window.history.state).toEqual({ from: "library" }); + }); + + it("keeps scene URLs current without adding history entries", async () => { + const user = userEvent.setup(); + render(); + const length = window.history.length; + expect(window.location.hash).toBe("#composer"); + await user.click(screen.getByRole("button", { name: "Next scene" })); + expect(window.location.hash).toBe("#run-rail"); + await user.click(screen.getByRole("tab", { name: "Context" })); + expect(window.location.hash).toBe("#context"); + expect(window.history.length).toBe(length); + }); + + it("follows scene hash history without replacing a Composer draft", () => { + render(); + const draft = screen.getByRole("textbox", { name: "Message" }); + fireEvent.change(draft, { target: { value: "Keep this local draft" } }); + act(() => { + window.history.replaceState(null, "", "/lab#run-rail"); + window.dispatchEvent(new HashChangeEvent("hashchange")); + }); + expect(screen.getByRole("tab", { name: "Run rail" })).toHaveAttribute( + "aria-selected", + "true", + ); + act(() => { + window.history.replaceState(null, "", "/lab#composer"); + window.dispatchEvent(new HashChangeEvent("hashchange")); + }); + expect(screen.getByRole("textbox", { name: "Message" })).toBe(draft); + expect(draft).toHaveValue("Keep this local draft"); + }); + + it("canonicalizes unknown scene links to Composer", () => { + window.history.replaceState(null, "", "/lab#unknown"); + render(); + expect(window.location.hash).toBe("#composer"); + act(() => { + window.history.replaceState(null, "", "/lab#still-unknown"); + window.dispatchEvent(new HashChangeEvent("hashchange")); + }); + expect(window.location.hash).toBe("#composer"); + expect(screen.getByRole("tab", { name: "Composer" })).toHaveAttribute( + "aria-selected", + "true", + ); + }); + it("covers rather than unmounts the preview and returns focus on Escape", async () => { const user = userEvent.setup(); render( @@ -43,6 +111,24 @@ describe("specimen source and scenes", () => { expect(toggle).toHaveAttribute("aria-selected", "false"); }); + it("uses text-only line tabs ahead of the preview canvas", () => { + const { container } = render( + + + , + ); + const tabs = screen.getByRole("tablist", { name: "Demo view" }); + expect(tabs).toHaveAttribute("data-variant", "line"); + expect(tabs.querySelector("svg")).toBeNull(); + expect(tabs.nextElementSibling).toBe( + container.querySelector(".specimen-preview__canvas"), + ); + }); + it("highlights TypeScript, JSX and shell source without interpreting HTML", () => { const source = 'const label = "";\n'; diff --git a/public/fonts/eb-garamond/OFL.txt b/public/fonts/eb-garamond/OFL.txt new file mode 100644 index 0000000..d4143a8 --- /dev/null +++ b/public/fonts/eb-garamond/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2017 The EB Garamond Project Authors (https://github.com/octaviopardo/EBGaramond12) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/public/fonts/eb-garamond/eb-garamond-latin.woff2 b/public/fonts/eb-garamond/eb-garamond-latin.woff2 new file mode 100644 index 0000000..ed33cd8 Binary files /dev/null and b/public/fonts/eb-garamond/eb-garamond-latin.woff2 differ diff --git a/public/fonts/inter/OFL.txt b/public/fonts/inter/OFL.txt new file mode 100644 index 0000000..909a6bf --- /dev/null +++ b/public/fonts/inter/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/public/fonts/inter/inter-latin.woff2 b/public/fonts/inter/inter-latin.woff2 new file mode 100644 index 0000000..91dc3e8 Binary files /dev/null and b/public/fonts/inter/inter-latin.woff2 differ diff --git a/public/fonts/jetbrains-mono/OFL.txt b/public/fonts/jetbrains-mono/OFL.txt new file mode 100644 index 0000000..821a3da --- /dev/null +++ b/public/fonts/jetbrains-mono/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. + +This license is copied below, and is also available with a FAQ at: https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/public/fonts/jetbrains-mono/jetbrains-mono-latin.woff2 b/public/fonts/jetbrains-mono/jetbrains-mono-latin.woff2 new file mode 100644 index 0000000..4d09cda Binary files /dev/null and b/public/fonts/jetbrains-mono/jetbrains-mono-latin.woff2 differ diff --git a/scripts/mobile-quality-review.mjs b/scripts/mobile-quality-review.mjs index 4483c1c..df2c6e4 100644 --- a/scripts/mobile-quality-review.mjs +++ b/scripts/mobile-quality-review.mjs @@ -297,6 +297,7 @@ try { )}; if (${Boolean(scenario.wideDisplayFont)}) { document.documentElement.style.setProperty("--oc-font-display", "ui-monospace, monospace"); + document.documentElement.style.setProperty("--font-editorial", "ui-monospace, monospace"); } })()`); @@ -437,6 +438,17 @@ try { rect(rail)?.bottom ?? 0, ), firstCardTop: rect(firstCard)?.top ?? null, + headingWordLines: (() => { + const heading = document.querySelector("h1"); + let start = 0; + return heading.textContent.split(" ").map(word => { + const range = document.createRange(); + range.setStart(heading.firstChild, start); + range.setEnd(heading.firstChild, start + word.length); + start += word.length + 1; + return range.getClientRects().length; + }); + })(), railNavOverflow: clipped( document.querySelector(".specimen-mobile-nav"), ), @@ -574,6 +586,9 @@ try { if (measurement.cardCount !== 16) { failures.push(`expected 16 cards, got ${measurement.cardCount}`); } + if (measurement.headingWordLines.some((lines) => lines !== 1)) { + failures.push("page heading breaks inside a word"); + } if (measurement.tabRootCount !== 16) { failures.push( `expected 16 card tab roots, got ${measurement.tabRootCount}`, diff --git a/scripts/verify-contracts.mjs b/scripts/verify-contracts.mjs index 8863282..664ea64 100644 --- a/scripts/verify-contracts.mjs +++ b/scripts/verify-contracts.mjs @@ -124,10 +124,53 @@ const assertions = [ specimenApp.includes("

{group}

"), ], [ - "CLI and React API tabs stay separate", - specimenApp.includes('CLI') && + "specimens frame the canvas rather than the preview toolbar", + /\.specimen-card\s*\{[^}]*border:\s*0;[^}]*background:\s*transparent;/.test( + specimenCss, + ) && + /\.specimen-preview\s*\{[^}]*border:\s*0;/.test(specimenCss) && + /\.specimen-preview__canvas\s*\{[^}]*border:\s*1px solid var\(--border\);[^}]*background:\s*var\(--background\);/.test( + specimenCss, + ), + ], + [ + "documentation headings use the interface font", + /\.specimen-hero h1\s*\{[^}]*font-family:\s*var\(--font-display\);/.test( + specimenCss, + ) && + /\.catalog-group__header h2\s*\{[^}]*font-family:\s*var\(--font-display\);/.test( + specimenCss, + ), + ], + [ + "documentation chrome uses neutral surfaces", + /\.specimen-topbar\s*\{[^}]*background:\s*var\(--background\);/.test( + specimenCss, + ) && + /\.specimen-rail\s*\{[^}]*background:\s*var\(--background\);/.test( + specimenCss, + ), + ], + [ + "topbar centers search between balanced side columns", + /\.specimen-topbar__inner\s*\{[^}]*display:\s*grid;[^}]*min-height:\s*3\.25rem;[^}]*grid-template-columns:\s*minmax\(0, 1fr\) minmax\(0, min\(22rem, 40vw\)\) minmax\(\s*0,\s*1fr\s*\);/.test( + specimenCss, + ), + ], + [ + "mobile examples retain intrinsic control widths", + /\.specimen-stage\s*\{[^}]*justify-items:\s*center;/.test(specimenFixes) && + !/\.specimen-stage > \*\s*\{[^}]*\n\s*width:\s*100%;/.test(specimenFixes), + ], + [ + "code snippets use a readable base size", + /\.specimen-command\s*\{[^}]*font-size:\s*0\.75rem;/.test(specimenCss), + ], + [ + "Install and Import tabs stay separate", + specimenApp.includes('Install') && specimenApp.includes( - 'React API', + 'Import', ) && !specimenApp.includes('API') && specimenApp.includes('language="typescript"') && @@ -236,19 +279,19 @@ const assertions = [ ], [ "text resize keeps shell chrome and hero contained", - specimenFixes.includes( - ".specimen-topbar__inner {\n display: flex;\n flex-wrap: wrap;", + /\.specimen-topbar__leading\s*\{[^}]*min-width:\s*0;[^}]*flex-wrap:\s*wrap;/.test( + specimenCss, ) && - /\.specimen-topbar__actions\s*\{[^}]*display:\s*flex;[^}]*flex:\s*1 0 100%;[^}]*flex-wrap:\s*wrap;/.test( - specimenFixes, + specimenCss.includes( + 'grid-template-areas: "leading actions" "search search";', ) && - /\.specimen-search\s*\{[^}]*width:\s*auto;[^}]*min-width:\s*7rem;[^}]*flex:\s*1 1 10rem;/.test( - specimenFixes, + /\.specimen-search\s*\{[^}]*width:\s*100%;[^}]*min-width:\s*0;/.test( + specimenCss, ) && /\.specimen-main__inner[^{}]*\{[^}]*box-sizing:\s*border-box;/.test( specimenFixes, ) && - /\.specimen-stats\s*\{[^}]*grid-template-columns:\s*repeat\(2,\s*minmax\(0,\s*1fr\)\);/.test( + /\.specimen-hero h1\s*\{[^}]*overflow-wrap:\s*anywhere;/.test( specimenStyles, ), ], @@ -306,12 +349,7 @@ const specimenSelectorPairs = [ ], ["hero", 'className="specimen-hero"', ".specimen-hero"], ["hero copy", 'className="specimen-hero__copy"', ".specimen-hero__copy"], - ["hero stats", 'className="specimen-stats"', ".specimen-stats"], - [ - "catalog eyebrow", - 'className="catalog-group__eyebrow numeric"', - ".catalog-group__eyebrow", - ], + ["heading links", 'className="specimen-permalink"', ".specimen-permalink"], [ "catalog summary", 'className="catalog-group__summary"', @@ -334,6 +372,8 @@ const obsoleteSpecimenSelectors = [ ".specimen-rail__intro", ".specimen-eyebrow", ".catalog-group__grid", + ".catalog-group__eyebrow", + ".catalog-group__count", ]; for (const selector of obsoleteSpecimenSelectors) { diff --git a/scripts/visual-review.mjs b/scripts/visual-review.mjs index 7da0397..644fe06 100644 --- a/scripts/visual-review.mjs +++ b/scripts/visual-review.mjs @@ -284,7 +284,7 @@ async function pressKey(client, key, code, keyCode, modifiers = 0) { } } -async function auditInteractions(client, scenario) { +async function auditNavigation(client, scenario) { await navigate( client, new URL("/?navigation-audit#session-header", baseUrl).href, @@ -319,6 +319,13 @@ async function auditInteractions(client, scenario) { document.querySelector('.specimen-rail__nav a[href="#mode-switch"]').getAttribute("aria-current") === "location"`, "component navigation", ); + await clickElement(client, "#send-control .specimen-permalink"); + await waitForValue( + client, + `location.hash === "#send-control" && + document.querySelector('.specimen-rail__nav a[href="#send-control"]').getAttribute("aria-current") === "location"`, + "component heading permalink", + ); await evaluateValue( client, `document.getElementById("plan-row").scrollIntoView({ block: "start" })`, @@ -328,6 +335,9 @@ async function auditInteractions(client, scenario) { `document.querySelector('.specimen-rail__nav a[href="#plan-row"]').getAttribute("aria-current") === "location"`, "scroll-synchronized component navigation", ); +} + +async function auditSearchRecovery(client, scenario) { await pressKey(client, "k", "KeyK", 75, 2); await waitForValue( client, @@ -341,6 +351,68 @@ async function auditInteractions(client, scenario) { 'location.hash === ""', "filtered-out component fragment cleared", ); + await waitForValue( + client, + `document.querySelector('[role="status"][aria-label="Search results"]').textContent === "0 results"`, + "empty search feedback", + ); + const searchLayout = await evaluateValue( + client, + `(() => { + const root = document.documentElement; + const emptyStyle = getComputedStyle(document.querySelector(".catalog-empty")); + const elements = [...document.querySelectorAll(".catalog-results, .catalog-clear-search, .catalog-empty, .catalog-empty h2, .catalog-empty p")]; + return { + fontSize: getComputedStyle(root).fontSize, + emptyPadding: parseFloat(emptyStyle.paddingLeft), + emptyMinHeight: parseFloat(emptyStyle.minHeight), + documentOverflow: Math.max(0, root.scrollWidth - root.clientWidth), + clipped: elements.filter(element => element.scrollWidth > element.clientWidth + 1) + .map(element => ({ element: element.className || element.tagName, overflow: element.scrollWidth - element.clientWidth })), + }; + })()`, + ); + if (scenario.textScale) { + await evaluateValue( + client, + `(async () => { + document.querySelector(".catalog-results").scrollIntoView({ block: "start" }); + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); + })()`, + true, + ); + } + const emptySearch = await client.send("Page.captureScreenshot", { + format: "png", + fromSurface: true, + captureBeyondViewport: false, + }); + await writeFile( + path.join(outputDir, `${scenario.name}-search-empty.png`), + Buffer.from(emptySearch.data, "base64"), + ); + if ( + searchLayout.documentOverflow > 1 || + searchLayout.clipped.length || + searchLayout.emptyPadding > 32 || + searchLayout.emptyMinHeight > 320 + ) { + throw new Error( + `${scenario.name}: search layout ${JSON.stringify(searchLayout)}`, + ); + } + await clickElement(client, ".catalog-clear-search"); + await waitForValue( + client, + `(() => { + const input = document.querySelector(".specimen-search input"); + const bounds = input.getBoundingClientRect(); + return document.activeElement === input && input.value === "" && + document.querySelectorAll(".specimen-card").length === 16 && + bounds.top >= 0 && bounds.bottom <= innerHeight; + })()`, + "clear search restores visible input focus", + ); await pressKey(client, "a", "KeyA", 65, selectAllModifier); await client.send("Input.insertText", { text: "Context meter" }); await waitForValue( @@ -351,14 +423,19 @@ async function auditInteractions(client, scenario) { document.querySelectorAll("#component-picker option").length === 2`, "filtered specimen", ); - await pressKey(client, "a", "KeyA", 65, selectAllModifier); - await pressKey(client, "Backspace", "Backspace", 8); + await pressKey(client, "Escape", "Escape", 27); await waitForValue( client, - `document.querySelectorAll(".specimen-card").length === 16`, + `document.querySelectorAll(".specimen-card").length === 16 && + document.activeElement === document.querySelector(".specimen-search input")`, "restored catalog", ); + return searchLayout; +} +async function auditInteractions(client, scenario) { + await auditNavigation(client, scenario); + await auditSearchRecovery(client, scenario); await clickElement(client, ".scheme-control"); await evaluateValue( client, @@ -455,6 +532,30 @@ async function auditInteractions(client, scenario) { "stop control", ); + const sceneLoaded = client.waitForEvent("Page.loadEventFired"); + await clickElement(client, "#run-rail .specimen-lab-link"); + await sceneLoaded; + await waitForValue( + client, + `location.pathname === "/lab" && location.hash === "#run-rail" && document.querySelector(".assembled-lab__tabs [aria-selected=true]")?.dataset.sceneId === "run-rail"`, + "direct Library-to-Lab scene link", + ); + await evaluateValue(client, 'document.querySelector(".skip-link").focus()'); + await pressKey(client, "Enter", "Enter", 13); + await waitForValue( + client, + `document.activeElement?.id === "specimen-main" && location.hash === "#run-rail" && document.querySelector(".assembled-lab__tabs [aria-selected=true]")?.dataset.sceneId === "run-rail"`, + "Lab skip link preserves the active scene", + ); + const libraryLoaded = client.waitForEvent("Page.loadEventFired"); + await clickElement(client, ".lab-footer a"); + await libraryLoaded; + await waitForValue( + client, + `location.pathname === "/" && location.hash === "#run-rail" && Boolean(document.querySelector("#run-rail .specimen-lab-link"))`, + "return to the matching library specimen", + ); + const loaded = client.waitForEvent("Page.loadEventFired"); await clickElement(client, '.surface-switcher a[href="/lab"]'); await loaded; @@ -465,15 +566,27 @@ async function auditInteractions(client, scenario) { await clickElement(client, 'button[aria-label="Next scene"]'); await waitForValue( client, - `document.querySelector(".assembled-lab__tabs [aria-selected=true]").dataset.sceneId === "run-rail"`, + `location.hash === "#run-rail" && document.querySelector(".assembled-lab__tabs [aria-selected=true]").dataset.sceneId === "run-rail"`, "next scene", ); await clickElement(client, 'button[aria-label="Previous scene"]'); await waitForValue( client, - `document.querySelector(".lab-scene-panel:not([hidden]) textarea")?.value === "Keep this carousel draft"`, + `location.hash === "#composer" && document.querySelector(".lab-scene-panel:not([hidden]) textarea")?.value === "Keep this carousel draft"`, "carousel draft preservation", ); + await evaluateValue(client, 'location.hash = "#messages"'); + await waitForValue( + client, + `document.querySelector(".assembled-lab__tabs [aria-selected=true]")?.dataset.sceneId === "messages"`, + "external scene hash", + ); + await evaluateValue(client, "history.back()"); + await waitForValue( + client, + `location.hash === "#composer" && document.querySelector(".lab-scene-panel:not([hidden]) textarea")?.value === "Keep this carousel draft"`, + "browser Back restores the scene without losing its draft", + ); await clickElement(client, ".assembled-lab__tabs [data-scene-id=composer]"); await pressKey(client, "ArrowRight", "ArrowRight", 39); await waitForValue( @@ -578,11 +691,15 @@ async function auditInventory(client, scenario) { const overlay = card.querySelector(".specimen-source-overlay"); const canvas = card.querySelector(".specimen-preview__canvas"); const source = overlay.querySelector("code").textContent; + const codeViewport = overlay.querySelector("pre"); + const codeStyle = getComputedStyle(codeViewport); const result = { source, + visibleCodeLines: (codeViewport.clientHeight - parseFloat(codeStyle.paddingTop) - parseFloat(codeStyle.paddingBottom)) / parseFloat(codeStyle.lineHeight), + permalink: card.querySelector(".specimen-permalink")?.getAttribute("href") === "#" + card.id, stable: Math.abs(before.page - document.documentElement.scrollHeight) < 1 && Math.abs(before.card - card.getBoundingClientRect().height) < 1 && Math.abs(before.scroll - scrollY) < 1, - covers: Math.abs(overlay.getBoundingClientRect().height - canvas.getBoundingClientRect().height) < 1 && + covers: Math.abs(overlay.getBoundingClientRect().height - canvas.clientHeight) < 1 && card.querySelector(".specimen-live-panel").inert, overflow: Math.max(card.scrollWidth - card.clientWidth, stage.scrollWidth - stage.clientWidth), }; @@ -601,6 +718,9 @@ async function auditInventory(client, scenario) { if ( !result.stable || !result.covers || + !result.permalink || + !Number.isFinite(result.visibleCodeLines) || + result.visibleCodeLines < 2 || result.overflow > 1 || result.source !== expected ) { @@ -631,6 +751,7 @@ async function auditInventory(client, scenario) { screenshot, stable: result.stable, overflow: result.overflow, + visibleCodeLines: result.visibleCodeLines, }); } return { @@ -683,7 +804,7 @@ const scenarios = [ legacyDensity: "default", mobile: false, expected: "library", - revealCodeTab: "CLI", + revealCodeTab: "Install", codeTargetId: "mode-switch", expectedCode: 'pnpm dlx shadcn@latest add "https://ui.opencoven.ai/r/mode-switch.json"', @@ -703,7 +824,7 @@ const scenarios = [ legacyDensity: "compact", mobile: false, expected: "library", - revealCodeTab: "React API", + revealCodeTab: "Import", codeTargetId: "session-header", expectedCode: 'import { SessionHeader } from "@opencoven/ui/blocks/session-header";', @@ -719,7 +840,7 @@ const scenarios = [ mobile: true, expected: "library", textScale: 2, - revealCodeTab: "React API", + revealCodeTab: "Import", codeTargetId: "session-header", expectedCode: 'import { SessionHeader } from "@opencoven/ui/blocks/session-header";', @@ -1188,7 +1309,11 @@ try { [a.left + 8, a.bottom - 8], [a.right - 8, a.bottom - 8], [a.left + a.width / 2, a.top + a.height / 2], ]; - return ["top","left","width","height"].every(key => Math.abs(a[key] - b[key]) < 1) && + const interior = { + top: b.top + canvas.clientTop, left: b.left + canvas.clientLeft, + width: canvas.clientWidth, height: canvas.clientHeight, + }; + return ["top","left","width","height"].every(key => Math.abs(a[key] - interior[key]) < 1) && live.inert && live.getAttribute("aria-hidden") === "true" && samplePoints.every(([x, y]) => overlay.contains(document.elementFromPoint(x, y))); })(), @@ -1208,6 +1333,22 @@ try { previewVisible: isVisible(codeCard?.querySelector(".specimen-preview")), logoMask: getComputedStyle(document.querySelector(".specimen-brand__mark > span")).maskImage, topbarVisible: isVisible(topbar), + headerPresentation: (() => { + const search = document.querySelector(".specimen-search"); + const header = document.querySelector(".specimen-topbar__inner"); + const searchBox = bounds(search); + const headerBox = bounds(header); + const theme = document.querySelector(".scheme-control"); + return { + searchCenterOffset: searchBox + ? Math.abs(searchBox.left + searchBox.width / 2 - (headerBox.left + headerBox.width / 2)) + : null, + height: bounds(topbar).height, + searchHeight: searchBox?.height ?? null, + themeText: theme.textContent.trim(), + titleSize: parseFloat(getComputedStyle(document.querySelector("h1")).fontSize), + }; + })(), railVisible: isVisible(rail), catalogLayout: (() => { const inner = document.querySelector(".specimen-main__inner"); @@ -1238,6 +1379,27 @@ try { heroHeight: heroBounds?.height ?? null, contentTop: contentBounds?.top ?? null, cardCount: cards.length, + smallPreviewHeight: bounds(document.querySelector("#mode-switch .specimen-stage"))?.height, + previewPresentation: (() => { + const card = document.querySelector("#mode-switch"); + if (!card) return null; + const preview = card.querySelector(".specimen-preview"); + const canvas = card.querySelector(".specimen-preview__canvas"); + const tabs = card.querySelector(".specimen-view-tabs"); + return { + top: bounds(preview).top, + bottom: bounds(preview).bottom, + articleBorder: parseFloat(getComputedStyle(card).borderTopWidth), + previewBorder: parseFloat(getComputedStyle(preview).borderTopWidth), + canvasBorder: parseFloat(getComputedStyle(canvas).borderTopWidth), + tabsAboveCanvas: bounds(tabs).bottom <= bounds(canvas).top, + textOnlyTabs: tabs.querySelectorAll("svg").length === 0, + neutralCanvas: getComputedStyle(canvas).backgroundColor === getComputedStyle(document.body).backgroundColor, + stageWidth: bounds(card.querySelector(".specimen-stage")).width, + controlWidth: bounds(card.querySelector('[data-slot="mode-switch"]')).width, + codeSize: parseFloat(getComputedStyle(card.querySelector(".specimen-command")).fontSize), + }; + })(), stateFooterCount: document.querySelectorAll(".specimen-states").length, libraryColumns: [...document.querySelectorAll(".specimen-grid")].map(grid => getComputedStyle(grid).gridTemplateColumns.split(" ").length), runRailColumns: (() => { @@ -1246,8 +1408,84 @@ try { })(), statesTabCount: [...document.querySelectorAll(".specimen-card [role=tab]")].filter(tab => tab.textContent.trim() === "States").length, brandAction: getComputedStyle(root).getPropertyValue("--oc-action").trim(), + backgroundColors: ["body", ".specimen-topbar", ".specimen-rail", ".specimen-card", ".specimen-preview__canvas", ".assembled-lab__stage"] + .flatMap(selector => { + let element = document.querySelector(selector); + if (!element) return []; + let color = getComputedStyle(element).backgroundColor; + while (color === "rgba(0, 0, 0, 0)" && element.parentElement) { + element = element.parentElement; + color = getComputedStyle(element).backgroundColor; + } + return [{ selector, color }]; + }), + presentation: root.dataset.presentation, + presentationBackground: getComputedStyle(root).getPropertyValue("--oc-bg").trim(), + neutralHeading: getComputedStyle(document.querySelector("h1")).color === getComputedStyle(document.body).color, + documentationChrome: { + unifiedSurfaces: [topbar, rail].filter(Boolean).every(element => + getComputedStyle(element).backgroundColor === getComputedStyle(document.body).backgroundColor), + sansSectionHeadings: [...document.querySelectorAll(".catalog-group h2")].every(element => + getComputedStyle(element).fontFamily.includes("Inter")), + sidebarBorder: rail ? parseFloat(getComputedStyle(rail).borderInlineEndWidth) : 0, + decorativeLabels: document.querySelectorAll(".catalog-group__eyebrow, .catalog-group__count, .specimen-rail__nav small, .specimen-brand small").length, + }, + presentationContrast: (() => { + const style = getComputedStyle(root); + const value = name => style.getPropertyValue(name).trim(); + const canvas = document.createElement("canvas"); + canvas.width = canvas.height = 1; + const context = canvas.getContext("2d"); + const luminance = rgb => { + const linear = [...rgb].slice(0, 3).map(channel => { + const value = channel / 255; + return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; + }); + return linear[0] * 0.2126 + linear[1] * 0.7152 + linear[2] * 0.0722; + }; + const pairs = [ + ["text", "--foreground", "--background", 4.5], + ["muted text", "--muted-foreground", "--background", 4.5], + ["preview captions", "--muted-foreground", "--oc-surface-2", 4.5], + ["primary action", "--primary-foreground", "--primary", 4.5], + ["selected controls", "--presence", "--oc-surface-2", 4.5], + ["focus ring", "--ring", "--background", 3], + ["input boundary", "--input", "--card", 3], + ["success", "--success", "--card", 4.5], + ["success surface", "--success", "--oc-success-bg", 4.5], + ["warning", "--warning", "--card", 4.5], + ["warning surface", "--warning", "--oc-pending-bg", 4.5], + ["error", "--destructive", "--card", 4.5], + ["information", "--information", "--card", 4.5], + ]; + const resolvedPairs = pairs.map(([name, foreground, background, minimum]) => + [name, value(foreground), value(background), minimum] + ); + const count = document.querySelector(".specimen-rail__nav small"); + if (count) { + resolvedPairs.push(["navigation count", getComputedStyle(count).color, getComputedStyle(document.body).backgroundColor, 4.5]); + } + for (const token of visibleSyntaxTokens) { + const command = token.closest(".specimen-command"); + if (command) { + resolvedPairs.push([token.className, getComputedStyle(token).color, getComputedStyle(command).backgroundColor, 4.5]); + } + } + return resolvedPairs.map(([name, foreground, background, minimum]) => { + context.clearRect(0, 0, 1, 1); + context.fillStyle = background; + context.fillRect(0, 0, 1, 1); + const back = luminance(context.getImageData(0, 0, 1, 1).data); + context.fillStyle = foreground; + context.fillRect(0, 0, 1, 1); + const front = luminance(context.getImageData(0, 0, 1, 1).data); + return { name, minimum, ratio: (Math.max(front, back) + 0.05) / (Math.min(front, back) + 0.05) }; + }); + })(), brandSpacing: getComputedStyle(root).getPropertyValue("--oc-space-6").trim(), displayFont: getComputedStyle(document.querySelector("h1")).fontFamily, + interfaceFont: getComputedStyle(document.body).fontFamily, + loadedFonts: [...document.fonts].filter(face => face.status === "loaded").map(face => face.family), forcedSelectionVisible: (() => { const tab = document.querySelector(".specimen-view-tabs [aria-selected=true]"); if (!tab) return false; @@ -1319,6 +1557,78 @@ try { ); const failures = []; + const header = layout.headerPresentation; + if ( + (header.searchCenterOffset !== null && header.searchCenterOffset > 1) || + header.themeText !== "" || + (!scenario.textScale && + scenario.width > 1088 && + (header.height > 53 || + header.titleSize > 28 || + (header.searchHeight !== null && header.searchHeight > 36))) + ) { + failures.push( + `centered slim header regressed: ${JSON.stringify(header)}`, + ); + } + if (layout.previewPresentation) { + const preview = layout.previewPresentation; + if ( + preview.articleBorder !== 0 || + preview.previewBorder !== 0 || + preview.canvasBorder !== 1 || + !preview.tabsAboveCanvas || + !preview.textOnlyTabs || + !preview.neutralCanvas || + preview.codeSize < 12 + ) { + failures.push( + `preview hierarchy or source readability regressed: ${JSON.stringify(preview)}`, + ); + } + if ( + !layout.documentationChrome.unifiedSurfaces || + !layout.documentationChrome.sansSectionHeadings || + layout.documentationChrome.sidebarBorder !== 0 || + layout.documentationChrome.decorativeLabels !== 0 + ) { + failures.push( + `documentation chrome regressed: ${JSON.stringify(layout.documentationChrome)}`, + ); + } + if (!scenario.textScale) { + if (preview.controlWidth > preview.stageWidth - 64) { + failures.push("small controls are stretched across their preview"); + } + if (layout.scrollY < 1 && scenario.height >= 800) { + const maximumTop = + layout.viewportWidth > 880 + ? layout.topbarHeight + 340 + : layout.mobileChromeBottom + 380; + if (preview.top > maximumTop || preview.bottom > scenario.height) { + failures.push( + `first preview is too far below the fold: ${preview.top}-${preview.bottom}`, + ); + } + } + } + } + for (const pair of layout.presentationContrast) { + if (!Number.isFinite(pair.ratio) || pair.ratio < pair.minimum) { + failures.push( + `${pair.name} contrast ${pair.ratio.toFixed(2)} is below ${pair.minimum}`, + ); + } + } + if ( + scenario.expected === "library" && + !scenario.textScale && + layout.smallPreviewHeight > 161 + ) { + failures.push( + `small preview has excessive empty space: ${layout.smallPreviewHeight}px`, + ); + } if (scenario.expected === "library") { const nav = layout.catalogLayout; const desktop = layout.viewportWidth > 880; @@ -1361,16 +1671,43 @@ try { ) { failures.push("run rail does not use the available preview width"); } + if (!scenario.forcedColors) { + for (const surface of layout.backgroundColors) { + const channels = surface.color + .match(/^rgb\(([\d.]+),\s*([\d.]+),\s*([\d.]+)\)$/) + ?.slice(1) + .map(Number); + if (!channels || Math.max(...channels) - Math.min(...channels) > 1) { + failures.push( + `non-neutral background at ${surface.selector}: ${surface.color}`, + ); + } + } + } if ( layout.brandAction !== - (scenario.scheme === "dark" ? "#8e3dff" : "#7a22ee") || + (scenario.scheme === "dark" ? "#9386d0" : "#6859ac") || + layout.presentation !== "coven" || + layout.presentationBackground !== + (scenario.scheme === "dark" ? "#121212" : "#f5f5f5") || + !layout.neutralHeading || layout.brandSpacing !== "1.5rem" || - !layout.displayFont.startsWith("Geist") + !layout.displayFont.includes("Inter") || + !layout.interfaceFont.includes("Inter") ) { failures.push( - `canonical brand mismatch: ${layout.brandAction}, ${layout.brandSpacing}, ${layout.displayFont}`, + `Coven presentation mismatch: ${layout.brandAction}, ${layout.presentationBackground}, ${layout.brandSpacing}, ${layout.displayFont}`, ); } + const requiredFonts = ["Inter"]; + if (layout.codeSnippetCount > 0) requiredFonts.push("JetBrains Mono"); + for (const family of requiredFonts) { + if (!layout.loadedFonts.some((loaded) => loaded.includes(family))) { + failures.push( + `visible content has not loaded the bundled ${family} font`, + ); + } + } if ( !layout.topbarVisible || (scenario.expected !== "lab" && !layout.railVisible) || @@ -1645,6 +1982,9 @@ try { if (scenario.auditInventory) { layout.inventory = await auditInventory(client, scenario); } + if (scenario.expected === "library" && scenario.textScale) { + layout.searchRecovery = await auditSearchRecovery(client, scenario); + } if (scenario.auditInteractions) { layout.interactions = await auditInteractions(client, scenario); } diff --git a/scripts/zoom-probe.mjs b/scripts/zoom-probe.mjs index 3c61b38..5d1141b 100644 --- a/scripts/zoom-probe.mjs +++ b/scripts/zoom-probe.mjs @@ -1,10 +1,9 @@ /* - * Detail probe for the specimen shell's ritual-tech finish. + * Detail probe for the specimen shell's documentation frame and typography. * * visual-review.mjs captures whole pages at 1x, which is the right scale for - * layout regressions but too coarse to confirm a 1px circuitry trace actually - * paints. This grabs one card at 3x and asserts the decorative layers resolve - * to real colours rather than to an unresolved custom property. + * layout regressions. This grabs one card at 3x and records the canvas border, + * heading typography, and background for closer visual inspection. * * The evaluate callbacks below run in the page, not in Node. */ @@ -39,25 +38,27 @@ await card.scrollIntoViewIfNeeded(); await page.waitForTimeout(300); await card.screenshot({ path: `${outDir}/zoom-card.png` }); -// Prove the circuitry trace is painted, not merely declared. -const trace = await card.evaluate((node) => { - const style = getComputedStyle(node, "::before"); - return { - content: style.content, - height: style.height, - backgroundImage: style.backgroundImage.slice(0, 160), - display: style.display, - }; -}); +const frame = await card + .locator(".specimen-preview__canvas") + .evaluate((node) => { + const style = getComputedStyle(node); + return { + borderWidth: style.borderTopWidth, + borderColor: style.borderTopColor, + borderRadius: style.borderRadius, + background: style.backgroundColor, + }; + }); -const eyebrow = await page - .locator(".catalog-group__eyebrow") +const heading = await page + .locator(".catalog-group h2") .first() .evaluate((node) => { - const style = getComputedStyle(node, "::before"); + const style = getComputedStyle(node); return { - width: style.width, - backgroundImage: style.backgroundImage.slice(0, 120), + fontFamily: style.fontFamily, + fontSize: style.fontSize, + fontWeight: style.fontWeight, }; }); @@ -79,7 +80,7 @@ const animations = await page.evaluate(() => console.log( JSON.stringify( - { trace, eyebrow, bodyLayers, runningAnimations: animations }, + { frame, heading, bodyLayers, runningAnimations: animations }, null, 2, ),