-
+
{childrenArray.map((child: any, idx: number) => {
const label = child?.props?.label || `Tab ${idx + 1}`
return (
@@ -50,7 +49,24 @@ export function Tabs({ children, defaultValue, className }: TabsProps) {
)
})}
-
{childrenArray[activeTab]}
+ {/*
+ Every panel stays mounted (inactive ones carry the `hidden` attribute)
+ so hidden panels remain searchable and can be expanded by the print
+ stylesheet — same convention as the primitives-based `Tabs`, whose
+ panels use `keepMounted`.
+ */}
+
+ {childrenArray.map((child: any, idx: number) => (
+
+ {child}
+
+ ))}
+
)
}
diff --git a/apps/docs/src/styles/globals.css b/apps/docs/src/styles/globals.css
index 6824f52..44e6e0a 100644
--- a/apps/docs/src/styles/globals.css
+++ b/apps/docs/src/styles/globals.css
@@ -1 +1,13 @@
+/* Print stylesheet for documentation pages (DX-063 / issue #575).
+ Its rules live in the `docs-print` layer, declared FIRST in the layer order
+ (before Tailwind's `theme, base, components, utilities`). For `!important`
+ declarations the cascade gives priority to the earliest layer, so
+ `docs-print` important rules beat the base-layer preflight rule
+ `[hidden] { display: none !important }` — which is what lets print expand
+ `hidden` tab panels (the MDX content tabs and Base UI's `keepMounted`
+ panels both hide inactive panels with the hidden attribute). The layer
+ statement must precede the imports that pull Tailwind in. */
+@layer docs-print, theme, base, components, utilities;
+
@import "@workspace/ui/globals.css";
+@import "./print.css" layer(docs-print);
diff --git a/apps/docs/src/styles/print.css b/apps/docs/src/styles/print.css
new file mode 100644
index 0000000..e15b750
--- /dev/null
+++ b/apps/docs/src/styles/print.css
@@ -0,0 +1,315 @@
+/*
+ * Print stylesheet for documentation pages (DX-063 / issue #575).
+ *
+ * Reference pages — particularly /resources/terms and /concepts/risk — get
+ * printed and saved as PDFs. Without print rules the output is a navigation
+ * header followed by whatever the active theme happens to be. This file makes
+ * the printed page content-only, always light, and split-safe.
+ *
+ * Scope notes:
+ * - The static content pages generated by `scripts/build.ts` render
+ * `body > header` (site chrome) and `body > main` (the article). The SPA
+ * shell (`DocsLayout`) renders the three-column chrome via `data-slot`
+ * hooks; both shapes are covered here.
+ * - The reading-progress indicator (DX-062) and the page-feedback control are
+ * not built yet. When they land, either place them inside the header (which
+ * print hides) or give the root element `data-reading-progress` /
+ * `data-page-feedback` — both hooks are hidden below — or simply add
+ * Tailwind's `print:hidden` utility, which is the repo-wide convention.
+ * - Code blocks highlighted by Shiki resolve their colors through the same
+ * semantic tokens (`var(--color-text-primary)`, `var(--color-surface-sunken)`,
+ * …), so the light-token mirror below recolors them for print too.
+ */
+
+/* The print-only footer (rendered by `scripts/build.ts`) is invisible on screen. */
+.docs-print-footer {
+ display: none;
+}
+
+@media print {
+ /*
+ * 1. Force the light palette regardless of the active theme.
+ *
+ * The dark theme is a `.dark` class on `` whose custom properties are
+ * overridden on the same element as `:root` and win by cascade order. This
+ * block re-declares every property the `.dark` block in
+ * `packages/ui/src/styles/globals.css` overrides, using that file's `:root`
+ * (light) values — keep the two lists in sync. Declaring on `:root, .dark`
+ * and importing this file after the theme makes light win whichever theme
+ * attribute is set.
+ */
+ :root,
+ .dark {
+ color-scheme: light;
+
+ /* shadcn/base semantic tokens — mirrors `:root` in the UI package. */
+ --background: oklch(1 0 0);
+ --foreground: oklch(0.145 0 0);
+ --card: oklch(1 0 0);
+ --card-foreground: oklch(0.145 0 0);
+ --popover: oklch(1 0 0);
+ --popover-foreground: oklch(0.145 0 0);
+ --primary: oklch(0.5 0.134 242.749);
+ --primary-foreground: oklch(0.977 0.013 236.62);
+ --secondary: oklch(0.967 0.001 286.375);
+ --secondary-foreground: oklch(0.21 0.006 285.885);
+ --muted: oklch(0.97 0 0);
+ --muted-foreground: oklch(0.556 0 0);
+ --accent: oklch(0.97 0 0);
+ --accent-foreground: oklch(0.205 0 0);
+ --destructive: oklch(0.577 0.245 27.325);
+ --destructive-foreground: oklch(0.985 0 0);
+ --border: oklch(0.922 0 0);
+ --input: oklch(0.922 0 0);
+ --ring: oklch(0.708 0 0);
+
+ /* Surface roles (DESIGN.md "Surface roles"). Light values are not yet
+ declared upstream — light mode currently degrades to inherited/initial
+ values — so print states them explicitly here. */
+ --surface-canvas: oklch(1 0 0);
+ --surface-sunken: oklch(0.97 0 0);
+ --surface-raised: oklch(1 0 0);
+ --surface-overlay: oklch(1 0 0);
+ --surface-interactive: oklch(0.967 0.001 286.375);
+
+ /* Text roles (DESIGN.md "Text and icon roles"). */
+ --text-primary: oklch(0.145 0 0);
+ --text-secondary: oklch(0.37 0 0);
+ --text-tertiary: oklch(0.556 0 0);
+ --text-disabled: oklch(0.708 0 0);
+ --text-inverse: oklch(0.985 0 0);
+ --text-link: var(--primary);
+
+ /* Informational status color — `.dark` moves it to the GMX brand blue. */
+ --info: oklch(0.546 0.245 262.881);
+
+ /* Sidebar + chart roles — hidden chrome in print, mirrored for completeness. */
+ --sidebar: oklch(0.985 0 0);
+ --sidebar-foreground: oklch(0.145 0 0);
+ --sidebar-primary: oklch(0.685 0.169 237.323);
+ --sidebar-primary-foreground: oklch(0.293 0.066 243.157);
+ --sidebar-accent: oklch(0.967 0.001 286.375);
+ --sidebar-accent-foreground: oklch(0.205 0 0);
+ --sidebar-border: oklch(0.922 0 0);
+ --sidebar-ring: oklch(0.708 0 0);
+ --chart-surface: oklch(1 0 0);
+ --chart-text: var(--muted-foreground);
+ --chart-grid: oklch(0.922 0 0);
+ --chart-crosshair: oklch(0.556 0 0);
+ --chart-crosshair-label: oklch(0.97 0 0);
+ --chart-border: oklch(0.922 0 0);
+
+ /* Trading-state subtle/border fills as tuned for a white canvas (`:root`). */
+ --long-subtle: oklch(0.18 0.05 163.225);
+ --long-border: oklch(0.35 0.1 163.225);
+ --short-subtle: oklch(0.18 0.05 27.325);
+ --short-border: oklch(0.35 0.1 27.325);
+ --liquidation-subtle: oklch(0.18 0.05 75.834);
+ --liquidation-border: oklch(0.35 0.1 75.834);
+ --success-subtle: oklch(0.18 0.05 163.225);
+ --success-border: oklch(0.35 0.1 163.225);
+ --warning-subtle: oklch(0.18 0.05 75.834);
+ --warning-border: oklch(0.35 0.1 75.834);
+ --info-subtle: oklch(0.18 0.05 262.881);
+ --info-border: oklch(0.35 0.1 262.881);
+ --danger-subtle: oklch(0.18 0.05 27.325);
+ --danger-border: oklch(0.35 0.1 27.325);
+ --neutral-subtle: oklch(0.2 0 0);
+ --neutral-border: oklch(0.32 0 0);
+ }
+
+ /* Keep the light tints (callouts, sunken code wells) in the printed output. */
+ html {
+ -webkit-print-color-adjust: exact;
+ print-color-adjust: exact;
+ }
+
+ /*
+ * 2. Hide chrome; print content only.
+ *
+ * `body > header` is the static content-page header (site links, and later
+ * the DX-062 progress indicator). The `data-slot` hooks are DocsLayout's
+ * sidebar rail, TOC rail, sticky header, and site footer. The mobile TOC is
+ * the one Collapsible rendered as a direct child of `main`. The last two
+ * attribute hooks are the forward-compatible escape hatches described in the
+ * file header for components that land outside the header.
+ */
+ body > header,
+ [data-slot="docs-header"],
+ [data-slot="docs-sidebar"],
+ [data-slot="docs-toc"],
+ [data-slot="docs-footer"],
+ [data-slot="skip-link"],
+ [data-search-dialog],
+ main > [data-slot="collapsible"],
+ main a.heading-anchor,
+ [data-reading-progress],
+ [data-page-feedback] {
+ display: none !important;
+ }
+
+ /*
+ * Callouts print as a bordered box on white. The upstream `:root` values of
+ * the status `subtle` fills are dark-mode tints (e.g. `--warning-subtle` is
+ * oklch(0.18 …)), so on paper they would render as near-black boxes with
+ * dark text; the mirrored token values above cannot fix that. Neutralizing
+ * the fill in print keeps callouts readable while the variant's colored
+ * text/icons (which have readable light values) still distinguish them.
+ */
+ body > main aside,
+ main#main-content [role="status"],
+ main#main-content [role="alert"] {
+ background: none !important;
+ border: 1px solid var(--border) !important;
+ }
+
+ /*
+ * 3. Expand the content column to the full page width.
+ */
+ body > main,
+ main#main-content,
+ main#main-content article,
+ [data-slot="docs-shell"],
+ [data-slot="docs-shell"] > div {
+ max-width: none !important;
+ }
+
+ body > main,
+ main#main-content,
+ [data-slot="docs-shell"] > div {
+ padding-left: 0 !important;
+ padding-right: 0 !important;
+ }
+
+ body > main,
+ main#main-content {
+ margin: 0 !important;
+ padding-top: 0 !important;
+ padding-bottom: 0 !important;
+ }
+
+ /*
+ * 4. Expand collapsed sections.
+ *
+ * Collapsible keeps its content mounted and collapses with the
+ * `grid-template-rows: 0fr` + `overflow: hidden` technique, so print CSS
+ * alone can open it. (The mobile TOC collapsible is excluded because the
+ * chrome rule above already removes it.)
+ */
+ [data-slot="collapsible-content"],
+ [data-slot="collapsible-content"] > div {
+ overflow: visible !important;
+ }
+
+ [data-slot="collapsible-content"] {
+ grid-template-rows: 1fr !important;
+ padding-bottom: 0.75rem !important;
+ }
+
+ /*
+ * 5. Expand every tab panel.
+ *
+ * Both tab implementations keep inactive panels in the DOM (the MDX content
+ * `Tabs` mounts all panels wrapped in `hidden`; the `@base-ui/react` version
+ * uses `keepMounted`), so forcing `display: block` is enough. The tab list
+ * itself is hidden and each panel is prefixed with its tab label so the
+ * reader can tell the alternatives apart.
+ */
+ main [data-tab-list],
+ main [data-slot="tabs-list"] {
+ display: none !important;
+ }
+
+ main [data-tab-panel],
+ main [data-slot="tabs-content"] {
+ display: block !important;
+ }
+
+ main [data-tab-label]::before {
+ content: attr(data-tab-label);
+ display: block;
+ margin-bottom: 0.5rem;
+ font-weight: 600;
+ color: var(--text-primary);
+ }
+
+ /*
+ * 6. Print the destination after internal links.
+ *
+ * Scoped to the content column so navigation/UI links never print URLs
+ * (they are hidden anyway). Fragment-only anchors (`.heading-anchor`,
+ * href="#…") don't match `href^="/"`.
+ */
+ body > main a[href^="/"]::after,
+ main#main-content article a[href^="/"]::after {
+ content: " (" attr(href) ")";
+ font-size: 0.8em;
+ color: var(--text-tertiary);
+ word-break: break-all;
+ }
+
+ /*
+ * 7. Never split a code block, callout, or table row across pages.
+ */
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ break-after: avoid;
+ break-inside: avoid;
+ }
+
+ pre,
+ code,
+ blockquote,
+ aside,
+ figure,
+ img,
+ [role="status"],
+ [role="alert"],
+ .code-block-wrapper,
+ .docs-print-footer {
+ break-inside: avoid;
+ }
+
+ tr,
+ thead,
+ tfoot {
+ break-inside: avoid;
+ }
+
+ /* Repeat table headers when a table does span pages. */
+ thead {
+ display: table-header-group;
+ }
+
+ p,
+ li {
+ orphans: 3;
+ widows: 3;
+ }
+
+ /*
+ * 8. Print-only footer: page URL + last-updated date.
+ *
+ * `scripts/build.ts` renders the footer inside `main` with the canonical URL
+ * (`https://docs.so4.market` + route, matching the canonical/OG metadata in
+ * `src/lib/seo.ts`) in `data-print-url` and the frontmatter `updated` date
+ * in a `
` element, so no client JS is needed for any print path.
+ */
+ .docs-print-footer {
+ display: block;
+ margin-top: 2.5rem;
+ padding-top: 0.75rem;
+ border-top: 1px solid var(--border);
+ font-size: 0.75rem;
+ line-height: 1.4;
+ color: var(--text-tertiary);
+ }
+
+ .docs-print-footer::after {
+ content: " · " attr(data-print-url);
+ }
+}