Source Test
+The input can be a plain Playwright-style system test or a Blackbox BDD-DSL test. Plain tests are decompiled best-effort; DSL-authored tests preserve more intent.
+
+
-
-
Suites
-
-
- - Automatically generate mock objects, eliminate manual setup and reduce - boilerplate code of your unit tests -
- > - ), - icon: faAreaChart, - link: { - ref: "/docs/guides/", - text: "Developer Guide", - }, - }, - { - title: "Scale Your Test Suites", - description: ( - <> -- Suites' flexible architecture supports projects of all sizes, from - small microservices to large monoliths -
- > - ), - icon: faArrowUpWideShort, - link: { - ref: "/docs/get-started/", - text: "Getting Started", - }, - }, -]; - -function Feature({ title, description, icon, link }: FeatureItem) { - return ( - - ); -} - -export function HomepageFeatures(): JSX.Element { - return ( -404
-- We may have moved or renamed it during the recent docs refresh. - Try one of these instead: -
- -- Still stuck? Go to the homepage or{" "} - - open an issue on GitHub - - . -
-Future package-backed REPL flow: load a test, analyze the AAA/Given-When-Then shape, emit Gherkin, then run the gates.
+The input can be a plain Playwright-style system test or a Blackbox BDD-DSL test. Plain tests are decompiled best-effort; DSL-authored tests preserve more intent.
+
+ The analyzer turns test structure into a behavior trace. The linter checks that the trace has a valid AAA shape: `Given*`, `When+`, `Then+`.
+
+ The feature file is a readable projection from the test source. It is useful for review, but it is still checked against the source instead of trusted as disconnected prose.
+
+ The gate is two-part: Cucumber-compatible Gherkin syntax validation, plus feature-file drift detection against the test source. Runtime effects and observation comparison can add stronger gates later.
+
+ {`scenario('subscribe-flow', 'subscribing as a pro tier user', ({ given }) => {
+ given('alice is an existing user', async ({ system, capture, when }) => {
+ await when('alice POSTs /subscriptions', async ({ then }) => {
+ await then('the captured effects satisfy the subscribe-flow catalog entry', async () => {
+ await expect(capture).toMatchCatalog();
+ });
+ });
+ });
+});`}
+ {`# the file is generated; run the simulation to see it appear`}
+ subscribe-flow.feature appears. Every line is backed by a captured effect.Install Blackbox first. Then choose the path that matches your repo: an existing system or E2E test, or the Blackbox showcase.
+One run records effects, one catalog entry is reviewed, and the next run reports a satisfied effect coverage entry.
+Use this path when you already have a system or E2E test that exercises a valuable flow. The first win is not a new suite. It is evidence for behavior your existing test already drives.
+Keep the request and response assertions you already trust. Add the Blackbox system boundary and `toMatchCatalog()` at the end of one valuable flow.
+
+ Blackbox does not need to replace your runner. Use the command your project already uses; the Playwright command below is only an example.
+
+
+ You should see a baseline-pending message the first time the catalog entry does not exist.
+
+ The first useful artifact is the generated catalog. It starts as observed behavior, not a trusted contract. Review it before committing it.
+
+ `toMatchCatalog()` is already the matcher. Your job here is to edit the catalog: keep one required effect that proves useful behavior and add one forbid for behavior that must not happen.
+
+ On the next run, the terminal should show whether the reviewed catalog entry was satisfied, failed, or uncovered.
+
+ That is the first quickstart win: the same flow now proves something about the behavior inside the system, not only its response.
+Use this path when you do not yet have a useful system test. First learn the proof loop in the Blackbox showcase, then write one narrow flow in your own system.
+From the Blackbox showcase repo root, run the system-test script. The subscription flow is intentionally small but effect-rich: Redis, Postgres, HTTP, and SQS all participate.
+
+ A successful showcase run should produce a catalog and coverage artifacts. Start with these three; spans and V8 payloads are diagnostics for later.
+
+ The important shape is `test.system(...)` plus `toMatchCatalog()`. The matcher seeds a baseline when no catalog entry exists, then enforces the reviewed catalog on later runs.
+
+ Pick a flow where input/output success is not enough: checkout, signup, webhook handling, account deletion, refund prevention, or another side-effect-heavy path.
+The goal is not a broad journey. The goal is one controlled system flow whose effects matter.
+The first custom catalog begins as observed behavior. Keep the effects that define correctness, add forbids for dangerous behavior, then rerun to see effect coverage.
+
+
+ + Last updated + +
diff --git a/src/components/docs/PageActions.astro b/src/components/docs/PageActions.astro new file mode 100644 index 0000000..26bd2e4 --- /dev/null +++ b/src/components/docs/PageActions.astro @@ -0,0 +1,132 @@ +--- +/* + * Docs page-actions cluster. + * + * Three affordances, dark-mode-first: + * 1. Copy Markdown -- copies the page's raw markdown body to the clipboard so + * the reader can paste it into an LLM, an issue, or their notes. The body + * is rendered as a hidden + + diff --git a/src/components/docs/PrevNext.astro b/src/components/docs/PrevNext.astro new file mode 100644 index 0000000..2b8d723 --- /dev/null +++ b/src/components/docs/PrevNext.astro @@ -0,0 +1,39 @@ +--- +import sidebar, { productFromPath } from '@/config/sidebar'; + +interface Props { + slug: string; +} +const { slug } = Astro.props; + +const currentHref = `/docs/${slug}`.replace(/\/$/, ''); +const product = productFromPath(`${currentHref}/`); +const flat = sidebar[product].flatMap((group) => group.items); + +const idx = flat.findIndex((item) => item.href.replace(/\/$/, '') === currentHref); + +const prev = idx > 0 ? flat[idx - 1] : null; +const next = idx >= 0 && idx < flat.length - 1 ? flat[idx + 1] : null; +--- +{(prev || next) && ( + +)} diff --git a/src/components/docs/Sidebar.astro b/src/components/docs/Sidebar.astro new file mode 100644 index 0000000..b2777f2 --- /dev/null +++ b/src/components/docs/Sidebar.astro @@ -0,0 +1,46 @@ +--- +import sidebar, { productFromPath } from '@/config/sidebar'; + +const currentPath = Astro.url.pathname.replace(/\/$/, '') || '/'; +const activeProduct = productFromPath(Astro.url.pathname); +const groups = sidebar[activeProduct]; + +function isGroupActive(items: { href: string }[]) { + return items.some((item) => { + const h = item.href.replace(/\/$/, ''); + return currentPath === h || currentPath.startsWith(h + '/'); + }); +} +--- + diff --git a/src/components/docs/TOC.astro b/src/components/docs/TOC.astro new file mode 100644 index 0000000..3a3ed6b --- /dev/null +++ b/src/components/docs/TOC.astro @@ -0,0 +1,90 @@ +--- +interface Heading { + depth: number; + text: string; + slug: string; +} +interface Props { + headings: Heading[]; +} +const { headings } = Astro.props; +const filtered = headings.filter((h) => h.depth >= 2 && h.depth <= 4); +--- +{filtered.length > 0 && ( +
+ Mermaid render error: {error}
+
+ );
+ }
+
+ const accessibleLabel = alt ?? caption ?? 'Diagram';
+
+ return (
+ {desc}
} +{description}
} +404
++ We may have moved or renamed it during the recent docs refresh. + Try one of these instead: +
+ ++ Still stuck? Go to the homepage or{' '} + open an issue on GitHub. +
++ + A unit testing framework for TypeScript backends working with + inversion of control and dependency injection + +
+
+ NestJS
+ Official
+
+
+
+ InversifyJS
+ Official
+
+
+ Vitest
+
+ Jest
+
+ Sinon
+
+
+ Suites' declarative API creates fully-typed, isolated test environments with a single declaration. Suites auto-generates all mocks and wires dependencies automatically.
+Generate type-safe mocks bound to implementations. Eliminate broken tests after refactors, silent runtime failures, and manual type casting.
+Change constructors, add dependencies, refactor classes - tests adapt automatically. Skip manual mock updates. Catch breaking changes at compile time, not runtime.
+One canonical pattern teaches AI agents the entire API. Coding agents like Claude Code and Cursor write correct tests in a single pass with 95% less context consumption compared to manual mocking patterns.
++ Using Suites?{' '} + Share your experience + {' '}and help us shape the future of Suites +
++ Stop relearning test patterns on every project. Suites provides a + consistent, standardized approach that works identically across + NestJS, InversifyJS, and any DI framework, giving teams a unified + testing experience. +
+
+ + Suites' declarative API removes 90% of test setup code. No more + scrolling through mock wiring, logic is front and center. New team + members write tests on day one, not day ten. +
+
+ + No more debugging broken mocks. Suites automatically generates + fully-typed mocks bound to implementation. Catch errors at compile + time, not runtime. Refactor with confidence while mocks stay valid + when dependencies change. +
+
+ + Manual mocking forces AI agents to hold 40+ lines of boilerplate + per test in context. Suites provides one canonical pattern that + reduces token consumption by 95%. AI coding assistants like Claude + Code, Cursor, and GitHub Copilot generate accurate tests in a + single pass without burning tokens on repetitive setup code. +
+
+ - - A unit testing framework for TypeScript backends working with - inversion of control and dependency injection - -
-Works with projects using
-
- NestJS
- Official
-
-
-
- InversifyJS
- Official
-
-
- Vitest
-
- Jest
-
- Sinon
- - Suites' declarative API creates fully-typed, isolated test environments with a single declaration. - Suites auto-generates all mocks and wires dependencies automatically. -
-- Generate type-safe mocks bound to implementations. Eliminate - broken tests after refactors, silent runtime failures, and - manual type casting. -
-- Change constructors, add dependencies, refactor classes - tests - adapt automatically. Skip manual mock updates. Catch breaking - changes at compile time, not runtime. -
-- One canonical pattern teaches AI agents the entire API. Coding - agents like Claude Code and Cursor write correct tests in a - single pass with 95% less context consumption compared to manual - mocking patterns. -
-Used by
-- Using Suites?{" "} - - Share your experience - {" "} - and help us shape the future of Suites -
-- Stop relearning test patterns on every project. Suites provides a - consistent, standardized approach that works identically across - NestJS, InversifyJS, and any DI framework, giving teams a unified - testing experience. -
- - See Framework Support → - -- Suites' declarative API removes 90% of test setup code. No more - scrolling through mock wiring, logic is front and center. New team - members write tests on day one, not day ten. -
- - See Quick Start → - -- No more debugging broken mocks. Suites automatically generates - fully-typed mocks bound to implementation. Catch errors at compile - time, not runtime. Refactor with confidence while mocks stay valid - when dependencies change. -
- - Learn about Mocking → - -- Manual mocking forces AI agents to hold 40+ lines of boilerplate - per test in context. Suites provides one canonical pattern that - reduces token consumption by 95%. AI coding assistants like Claude - Code, Cursor, and GitHub Copilot generate accurate tests in a - single pass without burning tokens on repetitive setup code. -
- - Suites and AI → - - inside prose. Without this, inline code inherits the
+ 16px body size and reads visually larger than 13px expressive-code blocks.
+ Per audit J.5 + Top-12 fix 8. */
+.article :not(pre) > code {
+ font-family: var(--font-code);
+ font-size: 0.9em;
+ font-weight: var(--fw-medium);
+ padding: 0.15em 0.4em;
+ border-radius: 6px;
+ background: rgba(255,255,255,.06);
+ color: var(--code-text);
+}
+
+/* Block-primitive rhythm.
+ Every block-level child of .article that isn't a heading or paragraph
+ needs an explicit bottom margin, otherwise it sits flush against the
+ following text (code blocks, asides, tables, quotes were all touching
+ the next sibling). Matches the existing 16px margin on .article p but
+ bumps to 24px because these blocks are heavier than a paragraph. */
+.article > pre,
+.article > [class*="expressive-code"],
+.article > .code,
+.article > .aside,
+.article > .alert,
+.article > table,
+.article > blockquote,
+.article > figure,
+.article > hr {
+ margin: 0 0 24px;
+}
+
+.article > blockquote {
+ border-left: 2px solid var(--border-strong);
+ padding: 4px 0 4px 16px;
+ color: var(--text-muted);
+ font-style: italic;
+}
+
+.article > hr {
+ border: 0;
+ border-top: 1px solid var(--border);
+ margin: 32px 0;
+}
+
+.article > figure {
+ margin-top: 8px;
+}
+
+/* ============================================================
+ * Docs article header chrome
+ * Owns breadcrumb + title row (with badge + page-actions) + lede.
+ * Sits above the MDX body, below the topbar.
+ * ============================================================ */
+
+.article-header {
+ margin: 0 0 32px;
+}
+
+/* Breadcrumb */
+.breadcrumb {
+ margin: 0 0 20px;
+}
+.breadcrumb-list {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ font-size: 13px;
+ line-height: 1.5;
+ color: var(--text-muted);
+}
+.breadcrumb-item {
+ display: inline-flex;
+ align-items: baseline;
+}
+.breadcrumb-link {
+ color: var(--text-muted);
+ text-decoration: none;
+ transition: color 160ms ease;
+}
+.breadcrumb-link:hover {
+ color: var(--text);
+}
+.breadcrumb-current {
+ color: var(--text);
+ font-weight: var(--fw-medium);
+}
+/* Inline slash separator. Sits between items with even breathing on each side. */
+.breadcrumb-sep {
+ display: inline-block;
+ padding: 0 8px;
+ color: var(--text-soft);
+ font-weight: var(--fw-regular);
+ user-select: none;
+}
+
+/* Title row: h1 + page-actions on a single line on desktop, stacked on mobile */
+.article-title-row {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 24px;
+ margin: 0 0 8px;
+}
+
+.article-title {
+ flex: 1;
+ min-width: 0;
+ margin: 0;
+ display: inline-flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 12px;
+}
+
+/* Small pill next to the h1 indicating maturity. Same family as the
+ product-tab badge in the topbar. */
+.article-title-badge {
+ display: inline-flex;
+ align-items: center;
+ padding: 4px 10px;
+ font-size: 11px;
+ font-family: var(--font-body);
+ font-weight: var(--fw-semibold);
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ border-radius: 999px;
+ line-height: 1;
+ vertical-align: middle;
+ align-self: center;
+}
+.article-title-badge[data-variant="alpha"] {
+ color: var(--primary-light);
+ background: var(--primary-soft);
+ border: 1px solid var(--primary-border);
+}
+.article-title-badge[data-variant="beta"] {
+ color: var(--info);
+ background: rgba(147, 197, 253, 0.12);
+ border: 1px solid rgba(147, 197, 253, 0.32);
+}
+.article-title-badge[data-variant="new"] {
+ color: var(--success);
+ background: rgba(134, 239, 172, 0.12);
+ border: 1px solid rgba(134, 239, 172, 0.32);
+}
+.article-title-badge[data-variant="deprecated"] {
+ color: var(--text-soft);
+ background: rgba(255, 255, 255, 0.04);
+ border: 1px solid var(--border);
+}
+:root[data-theme="light"] .article-title-badge[data-variant="deprecated"] {
+ background: rgba(15, 15, 18, 0.05);
+}
+
+/* Description (lede paragraph) directly under the h1. */
+.article-lede {
+ margin: 0;
+ font-size: 17px;
+ line-height: 1.55;
+ color: var(--text-muted);
+ max-width: 64ch;
+}
+
+/* ============================================================
+ * Page actions cluster
+ * Three affordances: Copy Markdown / Open in LLM dropdown / Edit on GitHub.
+ * 36px tall pills on desktop, wraps below the title on mobile.
+ * ============================================================ */
+
+.page-actions {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ flex-shrink: 0;
+ flex-wrap: wrap;
+}
+
+.page-action {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ height: 32px;
+ padding: 0 12px;
+ border-radius: 8px;
+ border: 1px solid var(--border);
+ background: transparent;
+ color: var(--text-muted);
+ font-size: 13px;
+ font-weight: var(--fw-medium);
+ font-family: var(--font-body);
+ text-decoration: none;
+ cursor: pointer;
+ transition: color 160ms ease, background-color 160ms ease, border-color 160ms ease;
+ user-select: none;
+}
+.page-action:hover {
+ color: var(--text);
+ background: rgba(255, 255, 255, 0.04);
+ border-color: var(--border-strong);
+}
+:root[data-theme="light"] .page-action:hover {
+ background: rgba(15, 15, 18, 0.05);
+}
+.page-action:focus-visible {
+ outline: 2px solid var(--primary);
+ outline-offset: 2px;
+}
+.page-action-icon {
+ flex: none;
+ color: currentColor;
+}
+.page-action-caret {
+ flex: none;
+ color: var(--text-soft);
+ transition: transform 160ms ease;
+}
+
+/* Open in LLM dropdown menu */
+.page-action-menu {
+ position: relative;
+}
+.page-action-menu > summary {
+ list-style: none;
+ cursor: pointer;
+}
+.page-action-menu > summary::-webkit-details-marker {
+ display: none;
+}
+.page-action-menu[open] > summary {
+ color: var(--text);
+ background: rgba(255, 255, 255, 0.04);
+ border-color: var(--border-strong);
+}
+:root[data-theme="light"] .page-action-menu[open] > summary {
+ background: rgba(15, 15, 18, 0.05);
+}
+.page-action-menu[open] .page-action-caret {
+ transform: rotate(180deg);
+}
+.page-action-menu-panel {
+ position: absolute;
+ top: calc(100% + 6px);
+ right: 0;
+ z-index: var(--z-overlay);
+ min-width: 220px;
+ padding: 6px;
+ background: var(--surface-raised);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ box-shadow: var(--shadow);
+ display: grid;
+ gap: 2px;
+}
+.page-action-menu-item {
+ display: block;
+ padding: 8px 10px;
+ font-size: 13px;
+ color: var(--text);
+ border-radius: 8px;
+ text-decoration: none;
+ transition: background-color 160ms ease;
+}
+.page-action-menu-item:hover {
+ background: rgba(255, 255, 255, 0.05);
+}
+:root[data-theme="light"] .page-action-menu-item:hover {
+ background: rgba(15, 15, 18, 0.06);
+}
+
+/* Last updated stamp (above prev/next pager) */
+.doc-last-updated {
+ margin: 40px 0 16px;
+ padding: 12px 0 0;
+ border-top: 1px solid var(--border);
+ font-size: 13px;
+ color: var(--text-soft);
+}
+.doc-last-updated-label {
+ margin-right: 6px;
+ font-weight: var(--fw-medium);
+ color: var(--text-muted);
+}
+
+/* Responsive: stack the title row on narrow viewports so the h1 keeps its
+ own line and the actions wrap below. */
+@media (max-width: 767px) {
+ .article-title-row {
+ flex-direction: column;
+ align-items: stretch;
+ gap: 16px;
+ }
+ .page-actions {
+ align-self: flex-start;
+ }
+ .page-action {
+ /* 44x44 minimum touch target on mobile per ui-ux-pro-max touch-target-size. */
+ min-height: 44px;
+ padding: 0 14px;
+ }
+}
+
+/* Right TOC (prisma pattern): sticky, no background frame */
+.toc {
+ position: sticky;
+ top: var(--header-h);
+ align-self: start;
+ height: calc(100vh - var(--header-h));
+ max-height: calc(100vh - var(--header-h));
+ overflow-y: auto;
+ padding: 48px 16px 24px 24px;
+ font-size: 14px;
+ line-height: 20px;
+ border-left: 1px solid var(--border);
+}
+
+.toc-label {
+ font-size: 12px;
+ font-weight: var(--fw-semibold);
+ color: var(--text-soft);
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ margin: 0 0 12px;
+ padding: 0 10px;
+}
+
+.toc-list { list-style: none; padding: 0; margin: 0; }
+
+.toc-list a {
+ display: block;
+ padding: 6px 10px;
+ color: var(--text-muted);
+ border-left: 2px solid transparent;
+ margin-left: -2px;
+ overflow-wrap: anywhere;
+ transition: color 160ms ease-out, border-left-color 160ms ease-out;
+}
+
+/* Hover: brighten text only (no pink) so it does not duplicate the active cue. */
+.toc-list a:hover {
+ color: var(--text);
+}
+
+/* Active section: pink left bar + pink text. Distinct from hover. */
+.toc-list a.active {
+ color: var(--primary-light);
+ border-left-color: var(--primary);
+ font-weight: var(--fw-semibold);
+}
+
+.toc-list .depth-3 { padding-left: 22px; }
+.toc-list .depth-4 { padding-left: 34px; }
+
+/* Match focus-ring radius to a comfortable corner for the inline link box. */
+.toc-list a:focus-visible { border-radius: 6px; }
+
+table {
+ width: 100%;
+ border-collapse: collapse;
+ border: 1px solid var(--border);
+ border-radius: 20px;
+ overflow: hidden;
+}
+
+th, td {
+ padding: 14px 16px;
+ border-bottom: 1px solid var(--border);
+ text-align: left;
+ color: var(--text-muted);
+ font-size: 14px;
+}
+
+th {
+ background: rgba(255,255,255,.04);
+ color: var(--text);
+ font-weight: var(--fw-semibold);
+}
+
+tr:last-child td { border-bottom: 0; }
+
+.type-specimen {
+ display: grid;
+ gap: 22px;
+}
+
+.specimen-line {
+ padding-bottom: 22px;
+ border-bottom: 1px solid var(--border);
+}
+
+.specimen-line:last-child { border-bottom: 0; padding-bottom: 0; }
+
+.specimen-label {
+ color: var(--text-soft);
+ font-family: var(--font-code);
+ font-size: 12px;
+ margin-bottom: 6px;
+}
+
+.display-sample {
+ font-family: var(--font-title);
+ font-size: clamp(40px, 7vw, 86px);
+ letter-spacing: -0.044em;
+ line-height: .92;
+ font-weight: var(--fw-display);
+}
+
+.heading-sample {
+ font-family: var(--font-title);
+ font-size: clamp(28px, 5vw, 48px);
+ letter-spacing: -0.034em;
+ line-height: 1;
+ font-weight: var(--fw-display);
+}
+
+.body-sample {
+ max-width: 760px;
+ color: var(--text-muted);
+ font-size: 18px;
+ line-height: 1.68;
+}
+
+.mono-sample {
+ font-family: var(--font-code);
+ color: var(--code-text);
+ background: var(--code-bg);
+ border: 1px solid var(--border);
+ border-radius: 18px;
+ padding: 16px;
+ overflow: auto;
+}
+
+.scale {
+ display: grid;
+ grid-template-columns: 90px 1fr;
+ gap: 12px;
+ align-items: center;
+ margin: 12px 0;
+}
+
+.scale-label {
+ color: var(--text-soft);
+ font-family: var(--font-code);
+ font-size: 12px;
+}
+
+.scale-bar {
+ height: 12px;
+ border-radius: 999px;
+ background: rgba(255,255,255,.06);
+ overflow: hidden;
+ border: 1px solid var(--border);
+}
+
+.scale-fill {
+ height: 100%;
+ border-radius: 999px;
+ background: var(--primary);
+}
+
+@media (max-width: 980px) {
+ .navlinks { display: none; }
+ .cols-5, .cols-4, .cols-3, .cols-2 {
+ grid-template-columns: 1fr;
+ }
+ .section-head {
+ align-items: start;
+ flex-direction: column;
+ }
+ .usage-row {
+ grid-template-columns: 1fr;
+ }
+ .ruler-track {
+ grid-template-columns: repeat(3, 1fr);
+ }
+}
+
+/* Skip-to-main-content link. BaseLayout renders this as the first
+ focusable element; off-screen until keyboard-focused. */
+.skip-link {
+ position: absolute;
+ top: -100px;
+ left: 16px;
+ z-index: var(--z-skip);
+ padding: 10px 16px;
+ background: var(--surface-raised);
+ color: var(--text);
+ border: 1px solid var(--border-strong);
+ border-radius: 10px;
+ font-size: 14px;
+ font-weight: var(--fw-semibold);
+ transition: top 150ms ease-out;
+}
+
+.skip-link:focus,
+.skip-link:focus-visible {
+ top: 16px;
+ outline: 2px solid var(--primary);
+ outline-offset: 2px;
+}
+
+/* Cursor affordance on interactive non-link elements. */
+.btn, .tab, [role="button"] { cursor: pointer; }
+.btn:disabled, .tab:disabled { cursor: not-allowed; }
+
+/* Icon-only action button used in header right cluster. */
+.icon-link {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 36px;
+ height: 36px;
+ color: var(--text-muted);
+ border-radius: 8px;
+}
+.icon-link:hover { color: var(--text); background: rgba(255,255,255,.04); }
+.icon-link svg { width: 18px; height: 18px; }
+
+/* Light-mode icon-link hover wash. The dark rgba(255,255,255,.04) would be
+ invisible on white, so use a neutral graphite tint. */
+:root[data-theme="light"] .icon-link:hover {
+ background: rgba(15, 15, 18, 0.06);
+}
+
+/* Theme toggle button: same hit-box as .icon-link but with stacked sun/moon
+ icons. One icon is visible per active theme so the glyph always shows the
+ destination (clicking it produces the depicted state). */
+.theme-toggle {
+ background: transparent;
+ border: 0;
+ cursor: pointer;
+ padding: 0;
+}
+.theme-toggle .theme-toggle-sun,
+.theme-toggle .theme-toggle-moon {
+ display: none;
+}
+/* In dark mode the sun icon is shown (click goes to light). */
+:root[data-theme="dark"] .theme-toggle .theme-toggle-sun {
+ display: inline-block;
+}
+/* In light mode the moon icon is shown (click goes to dark). */
+:root[data-theme="light"] .theme-toggle .theme-toggle-moon {
+ display: inline-block;
+}
+
+/* Brand divider + product slug in header (Prisma's "logo / docs" pattern). */
+.brand-divider {
+ color: var(--text-soft);
+ margin: 0 4px;
+}
+.brand-product {
+ font-family: var(--font-code);
+ font-size: 15px;
+ color: var(--text-muted);
+ font-weight: var(--fw-medium);
+}
+
+/* Header right-cluster action (text links). */
+.nav-action {
+ font-size: 14px;
+ font-weight: var(--fw-medium);
+ color: var(--text-muted);
+ padding: 8px 4px;
+}
+.nav-action:hover { color: var(--text); }
+
+/* Screen-reader-only utility. */
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0,0,0,0);
+ white-space: nowrap;
+ border: 0;
+}
+
+/* Homepage card decorative icon container. */
+.card-icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 36px;
+ height: 36px;
+ border-radius: 10px;
+ background: var(--primary-soft);
+ color: var(--primary-light);
+ margin-bottom: 12px;
+}
+.card-icon svg { width: 20px; height: 20px; }
+
+/* ===========================================================================
+ Utilities added to satisfy component markup contract (post-de-inline pass)
+ =========================================================================== */
+
+/* Small variant of .btn, used for compact CTAs. */
+.btn-sm {
+ min-height: 32px;
+ padding: 0 12px;
+ font-size: 13px;
+ font-weight: var(--fw-medium);
+}
+
+/* Header right-side action group (Examples / GitHub icon). */
+.nav-cluster {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+}
+
+/* Sidebar section group + section label (uppercase eyebrow). */
+/* Sidebar group is a native for accessible collapse without JS.
+ The summary line is the group label; the items live in .sidebar-group-items. */
+.sidebar-group {
+ margin-bottom: 16px;
+}
+.sidebar-group:last-child { margin-bottom: 0; }
+.sidebar-group[open] { margin-bottom: 20px; }
+
+/* The summary IS the group label. Strip the default marker and lay out a caret. */
+.sidebar-group-label {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ padding: 8px 10px 6px;
+ font-size: 12px;
+ font-weight: var(--fw-semibold);
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ cursor: pointer;
+ user-select: none;
+ border-radius: 8px;
+ list-style: none;
+}
+.sidebar-group-label::-webkit-details-marker { display: none; }
+.sidebar-group-label::marker { content: ''; }
+.sidebar-group-label:hover { color: var(--text); background: rgba(255,255,255,.025); }
+
+/* Match the focus ring corner to the label's own 8px radius. */
+.sidebar-group-label:focus-visible { border-radius: 8px; }
+
+.sidebar-group-caret {
+ color: var(--text-soft);
+ transform: rotate(-90deg);
+ transition: transform 180ms ease-out, color 180ms ease-out;
+ flex: none;
+}
+.sidebar-group[open] > .sidebar-group-label .sidebar-group-caret {
+ transform: rotate(0deg);
+ color: var(--text-muted);
+}
+
+/* Items inside the group fade in when the group opens. Native toggles
+ block display synchronously; the opacity transition softens the appearance.
+ A faint vertical structure-line on the left of the items signals hierarchy. */
+.sidebar-group-items {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ padding-top: 4px;
+ padding-left: 12px;
+ margin-left: 14px;
+ border-left: 1px solid var(--border);
+ animation: sidebar-group-fade-in 180ms ease-out;
+}
+
+/* When a link inside an open group is active, brighten the structure-line
+ segment leading to it. Implemented as a global open-group line color bump. */
+.sidebar-group[open] > .sidebar-group-items {
+ border-left-color: var(--border-strong);
+}
+
+@keyframes sidebar-group-fade-in {
+ from { opacity: 0; transform: translateY(-2px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .sidebar-group-caret { transition: none; }
+ .sidebar-group-items { animation: none; }
+}
+
+/* Prev/Next pager at the bottom of every doc page. */
+.doc-pager {
+ display: flex;
+ justify-content: space-between;
+ align-items: stretch;
+ gap: 16px;
+ margin-top: 48px;
+ padding-top: 24px;
+ border-top: 1px solid var(--border);
+}
+
+.doc-pager a {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ padding: 14px 18px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ color: var(--text-muted);
+ background: rgba(255,255,255,.02);
+ border-bottom: 1px solid var(--border);
+ transition: border-color 200ms ease-out, color 200ms ease-out, background 200ms ease-out;
+}
+
+.doc-pager a:hover {
+ color: var(--text);
+ border-color: var(--border-strong);
+ background: rgba(255,255,255,.04);
+}
+
+.doc-pager a > span:first-child {
+ font-size: 12px;
+ font-weight: var(--fw-semibold);
+ color: var(--text-soft);
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+}
+
+.doc-pager a > span:last-child {
+ font-size: 15px;
+ font-weight: var(--fw-bold);
+ color: var(--text);
+}
+
+.doc-pager-next { text-align: right; align-items: flex-end; }
+.doc-pager-prev { text-align: left; align-items: flex-start; }
+
+@media (max-width: 640px) {
+ .doc-pager { flex-direction: column; }
+}
+
+/* Site footer chrome. Prisma's apps/docs footer structure:
+ brand column on the left + 4-column link grid on the right, hairline,
+ then a copyright row. Source reference:
+ prisma-docs/packages/ui/src/components/footer.tsx + data/footer.ts. */
+.site-footer {
+ margin-top: 80px;
+ border-top: 1px solid var(--border);
+ background: rgba(0, 0, 0, 0.2);
+}
+
+.site-footer-inner {
+ max-width: var(--max);
+ margin: 0 auto;
+ padding: 56px 24px 32px;
+ color: var(--text-muted);
+ font-size: 14px;
+}
+
+/* Two-region top: brand on the left, link grid on the right.
+ Below 960px the brand stacks above the grid. */
+.site-footer-top {
+ display: grid;
+ grid-template-columns: minmax(0, 320px) 1fr;
+ gap: 48px;
+ align-items: start;
+}
+
+.site-footer-brand .brand {
+ font-family: var(--font-title);
+ font-weight: var(--fw-bold);
+ font-size: 18px;
+ color: var(--text);
+ letter-spacing: -0.01em;
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.site-footer-brand .mark {
+ width: 22px;
+ height: 22px;
+ border-radius: 6px;
+ background: linear-gradient(160deg, #FB6F9D 0%, var(--primary) 100%);
+ display: inline-block;
+}
+
+.site-footer-tagline {
+ margin: 16px 0 20px;
+ font-size: 14px;
+ line-height: 1.55;
+ color: var(--text-muted);
+ max-width: 32ch;
+}
+
+.site-footer-socials {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ margin-left: -8px;
+}
+
+/* The 4-column link grid. Single row on >= 640px, 2x2 below. */
+.site-footer-grid {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 32px;
+}
+
+.site-footer-col-title {
+ margin: 0 0 16px;
+ font-family: var(--font-title);
+ font-size: 11px;
+ font-weight: var(--fw-semibold);
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--text);
+}
+
+.site-footer-col-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.site-footer-col-list a {
+ color: var(--text-muted);
+ font-size: 14px;
+ font-weight: var(--fw-medium);
+ text-decoration: none;
+ transition: color 160ms ease;
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.site-footer-col-list a:hover {
+ color: var(--text);
+}
+
+/* Tiny alpha-pill next to the Blackbox link. Same visual family as the
+ top-nav product-tab badge so the brand identity carries through. */
+.site-footer-badge {
+ display: inline-flex;
+ align-items: center;
+ padding: 2px 6px;
+ font-size: 9px;
+ font-weight: var(--fw-semibold);
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--primary-light);
+ background: var(--primary-soft);
+ border: 1px solid var(--primary-border);
+ border-radius: 999px;
+ line-height: 1;
+}
+
+.site-footer-divider {
+ height: 1px;
+ margin: 40px 0 20px;
+ background: var(--border);
+}
+
+.site-footer-bottom {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.site-footer-meta {
+ margin: 0;
+ font-size: 13px;
+ color: var(--text-soft);
+}
+
+.site-footer-link--inline {
+ color: var(--text-muted);
+ text-decoration: underline;
+ text-decoration-color: var(--border-strong);
+ text-underline-offset: 3px;
+ transition: color 160ms ease, text-decoration-color 160ms ease;
+}
+
+.site-footer-link--inline:hover {
+ color: var(--primary-light);
+ text-decoration-color: var(--primary-border);
+}
+
+@media (max-width: 960px) {
+ .site-footer-top {
+ grid-template-columns: 1fr;
+ gap: 40px;
+ }
+}
+
+@media (max-width: 640px) {
+ .site-footer-inner {
+ padding: 40px 16px 28px;
+ }
+ .site-footer-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 28px;
+ }
+}
+
+/* Mobile touch target floor (ui-ux-pro-max touch-target-size, 44x44 minimum).
+ Per audit section E + Top-12 fix 6. Sidebar already overrides at 767px
+ (see .sidebar a above); matching breakpoint here for consistency. */
+@media (max-width: 767px) {
+ .btn-sm {
+ min-height: 44px;
+ padding: 0 14px;
+ }
+ .tab {
+ min-height: 44px;
+ padding: 10px 16px;
+ }
+ .icon-link {
+ width: 44px;
+ height: 44px;
+ }
+
+ /* Marketing header collapses links + dropdowns into the mobile drawer
+ toggle. The drawer panel is fixed under the topbar and scrolls. */
+ .marketing-navlinks {
+ display: none;
+ }
+ .marketing-mobile-menu {
+ display: inline-flex;
+ }
+ .marketing-mobile-menu[open] .marketing-mobile-panel {
+ display: block;
+ position: fixed;
+ top: var(--header-h);
+ right: 0;
+ left: 0;
+ z-index: var(--z-overlay);
+ max-height: calc(100dvh - var(--header-h));
+ overflow: auto;
+ padding: 16px 20px 24px;
+ background: var(--bg);
+ border-bottom: 1px solid var(--border);
+ box-shadow: var(--shadow);
+ }
+}
+
+/* ==========================================================================
+ UI/UX hardening pass (2026-06-14)
+ Six parallel opus agents reviewed cohesive component groups with the
+ ui-ux-pro-max skill. Deltas appended below so the CSS cascade naturally
+ overrides earlier definitions (last-wins). Originating scratch files:
+ .scratch/ui-ux/{site-chrome,docs-chrome,ui-primitives,mdx,islands}.css
+ ========================================================================== */
+
+/* === site-chrome === */
+/* Site chrome polish: focus rings, hover transitions, mobile touch targets,
+ footer link affordances. All changes reuse existing tokens. */
+
+/* APPEND-TO: .brand (components.css L96) */
+/* Add an explicit focus-visible radius so the global 2px outline wraps
+ cleanly around the inline-flex brand bounding box instead of clipping
+ into the mark and product slug. */
+.brand {
+ border-radius: 10px;
+}
+
+/* NEW */
+/* Match the focus ring radius to the brand's own corner so the ring
+ visually hugs the element on keyboard focus. */
+.brand:focus-visible {
+ outline-offset: 4px;
+}
+
+/* REPLACES: components.css L1123-L1131 */
+/* Icon-only header action (GitHub). Adds smooth hover transition for
+ color and background per ui-ux-pro-max duration-timing rule (200ms).
+ Global @media (prefers-reduced-motion: reduce) zeroes this transition. */
+.icon-link {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 36px;
+ height: 36px;
+ color: var(--text-muted);
+ border-radius: 8px;
+ transition: color 200ms ease, background-color 200ms ease;
+}
+
+/* APPEND-TO: .nav-action (components.css L1148) */
+/* Smooth hover transition for the Examples text action. */
+.nav-action {
+ border-radius: 6px;
+ transition: color 200ms ease;
+}
+
+/* APPEND-TO: .nav-tabs a (components.css L57) */
+/* Smooth color + underline transition when switching active product tab. */
+.nav-tabs a {
+ transition: color 200ms ease, border-bottom-color 200ms ease;
+}
+
+/* APPEND-TO: .navlinks a (implicit, components.css L131) */
+/* Smooth fade between muted and primary text colors on navlink hover. */
+.navlinks a {
+ transition: color 200ms ease;
+}
+
+/* NEW */
+/* Mobile touch target floor for header text actions and product tabs.
+ ui-ux-pro-max touch-target-size requires minimum 44x44px. The existing
+ 767px breakpoint at the bottom of components.css already handles
+ .btn-sm, .tab, and .icon-link. Extending coverage to .nav-action and
+ .nav-tabs a so every header touch target meets the floor. */
+@media (max-width: 767px) {
+ .nav-action {
+ min-height: 44px;
+ display: inline-flex;
+ align-items: center;
+ padding: 8px 10px;
+ }
+ .nav-tabs a {
+ min-height: 44px;
+ display: inline-flex;
+ align-items: center;
+ padding: 10px 0;
+ }
+}
+
+/* NEW */
+/* Footer link affordance. The Footer template renders
+ for the author name; without an explicit
+ rule it inherits the global link color (primary) and has no hover or
+ focus feedback. Mirror the navlinks pattern: muted at rest, full text
+ on hover, smooth color transition, rounded focus ring. */
+.site-footer-link {
+ color: var(--text);
+ text-decoration: underline;
+ text-decoration-color: var(--border-strong);
+ text-underline-offset: 3px;
+ border-radius: 4px;
+ transition: color 200ms ease, text-decoration-color 200ms ease;
+}
+.site-footer-link:hover {
+ color: var(--primary-light);
+ text-decoration-color: var(--primary-border);
+}
+
+/* NEW */
+/* The Footer icon-link sits in a flex row alongside two tags. The
+ site-footer-inner uses space-between, so the GitHub anchor naturally
+ anchors left. On focus, give it a slightly larger offset so the ring
+ does not collide with the surrounding text baseline. */
+.site-footer .icon-link:focus-visible {
+ outline-offset: 3px;
+}
+
+
+/* === docs-chrome === */
+/* Audit and fixes for sidebar, TOC, and prev/next pager.
+ Pairs with edits to:
+ src/components/docs/Sidebar.astro (wraps groups in