with no size of its own, so it needs one from the
+ * caller, usually inset:0 inside a positioned parent. It is WebGPU, so it must
+ * not render on the server. Import it through FadedDither, which handles both
+ * that and choosing the tone.
+ */
+/* The stages outline themselves in whichever base applies, through
+ --ps-stage-stroke in ProductSection.module.css and the equivalent in
+ CloudSection.module.css. Change a base here and change it there too. */
+/* Desktop keeps the preset's wave. A phone shows so little of it that the same
+ values read as one flat swell filling the stage, so phone-width screens get
+ more cycles and a thinner line, not fewer and fatter. */
+const WAVE = {
+ wide: { frequency: 0.5, thickness: 0.4 },
+ compact: { frequency: 1.8, thickness: 0.16 },
+} as const;
+
+const TONES = {
+ dark: { base: "#0e0e0e", stipple: "#202020" },
+ light: { base: "#f8f8f8", stipple: "#e6e6e6" },
+} as const;
+
+export function FadedDitherCanvas({
+ tone = "dark",
+ compact = false,
+ className,
+ style,
+}: {
+ tone?: keyof typeof TONES;
+ /* Set when the viewport is phone width. */
+ compact?: boolean;
+ className?: string;
+ style?: CSSProperties;
+}) {
+ const { base, stipple } = TONES[tone];
+ const wave = WAVE[compact ? "compact" : "wide"];
+
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/docs/app/(home)/components/StackChip/StackChip.module.css b/docs/app/(home)/components/StackChip/StackChip.module.css
index 3367ed5f63..6183e2cbf7 100644
--- a/docs/app/(home)/components/StackChip/StackChip.module.css
+++ b/docs/app/(home)/components/StackChip/StackChip.module.css
@@ -61,6 +61,13 @@
background: var(--openui-text-black);
}
+/* On a dark page a black badge is the same colour as the ground behind it, so
+ the circle disappears and the mark floats. The rim gives it its edge back.
+ Light pages need nothing: the black circle is its own edge there. */
+[data-theme="dark"] .badgeBlack {
+ border: 1px solid rgb(255 255 255 / 0.22);
+}
+
.badgeAnthropic {
background: #d4a574;
}
@@ -90,7 +97,35 @@
}
.badgeDaisyUi {
- background: #f472b6;
+ background: #1ad1a5;
+}
+
+.badgeConvex {
+ background: #ee342f;
+}
+
+.badgeCloudflare {
+ background: #f38020;
+}
+
+.badgeMui {
+ background: #007fff;
+}
+
+.badgeAntDesign {
+ background: #0170fe;
+}
+
+.badgeChakra {
+ background: #319795;
+}
+
+.badgeMantine {
+ background: #339af0;
+}
+
+.badgeNuxt {
+ background: #00dc82;
}
.badgeBaseUi {
@@ -113,6 +148,12 @@
object-fit: contain;
}
+/* The local DeepSeek mark carries no fill of its own, so it paints black.
+ Invert it to white, which is what reads on the blue badge. */
+.badgeImage[src*="deepseek"] {
+ filter: invert(1);
+}
+
/* The xAI mark is a small wordmark; shrink + invert it to read on the dark badge. */
.badgeImage[src*="xai"] {
width: 12px;
@@ -152,8 +193,12 @@
color: var(--openui-text-neutral-primary);
}
-/* Logo-only variant for diagrams; regular chips keep their existing sizing. */
+/* Logo-only variant for diagrams; regular chips keep their existing sizing.
+ Block-level, unlike the inline-flex chip: an inline box is aligned on the
+ line's baseline, and a badge holding text rather than an image gets a
+ different baseline, so it sat a couple of pixels lower than its neighbours. */
.chipIconOnly {
+ display: flex;
width: var(--stack-logo-size, 44px);
height: var(--stack-logo-size, 44px);
padding: 0;
@@ -166,21 +211,37 @@
height: var(--stack-logo-size, 44px);
}
+/* The glyph tracks the badge so callers can resize the whole logo with one var. */
.chipIconOnly .badgeImage,
.chipIconOnly .iconSvg,
.chipIconOnly .mascotSvg {
- width: 24px;
- height: 24px;
+ width: var(--stack-logo-glyph, 24px);
+ height: var(--stack-logo-glyph, 24px);
+}
+
+/* Shiro is line art rather than a solid mark, so at the shared size it reads
+ smaller than the rest. Only the glyph grows; the badge is unchanged. */
+.chipIconOnly .mascotSvg {
+ width: calc(var(--stack-logo-glyph, 24px) * 1.45);
+ height: calc(var(--stack-logo-glyph, 24px) * 1.45);
+}
+
+/* Same for the OpenAI rosette: fine, open line work that reads small next to
+ the solid marks around it. Scoped to the icon-only variant, so the regular
+ chips elsewhere keep their sizing. */
+.chipIconOnly .badgeImage[src*="openai"] {
+ width: calc(var(--stack-logo-glyph, 24px) * 1.25);
+ height: calc(var(--stack-logo-glyph, 24px) * 1.25);
}
.chipIconOnly .badgeImage[src*="xai"] {
- width: 20px;
- height: 20px;
+ width: calc(var(--stack-logo-glyph, 24px) * 0.8);
+ height: calc(var(--stack-logo-glyph, 24px) * 0.8);
}
.chipIconOnly .badgeText,
.chipIconOnly .moreIcon {
- font-size: 20px;
+ font-size: calc(var(--stack-logo-glyph, 24px) * 0.82);
}
.chipIconOnly .badgeBlack .badgeText {
diff --git a/docs/app/(home)/components/StaticTweetCard/StaticTweetCard.module.css b/docs/app/(home)/components/StaticTweetCard/StaticTweetCard.module.css
index 6f096fbb28..15989d85da 100644
--- a/docs/app/(home)/components/StaticTweetCard/StaticTweetCard.module.css
+++ b/docs/app/(home)/components/StaticTweetCard/StaticTweetCard.module.css
@@ -5,7 +5,12 @@
border: 1px solid var(--home-hairline);
border-radius: 12px;
background: var(--openui-foreground);
- box-shadow: var(--home-shadow-card);
+ /* Local override of --home-shadow-card: wider and much softer than the shared
+ card shadow, so the wall reads as floating cards rather than outlined ones.
+ Kept here because three other sections still want the tighter shared one. */
+ box-shadow:
+ 0 2px 6px rgba(22, 34, 51, 0.03),
+ 0 10px 28px rgba(22, 34, 51, 0.045);
color: var(--openui-text-neutral-primary);
font-family: var(--home-font-text);
text-decoration: none;
diff --git a/docs/app/(home)/components/TweetWall/TweetWall.module.css b/docs/app/(home)/components/TweetWall/TweetWall.module.css
index 904b37808f..e53078bb20 100644
--- a/docs/app/(home)/components/TweetWall/TweetWall.module.css
+++ b/docs/app/(home)/components/TweetWall/TweetWall.module.css
@@ -33,6 +33,13 @@
flex: 1 1 0;
min-width: 0;
height: 100%;
+ /* The column clips vertically so the marquee can run, and CSS will not allow
+ overflow-x: visible alongside overflow-y: hidden. So widen the clip box
+ instead: the padding gives card shadows room at the column edges, and the
+ matching negative margin keeps the layout footprint and the 12px gap
+ exactly as they were. Box-sizing is border-box, so the cards stay 335px. */
+ padding-inline: 16px;
+ margin-inline: -16px;
overflow: hidden;
}
@@ -77,16 +84,25 @@
gap: 0.5rem;
}
+ /* 230px of card plus the 16px of shadow bleed either side. The column sizes
+ its border box, so basing this on the card alone left only 198px of content
+ box and the slot below overhung the clip by 16px, cutting every card's
+ right edge off. */
.column {
- flex: 0 0 230px;
+ flex: 0 0 262px;
}
/* Render the card at 320px then scale the whole box down with zoom (shrinks the
layout box too, so columns stay tight and the loop has no gaps). 320 * 0.72 ≈ 230.
The wide base width lets tweet text breathe so cards aren't squeezed/too tall,
- while the 0.72 scale keeps the cards compact. */
+ while the 0.72 scale keeps the cards compact.
+
+ The width is a percentage rather than a flat 320px because under zoom a
+ percentage resolves in the slot's own scaled space: 100% of the column's
+ 230px content box lays the card out at ~320px and paints it at 230, so it
+ tracks the column instead of overhanging it. */
.slot {
- width: 320px;
+ width: 100%;
zoom: 0.72;
margin-bottom: 0.625rem;
}
diff --git a/docs/app/(home)/page.module.css b/docs/app/(home)/page.module.css
index 754acae838..38f2756349 100644
--- a/docs/app/(home)/page.module.css
+++ b/docs/app/(home)/page.module.css
@@ -1,4 +1,14 @@
+/* The design system's light --openui-foreground is rgb(253,253,253), two levels
+ off white. Sections that paint pure #fff sit on it and the step reads as a
+ faint frame around them, so this page takes the token to white instead.
+
+ Scoped to this element on purpose. The token is declared in the react-ui
+ package and read in 167 places across the site, and .homeTheme covers all
+ thirteen marketing routes; setting it here keeps the change to the home page
+ while everything below still goes through the token rather than a literal. */
.page {
+ --openui-foreground: #fff;
+
position: relative;
min-height: 100vh;
width: 100%;
@@ -85,12 +95,24 @@
padding-block: 4rem;
}
+ /* The feature grid states the Lang band's own capabilities, so it should read
+ as part of that band. The shell's gap is the measure between unrelated
+ sections, which is too much here; this pulls the grid back up under it. */
+ .langBand {
+ margin-bottom: -3.75rem;
+ }
+
.faqBand {
padding-inline: 1.25rem;
}
}
/* Keep the main page field solid in both themes. */
+/* Dark mode keeps the package's own value, so only the light one is pinned. */
+[data-theme="dark"] .page {
+ --openui-foreground: oklch(0.205 0 0 / 1);
+}
+
[data-theme="dark"] .page,
[data-theme="dark"] .heroShell {
background: #000;
diff --git a/docs/app/(home)/page.tsx b/docs/app/(home)/page.tsx
index 42692c45a7..f8859f3dc8 100644
--- a/docs/app/(home)/page.tsx
+++ b/docs/app/(home)/page.tsx
@@ -1,14 +1,13 @@
import type { Metadata } from "next";
import { AGENT_SETUP_PROMPT, AgentPicker } from "./components/AgentPicker/AgentPicker";
import styles from "./page.module.css";
-import { CloudBanner } from "./sections/CloudBanner/CloudBanner";
+import { BANNER_END_ANCHOR_ID, CloudBanner } from "./sections/CloudBanner/CloudBanner";
import { CloudSection } from "./sections/CloudSection/CloudSection";
import { FaqSection } from "./sections/FaqSection/FaqSection";
import { FeatureGridSection } from "./sections/FeatureGridSection/FeatureGridSection";
import { Footer } from "./sections/Footer/Footer";
import { HeroSection } from "./sections/HeroSection/HeroSection";
import { LogoStrip } from "./sections/LogoStrip/LogoStrip";
-import { OpenSourceIllustration } from "./sections/ProductIllustrations/ProductIllustrations";
import { ProductSection } from "./sections/ProductSection/ProductSection";
import { LANG_PRODUCT } from "./sections/ProductSection/products";
import { ShiroPeek } from "./sections/ShiroPeek/ShiroPeek";
@@ -43,7 +42,7 @@ export default function HomePage() {
{/* The benchmark header and the compatibility band, without the feature
grid that used to sit between them. */}
@@ -51,14 +50,19 @@ export default function HomePage() {
follows it, so the page proves the point before saying what it
works with. The grid runs on its own six features — Live data and
Cross-platform are only stated here. */}
-
+
-
diff --git a/docs/app/(home)/sections/CloudBanner/CloudBanner.tsx b/docs/app/(home)/sections/CloudBanner/CloudBanner.tsx
index 37950aa243..f1e79dcf05 100644
--- a/docs/app/(home)/sections/CloudBanner/CloudBanner.tsx
+++ b/docs/app/(home)/sections/CloudBanner/CloudBanner.tsx
@@ -5,15 +5,23 @@ import Link from "next/link";
import { useEffect, useState } from "react";
import styles from "./CloudBanner.module.css";
+/* The home page tags its FAQ wrapper with this so the banner knows where to
+ stop. Anything without that id simply keeps the banner to the end. */
+export const BANNER_END_ANCHOR_ID = "faq-band";
+
export function CloudBanner() {
const [shouldShow, setShouldShow] = useState(false);
const [mounted, setMounted] = useState(false);
const [open, setOpen] = useState(false);
- // Appear as soon as the hero is scrolled past, and stay for the rest of the page.
+ // Appear once the hero is scrolled past, then retire when the FAQ arrives:
+ // by that point the page is answering questions, not pitching.
useEffect(() => {
const update = () => {
- setShouldShow(window.scrollY > window.innerHeight * 0.6);
+ const pastHero = window.scrollY > window.innerHeight * 0.6;
+ const end = document.getElementById(BANNER_END_ANCHOR_ID);
+ const reachedEnd = end ? end.getBoundingClientRect().top <= window.innerHeight : false;
+ setShouldShow(pastHero && !reachedEnd);
};
update();
window.addEventListener("scroll", update, { passive: true });
diff --git a/docs/app/(home)/sections/CloudSection/CloudSection.module.css b/docs/app/(home)/sections/CloudSection/CloudSection.module.css
index 2b3d2394ce..30a62424f0 100644
--- a/docs/app/(home)/sections/CloudSection/CloudSection.module.css
+++ b/docs/app/(home)/sections/CloudSection/CloudSection.module.css
@@ -32,6 +32,19 @@
flex-direction: column;
gap: clamp(7rem, 14vw, 13rem);
}
+/* Full width of the band, with equal air above and below. Half the gap the
+ observability band used to set as its own top margin, on each side, so the
+ rhythm between the two is unchanged and the rule sits centred in it. */
+.bandSeparator {
+ height: 1px;
+ margin-block: clamp(4rem, 7.5vw, 5.375rem);
+ background: rgba(255, 255, 255, 0.12);
+}
+
+[data-theme="dark"] .bandSeparator {
+ background: rgba(10, 10, 10, 0.1);
+}
+
.contact {
display: flex;
flex-direction: column;
@@ -39,7 +52,7 @@
gap: 1.5rem;
/* On top of the section's own padding, so this block sits clear of both the
band's top edge and the product bands beneath it. */
- margin-top: clamp(1.5rem, 3vw, 3rem);
+ margin-top: clamp(0.25rem, 0.75vw, 0.75rem);
margin-bottom: clamp(6rem, 12vw, 11rem);
text-align: center;
}
@@ -69,7 +82,7 @@
dark text. The section title + subtitle come from the shared SectionHeader
component (tone="dark"); they are re-colored in SectionHeader.module.css. */
[data-theme="dark"] .section {
- background: #fdfdfd;
+ background: #fff;
color: #0a0a0a;
}
[data-theme="dark"] .contactCta {
@@ -80,22 +93,24 @@
--bevel-badge-bg: #0a0a0a;
--bevel-badge-fg: #fff;
}
+/* Same shape as a ProductSection band: copy across the top, then a stage the
+ full width of the section. Its stage used to be a column beside the copy,
+ which made it roughly half the width of the reliability band's. */
.observabilityBand {
- display: grid;
- grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
- align-items: stretch;
- gap: clamp(2.5rem, 5vw, 3.75rem);
- margin-top: clamp(8rem, 15vw, 10.75rem);
+ display: flex;
+ flex-direction: column;
+ gap: clamp(0.75rem, 2vw, 1.75rem);
scroll-margin-top: 112px;
}
+/* The same split ProductSection's .lockup uses, so the two bands' headers sit
+ on the same grid. */
.observabilityCopy {
- display: flex;
- flex-direction: column;
- justify-content: space-between;
- gap: 3rem;
+ display: grid;
+ grid-template-columns: minmax(0, 1.15fr) minmax(0, 0.85fr);
+ align-items: start;
+ gap: clamp(1.5rem, 4vw, 4rem);
min-width: 0;
- min-height: 20rem;
}
.observabilityLead {
@@ -157,15 +172,34 @@
align-items: flex-end;
justify-content: center;
overflow: hidden;
+ position: relative;
+ width: 100%;
min-width: 0;
min-height: 0;
- aspect-ratio: 583 / 365;
- border: 0;
+ /* The reliability band's stage ratio, and the artwork's own. */
+ aspect-ratio: 1120 / 440;
+ /* The shader's dark base: this band is black here, so its shader is the dark
+ tone. Mirrors TONES in FadedDitherCanvas.tsx. */
+ border: 1px solid #0e0e0e;
border-radius: 24px;
background: transparent;
}
+/* Fills the stage behind the artwork, clipped to the stage's rounded corners. */
+.observabilityShader {
+ position: absolute;
+ z-index: 0;
+ inset: 0;
+}
+
+.observabilityShader > * {
+ width: 100%;
+ height: 100%;
+}
+
.observabilityImage {
+ position: relative;
+ z-index: 1;
display: block;
width: 100%;
height: 100%;
@@ -173,6 +207,16 @@
object-position: center top;
}
+/* The breakpoint switches these wrappers; the tone rules below pick which
+ image inside the visible one shows. */
+.observabilityWide {
+ display: contents;
+}
+
+.observabilityCompact {
+ display: none;
+}
+
.observabilityImageLight {
display: none;
}
@@ -182,6 +226,8 @@
}
[data-theme="dark"] .observabilityStage {
+ /* The band inverts to white here, so the shader switches to its light base. */
+ border-color: #f8f8f8;
background: transparent;
}
@@ -198,35 +244,69 @@
}
@media (max-width: 1023px) {
+ .bandSeparator {
+ margin-block: 3.5rem;
+ }
+
.observabilityBand {
- grid-template-columns: minmax(0, 1fr);
gap: 2.5rem;
- margin-top: 7rem;
}
.observabilityCopy {
+ grid-template-columns: minmax(0, 1fr);
gap: 2rem;
- min-height: 0;
- }
-
- .observabilityStage {
- min-height: 0;
- aspect-ratio: 583 / 365;
}
}
@media (max-width: 767px) {
+ /* The band's own two product lockups already name Cloud, so on a phone this
+ heading is a third title in a row before any content. The section keeps
+ aria-labelledby pointing at it: a display:none element referenced that way
+ still supplies the accessible name. */
+ .contact {
+ display: none;
+ }
+
+ .bandSeparator {
+ margin-block: 2.75rem;
+ /* Faded towards both ends, so it reads as a divider rather than a rule
+ butting into the gutters. */
+ -webkit-mask-image: linear-gradient(to right, transparent, #000 20%, #000 80%, transparent);
+ mask-image: linear-gradient(to right, transparent, #000 20%, #000 80%, transparent);
+ }
+
.observabilityBand {
gap: 2rem;
- margin-top: 5.5rem;
+ }
+
+ /* Same order swap as ProductSection: the copy grid dissolves so the
+ description can follow the stage instead of preceding it. */
+ .observabilityCopy {
+ display: contents;
+ }
+
+ .observabilityLead {
+ margin-bottom: 0.5rem;
+ order: 1;
+ }
+
+ .observabilityWide {
+ display: none;
+ }
+
+ .observabilityCompact {
+ display: contents;
}
.observabilityStage {
- aspect-ratio: 4 / 3;
+ /* The phone artwork's own ratio. */
+ aspect-ratio: 1440 / 1200;
border-radius: 18px;
+ order: 2;
}
- .observabilityImage {
- object-position: 51% top;
+ .observabilityDescription {
+ max-width: none;
+ order: 3;
}
}
diff --git a/docs/app/(home)/sections/CloudSection/CloudSection.tsx b/docs/app/(home)/sections/CloudSection/CloudSection.tsx
index 0f3502fa4f..32d6746e13 100644
--- a/docs/app/(home)/sections/CloudSection/CloudSection.tsx
+++ b/docs/app/(home)/sections/CloudSection/CloudSection.tsx
@@ -2,6 +2,7 @@
import { ArrowRight } from "@phosphor-icons/react/dist/ssr";
import Image from "next/image";
import { BevelButton } from "../../components/Button/BevelButton";
+import { FadedDither } from "../../components/FadedDither/FadedDither";
import { SectionHeader } from "../../components/SectionHeader/SectionHeader";
import { ProductLabel, ProductSection } from "../ProductSection/ProductSection";
import { GATEWAY_PRODUCT } from "../ProductSection/products";
@@ -33,6 +34,10 @@ export function CloudSection() {
+ {/* Divides the two bands. It carries the whole gap between them, so the
+ band below no longer sets a top margin of its own. */}
+
+
@@ -50,7 +55,7 @@ export function CloudSection() {
className={styles.observabilityCta}
variant="primary"
href="/cloud/observability"
- label="Join waitlist"
+ label="Get Early Access"
badge={
}
/>
@@ -64,28 +69,61 @@ export function CloudSection() {
role="img"
aria-label="OpenUI Observability session replay showing the exact generated interface and user interactions"
>
-
-
+ {/* The Cloud band is dark, so the shader follows it: black ground in
+ light theme, white when the band inverts. */}
+
+ {/* Light and dark refer to the picture's own tone. The band is black
+ in light theme and inverts to white in dark theme, so the rules
+ in the stylesheet pick the one that suits the ground.
+
+ Two pairs, each in a wrapper the breakpoint switches off. The
+ phone artwork is its own drawing at 1440x1200 rather than the
+ wide one cropped, and the wrapper is display:contents when
+ shown, so the theme rules still reach the images. */}
+
+
+
+
+
+
+
+
diff --git a/docs/app/(home)/sections/CompatibilitySection/CompatibilityDiagram.module.css b/docs/app/(home)/sections/CompatibilitySection/CompatibilityDiagram.module.css
index 0ead964ecf..ea84832611 100644
--- a/docs/app/(home)/sections/CompatibilitySection/CompatibilityDiagram.module.css
+++ b/docs/app/(home)/sections/CompatibilitySection/CompatibilityDiagram.module.css
@@ -1,476 +1,218 @@
+/* The bands are wider than the diagram, so it clips them and fades both ends
+ rather than showing a hard edge. */
.diagram {
- --stack-logo-size: 40px;
+ --cell: 3.5rem;
+ --centre: calc(var(--cell) * 4);
+ --line: rgb(0 0 0 / 0.08);
+ /* --openui-text-neutral-primary is pinned near-black in both themes on this
+ page, so it cannot drive dark mode. The diagram carries its own text colour
+ and re-points it below, as StackDiagramSection does. */
+ --text: var(--openui-text-neutral-primary);
+ /* Sides only: no y-offset, and the spread is exactly minus half the blur so
+ the shadow's vertical extent is the column's own height. */
+ --centre-shadow: -10px 0 18px -9px rgb(10 10 10 / 0.1), 10px 0 18px -9px rgb(10 10 10 / 0.1);
+
position: relative;
- /* The source artwork is a 1280px frame. Letting it grow beyond that frame
- stretched the curves and pushed the outer marks into the viewport edges. */
- width: min(100vw, 80rem);
- height: clamp(480px, 47.8125vw, 612px);
+ width: 100%;
+ max-width: 56rem;
+ margin-inline: auto;
overflow: hidden;
- color: var(--openui-text-neutral-primary);
+ -webkit-mask-image: linear-gradient(
+ to right,
+ transparent 0,
+ #000 14%,
+ #000 86%,
+ transparent 100%
+ );
+ mask-image: linear-gradient(to right, transparent 0, #000 14%, #000 86%, transparent 100%);
}
-.network {
- position: absolute;
- inset: 0;
- display: block;
- width: 100%;
- height: 100%;
- /* End each rail beneath the center of its outermost logo so no stroke is
- visible beyond the final circle. */
- clip-path: inset(0 24px);
- pointer-events: none;
+[data-theme="dark"] .diagram {
+ --line: rgb(255 255 255 / 0.1);
+ --text: rgb(255 255 255 / 0.94);
+ --centre-shadow: -10px 0 18px -9px rgb(0 0 0 / 0.45), 10px 0 18px -9px rgb(0 0 0 / 0.45);
}
-.mobileNetwork {
- display: none;
+/* Cells carry their right and bottom edge, so the rows stack into a lattice
+ with only the top edge left to draw here. */
+.rows {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ border-top: 1px solid var(--line);
}
-.rails path {
- fill: none;
- stroke: color-mix(in srgb, var(--openui-text-neutral-primary) 16%, transparent);
- stroke-width: 1px;
+.row {
+ display: flex;
+ height: var(--cell);
}
-.pulses path {
- fill: none;
- stroke: #0040ff;
- stroke-width: 1.4px;
- stroke-linecap: round;
- stroke-dasharray: 0.014 0.986;
- filter: drop-shadow(0 0 1.5px rgb(0 64 255 / 0.42));
- animation: connectorPulse 6.2s linear infinite;
- animation-delay: var(--pulse-delay, 0s);
+/* ---- Drifting bands ---- */
+/* One either side of the centre, each clipping its own track. */
+.rail {
+ position: relative;
+ flex: 1 1 0;
+ min-width: 0;
+ overflow: hidden;
}
-@keyframes connectorPulse {
- from {
- stroke-dashoffset: 1;
- }
+/* The bands flow inward: the left one travels left to right, the right one
+ right to left. Every row runs the same pair with no delay, so cell edges stay
+ aligned down the diagram and the rows read as one grid. The pass is rendered
+ twice, so travelling exactly half of it and restarting is seamless. */
+.track {
+ display: flex;
+ width: max-content;
+ height: 100%;
+ animation: bandDrift 60s linear infinite;
+}
- to {
- stroke-dashoffset: 0;
- }
+.trackInFromLeft {
+ animation-direction: reverse;
}
-@keyframes mobileLogoCycle {
- 0%,
- 38% {
- opacity: 1;
- transform: scale(1);
- }
+.trackInFromRight {
+ animation-direction: normal;
+}
- 44%,
- 94% {
- opacity: 0;
- transform: scale(0.84);
+@keyframes bandDrift {
+ from {
+ transform: translateX(0);
}
- 100% {
- opacity: 1;
- transform: scale(1);
+ to {
+ transform: translateX(-50%);
}
}
-@keyframes mobileLogoSingleCycle {
- 0%,
- 18% {
- opacity: 1;
- transform: scale(1);
- }
+/* Held back, so the centre column's logo is the one that reads. */
+.square {
+ --stack-logo-size: 26px;
+ --stack-logo-glyph: 14px;
- 22%,
- 97% {
- opacity: 0;
- transform: scale(0.84);
- }
+ display: grid;
+ opacity: 0.5;
+ flex: none;
+ place-items: center;
+ width: var(--cell);
+ border-right: 1px solid var(--line);
+ border-bottom: 1px solid var(--line);
+}
- 100% {
- opacity: 1;
- transform: scale(1);
- }
+/* ---- Centre column ---- */
+/* The space the centre column sits over. Empty: it only holds the bands apart
+ so nothing drifts underneath the column. */
+.centreGap {
+ flex: none;
+ width: var(--centre);
}
-.card {
+/* One element over all four rows, so its shadow falls on the bands either side
+ and never on itself. Centred by the auto margins rather than a transform. */
+.centre {
+ --stack-logo-size: 24px;
+ --stack-logo-glyph: 14px;
+
position: absolute;
- z-index: 3;
- top: 50%;
- left: 50%;
+ z-index: 1;
+ inset-block: 0;
+ inset-inline: 0;
display: flex;
- align-items: center;
- justify-content: center;
- padding: 8px;
- transform: translate(-50%, -50%);
- border: 1px solid rgb(255 255 255 / 0.52);
- border-radius: 20px;
- background: rgb(255 255 255 / 0.3);
- box-shadow:
- inset 0 1px 0 rgb(255 255 255 / 0.72),
- 0 8px 24px rgb(10 10 10 / 0.05);
- -webkit-backdrop-filter: blur(12px) saturate(118%);
- backdrop-filter: blur(12px) saturate(118%);
+ flex-direction: column;
+ width: var(--centre);
+ margin-inline: auto;
+ border-inline: 1px solid var(--line);
+ box-shadow: var(--centre-shadow);
}
-.cardInner {
- position: relative;
+/* One per layer, the same height as a band row so the two line up. */
+.centreRow {
display: flex;
align-items: center;
- justify-content: center;
- padding: 11px 19px;
- border: 1px solid rgb(0 0 0 / 0.08);
- border-radius: 16px;
- background: #f7f7f8;
- box-shadow:
- inset 0 2px 1px rgb(255 255 255),
- inset 0 -3px 6px rgb(0 0 0 / 0.04);
+ gap: 0.625rem;
+ height: var(--cell);
+ padding-inline: 0.875rem;
+ border-bottom: 1px solid var(--line);
}
-[data-theme="dark"] .card {
- border-color: rgb(255 255 255 / 0.1);
- background: rgb(24 24 24 / 0.46);
- box-shadow:
- inset 0 1px 0 rgb(255 255 255 / 0.1),
- 0 10px 28px rgb(0 0 0 / 0.18);
-}
-
-[data-theme="dark"] .cardInner {
- border-color: rgb(255 255 255 / 0.11);
- background: #181818;
- box-shadow:
- inset 0 2px 1px rgb(255 255 255 / 0.08),
- inset 0 -3px 6px rgb(0 0 0 / 0.28);
-}
-
-.cardInner h2 {
- margin: 0;
- scroll-margin-top: 300px;
- font-family: var(--home-font-mono, "Geist Mono", monospace);
- font-size: 1rem;
- font-weight: 500;
- line-height: 1.7875;
- letter-spacing: -0.2px;
- text-transform: uppercase;
- white-space: nowrap;
+/* The logos are stacked on top of each other and faded between, so the cell
+ never reflows as they cycle. */
+.centreIcon {
+ position: relative;
+ flex: none;
+ width: 24px;
+ height: 24px;
}
-.branch {
+/* Cross-fade only. Scaling the incoming logo up made it read as smaller and
+ lower than the other rows' for the length of the transition. */
+.centreSlot {
position: absolute;
- z-index: 2;
inset: 0;
- pointer-events: none;
+ opacity: 0;
+ transition: opacity 260ms ease;
}
-.category {
- position: absolute;
- /* Each label sits directly over its connector, masking the rail beneath it. */
- top: 27.124%;
- left: 32.93%;
- transform: translate(-50%, -50%);
- margin: 0;
- padding: 8px 14px;
- border: 1px solid var(--openui-border-default);
- border-radius: var(--openui-radius-full);
- background: var(--openui-foreground);
- font-family: var(--home-font-text);
+.centreSlot[data-active] {
+ opacity: 1;
+}
+
+.centreText {
+ font-family: var(--home-font-mono, "Geist Mono", monospace);
font-size: 0.9375rem;
font-weight: 500;
- line-height: 1.4;
+ line-height: 1.6;
+ letter-spacing: -0.2px;
white-space: nowrap;
+ color: var(--text);
}
-.branch[data-side="right"] .category {
- top: 27.124%;
- left: 67.7%;
-}
-
-.branch[data-row="bottom"] .category {
- top: 72.876%;
- left: 31.8%;
-}
-
-.branch[data-side="right"][data-row="bottom"] .category {
- top: 72.876%;
- left: 68.5%;
-}
-
-.logos {
+/* Names every logo for screen readers, which only ever see the titles. */
+.readerOnly {
position: absolute;
- /* 166 / 612 is the exact top-rail center in the SVG viewBox. */
- top: 27.124%;
- left: 24px;
- display: flex;
- width: 274px;
- align-items: center;
- justify-content: space-between;
- margin: 0;
- padding: 0;
- transform: translateY(-50%);
- list-style: none;
- pointer-events: auto;
-}
-
-.branch[data-side="right"] .logos {
- right: 24px;
- left: auto;
-}
-
-.branch[data-row="bottom"] .logos {
- /* 446 / 612 is the exact bottom-rail center in the SVG viewBox. */
- top: 72.876%;
-}
-
-.logos li {
- position: relative;
- display: flex;
- flex: 0 0 auto;
- align-items: center;
- justify-content: center;
- border-radius: var(--openui-radius-full);
- background: var(--openui-foreground);
-}
-
-:global(.dark) .logos li,
-:global([data-theme="dark"]) .logos li {
- background: var(--openui-text-white);
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
}
-@media (hover: hover) and (pointer: fine) {
- .logos li::after {
- position: absolute;
- z-index: 6;
- bottom: calc(100% + 8px);
- left: 50%;
- padding: 5px 8px;
- transform: translate(-50%, 3px);
- border: 1px solid var(--openui-border-default);
- border-radius: 6px;
- background: var(--openui-text-neutral-primary);
- box-shadow: 0 6px 18px rgb(0 0 0 / 0.1);
- color: var(--openui-foreground);
- content: attr(data-tooltip);
- font-family: var(--home-font-text);
- font-size: 0.6875rem;
- font-weight: 500;
- line-height: 1.25;
- opacity: 0;
- pointer-events: none;
- transition:
- opacity 120ms ease,
- transform 120ms ease;
- white-space: nowrap;
- }
-
- .logos li:hover::after,
- .logos li:focus-visible::after {
- transform: translate(-50%, 0);
- opacity: 1;
- }
-
- .branch[data-side="left"] .logos li:first-child::after {
- left: 0;
- transform: translate(0, 3px);
- }
-
- .branch[data-side="left"] .logos li:first-child:hover::after,
- .branch[data-side="left"] .logos li:first-child:focus-visible::after {
- transform: translate(0, 0);
- }
-
- .branch[data-side="right"] .logos li:last-child::after {
- right: 0;
- left: auto;
- transform: translate(0, 3px);
+@media (prefers-reduced-motion: reduce) {
+ .track {
+ animation: none;
}
- .branch[data-side="right"] .logos li:last-child:hover::after,
- .branch[data-side="right"] .logos li:last-child:focus-visible::after {
- transform: translate(0, 0);
+ .centreSlot {
+ transition: none;
}
}
@media (max-width: 767px) {
.diagram {
- --stack-logo-size: 28px;
- left: auto;
- width: 100%;
- height: 420px;
- transform: none;
- }
-
- .network {
- clip-path: inset(0 8px);
- }
-
- .desktopNetwork {
- display: none;
- }
-
- .mobileNetwork {
- display: block;
- }
-
- .card {
- padding: 6px;
- border-radius: 17px;
- }
-
- .cardInner {
- padding: 9px 14px;
- border-radius: 13px;
- }
-
- .cardInner h2 {
- scroll-margin-top: 140px;
- font-size: 0.75rem;
- line-height: 1.6;
- letter-spacing: -0.1px;
- }
-
- .category {
- max-width: 7.5rem;
- padding: 6px 9px;
- overflow: hidden;
- font-size: 0.72rem;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
-
- .branch[data-side="left"] .category {
- left: 37%;
- }
-
- .branch[data-side="right"] .category {
- left: 63%;
- }
-
- .branch[data-side="left"][data-row="top"] .category,
- .branch[data-side="left"][data-row="top"] .logos {
- top: 22.619%;
- }
-
- .branch[data-side="right"][data-row="top"] .category,
- .branch[data-side="right"][data-row="top"] .logos {
- top: 34.524%;
- }
-
- .branch[data-side="left"][data-row="bottom"] .category,
- .branch[data-side="left"][data-row="bottom"] .logos {
- top: 65.476%;
- }
-
- .branch[data-side="left"][data-row="bottom"] .category {
- left: 37%;
- }
-
- .branch[data-side="right"][data-row="bottom"] .category,
- .branch[data-side="right"][data-row="bottom"] .logos {
- top: 77.381%;
- }
-
- .branch[data-side="right"][data-row="bottom"] .category {
- left: 63%;
- }
-
- .logos {
- width: 64px;
- height: var(--stack-logo-size);
+ --cell: 2.75rem;
}
- .branch[data-side="left"] .logos {
- right: auto;
- left: 8px;
+ .square {
+ --stack-logo-size: 22px;
+ --stack-logo-glyph: 12px;
}
- .branch[data-side="right"] .logos {
- right: 8px;
- left: auto;
+ .centre {
+ --stack-logo-size: 20px;
+ --stack-logo-glyph: 12px;
}
- .logos li {
- position: absolute;
- top: 0;
- opacity: 0;
- animation: mobileLogoCycle 8s ease-in-out infinite;
- animation-delay: var(--mobile-cycle-offset, 0s);
+ .centreRow {
+ gap: 0.5rem;
+ padding-inline: 0.625rem;
}
- .logos li:nth-child(odd) {
- left: 0;
+ .centreIcon {
+ width: 20px;
+ height: 20px;
}
- .logos li:nth-child(even) {
- left: 36px;
- }
-
- .logos li:nth-child(n + 3) {
- animation-delay: calc(var(--mobile-cycle-offset, 0s) - 4s);
- }
-
- .branch[data-row="bottom"] {
- --mobile-cycle-offset: -1s;
- }
-
- .branch[data-side="right"][data-row="top"] {
- --mobile-cycle-offset: -2s;
- }
-
- .branch[data-side="right"][data-row="bottom"] {
- --mobile-cycle-offset: -3s;
- }
-}
-
-@media (max-width: 380px) {
- .cardInner h2 {
- font-size: 0.68rem;
- letter-spacing: 0.015em;
- }
-
- .category {
- max-width: 6.75rem;
- font-size: 0.68rem;
- }
-}
-
-@media (max-width: 359px) {
- .logos {
- width: var(--stack-logo-size);
- }
-
- .logos li,
- .logos li:nth-child(even) {
- left: 0;
- animation-name: mobileLogoSingleCycle;
- }
-
- .logos li:nth-child(2) {
- animation-delay: calc(var(--mobile-cycle-offset, 0s) - 2s);
- }
-
- .logos li:nth-child(3) {
- animation-delay: calc(var(--mobile-cycle-offset, 0s) - 4s);
- }
-
- .logos li:nth-child(4) {
- animation-delay: calc(var(--mobile-cycle-offset, 0s) - 6s);
- }
-}
-
-@media (prefers-reduced-motion: reduce) {
- .pulses path {
- animation: none;
- stroke-dashoffset: 0.45;
- }
-
- .logos li {
- animation: none;
- }
-
- @media (max-width: 767px) {
- .logos li:nth-child(-n + 2) {
- opacity: 1;
- }
-
- .logos li:nth-child(n + 3) {
- display: none;
- }
+ .centreText {
+ font-size: 0.8125rem;
}
}
diff --git a/docs/app/(home)/sections/CompatibilitySection/CompatibilityDiagram.tsx b/docs/app/(home)/sections/CompatibilitySection/CompatibilityDiagram.tsx
index 29fce8603d..a313ae5667 100644
--- a/docs/app/(home)/sections/CompatibilitySection/CompatibilityDiagram.tsx
+++ b/docs/app/(home)/sections/CompatibilitySection/CompatibilityDiagram.tsx
@@ -1,106 +1,36 @@
-import { useId, type CSSProperties } from "react";
-import { StackChip, type StackChipItem } from "../../components/StackChip/StackChip";
+import type { StackChipItem } from "../../components/StackChip/StackChip";
import styles from "./CompatibilityDiagram.module.css";
+import { StackCentreColumn } from "./StackCentreColumn";
+import { StackLayerRow } from "./StackLayerRow";
type StackGroup = { label: string; items: StackChipItem[] };
-const CONNECTORS = [
- "M640 306H528C477 306 446 279 446 235C446 195 418 166 376 166H0",
- "M640 306H528C477 306 446 333 446 377C446 417 418 446 376 446H0",
- "M640 306H752C803 306 834 279 834 235C834 195 862 166 904 166H1280",
- "M640 306H752C803 306 834 333 834 377C834 417 862 446 904 446H1280",
-];
-
-// Mobile uses four staggered rails rather than forcing both sides onto the
-// same two rows. This keeps the category chips and rotating logos legible in
-// the narrow composition while retaining the same center-in / center-out flow.
-const MOBILE_CONNECTORS = [
- "M180 210H156C142 210 136 198 136 180C136 132 122 95 94 95H0",
- "M180 210H156C142 210 136 222 136 240C136 260 122 275 94 275H0",
- "M180 210H204C218 210 224 198 224 180C224 160 238 145 266 145H360",
- "M180 210H204C218 210 224 222 224 240C224 288 238 325 266 325H360",
-];
-
+/* Four rows of square cells, one per layer, their bands drifting inward in step
+ * so the rows read as one grid rather than four strips. The centre of the grid
+ * is a single column, drawn over all four rows, naming each layer.
+ */
export function CompatibilityDiagram({ groups }: { groups: StackGroup[] }) {
- const id = useId();
-
return (
-
-
- {CONNECTORS.map((path) => (
-
- ))}
-
-
- {CONNECTORS.map((path, index) => (
-
- ))}
-
-
-
-
-
- {MOBILE_CONNECTORS.map((path) => (
-
- ))}
-
-
- {MOBILE_CONNECTORS.map((path, index) => (
-
- ))}
-
-
-
-
-
-
Works with any stack
-
+
+ {groups.map((group, index) => (
+
+ ))}
+
- {groups.map((group, index) => (
-
-
- {group.label}
-
-
- {group.items.slice(0, 4).map((item) => (
-
-
-
- ))}
-
-
- ))}
+
+ {groups.map((group) => (
+
+ {group.label}
+
+ {group.items.map((item) => (
+ {item.name}
+ ))}
+
+
+ ))}
+
);
}
diff --git a/docs/app/(home)/sections/CompatibilitySection/CompatibilitySection.module.css b/docs/app/(home)/sections/CompatibilitySection/CompatibilitySection.module.css
index ee905c2130..7edace86f0 100644
--- a/docs/app/(home)/sections/CompatibilitySection/CompatibilitySection.module.css
+++ b/docs/app/(home)/sections/CompatibilitySection/CompatibilitySection.module.css
@@ -16,10 +16,21 @@
align-items: center;
}
+/* The heading sits under the diagram, reading on from it, so the gap goes
+ above it. */
.header {
- display: flex;
- justify-content: center;
- width: 100%;
+ max-width: 46rem;
+ margin: clamp(2.5rem, 5vw, 4rem) auto clamp(1rem, 2vw, 1.5rem);
+ text-align: center;
+}
+
+/* The caption, and the only
SectionHeader renders here since this header
+ passes no subtitle. Narrower than the heading above it, and balanced so the
+ two lines come out close to even. */
+.header p {
+ max-width: 32rem;
+ margin-inline: auto;
+ text-wrap: balance;
}
.title {
diff --git a/docs/app/(home)/sections/CompatibilitySection/CompatibilitySection.tsx b/docs/app/(home)/sections/CompatibilitySection/CompatibilitySection.tsx
index b36ee0ce83..c3636bb6c8 100644
--- a/docs/app/(home)/sections/CompatibilitySection/CompatibilitySection.tsx
+++ b/docs/app/(home)/sections/CompatibilitySection/CompatibilitySection.tsx
@@ -1,6 +1,6 @@
"use client";
-import svgPaths from "@/imports/svg-urruvoh2be";
+import { SectionHeader } from "../../components/SectionHeader/SectionHeader";
import { stackChipStyles, type StackChipItem } from "../../components/StackChip/StackChip";
import { CompatibilityDiagram } from "./CompatibilityDiagram";
import styles from "./CompatibilitySection.module.css";
@@ -10,11 +10,19 @@ interface StackRow {
items: StackChipItem[];
}
-/* Keep the four existing compatibility groups and their supported integrations
- together; every branch uses this same source at every screen size. */
+/* The four compatibility groups, ordered the way the stack is drawn top to
+ bottom: the model at the top, then the service that calls it, the library it
+ renders with, and the client the user touches at the base. The diagram draws
+ one StackLayerRow per group, in this order, and runs that group's own logos
+ past it.
+
+ Every entry needs a real mark. Anything with no logo to be found is left out
+ rather than stood in for by a related company's. */
const STACK_ROWS: StackRow[] = [
{
- label: "LLM",
+ /* OpenAI, xAI and DeepSeek are local files: Simple Icons carries no OpenAI
+ mark, and its "x" is the social network, not xAI. */
+ label: "LLMs",
items: [
{
name: "OpenAI",
@@ -38,11 +46,11 @@ const STACK_ROWS: StackRow[] = [
badgeClassName: `${stackChipStyles.badgeWhite} ${stackChipStyles.badgeWithBorder}`,
},
{
- name: "Mistral",
+ name: "DeepSeek",
iconKind: "image",
- slug: "mistralai",
+ localSrc: "/brand-icons/deepseek.svg",
iconColor: "ffffff",
- badgeClassName: stackChipStyles.badgeMistral,
+ badgeClassName: stackChipStyles.badgeDeepSeek,
},
{
name: "xAI",
@@ -52,23 +60,25 @@ const STACK_ROWS: StackRow[] = [
badgeClassName: stackChipStyles.badgeBlack,
},
{
- name: "DeepSeek",
+ name: "Mistral",
iconKind: "image",
- localSrc: "/brand-icons/deepseek.svg",
+ slug: "mistralai",
iconColor: "ffffff",
- badgeClassName: stackChipStyles.badgeDeepSeek,
+ badgeClassName: stackChipStyles.badgeMistral,
},
],
},
{
+ /* Llama, LlamaIndex and Mastra are absent: no logo could be found for any of
+ them, and a row is better short than wrong. */
label: "Backend",
items: [
{
- name: "Cursor",
+ name: "Vercel AI SDK",
iconKind: "image",
- localSrc: "/brand-icons/cursor.svg",
- iconColor: "000000",
- badgeClassName: `${stackChipStyles.badgeWhite} ${stackChipStyles.badgeWithBorder}`,
+ slug: "vercel",
+ iconColor: "ffffff",
+ badgeClassName: stackChipStyles.badgeBlack,
},
{
name: "LangChain",
@@ -78,116 +88,138 @@ const STACK_ROWS: StackRow[] = [
badgeClassName: stackChipStyles.badgeLangChain,
},
{
- name: "CrewAI",
+ name: "LangGraph",
iconKind: "image",
- slug: "crewai",
+ slug: "langgraph",
iconColor: "ffffff",
- badgeClassName: stackChipStyles.badgeCrewAi,
+ badgeClassName: stackChipStyles.badgeLangChain,
},
{
- name: "OpenAI Agents SDK",
+ name: "Convex",
iconKind: "image",
- localSrc: "/brand-icons/openai.svg",
- iconColor: "000000",
- badgeClassName: `${stackChipStyles.badgeWhite} ${stackChipStyles.badgeWithBorder}`,
+ slug: "convex",
+ iconColor: "ffffff",
+ badgeClassName: stackChipStyles.badgeConvex,
},
{
- name: "Anthropic Agents SDK",
+ name: "Cloudflare",
iconKind: "image",
- slug: "anthropic",
+ slug: "cloudflare",
iconColor: "ffffff",
- badgeClassName: stackChipStyles.badgeAnthropic,
+ badgeClassName: stackChipStyles.badgeCloudflare,
},
],
},
{
- /* Where it renders. Every entry is a package we publish (react-lang,
- vue-lang, svelte-lang, react-email) or a listed integration — no Flutter
- and no direct native, which A2UI has and we do not. */
- label: "Client",
+ label: "UI library",
items: [
{
- name: "React",
- iconKind: "image",
- slug: "react",
- iconColor: "000000",
- badgeClassName: `${stackChipStyles.badgeWhite} ${stackChipStyles.badgeWithBorder}`,
+ name: "OpenUI Design system",
+ iconKind: "mascot",
+ badgeClassName: stackChipStyles.badgeOpenUi,
},
{
- name: "Vue",
+ name: "shadcn/ui",
iconKind: "image",
- slug: "vuedotjs",
+ slug: "shadcnui",
iconColor: "ffffff",
badgeClassName: stackChipStyles.badgeBlack,
},
{
- name: "Svelte",
+ name: "Material UI",
iconKind: "image",
- slug: "svelte",
+ slug: "mui",
+ iconColor: "ffffff",
+ badgeClassName: stackChipStyles.badgeMui,
+ },
+ {
+ name: "Ant Design",
+ iconKind: "image",
+ slug: "antdesign",
+ iconColor: "ffffff",
+ badgeClassName: stackChipStyles.badgeAntDesign,
+ },
+ {
+ name: "Radix UI",
+ iconKind: "image",
+ slug: "radixui",
iconColor: "ffffff",
badgeClassName: stackChipStyles.badgeBlack,
},
{
- name: "React Native",
+ name: "Chakra UI",
iconKind: "image",
- slug: "react",
- iconColor: "000000",
- badgeClassName: `${stackChipStyles.badgeWhite} ${stackChipStyles.badgeWithBorder}`,
+ slug: "chakraui",
+ iconColor: "ffffff",
+ badgeClassName: stackChipStyles.badgeChakra,
},
{
- name: "Lynx",
+ name: "Mantine",
iconKind: "image",
- localSrc: "/integration-logos/lynx.svg",
+ slug: "mantine",
iconColor: "ffffff",
- badgeClassName: `${stackChipStyles.badgeWhite} ${stackChipStyles.badgeWithBorder}`,
+ badgeClassName: stackChipStyles.badgeMantine,
},
{
- name: "Email",
- iconKind: "text",
- iconText: "@",
- badgeClassName: stackChipStyles.badgeBlack,
+ name: "daisyUI",
+ iconKind: "image",
+ slug: "daisyui",
+ iconColor: "ffffff",
+ badgeClassName: stackChipStyles.badgeDaisyUi,
},
],
},
{
- label: "Design library",
+ label: "Clients",
items: [
{
- name: "OpenUI Design system",
- iconKind: "mascot",
- badgeClassName: stackChipStyles.badgeOpenUi,
+ name: "React",
+ iconKind: "image",
+ slug: "react",
+ iconColor: "000000",
+ badgeClassName: `${stackChipStyles.badgeWhite} ${stackChipStyles.badgeWithBorder}`,
},
{
- name: "ShadCN",
- iconKind: "vector",
+ name: "Next.js",
+ iconKind: "image",
+ slug: "nextdotjs",
+ iconColor: "ffffff",
badgeClassName: stackChipStyles.badgeBlack,
- iconViewBox: "0 0 24 24",
- iconPath: svgPaths.p46a4800,
- iconFill: "white",
- clipId: "clip_shadcn",
- clipSize: "24",
- },
- {
- name: "Material Design system",
- iconKind: "vector",
- badgeClassName: stackChipStyles.badgeMaterial,
- iconViewBox: "0 0 30 30",
- iconPath: svgPaths.p3a7bdd80,
- iconFill: "white",
- clipId: "clip_material",
- clipSize: "30",
- },
- {
- name: "DaisyUI",
- iconKind: "text",
- iconText: "D",
- badgeClassName: stackChipStyles.badgeDaisyUi,
},
{
- name: "Base UI",
- iconKind: "text",
- iconText: "B",
- badgeClassName: stackChipStyles.badgeBaseUi,
+ name: "Vue",
+ iconKind: "image",
+ slug: "vuedotjs",
+ iconColor: "ffffff",
+ badgeClassName: stackChipStyles.badgeBlack,
+ },
+ {
+ name: "Svelte",
+ iconKind: "image",
+ slug: "svelte",
+ iconColor: "ffffff",
+ badgeClassName: stackChipStyles.badgeBlack,
+ },
+ {
+ name: "Angular",
+ iconKind: "image",
+ slug: "angular",
+ iconColor: "ffffff",
+ badgeClassName: stackChipStyles.badgeBlack,
+ },
+ {
+ name: "Nuxt",
+ iconKind: "image",
+ slug: "nuxt",
+ iconColor: "ffffff",
+ badgeClassName: stackChipStyles.badgeNuxt,
+ },
+ {
+ name: "Remix",
+ iconKind: "image",
+ slug: "remix",
+ iconColor: "ffffff",
+ badgeClassName: stackChipStyles.badgeBlack,
},
],
},
@@ -204,6 +236,19 @@ export function CompatibilitySection({ embedded = false }: { embedded?: boolean
+ {/* Sits under the diagram, reading as its caption. Same treatment as the
+ benchmark header above this band, so the two read as peers, and
+ titleId keeps the section's aria-labelledby valid. */}
+
+ {/* The diagram is images and a hidden list, so the caption is what
+ puts this band's claim into crawlable body text. One clause per
+ row above it, in the same order. */}
+
+
);
diff --git a/docs/app/(home)/sections/CompatibilitySection/StackCentreColumn.tsx b/docs/app/(home)/sections/CompatibilitySection/StackCentreColumn.tsx
new file mode 100644
index 0000000000..cfa95091c4
--- /dev/null
+++ b/docs/app/(home)/sections/CompatibilitySection/StackCentreColumn.tsx
@@ -0,0 +1,78 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { StackChip, type StackChipItem } from "../../components/StackChip/StackChip";
+import styles from "./CompatibilityDiagram.module.css";
+
+/* The whole centre of the diagram as one element: every layer's logo and title,
+ * stacked, sitting over the drifting bands and lining up with their rows.
+ *
+ * One element rather than one per row, so the shadow it casts onto the bands is
+ * a single shadow around a single box. Four stacked boxes each cast onto the
+ * ones above and below them, which are the rest of this same column.
+ */
+
+/* How long a layer's logo holds before crossing to the next one. */
+const SWAP_MS = 2200;
+
+interface Layer {
+ label: string;
+ items: StackChipItem[];
+}
+
+function CentreEntry({ label, items, swapDelay }: Layer & { swapDelay: number }) {
+ const [active, setActive] = useState(0);
+
+ /* Held still for anyone who has asked for reduced motion, since it is
+ content changing on its own. */
+ useEffect(() => {
+ if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
+
+ let interval = 0;
+ const lead = window.setTimeout(() => {
+ interval = window.setInterval(() => {
+ setActive((current) => (current + 1) % items.length);
+ }, SWAP_MS);
+ }, swapDelay);
+
+ return () => {
+ window.clearTimeout(lead);
+ window.clearInterval(interval);
+ };
+ }, [items.length, swapDelay]);
+
+ return (
+
+ {/* All of them are mounted and only one is shown, so changing logo is a
+ cross-fade rather than an image load. */}
+
+ {items.map((item, index) => (
+
+
+
+ ))}
+
+
{label}
+
+ );
+}
+
+export function StackCentreColumn({ layers }: { layers: Layer[] }) {
+ return (
+
+ {layers.map((layer, index) => (
+
+ ))}
+
+ );
+}
diff --git a/docs/app/(home)/sections/CompatibilitySection/StackLayerRow.tsx b/docs/app/(home)/sections/CompatibilitySection/StackLayerRow.tsx
new file mode 100644
index 0000000000..3d284f78df
--- /dev/null
+++ b/docs/app/(home)/sections/CompatibilitySection/StackLayerRow.tsx
@@ -0,0 +1,81 @@
+"use client";
+
+import { StackChip, type StackChipItem } from "../../components/StackChip/StackChip";
+import styles from "./CompatibilityDiagram.module.css";
+
+/* One layer of the stack: two bands of logo cells drifting inward from either
+ * side, with the space between them left for the centre column, which spans
+ * every row and is drawn once by StackCentreColumn.
+ *
+ * The bands are decorative and repeat, so the real, named list lives in the
+ * diagram's hidden summary, where each logo appears once under its layer.
+ */
+
+/* Cells in one pass of a band. The pass is rendered twice so the drift can
+ restart without a seam, so half of it has to cover a band's width: at the
+ diagram's widest that is (56rem - the centre) / 2, well under ten cells. */
+const SEQUENCE_CELLS = 10;
+
+/* A stable 0..1 from a seed. Deterministic, so the server and the client agree,
+ and the logos still look unsorted rather than cycling in order. */
+function hash(seed: number) {
+ const seeded = Math.sin(seed) * 43758.5453;
+ return seeded - Math.floor(seeded);
+}
+
+/* One pass of a band. Each side of each row gets its own, so the two bands are
+ not mirror images of each other. */
+function bandSequence(rowIndex: number, side: number, items: StackChipItem[]) {
+ const picked: StackChipItem[] = [];
+
+ for (let cell = 0; cell < SEQUENCE_CELLS; cell += 1) {
+ let index = Math.floor(hash(rowIndex * 31.7 + side * 97.3 + cell * 13.1) * items.length);
+
+ /* Step along rather than repeat the logo next door, including across the
+ seam where the pass loops back on itself. Bounded, since a short enough
+ list can leave nothing else to pick. */
+ const neighbours = [picked[cell - 1], cell === SEQUENCE_CELLS - 1 ? picked[0] : undefined];
+ for (let tries = 0; tries < items.length && neighbours.includes(items[index]); tries += 1) {
+ index = (index + 1) % items.length;
+ }
+
+ picked.push(items[index]);
+ }
+
+ return picked;
+}
+
+export interface StackLayerRowProps {
+ items: StackChipItem[];
+ /* Row's place in the diagram, which seeds its bands. */
+ rowIndex: number;
+}
+
+export function StackLayerRow({ items, rowIndex }: StackLayerRowProps) {
+
+ const band = (side: number, direction: string) => {
+ const sequence = bandSequence(rowIndex, side, items);
+
+ return (
+
+
+ {[...sequence, ...sequence].map((item, position) => (
+
+
+
+ ))}
+
+
+ );
+ };
+
+ return (
+
+ {band(0, styles.trackInFromLeft)}
+ {/* The centre column's footprint. It is drawn over this, once for the
+ whole diagram, so nothing is rendered here. */}
+
+ {band(1, styles.trackInFromRight)}
+
+ );
+}
diff --git a/docs/app/(home)/sections/FaqSection/FaqSection.module.css b/docs/app/(home)/sections/FaqSection/FaqSection.module.css
index 36e13644eb..f03745e5dd 100644
--- a/docs/app/(home)/sections/FaqSection/FaqSection.module.css
+++ b/docs/app/(home)/sections/FaqSection/FaqSection.module.css
@@ -3,7 +3,7 @@
--faq-text: var(--openui-text-neutral-primary);
--faq-muted: var(--openui-text-neutral-secondary);
--faq-surface: #fff;
- --faq-border: rgba(10, 10, 10, 0.035);
+ --faq-border: rgba(10, 10, 10, 0.08);
display: grid;
grid-template-columns: minmax(0, 0.85fr) minmax(0, 1.15fr);
@@ -20,7 +20,7 @@
[data-theme="dark"] .section {
--faq-surface: rgba(255, 255, 255, 0.042);
- --faq-border: rgba(255, 255, 255, 0.04);
+ --faq-border: rgba(255, 255, 255, 0.09);
}
/* ---- Left column ---- */
@@ -131,11 +131,11 @@
.marker {
position: relative;
flex: none;
- width: 14px;
- height: 14px;
+ width: 10px;
+ height: 10px;
/* Nudged onto the first line's optical centre. */
- margin-top: 5px;
- color: var(--faq-muted);
+ margin-top: 7px;
+ color: var(--faq-text);
}
.marker::before,
diff --git a/docs/app/(home)/sections/FaqSection/FaqSection.tsx b/docs/app/(home)/sections/FaqSection/FaqSection.tsx
index 63085d8ae8..385c5e7c19 100644
--- a/docs/app/(home)/sections/FaqSection/FaqSection.tsx
+++ b/docs/app/(home)/sections/FaqSection/FaqSection.tsx
@@ -94,7 +94,9 @@ export function FaqSection({
{contact ?? (
<>
- Have another question? Join our{" "}
+ Have another question?
+
+ Join our{" "}
.feature:nth-child(2),
.gridBalancedLastRow > .feature:nth-child(3),
.gridBalancedLastRow > .feature:nth-child(5) {
- border-left: 1px solid var(--home-hairline);
+ border-left: 1px solid var(--fg-line);
}
.gridBalancedLastRow.gridFlushOuterCards > :nth-child(5) {
@@ -334,7 +331,7 @@
width: auto;
max-width: none;
margin-inline: calc(0px - var(--home-section-padding-inline));
- background: linear-gradient(to right, transparent, var(--home-hairline) 15%);
+ background: linear-gradient(to right, transparent, var(--fg-line) 15%);
}
/* Feature rows: icon tile (left) + title, chevron (right), description below
@@ -360,7 +357,7 @@
out on the left. */
.feature:not(:first-child)::before {
inset-inline: calc(0px - var(--home-section-padding-inline));
- background: linear-gradient(to right, transparent, var(--home-hairline) 15%);
+ background: linear-gradient(to right, transparent, var(--fg-line) 15%);
}
/* Icon tile, matching the stepper number tile. */
@@ -375,11 +372,11 @@
width: 1.375rem;
height: 1.375rem;
margin-bottom: 0;
- border: 1px solid var(--openui-border-default);
+ border: 1px solid var(--fg-border);
border-radius: 0.4rem;
- background: var(--openui-foreground);
- box-shadow: var(--home-shadow-card);
- color: var(--openui-text-neutral-secondary);
+ background: var(--fg-surface);
+ box-shadow: var(--fg-shadow);
+ color: var(--fg-muted);
opacity: 1;
}
@@ -431,7 +428,7 @@
display: inline-flex;
align-items: center;
justify-content: center;
- color: var(--openui-text-neutral-tertiary);
+ color: var(--fg-faint);
transition: transform 0.2s ease;
}
diff --git a/docs/app/(home)/sections/FeatureGridSection/FeatureGridSection.tsx b/docs/app/(home)/sections/FeatureGridSection/FeatureGridSection.tsx
index 3c5f83d36b..3b90ad3741 100644
--- a/docs/app/(home)/sections/FeatureGridSection/FeatureGridSection.tsx
+++ b/docs/app/(home)/sections/FeatureGridSection/FeatureGridSection.tsx
@@ -1,6 +1,8 @@
"use client";
import {
+ ArrowsClockwise,
+ ArrowsLeftRight,
ArrowUpRight,
Broadcast,
ChartLineUp,
@@ -10,10 +12,14 @@ import {
Devices,
Handshake,
Key,
+ MagnifyingGlass,
+ PlayCircle,
Plugs,
Pulse,
PuzzlePiece,
ShieldCheck,
+ WarningDiamond,
+ Wrench,
type Icon,
} from "@phosphor-icons/react";
import type { ReactNode } from "react";
@@ -69,6 +75,14 @@ const FEATURES: GridFeature[] = [
const FEATURE_ICONS = {
chart: ChartLineUp,
cloud: CloudArrowUp,
+ /* Keys, not components: a server component can name one of these, but cannot
+ pass a component across the boundary into this client module. */
+ refresh: ArrowsClockwise,
+ replay: PlayCircle,
+ repair: Wrench,
+ search: MagnifyingGlass,
+ swap: ArrowsLeftRight,
+ warning: WarningDiamond,
database: Database,
devices: Devices,
handshake: Handshake,
@@ -86,6 +100,7 @@ export function FeatureGridSection({
lead,
showHeader = true,
showHeaderSeparator = true,
+ showCompatSeparator = true,
showCompat = true,
compatFirst = false,
gridFirst = false,
@@ -106,6 +121,8 @@ export function FeatureGridSection({
showHeader?: boolean;
/** The rule under that header. Off when there is no grid for it to divide. */
showHeaderSeparator?: boolean;
+ /** The rule above the compatibility band when it follows the grid. */
+ showCompatSeparator?: boolean;
/** The "Works with your stack" compatibility band (OpenUI-specific). */
showCompat?: boolean;
/** Put that band above the header instead of below the grid. */
@@ -218,7 +235,7 @@ export function FeatureGridSection({
{!gridFirst && gridBlock}
{!compatFirst && compatBand && (
<>
-
+ {showCompatSeparator &&
}
{compatBand}
>
)}
diff --git a/docs/app/(home)/sections/ProductSection/ProductSection.module.css b/docs/app/(home)/sections/ProductSection/ProductSection.module.css
index 27c16834d1..fccdcd7ace 100644
--- a/docs/app/(home)/sections/ProductSection/ProductSection.module.css
+++ b/docs/app/(home)/sections/ProductSection/ProductSection.module.css
@@ -10,7 +10,17 @@
/* One fill and one edge for the stage, the cards and the icon tiles, so the
band reads as a single material. */
--ps-surface: #fff;
+ /* The base colour FadedDither paints, so the stage's edge is the same grey as
+ its fill. Keep in step with TONES in FadedDitherCanvas.tsx: light #f8f8f8,
+ dark #0e0e0e. Which one applies follows the ground, exactly as the shader's
+ own tone does. */
+ --ps-stage-stroke: #f8f8f8;
--ps-border: rgba(10, 10, 10, 0.035);
+ /* A divider across the band, not a card edge: --ps-border is tuned to be
+ almost invisible against the surface it outlines and disappears when used
+ as a rule. */
+ --ps-line: rgba(10, 10, 10, 0.09);
+ --ps-shadow: none;
display: flex;
flex-direction: column;
@@ -28,9 +38,12 @@
[data-theme="dark"] .product {
--ps-surface: rgba(255, 255, 255, 0.042);
+ --ps-stage-stroke: #0e0e0e;
--ps-border: rgba(255, 255, 255, 0.04);
+ --ps-line: rgba(255, 255, 255, 0.14);
--ps-mark-bg: #fff;
--ps-mark-fg: #0a0a0a;
+ --ps-shadow: none;
}
/* Pinned rather than theme-derived: on a light page this tone sits on black. */
@@ -38,9 +51,12 @@
--ps-text: #fff;
--ps-muted: rgba(255, 255, 255, 0.6);
--ps-surface: rgba(255, 255, 255, 0.042);
+ --ps-stage-stroke: #0e0e0e;
--ps-border: rgba(255, 255, 255, 0.04);
+ --ps-line: rgba(255, 255, 255, 0.14);
--ps-mark-bg: #fff;
--ps-mark-fg: #0a0a0a;
+ --ps-shadow: none;
}
/* ---- Lockup ---- */
@@ -137,6 +153,7 @@
border: 1px solid var(--ps-border);
border-radius: 20px;
background: var(--ps-surface);
+ box-shadow: var(--ps-shadow);
}
/* Some final illustrations already contain their own app frame. Nesting those
@@ -144,18 +161,47 @@
crop directly to the section bounds instead. */
.stageFullBleed {
padding: 0;
- border: 0;
+ /* The shader's own grey, used as an edge, so the stage's outline is the same
+ shade as the fill inside it. */
+ border: 1px solid var(--ps-stage-stroke);
border-radius: 24px;
background: transparent;
+ /* The cards keep --ps-shadow; a full-bleed stage does not want the lift. */
+ box-shadow: none;
+}
+
+/* Fills the stage behind the artwork. The stage clips it, so it picks up the
+ rounded corners without needing its own. */
+.stageShader {
+ position: absolute;
+ z-index: 0;
+ inset: 0;
+}
+
+.stageShader > * {
+ width: 100%;
+ height: 100%;
}
.stageImage {
+ position: relative;
+ z-index: 1;
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 12px;
}
+/* The breakpoint switches these wrappers; the theme rules below pick which
+ image inside the visible one shows. */
+.stageWide {
+ display: contents;
+}
+
+.stageCompact {
+ display: none;
+}
+
.stageDark {
display: none;
}
@@ -168,7 +214,9 @@
display: block;
}
-/* The dark band shows the dark artwork on either page theme. */
+/* The dark band is black in light theme, so it wants the dark artwork. The
+ pair under [data-theme="dark"] below flips this back, because the band
+ inverts to white there. */
.onDark .stageLight {
display: none;
}
@@ -178,6 +226,20 @@
}
/* ---- Cards ---- */
+/* The phone-only accordion. The band's own tokens are handed to the grid here:
+ it defaults to the page tokens, which go invisible on the Cloud band because
+ that band inverts independently of the page theme. */
+.cardsCompact {
+ display: none;
+ --feature-grid-text: var(--ps-text);
+ --feature-grid-muted: var(--ps-muted);
+ --feature-grid-faint: var(--ps-muted);
+ --feature-grid-line: var(--ps-line);
+ --feature-grid-surface: var(--ps-surface);
+ --feature-grid-border: var(--ps-border);
+ --feature-grid-shadow: var(--ps-shadow);
+}
+
.cards {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
@@ -194,6 +256,7 @@
border: 1px solid var(--ps-border);
border-radius: 16px;
background: var(--ps-surface);
+ box-shadow: var(--ps-shadow);
}
.cardIcon {
@@ -239,13 +302,39 @@
}
@media (max-width: 767px) {
+ .stageWide {
+ display: none;
+ }
+
+ .stageCompact {
+ display: contents;
+ }
+
+ /* The lockup stops being a container here: display:contents drops its
+ children into the band's own column, so the description can be ordered
+ below the stage. The desktop grid has the description beside the headline
+ and no room for that, which is why it is an order swap and not markup. */
.lockup {
- grid-template-columns: minmax(0, 1fr);
- gap: 0.75rem;
+ display: contents;
+ }
+
+ .lead {
+ /* The lockup's own bottom margin, which display:contents discards. */
+ margin-bottom: clamp(0.75rem, 2vw, 1.75rem);
+ order: 1;
+ }
+
+ .stage {
+ order: 2;
}
.description {
padding-top: 0;
+ order: 3;
+ }
+
+ .cardsCompact {
+ order: 4;
}
.headline {
@@ -259,26 +348,11 @@
}
.cards {
- grid-template-columns: none;
- grid-auto-columns: clamp(15.5rem, 76vw, 18rem);
- grid-auto-flow: column;
- justify-content: start;
- overflow-x: auto;
- overscroll-behavior-inline: contain;
- padding-bottom: 0.25rem;
- scroll-padding-inline: 0.125rem;
- scroll-snap-type: inline mandatory;
- scrollbar-width: none;
- -webkit-overflow-scrolling: touch;
- }
-
- .cards::-webkit-scrollbar {
display: none;
}
- .card {
- scroll-snap-align: start;
- scroll-snap-stop: always;
+ .cardsCompact {
+ display: block;
}
/* The buttons go full width on phones, so stack them rather than wrapping. */
@@ -296,7 +370,12 @@
--ps-text: #0a0a0a;
--ps-muted: rgba(10, 10, 10, 0.6);
--ps-surface: #fff;
- --ps-border: rgba(10, 10, 10, 0.035);
+ --ps-stage-stroke: #f8f8f8;
+ /* The band is white here and so are the cards, so the edge and the lift are
+ the only things separating them from the ground. */
+ --ps-border: rgba(10, 10, 10, 0.08);
+ --ps-line: rgba(10, 10, 10, 0.12);
+ --ps-shadow: 0 1px 2px rgba(10, 10, 10, 0.04), 0 2px 8px rgba(10, 10, 10, 0.04);
--ps-mark-bg: #0a0a0a;
--ps-mark-fg: #fff;
}
diff --git a/docs/app/(home)/sections/ProductSection/ProductSection.tsx b/docs/app/(home)/sections/ProductSection/ProductSection.tsx
index dc3b09a511..d7c1c8fe26 100644
--- a/docs/app/(home)/sections/ProductSection/ProductSection.tsx
+++ b/docs/app/(home)/sections/ProductSection/ProductSection.tsx
@@ -6,10 +6,16 @@ import { ArrowRight, ArrowUpRight } from "@phosphor-icons/react/dist/ssr";
import Image from "next/image";
import type { CSSProperties, ReactNode } from "react";
import { BevelButton } from "../../components/Button/BevelButton";
+import { FadedDither } from "../../components/FadedDither/FadedDither";
+import { FeatureGridSection, type GridFeatureIcon } from "../FeatureGridSection/FeatureGridSection";
import styles from "./ProductSection.module.css";
export type ProductCard = {
Icon: Icon;
+ /* The same icon named as a string. The phone layout hands these cards to
+ FeatureGridSection, which is a client component, and a component reference
+ cannot cross that boundary from here. */
+ icon: GridFeatureIcon;
title: string;
description: string;
};
@@ -146,45 +152,95 @@ export function ProductSection({
} as CSSProperties
}
>
+ {/* Behind the artwork, filling the transparent margin it is drawn with.
+ Client-only, since it is WebGPU. */}
+
{art ??
(shot ? (
+ /* Two pairs, each in a wrapper the breakpoint switches off. The
+ phone artwork is a different drawing at 1440x1200, not the wide
+ one rescaled. The wrapper is display:contents when shown, so the
+ theme rules keep working on the images untouched. */
<>
-
-
+
+
+
+
+
+
+
+
>
) : null)}
{cards.length > 0 && (
-
- {cards.map(({ Icon: CardIcon, title, description: cardCopy }) => (
-
-
-
-
-
-
{title}
-
{cardCopy}
+ <>
+
+ {cards.map(({ Icon: CardIcon, title, description: cardCopy }) => (
+
-
- ))}
-
+ ))}
+
+
+ {/* Phones get the same capabilities as feature-grid rows, one open at
+ a time, so they read as the grid under the Lang band rather than
+ as a horizontal scroller of cards. The breakpoint shows exactly
+ one of the two. */}
+
+ ({
+ icon,
+ title,
+ description: cardCopy,
+ }))}
+ showHeader={false}
+ showHeaderSeparator={false}
+ showCompat={false}
+ showBottomSeparator={false}
+ flushSectionPadding
+ />
+
+ >
)}
);
diff --git a/docs/app/(home)/sections/ProductSection/products.ts b/docs/app/(home)/sections/ProductSection/products.ts
index 61e7d94b45..86b496e86b 100644
--- a/docs/app/(home)/sections/ProductSection/products.ts
+++ b/docs/app/(home)/sections/ProductSection/products.ts
@@ -8,8 +8,6 @@ import {
WarningDiamond,
Wrench,
} from "@phosphor-icons/react/dist/ssr";
-import { createElement } from "react";
-import { GatewayReliabilityDashboardIllustration } from "../ProductIllustrations/ProductIllustrations";
import type { ProductSectionProps } from "./ProductSection";
/* The product bands, in the order the page tells them: what OpenUI is, then
@@ -19,18 +17,15 @@ import type { ProductSectionProps } from "./ProductSection";
early access the home page gives it a line rather than a band. Its props are
kept here so promoting it back is a one-line change in CloudSection.
*
- * ARTWORK PENDING. None of the three has a `shot` yet, so each stage renders
- * empty, holding its space. Add the images to /public and set `shot` to their
- * basename (minus -light/-dark.webp); nothing else needs to change.
- *
- * What each visual should show:
- * Lang a prompt transforming into OpenUI Lang, then into a rich
- * interactive interface
- * Gateway code switching the OpenAI baseURL to OpenUI Gateway, the
- * request flowing through Route, Validate, Correct, Fallback,
- * Model
- * Observability the OpenUI Console: a generated UI session on one side, its
- * events, interactions, errors and insights on the other
+ * ARTWORK. Each band's `shot` is a basename; ProductSection appends
+ * -light.webp and -dark.webp. The suffix is the picture's own tone, not the
+ * page theme: the stylesheet picks the one that suits whatever the band's
+ * background happens to be, which for the dark band is black in light theme
+ * and white in dark theme. The export these came from names its folders the
+ * other way round, so Gateway's and Observability's are swapped relative to it.
+ * Every file is 1120x440 at 1x with its own transparent margin baked in, so
+ * all three set fullBleedArt and sit directly on the section rather than
+ * inside a stage frame.
*
* Homepage labels use the same outlined product-name chips as the product pages.
*
@@ -50,7 +45,11 @@ export const LANG_PRODUCT: ProductSectionProps = {
"Make your AI agents stream live charts, forms, cards, tables, and dashboards faster, with fewer tokens.",
secondaryCta: { label: "View docs", href: "/docs/openui-lang" },
tone: "light",
+ shot: "/homepage/openui-lang",
+ fullBleedArt: true,
stageAspectRatio: "1120 / 440",
+ /* The phone artwork is a different drawing, at 1440x1200. */
+ stageMobileAspectRatio: "1440 / 1200",
/* No cards. Interactive, Bring your UI library, Safe by default and Stream UI
live all moved to the feature grid directly below this band, which states
them once alongside Live data and Cross-platform. */
@@ -70,28 +69,35 @@ export const GATEWAY_PRODUCT: ProductSectionProps = {
},
secondaryCta: { label: "Learn more", href: "/cloud/gateway" },
tone: "dark",
- art: createElement(GatewayReliabilityDashboardIllustration, { inverted: true }),
+ shot: "/homepage/gateway",
fullBleedArt: true,
+ /* No mobile override: the artwork is drawn at this ratio, and cropping it to
+ a squarer one on phones cuts into the content. */
stageAspectRatio: "1120 / 440",
- stageMobileAspectRatio: "4 / 2.4",
+ /* The phone artwork is a different drawing, at 1440x1200. */
+ stageMobileAspectRatio: "1440 / 1200",
cards: [
{
Icon: ArrowsLeftRight,
+ icon: "swap",
title: "Keep your existing SDK",
description: "Chat Completions and Responses endpoints work with existing SDKs.",
},
{
Icon: Wrench,
+ icon: "repair",
title: "Repair before users see it",
description: "Fix invalid output as it streams, before it reaches users.",
},
{
Icon: ArrowsClockwise,
+ icon: "refresh",
title: "Stay online through outages",
description: "Gateway switches providers if one fails, keeping the same model.",
},
{
Icon: Key,
+ icon: "key",
title: "Use existing LLM spend",
description: "Use your existing model provider credentials and commitments.",
},
@@ -105,24 +111,33 @@ export const OBSERVABILITY_PRODUCT: ProductSectionProps = {
"See what your agent generated, what users experienced, and where your product needs to improve.",
secondaryCta: { label: "View docs", href: "/docs/agent/getting-started/openui-cloud" },
tone: "dark",
+ shot: "/homepage/observability",
+ fullBleedArt: true,
+ stageAspectRatio: "1120 / 440",
+ /* The phone artwork is a different drawing, at 1440x1200. */
+ stageMobileAspectRatio: "1440 / 1200",
cards: [
{
Icon: PlayCircle,
+ icon: "replay",
title: "Replay sessions",
description: "See the exact UI and interactions your users experienced.",
},
{
Icon: WarningDiamond,
+ icon: "warning",
title: "Track every failure",
description: "Capture errors, corrections, fallbacks, and failed generations.",
},
{
Icon: CursorClick,
+ icon: "interaction",
title: "Understand users",
description: "See what people use, where they struggle, and what works.",
},
{
Icon: MagnifyingGlass,
+ icon: "search",
title: "Find what\u2019s missing",
description: "Surface unmet needs, emerging demand, and opportunities to improve.",
},
diff --git a/docs/package.json b/docs/package.json
index b31650421b..167fd687f8 100644
--- a/docs/package.json
+++ b/docs/package.json
@@ -18,6 +18,9 @@
"audit:benchmark-publication": "node scripts/audit-benchmark-publication.mjs"
},
"dependencies": {
+ "@codemirror/language": "^6.12.4",
+ "@codemirror/state": "^6.7.1",
+ "@codemirror/view": "^6.43.6",
"@openuidev/cli": "workspace:*",
"@openuidev/lang-core": "workspace:^",
"@openuidev/react-headless": "workspace:^",
@@ -28,6 +31,7 @@
"@radix-ui/react-dialog": "1.1.15",
"@radix-ui/react-tooltip": "1.2.7",
"@takumi-rs/image-response": "^0.68.17",
+ "@uiw/react-codemirror": "^4.25.11",
"fumadocs-core": "16.9.1",
"fumadocs-mdx": "14.3.2",
"fumadocs-ui": "16.9.1",
@@ -43,16 +47,13 @@
"prism-react-renderer": "^2.4.1",
"react": "^19.2.4",
"react-dom": "^19.2.4",
+ "react-resizable-panels": "^4.12.2",
"reodotdev": "^1.1.0",
+ "semver": "^7.8.5",
+ "shaders": "^3.2.470",
"tailwind-merge": "^3.4.1",
"zod": "^4.3.6",
- "zustand": "4.5.7",
- "@codemirror/language": "^6.12.4",
- "@codemirror/state": "^6.7.1",
- "@codemirror/view": "^6.43.6",
- "@uiw/react-codemirror": "^4.25.11",
- "react-resizable-panels": "^4.12.2",
- "semver": "^7.8.5"
+ "zustand": "4.5.7"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.18",
@@ -60,9 +61,9 @@
"@types/node": "catalog:",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
+ "@types/semver": "^7.7.1",
"eslint-config-next": "16.2.6",
"postcss": "^8.5.6",
- "tailwindcss": "^4.1.18",
- "@types/semver": "^7.7.1"
+ "tailwindcss": "^4.1.18"
}
}
diff --git a/docs/public/homepage/gateway-dark.webp b/docs/public/homepage/gateway-dark.webp
new file mode 100644
index 0000000000..8c77d3aaf6
Binary files /dev/null and b/docs/public/homepage/gateway-dark.webp differ
diff --git a/docs/public/homepage/gateway-light.webp b/docs/public/homepage/gateway-light.webp
new file mode 100644
index 0000000000..726861e36f
Binary files /dev/null and b/docs/public/homepage/gateway-light.webp differ
diff --git a/docs/public/homepage/gateway-mobile-dark.webp b/docs/public/homepage/gateway-mobile-dark.webp
new file mode 100644
index 0000000000..c2d469d9b2
Binary files /dev/null and b/docs/public/homepage/gateway-mobile-dark.webp differ
diff --git a/docs/public/homepage/gateway-mobile-light.webp b/docs/public/homepage/gateway-mobile-light.webp
new file mode 100644
index 0000000000..60dbcfb73d
Binary files /dev/null and b/docs/public/homepage/gateway-mobile-light.webp differ
diff --git a/docs/public/homepage/observability-dark.webp b/docs/public/homepage/observability-dark.webp
new file mode 100644
index 0000000000..c65cb21ac0
Binary files /dev/null and b/docs/public/homepage/observability-dark.webp differ
diff --git a/docs/public/homepage/observability-light.webp b/docs/public/homepage/observability-light.webp
new file mode 100644
index 0000000000..d19c8ffc68
Binary files /dev/null and b/docs/public/homepage/observability-light.webp differ
diff --git a/docs/public/homepage/observability-mobile-dark.webp b/docs/public/homepage/observability-mobile-dark.webp
new file mode 100644
index 0000000000..774e03a78d
Binary files /dev/null and b/docs/public/homepage/observability-mobile-dark.webp differ
diff --git a/docs/public/homepage/observability-mobile-light.webp b/docs/public/homepage/observability-mobile-light.webp
new file mode 100644
index 0000000000..864e66ac5b
Binary files /dev/null and b/docs/public/homepage/observability-mobile-light.webp differ
diff --git a/docs/public/homepage/openui-lang-dark.webp b/docs/public/homepage/openui-lang-dark.webp
new file mode 100644
index 0000000000..44e8c5d0c6
Binary files /dev/null and b/docs/public/homepage/openui-lang-dark.webp differ
diff --git a/docs/public/homepage/openui-lang-light.webp b/docs/public/homepage/openui-lang-light.webp
new file mode 100644
index 0000000000..3b55cfd649
Binary files /dev/null and b/docs/public/homepage/openui-lang-light.webp differ
diff --git a/docs/public/homepage/openui-lang-mobile-dark.webp b/docs/public/homepage/openui-lang-mobile-dark.webp
new file mode 100644
index 0000000000..7c34bd3dea
Binary files /dev/null and b/docs/public/homepage/openui-lang-mobile-dark.webp differ
diff --git a/docs/public/homepage/openui-lang-mobile-light.webp b/docs/public/homepage/openui-lang-mobile-light.webp
new file mode 100644
index 0000000000..716be2ade1
Binary files /dev/null and b/docs/public/homepage/openui-lang-mobile-light.webp differ
diff --git a/docs/public/openui-observability/annotations-dark.webp b/docs/public/openui-observability/annotations-dark.webp
index 180c19b18b..5d31561770 100644
Binary files a/docs/public/openui-observability/annotations-dark.webp and b/docs/public/openui-observability/annotations-dark.webp differ
diff --git a/docs/public/openui-observability/annotations-light.webp b/docs/public/openui-observability/annotations-light.webp
index e34d6f9737..f9e6624699 100644
Binary files a/docs/public/openui-observability/annotations-light.webp and b/docs/public/openui-observability/annotations-light.webp differ
diff --git a/docs/public/openui-observability/evals-figma-dark.webp b/docs/public/openui-observability/evals-figma-dark.webp
index 032db93fb1..695c82f7cc 100644
Binary files a/docs/public/openui-observability/evals-figma-dark.webp and b/docs/public/openui-observability/evals-figma-dark.webp differ
diff --git a/docs/public/openui-observability/evals-figma-light.webp b/docs/public/openui-observability/evals-figma-light.webp
index ff2bd89243..cbc6df3d9f 100644
Binary files a/docs/public/openui-observability/evals-figma-light.webp and b/docs/public/openui-observability/evals-figma-light.webp differ
diff --git a/docs/public/openui-observability/hero-dark.webp b/docs/public/openui-observability/hero-dark.webp
index cf904df98b..f74c387492 100644
Binary files a/docs/public/openui-observability/hero-dark.webp and b/docs/public/openui-observability/hero-dark.webp differ
diff --git a/docs/public/openui-observability/hero-light.webp b/docs/public/openui-observability/hero-light.webp
index 36111c73f7..7a73daba67 100644
Binary files a/docs/public/openui-observability/hero-light.webp and b/docs/public/openui-observability/hero-light.webp differ
diff --git a/docs/public/openui-observability/hero-mobile-dark.webp b/docs/public/openui-observability/hero-mobile-dark.webp
index fd1a54e813..f14257205d 100644
Binary files a/docs/public/openui-observability/hero-mobile-dark.webp and b/docs/public/openui-observability/hero-mobile-dark.webp differ
diff --git a/docs/public/openui-observability/hero-mobile-light.webp b/docs/public/openui-observability/hero-mobile-light.webp
index ab89e0817e..2953a7dec3 100644
Binary files a/docs/public/openui-observability/hero-mobile-light.webp and b/docs/public/openui-observability/hero-mobile-light.webp differ
diff --git a/docs/public/openui-observability/insights-dark.webp b/docs/public/openui-observability/insights-dark.webp
index c4a21726e7..96ef014513 100644
Binary files a/docs/public/openui-observability/insights-dark.webp and b/docs/public/openui-observability/insights-dark.webp differ
diff --git a/docs/public/openui-observability/insights-light.webp b/docs/public/openui-observability/insights-light.webp
index 66b9c0cbca..3dc95329ee 100644
Binary files a/docs/public/openui-observability/insights-light.webp and b/docs/public/openui-observability/insights-light.webp differ
diff --git a/docs/public/openui-observability/session-replay-dark.webp b/docs/public/openui-observability/session-replay-dark.webp
index b466c11718..820cf49146 100644
Binary files a/docs/public/openui-observability/session-replay-dark.webp and b/docs/public/openui-observability/session-replay-dark.webp differ
diff --git a/docs/public/openui-observability/session-replay-light.webp b/docs/public/openui-observability/session-replay-light.webp
index db807aa55b..65a6f8aee6 100644
Binary files a/docs/public/openui-observability/session-replay-light.webp and b/docs/public/openui-observability/session-replay-light.webp differ
diff --git a/docs/public/openui-observability/triage-figma-dark.webp b/docs/public/openui-observability/triage-figma-dark.webp
index c4521797de..4a2b6a56ff 100644
Binary files a/docs/public/openui-observability/triage-figma-dark.webp and b/docs/public/openui-observability/triage-figma-dark.webp differ
diff --git a/docs/public/openui-observability/triage-figma-light.webp b/docs/public/openui-observability/triage-figma-light.webp
index 04865e00f4..0d87accb68 100644
Binary files a/docs/public/openui-observability/triage-figma-light.webp and b/docs/public/openui-observability/triage-figma-light.webp differ
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index ca83fb3df4..eaf52e4355 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -237,6 +237,9 @@ importers:
semver:
specifier: ^7.8.5
version: 7.8.5
+ shaders:
+ specifier: ^3.2.470
+ version: 3.2.470(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vue@3.5.40(typescript@6.0.3))
tailwind-merge:
specifier: ^3.4.1
version: 3.6.0
@@ -9944,6 +9947,27 @@ packages:
setprototypeof@1.2.0:
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
+ shaders@3.2.470:
+ resolution: {integrity: sha512-Qmqro6fwQW575Axue1RVMFsy1uiV1wxtHtEj/JWh5iPpIltA96fsIUznGKFF8tava4r5KAALP9wjdyF6ohDXyg==}
+ hasBin: true
+ peerDependencies:
+ react: ^18.0.0 || ^19.0.0
+ react-dom: ^18.0.0 || ^19.0.0
+ solid-js: ^1.8.0
+ svelte: ^5
+ vue: ^3.5.0
+ peerDependenciesMeta:
+ react:
+ optional: true
+ react-dom:
+ optional: true
+ solid-js:
+ optional: true
+ svelte:
+ optional: true
+ vue:
+ optional: true
+
sharp@0.34.5:
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
@@ -10238,6 +10262,10 @@ packages:
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
+ tinyest@0.3.3:
+ resolution: {integrity: sha512-LUDiIzyDUQ1mGo8Bam9c7Dubid6Psx14zMzGNwwvOEmNs03FWNUtOrHVXd+TcCorzogQ24Rk0fcfdZ+9rGr1Fw==}
+ engines: {node: '>=12.20.0'}
+
tinyexec@1.2.4:
resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==}
engines: {node: '>=18'}
@@ -10361,6 +10389,9 @@ packages:
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
+ tsover-runtime@0.0.7:
+ resolution: {integrity: sha512-FHHwMJzZbnP23+fU1PxXljy7j0RRRosL2r1/CRUNTqfW+l7m35JsUkM615x/cAzw4GqRdXPIl3axKMj9qzpZtQ==}
+
tsx@4.23.1:
resolution: {integrity: sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==}
engines: {node: '>=18.0.0'}
@@ -10393,6 +10424,14 @@ packages:
resolution: {integrity: sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==}
engines: {node: '>= 0.4'}
+ typed-binary@4.3.3:
+ resolution: {integrity: sha512-W2hLsSzt3k/tg38gDE4Fn/QiwcoqGuUHBc2cb3mXuH7KcYxe/GM57vzW14s2/bawB4R5knGgGq8Xb57vsaJ4Sg==}
+
+ typegpu@0.12.3:
+ resolution: {integrity: sha512-zYwQEhnPPwhq5vQKmollXRK1LCVjStOnL+2B0PiebqvAzYPr25rPNR4r3olLoow5sKKjyj3kzHKEcpD5SyDzGQ==}
+ engines: {node: '>=12.20.0'}
+ hasBin: true
+
typescript-eslint@8.65.0:
resolution: {integrity: sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -20956,6 +20995,15 @@ snapshots:
setprototypeof@1.2.0: {}
+ shaders@3.2.470(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vue@3.5.40(typescript@6.0.3)):
+ dependencies:
+ typegpu: 0.12.3
+ optionalDependencies:
+ react: 19.2.4
+ react-dom: 19.2.4(react@19.2.4)
+ svelte: 5.56.8(@typescript-eslint/types@8.65.0)
+ vue: 3.5.40(typescript@6.0.3)
+
sharp@0.34.5:
dependencies:
'@img/colour': 1.1.0
@@ -21363,6 +21411,8 @@ snapshots:
tinybench@2.9.0: {}
+ tinyest@0.3.3: {}
+
tinyexec@1.2.4: {}
tinyexec@1.3.1: {}
@@ -21499,6 +21549,8 @@ snapshots:
tslib@2.8.1: {}
+ tsover-runtime@0.0.7: {}
+
tsx@4.23.1:
dependencies:
esbuild: 0.28.1
@@ -21550,6 +21602,14 @@ snapshots:
possible-typed-array-names: 1.1.0
reflect.getprototypeof: 1.0.10
+ typed-binary@4.3.3: {}
+
+ typegpu@0.12.3:
+ dependencies:
+ tinyest: 0.3.3
+ tsover-runtime: 0.0.7
+ typed-binary: 4.3.3
+
typescript-eslint@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3):
dependencies:
'@typescript-eslint/eslint-plugin': 8.65.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)