diff --git a/docs/introduction.md b/docs/introduction.md index 5b1dae60b5..8f7445a302 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -3,6 +3,10 @@ title: OpenRewrite by Moderne | Large Scale Automated Refactoring keywords: [OpenRewrite, Moderne, refactoring, Java, Spring Boot Migration, auto-remediation, SAST, SCA] description: Large-scale automated source code refactoring slug: / +# The entry point reads as a landing page rather than a doc, so it shows no +# sidebar. Detaching here rather than hiding the column in CSS also releases the +# width Docusaurus reserves for it. +displayed_sidebar: null --- import ReactPlayer from 'react-player' diff --git a/docusaurus.config.ts b/docusaurus.config.ts index 0f1d06d77e..5560972a35 100644 --- a/docusaurus.config.ts +++ b/docusaurus.config.ts @@ -41,26 +41,16 @@ const config: Config = { }, innerHTML: JSON.stringify(structuredData), }, - // Runs before hydration so neither the bar nor the navbar offset flashes. - // Hides the notice bar for visitors who already dismissed it, and records whether - // a bar is showing on this route so custom.css can offset the sticky navbar. - // Which bar applies depends on the path, but the dismissal flags are global, hence - // the route check. Keys and attributes must match src/theme/AnnouncementBar/index.tsx. + // Runs before hydration so the notice bar does not flash for visitors who + // already dismissed it. The key and attribute must match + // src/theme/AnnouncementBar/index.tsx. { tagName: 'script', attributes: {}, innerHTML: `try { - var noticeDismissed = window.localStorage.getItem('code-genome-project-announcement-dismissed') === 'true'; - if (noticeDismissed) { + if (window.localStorage.getItem('code-genome-project-announcement-dismissed') === 'true') { document.documentElement.setAttribute('data-notice-bar-dismissed', 'true'); } - var onRecipes = window.location.pathname.indexOf('/recipes') === 0; - var dismissed = onRecipes - ? window.localStorage.getItem('docusaurus.announcement.dismiss') === 'true' - : noticeDismissed; - if (!dismissed) { - document.documentElement.setAttribute('data-bar-visible', 'true'); - } } catch (e) {}`, }, // @@ -84,7 +74,7 @@ const config: Config = { tagName: 'link', attributes: { rel: 'stylesheet', - href: 'https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap', + href: 'https://fonts.googleapis.com/css2?family=Geist:ital,wght@0,300..700;1,300..700&family=Geist+Mono:ital,wght@0,400;0,500;1,400&display=swap', }, }, @@ -136,7 +126,13 @@ const config: Config = { trackingID: "G-Y67JVX3WB7", }, theme: { - customCss: "./src/css/custom.css", + // Order matters: the design-system tokens define every --mod-* the + // site references, and custom.css is the Infima bridge, so it must + // load second for its --ifm-* mappings to win. + customCss: [ + require.resolve("@moderneinc/design-system-tokens/moderne.css"), + "./src/css/custom.css", + ], }, } satisfies Preset.Options, ], @@ -244,9 +240,8 @@ const config: Config = { }, ], }, - footer: { - copyright: `© Moderne, ${new Date().getFullYear()}`, - }, + // The footer is swizzled (src/theme/Footer), which owns its own links and + // copyright, so there is nothing to configure here. prism: { theme: prismThemes.vsDark, darkTheme: prismThemes.vsDark, diff --git a/package.json b/package.json index a644373024..5f03570c32 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "@docusaurus/theme-mermaid": "^3.9.2", "@mdx-js/react": "^3.1.0", "@mermaid-js/layout-elk": "^0.1.9", + "@moderneinc/design-system-tokens": "7.4.0", "clsx": "^2.0.0", "prism-react-renderer": "^2.3.0", "react": "^18.0.0", diff --git a/sidebars.ts b/sidebars.ts index 4810305669..92f3dd2565 100644 --- a/sidebars.ts +++ b/sidebars.ts @@ -87,6 +87,7 @@ const sidebars: SidebarsConfig = { description: 'How to create and test recipes with OpenRewrite and Moderne.', href: 'https://docs.moderne.io/hands-on-learning/fundamentals/workshop-overview', }, + 'training', ], }, { @@ -132,7 +133,6 @@ const sidebars: SidebarsConfig = { 'reference/building-openrewrite-from-source', ], }, - 'training', { type: 'category', label: 'Lists', diff --git a/src/components/SecondaryNav/SecondaryNav.module.css b/src/components/SecondaryNav/SecondaryNav.module.css new file mode 100644 index 0000000000..78270663ee --- /dev/null +++ b/src/components/SecondaryNav/SecondaryNav.module.css @@ -0,0 +1,110 @@ +/** + * Section navigation, below the primary navbar. Desktop only: on mobile the + * sidebar still lists every section, so this bar would only repeat it. + */ + +.secondaryNav { + display: none; + height: var(--ifm-secondary-nav-height); + padding: 0 var(--mod-spacing_4); + align-items: center; + justify-content: space-between; + /* Hairline that defines the header's lower edge. */ + border-bottom: 1px solid var(--mod-color-border-primary); +} + +@media (min-width: 997px) { + .secondaryNav { + display: flex; + } +} + +.sections { + display: flex; + align-items: center; + gap: var(--mod-spacing_3); + min-width: 0; +} + +.section { + font-size: var(--mod-font-size-default); + font-weight: var(--mod-font-weight-medium); + color: var(--mod-color-text-primary); + text-decoration: none; + white-space: nowrap; + transition: color 0.2s ease; +} + +.section:hover { + color: var(--mod-color-action-default); + text-decoration: none; +} + +/* Both active states take the same colour; custom.css owns the light/dark pair. */ +.sectionActive, +.sectionActive:hover, +.dropdownItemActive, +.dropdownItemActive:hover { + color: var(--docs-color-active); +} + +.menu { + position: relative; + flex-shrink: 0; +} + +.menuButton { + display: flex; + align-items: center; + gap: var(--mod-spacing_1_2); + padding: var(--mod-spacing_1) 0; + background: transparent; + border: none; + cursor: pointer; +} + +/* One definition of this glyph, shared with the sidebar caret in custom.css. + background-color + mask means it follows currentColor like an inline SVG. */ +.chevron { + width: 10px; + height: 6px; + flex-shrink: 0; + background-color: currentColor; + mask-image: var(--docs-chevron-down); + mask-repeat: no-repeat; + mask-size: contain; + mask-position: center; + transition: transform 0.2s ease; +} + +.chevronOpen { + transform: rotate(180deg); +} + +.dropdown { + position: absolute; + top: 100%; + right: 0; + min-width: 200px; + padding: var(--mod-spacing_1) 0; + background-color: var(--mod-color-surface-raised); + border: 1px solid var(--mod-color-border-primary); + border-radius: var(--mod-border-radius-input); + box-shadow: var(--docs-shadow-dropdown); + z-index: var(--ifm-z-index-dropdown); +} + +.dropdownItem { + display: block; + padding: var(--mod-spacing_1) var(--mod-spacing_2); + font-size: var(--mod-font-size-default); + color: var(--mod-color-text-primary); + text-decoration: none; + white-space: nowrap; +} + +.dropdownItem:hover { + background-color: var(--mod-color-surface-row-hover); + color: var(--mod-color-text-primary); + text-decoration: none; +} diff --git a/src/components/SecondaryNav/SecondaryNav.tsx b/src/components/SecondaryNav/SecondaryNav.tsx new file mode 100644 index 0000000000..8153544477 --- /dev/null +++ b/src/components/SecondaryNav/SecondaryNav.tsx @@ -0,0 +1,126 @@ +import Link from '@docusaurus/Link'; +import { useLocation } from '@docusaurus/router'; +import clsx from 'clsx'; +import { type FunctionComponent, useCallback, useEffect, useRef, useState } from 'react'; +import type { NavSection } from '@site/src/config/navSections'; +import { findSection } from '@site/src/theme/DocSidebarItems/filterUtils'; +import styles from './SecondaryNav.module.css'; + +export type SecondaryNavProps = { + /** Sections rendered as direct links, left-aligned. */ + sections: NavSection[]; + /** Label for the right-aligned dropdown. */ + menuLabel: string; + /** Items inside that dropdown. */ + menuItems: NavSection[]; +}; + +/** Chevron for the dropdown. Masked from --docs-chevron-down rather than + * inlined, so this glyph has one definition shared with the sidebar caret. The + * mask takes its colour from currentColor, as an inline SVG would. */ +const Chevron: FunctionComponent<{ open: boolean }> = ({ open }) => ( + +); + +/** + * Section navigation, shown below the primary navbar on desktop. + * + * Picks the section; the sidebar then shows that section's contents (see + * src/theme/DocSidebarItems, which scopes on desktop only). Hidden below 997px, + * where the drawer keeps every section and this bar would duplicate it. + */ +export const SecondaryNav: FunctionComponent = ({ + sections, + menuLabel, + menuItems, +}) => { + const [open, setOpen] = useState(false); + const menuRef = useRef(null); + const location = useLocation(); + + // Same matcher the sidebar filter uses, so the highlighted section and the + // scoped sidebar can never disagree. + const activeHref = findSection(location.pathname)?.href; + const isMenuActive = menuItems.some((item) => item.href === activeHref); + + const close = useCallback(() => setOpen(false), []); + + // Close on outside click and on Escape, so the menu never outlives its context. + useEffect(() => { + if (!open) { + return; + } + // mousedown + touchstart + focusin is the set theme-classic's own + // DropdownNavbarItem listens on; mousedown alone leaves the menu open on a + // touch tap and when focus moves away by keyboard. + const onOutside = (event: MouseEvent | TouchEvent | FocusEvent) => { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + close(); + } + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + close(); + } + }; + const events = ['mousedown', 'touchstart', 'focusin'] as const; + events.forEach((name) => document.addEventListener(name, onOutside)); + document.addEventListener('keydown', onKeyDown); + return () => { + events.forEach((name) => document.removeEventListener(name, onOutside)); + document.removeEventListener('keydown', onKeyDown); + }; + }, [open, close]); + + // A route change means the menu's job is done. + useEffect(close, [location.pathname, close]); + + return ( + + + {sections.map((section) => { + const active = section.href === activeHref; + return ( + + {section.name} + + ); + })} + + + + setOpen((prev) => !prev)} + aria-expanded={open} + aria-haspopup="true" + > + {menuLabel} + + + {open && ( + + {menuItems.map((item) => ( + + {item.name} + + ))} + + )} + + + ); +}; + +SecondaryNav.displayName = 'SecondaryNav'; diff --git a/src/components/SecondaryNav/index.ts b/src/components/SecondaryNav/index.ts new file mode 100644 index 0000000000..194f1a9e2a --- /dev/null +++ b/src/components/SecondaryNav/index.ts @@ -0,0 +1,2 @@ +export { SecondaryNav } from './SecondaryNav'; +export type { SecondaryNavProps } from './SecondaryNav'; diff --git a/src/components/button.module.css b/src/components/button.module.css index 921f345ce9..b249fb5e79 100644 --- a/src/components/button.module.css +++ b/src/components/button.module.css @@ -1,10 +1,12 @@ .container { display: grid; grid-template-columns: 1fr 1fr; - border: 1px solid #2546F0; - padding: 30px; + border: 1px solid var(--mod-color-border-primary); + padding: var(--mod-spacing_3_3_4); + /* 15px is off the spacing scale (14 and 16 are the neighbours). Left as a + literal rather than silently shifting the layout by a pixel. */ gap: 15px; - margin: 16px 0; + margin: var(--mod-spacing_2) 0; } .subContainer { @@ -14,27 +16,31 @@ } .heading { - font-weight: 500; - font-size: 18px; + font-weight: var(--mod-font-weight-medium); + font-size: var(--mod-font-size-h5); line-height: 23px; - /* color: #FBFDFF; */ } +/* Primary action: the design system's primary button is a high-contrast + neutral, not a blue fill. It inverts between themes (navy on cream, cream on + near-black), so it stays the strongest element in either mode. */ .link { - font-weight: 400; - font-size: 16px; + font-weight: var(--mod-font-weight-regular); + font-size: var(--mod-font-size-default); line-height: 23px; - background-color: #2546F0; - padding: 8px; + background-color: var(--mod-button-primary-bg); + padding: var(--mod-spacing_1); align-items: center; - border-radius: 6px; - color: #FBFDFF; + border-radius: var(--mod-border-radius-button); + color: var(--mod-color-text-on-primary); display: inline-flex; - gap: 8px; + gap: var(--mod-spacing_1); } +/* Pin the text colour: rendered as an , Infima's global a:hover would + otherwise repaint it with the link colour. */ .link:hover { - background-color: #2546F080; - color: #FBFDFF; + background-color: var(--mod-button-primary-bg-hover); + color: var(--mod-color-text-on-primary); text-decoration: none; } diff --git a/src/config/navSections.ts b/src/config/navSections.ts new file mode 100644 index 0000000000..be8fa2faa7 --- /dev/null +++ b/src/config/navSections.ts @@ -0,0 +1,127 @@ +import type { PropSidebarItem } from '@docusaurus/plugin-content-docs'; +import sidebars from '@site/sidebars'; + +/** + * Sections shown in the secondary nav. + * + * `href` must match a top-level category's generated-index slug in sidebars.ts — + * that is how a section resolves to its sidebar subtree, and nothing checks it + * at build time. An href matching no category still links, but its pages fall + * back to the full sidebar. + */ + +export type NavSection = { + name: string; + /** Route the section starts at, and the prefix that marks it active. */ + href: string; +}; + +/** Left-aligned, ordered as a reader meets them. Six is the practical ceiling + * before the bar wraps around 1200px. */ +export const primarySections: NavSection[] = [ + { name: 'Running Recipes', href: '/running-recipes' }, + { name: 'Authoring Recipes', href: '/authoring-recipes' }, + { name: 'Recipe catalog', href: '/recipes' }, + { name: 'Reference', href: '/reference' }, + { name: 'Concepts & explanations', href: '/concepts-explanations' }, + { name: 'Lists', href: '/lists' }, +]; + +/** + * Route -> section href, derived from the sidebar tree. + * + * A section cannot be identified by URL prefix alone. Two cases break it: + * Concepts & explanations is served at /concepts-explanations but its documents + * live under /concepts-and-explanations, and Lists owns seven documents under + * /reference — the same prefix Reference uses for its own thirteen. Walking the + * tree is the only thing that knows which section a document belongs to. + * + * Safe to build from doc ids because routeBasePath is "/" and no document in a + * section overrides its slug, so the route is always "/" + id. + */ +function buildRouteOwners(): Map { + const owners = new Map(); + + const collect = (items: unknown[], sectionHref: string): void => { + items.forEach((item) => { + if (typeof item === 'string') { + owners.set(`/${item}`.toLowerCase(), sectionHref); + return; + } + if (typeof item !== 'object' || item === null) { + return; + } + const node = item as { type?: string; id?: string; items?: unknown[] }; + if (node.type === 'doc' && node.id) { + owners.set(`/${node.id}`.toLowerCase(), sectionHref); + } + if (Array.isArray(node.items)) { + collect(node.items, sectionHref); + } + }); + }; + + ((sidebars.docs ?? []) as unknown[]).forEach((item) => { + if (typeof item !== 'object' || item === null) { + return; + } + const node = item as { type?: string; link?: { slug?: string }; items?: unknown[] }; + const slug = node.link?.slug; + if (node.type !== 'category' || !slug || !Array.isArray(node.items)) { + return; + } + collect(node.items, slug.toLowerCase()); + }); + + return owners; +} + +const routeOwners = buildRouteOwners(); + +/** The section href owning this exact route, if the tree claims one. */ +export function ownerOf(pathname: string): string | undefined { + return routeOwners.get(pathname.toLowerCase().replace(/\/$/, '')); +} + +/** How many releases the Releases dropdown lists before "More releases". */ +const RECENT_RELEASE_COUNT = 3; + +/** + * The most recent releases, read from the Changelog category in sidebars.ts. + * + * Derived rather than listed: an automated job adds a changelog entry on every + * release, so a hand-written list here would be stale within days. sidebars.ts + * orders that category newest-first and imports only a type, which is why it is + * safe to read from client code. + */ +function recentReleases(): NavSection[] { + const top = (sidebars.docs ?? []) as PropSidebarItem[]; + const changelog = top.find( + (item): item is PropSidebarItem & { items: unknown[] } => + typeof item === 'object' && 'label' in item && item.label === 'Changelog', + ); + + return (changelog?.items ?? []) + .filter((id): id is string => typeof id === 'string') + .slice(0, RECENT_RELEASE_COUNT) + .map((id) => ({ + // 'changelog/8-91-0-Release' -> '8.91.0' + name: `${id.replace(/^changelog\//, '').replace(/-Release$/i, '').replace(/-/g, '.')} release`, + href: `/${id}`, + })); +} + +/** Right-aligned dropdown, mirroring Releases on docs.moderne.io. */ +export const releaseSections: NavSection[] = [ + ...recentReleases(), + { name: 'All releases', href: '/changelog' }, +]; + +/** Sections the secondary nav can mark active. The release entries are + * individual documents rather than sections, so they are excluded — only + * /changelog itself scopes a sidebar. */ +export const selectableSections: NavSection[] = [ + ...primarySections, + { name: 'Changelog', href: '/changelog' }, + { name: 'Licensing', href: '/licensing/openrewrite-licensing' }, +]; diff --git a/src/css/custom.css b/src/css/custom.css index ef58cc272b..a963466191 100644 --- a/src/css/custom.css +++ b/src/css/custom.css @@ -1,58 +1,114 @@ /** - * Any CSS included here will be global. The classic template - * bundles Infima by default. Infima is a CSS framework designed to - * work well for content-centric websites. + * Design-system Infima bridge. + * + * Maps Docusaurus/Infima variables (--ifm-*) onto design-system tokens + * (--mod-*, from @moderneinc/design-system-tokens). Most --mod-* tokens are + * mode-aware, so a single :root declaration covers light and dark; the + * html[data-theme='dark'] block below only restates the vars Infima also + * defines in its own dark block, which out-specifies :root. + * + * Guidelines: + * - Reference var(--mod-*) without fallbacks so a missing token fails visibly + * rather than silently rendering transparent. + * - Add brand values here, never as one-off literals in components. */ -@font-face { - font-family: 'Beausite'; - src: url('https://cdn.prod.website-files.com/664e62fe66f5b31edc6c3aeb/664e7a833d9ad9fa2febd048_BeausiteSlickWeb-Light.woff2') format('woff2'), url('https://cdn.prod.website-files.com/664e62fe66f5b31edc6c3aeb/664e7a830c62d5d188a8f45a_BeausiteSlickWeb-Light.woff') format('woff'); - font-weight: 300; - font-style: normal; - font-display: swap; -} - /* You can override the default Infima variables here. */ :root { - --ifm-background-color: #F8F8FF; + --ifm-background-color: var(--mod-color-surface-page); + + /* Mint accent. No design-system token maps to it: the mark is OpenRewrite's + and the system has it only as the raw palette entry digitalGreen.300, which + has no semantic tier. Kept as a brand literal, matching how docs.moderne.io + preserves the same accent. */ --ifm-color-mint: #85FE99; - --ifm-color-primary: #4169e1; - --ifm-color-primary-dark: #2855dd; - --ifm-color-primary-darker: #224ed5; - --ifm-color-primary-darkest: #1c41af; - --ifm-color-primary-light: #5a7de5; - --ifm-color-primary-lighter: #6787e7; - --ifm-color-primary-lightest: #8ca4ed; - --ifm-color-border: #D9D9D9; + + /* Primary accent. The system has no blue ramp, so the derived Infima tiers + collapse onto the two action tokens that exist. */ + --ifm-color-primary: var(--mod-color-action-default); + --ifm-color-primary-dark: var(--mod-color-action-navy); + --ifm-color-primary-darker: var(--mod-color-action-navy); + --ifm-color-primary-darkest: var(--mod-color-action-navy); + --ifm-color-primary-light: var(--mod-color-action-default); + --ifm-color-primary-lighter: var(--mod-color-action-default); + --ifm-color-primary-lightest: var(--mod-color-action-default); + + --ifm-color-border: var(--mod-color-border-primary); --ifm-code-font-size: 95%; - --ifm-color-base: rgba(4, 24, 52); + --ifm-color-base: var(--mod-color-text-primary); --ifm-color-content: var(--ifm-color-base); --ifm-color-announcement-bar-bg: var(--ifm-color-mint); - --ifm-color-announcement-bar-text: #041834; - --ifm-navbar-background-color: var(--ifm-background-color); - --ifm-navbar-padding-vertical: 36px; + + /* Deliberately not mode-aware: the bar ground is always mint, so text that + followed the colour mode would go near-white on green in dark. --mod-ink-800 + is defined only in the package's :root block, never in its dark block, which + is exactly the property needed here. */ + --ifm-color-announcement-bar-text: var(--mod-ink-800); + + /* Transparent: the fixed wrapper in Navbar/Layout paints the header ground, + so the navbar painting its own would double up. */ + --ifm-navbar-background-color: transparent; + --ifm-navbar-padding-vertical: 0; --ifm-navbar-item-padding-horizontal: 1.4rem; - --ifm-navbar-height: 116px; - --ifm-font-family-base: "Inter", sans-serif; - --ifm-heading-font-weight: 400; + + /* The header is two rows: this navbar and the section nav below it. Infima + only knows about the first; --ifm-secondary-nav-height is declared with the + header offsets near the end of this file, where it is used. + + Taller than the 37px docs.moderne.io uses, because this site's chrome is + bigger: a 33px logo and a 38px search field, against their smaller lockup. + At 37px the search overflowed the bar and the logo sat 2.5px under the + announcement bar. The row centres its contents, so this height sets the + clearance on both sides: ~20px around the logo, ~17px around the search. */ + --ifm-navbar-height: var(--mod-spacing_9); + + /* System fallbacks in case the webfont fails to load. */ + --ifm-font-family-base: var(--mod-font-family-body), system-ui, sans-serif; + --ifm-font-family-monospace: var(--mod-font-family-code), ui-monospace, monospace; + + --ifm-heading-font-weight: var(--mod-font-weight-regular); --ifm-heading-line-height: 1.25; - --ifm-heading-color: #041834; - --ifm-font-weight-bold: 600; + --ifm-heading-color: var(--mod-color-text-primary); + --ifm-font-weight-bold: var(--mod-font-weight-semi-bold); --ifm-footer-padding-vertical: 2.375rem; --ifm-footer-background-color: transparent; - --ifm-breadcrumb-separator: url("data:image/svg+xml, %3Csvg%20width=%228%22%20height=%2213%22%20viewBox=%220%200%208%2013%22%20fill=%22none%22%20xmlns=%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%3Cpath%20d=%22M1%2012L7%206.5L1%201%22%20stroke=%22%23415067%22%20stroke-width=%221.25%22%2F%3E%0A%3C%2Fsvg%3E%0A"); - --ifm-breadcrumb-item-background-active: #FFF; + + /* Chevron drawn with mask-image rather than a data-URI background, so the + colour comes from a token instead of a hex baked inside the URL. */ + --ifm-breadcrumb-separator: none; + /* No ground on the active crumb: breadcrumbs are a position indicator, not a + control, so they read as text. */ + --ifm-breadcrumb-item-background-active: transparent; --ifm-breadcrumb-spacing: 0.875rem; + --ifm-menu-color-background-hover: transparent; --ifm-menu-color-background-active: transparent; - --ifm-link-color: #283AF7; - --ifm-link-hover-color: var(--ifm-color-content); - --ifm-menu-color: rgba(4, 24, 52); + --ifm-link-color: var(--mod-color-action-default); + --ifm-link-hover-color: var(--mod-color-action-navy); + --ifm-menu-color: var(--mod-color-text-tertiary); --ifm-menu-color-active: var(--ifm-link-color); --ifm-navbar-link-color: var(--ifm-heading-color); --ifm-navbar-shadow: none; --ifm-toc-border-color: transparent; - --ifm-section-divider-color: rgba(4, 24, 52, 0.3); + --ifm-section-divider-color: var(--mod-color-border-primary); + + /* Colour marking the current section, in the section nav and above the scoped + sidebar. Blue in light; mint is the brand accent but measures 1.11:1 on the + light ground, against 14.54:1 on the dark one, so it is used only there. */ + --docs-color-active: var(--mod-color-action-default); + + /* Card/dropdown shadow. Geometry is local because the package's + --mod-shadow-dropdown is a different shape (0 8px 15px); the colour is the + package's mode-aware shadow token, so this needs no light/dark pair. */ + --docs-shadow-dropdown: 0 var(--mod-spacing_1_1_2) var(--mod-spacing_3_1_2) + var(--mod-color-surface-shadow-neutral); + + /* Icon geometry, shared by the breadcrumb separator and the sidebar caret. + Used only as masks, so the stroke colour in each URL is irrelevant — a mask + reads alpha, and the colour comes from whatever applies it. That is the + point: a hex inside a data-URI cannot be themed. */ + --docs-chevron: url("data:image/svg+xml,%3Csvg%20xmlns='http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20width='8'%20height='13'%20viewBox='0%200%208%2013'%3E%3Cpath%20d='M1%2012L7%206.5L1%201'%20fill='none'%20stroke='%23000'%20stroke-width='1.25'%2F%3E%3C%2Fsvg%3E"); + --docs-chevron-down: url("data:image/svg+xml,%3Csvg%20xmlns='http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20width='12'%20height='8'%20viewBox='0%200%2012%208'%3E%3Cpath%20d='M0.5%200.5L6%206.5L11.5%200.5'%20fill='none'%20stroke='%23000'%20stroke-width='1.25'%2F%3E%3C%2Fsvg%3E"); } /* Use selector for specificity */ @@ -60,29 +116,74 @@ html.docs-wrapper { --doc-sidebar-width: 370px; } -/* Use selector for specificity */ -html[data-theme=light] { - --docsearch-searchbox-background: #fff; - --docsearch-muted-color: rgba(4, 24, 52, 0.75); +/* --------------------------------------------------------------------------- + Algolia DocSearch. + + Bound to design-system tokens rather than left on the package defaults, + which are a fixed blue-violet set (rgba(0,0,0,.65) ground, #36395a keys) + that reads as a foreign component beside the warm/ink palette. Matches the + treatment on docs.moderne.io. + + One block, no light/dark pair: every token below is mode-aware, so they + invert on their own. + --------------------------------------------------------------------------- */ +:root { + /* Trigger button in the navbar */ + --docsearch-searchbox-background: var(--mod-color-surface-search-input); + --docsearch-subtle-color: var(--mod-color-border-primary); + --docsearch-muted-color: var(--mod-color-text-tertiary); + --docsearch-key-background: var(--mod-color-surface-sunken); + --docsearch-key-color: var(--mod-color-text-secondary); + + /* Modal. Themed alongside the button because they share these variables — + leaving it on the defaults would open a differently-coloured product. */ + --docsearch-primary-color: var(--mod-color-action-default); + --docsearch-highlight-color: var(--mod-color-action-default); + --docsearch-text-color: var(--mod-color-text-primary); + --docsearch-modal-background: var(--mod-color-surface-raised); + --docsearch-container-background: var(--mod-color-surface-scrim); + --docsearch-footer-background: var(--mod-color-surface-raised); + --docsearch-hit-background: var(--mod-color-surface-page); + --docsearch-hit-color: var(--mod-color-text-primary); + --docsearch-icon-color: var(--mod-color-text-tertiary); +} + +/* The package hard-codes 4px; the reference rounds to 8px and the site's other + inputs use the same step. */ +.DocSearch-Button { + border-radius: var(--mod-border-radius-s); +} + +.DocSearch-Button:hover { + border-color: var(--mod-color-action-border); + box-shadow: none; +} + +/* Keys sit on the button's own ground, so they need their own edge to read as + keys rather than as a darker patch of it. */ +.DocSearch-Button-Key { + border: 1px solid var(--mod-color-border-primary); } /* For readability concerns, you should choose a lighter palette in dark mode. */ +/* Restates only the bridge vars Infima also defines in its own + [data-theme='dark'] block: that selector out-specifies :root, so the + mode-aware tokens have to be repeated here at matching specificity to win. + Vars Infima leaves alone in dark — links, primary, headings — resolve + correctly from :root and are deliberately omitted. */ html[data-theme='dark'] { - --ifm-background-color: #041834; - --ifm-color-primary: #7e9bd2; - --ifm-color-primary-dark: #6588c9; - --ifm-color-primary-darker: #597ec5; - --ifm-color-primary-darkest: #3d64ae; - --ifm-color-primary-light: #97aedb; - --ifm-color-primary-lighter: #a3b8df; - --ifm-color-primary-lightest: #c9d5ec; - --ifm-color-base: #fff; - --ifm-heading-color: #fff; - --ifm-menu-color: #fff; - --ifm-link-color: #85fe99; + --ifm-background-color: var(--mod-color-surface-page); + --ifm-color-base: var(--mod-color-text-primary); + --ifm-color-content: var(--mod-color-text-primary); + + /* The one bridge var that wants a different token per mode: muted tertiary + in light, primary in dark, where tertiary loses too much contrast. */ + --ifm-menu-color: var(--mod-color-text-primary); + --ifm-toc-border-color: transparent; - --ifm-section-divider-color: #d9d9d9; - --ifm-card-background-color: #fff; + --ifm-section-divider-color: var(--mod-color-border-primary); + --ifm-card-background-color: var(--mod-color-surface-raised); + --docs-color-active: var(--ifm-color-mint); --ifm-heading-line-height: 1.25; .theme-code-block { border: 1.5px solid var(--ifm-color-border); @@ -116,10 +217,6 @@ figure figcaption { overflow: clip; } -.navbar { - border-bottom: 1px solid var(--ifm-section-divider-color); -} - .navbar .navbar__link { font-weight: 400; } @@ -137,10 +234,15 @@ figure figcaption { color: var(--ifm-heading-color); } +/* Carets are masked, not painted: the SVG supplies the shape and the token + supplies the colour, so both themes are covered by one rule and neither + caret carries a hex the theme cannot reach. */ .theme-doc-sidebar-menu .menu__caret:before { - background-image: url("data:image/svg+xml, %3Csvg%20width=%228%22%20height=%2213%22%20viewBox=%220%200%208%2013%22%20fill=%22none%22%20xmlns=%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%3Cpath%20d=%22M1%2012L7%206.5L1%201%22%20stroke=%22%23415067%22%20stroke-width=%221.25%22%2F%3E%0A%3C%2Fsvg%3E%0A"); - background-repeat: no-repeat; - background-size: 100%; + background-image: none; + background-color: var(--mod-color-text-tertiary); + mask-image: var(--docs-chevron); + mask-repeat: no-repeat; + mask-size: 100%; width: 8px; height: 12px; transform: rotate(90deg); @@ -151,12 +253,9 @@ figure figcaption { transform: rotate(0); } -html[data-theme=dark] .theme-doc-sidebar-menu .menu__caret:before { - background-image: url("data:image/svg+xml, %3Csvg%20width=%228%22%20height=%2213%22%20viewBox=%220%200%208%2013%22%20fill=%22none%22%20xmlns=%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%3Cpath%20d=%22M1%2012L7%206.5L1%201%22%20stroke=%22%23D9D9D9%22%20stroke-width=%221.25%22%2F%3E%0A%3C%2Fsvg%3E%0A"); -} - .theme-doc-sidebar-menu .menu__link--active + .menu__caret:before { - background-image: url("data:image/svg+xml, %3Csvg%20width=%2212%22%20height=%228%22%20viewBox=%220%200%2012%208%22%20fill=%22none%22%20xmlns=%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%3Cpath%20d=%22M0.500001%200.499999L6%206.5L11.5%200.5%22%20stroke=%22%23283AF7%22%20stroke-width=%221.25%22%2F%3E%0A%3C%2Fsvg%3E%0A"); + background-color: var(--mod-color-action-default); + mask-image: var(--docs-chevron-down); width: 12px; height: 8px; transform: rotate(0); @@ -166,10 +265,6 @@ html[data-theme=dark] .theme-doc-sidebar-menu .menu__caret:before { transform: rotate(-90deg); } -html[data-theme=dark] .theme-doc-sidebar-menu .menu__link--active + .menu__caret:before { - background-image: url("data:image/svg+xml, %3Csvg%20width=%2212%22%20height=%228%22%20viewBox=%220%200%2012%208%22%20fill=%22none%22%20xmlns=%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%3Cpath%20d=%22M0.500001%200.499999L6%206.5L11.5%200.5%22%20stroke=%22%2385FE99%22%20stroke-width=%221.25%22%2F%3E%0A%3C%2Fsvg%3E%0A"); -} - .reactPlayer + p { margin-top: 1.25rem; } @@ -186,25 +281,32 @@ html[data-theme=dark] .theme-doc-sidebar-menu .menu__link--active + .menu__caret color: var(--ifm-heading-color); } -html[data-theme=dark] [class^=docMainContainer_] .breadcrumbs__link:hover { - --ifm-heading-color: #041834; -} - +/* Separator drawn as a mask so it takes a token colour. --ifm-breadcrumb-separator + is set to none in :root because Infima paints it as a background-image, which + would fight the mask. */ [class^=docMainContainer_] .breadcrumbs__item:not(:last-child):after { opacity: 1; margin-left: 0.1rem; + background-color: var(--mod-color-text-tertiary); + mask-image: var(--docs-chevron); + mask-repeat: no-repeat; + /* contain, not 100%: the separator box is square but the chevron is 8x13, and + stretching it to fill would flatten the glyph. */ + mask-size: contain; + mask-position: center; } +/* The active crumb is the current page: no border, and the muted colour that + marks it as context rather than a link. */ [class^=docMainContainer_] .breadcrumbs__item--active .breadcrumbs__link { - --ifm-heading-color: #041834; - - border: 1px solid var(--ifm-color-border); + border: none; + color: var(--mod-color-text-tertiary); } [class^=docMainContainer_] h1 { - --ifm-heading-font-weight: 300; - --ifm-heading-font-family: "Beausite", sans-serif; - --ifm-h1-font-size: 3.75rem; + --ifm-heading-font-weight: var(--mod-font-weight-semi-bold); + --ifm-heading-font-family: var(--mod-font-family-heading), sans-serif; + --ifm-h1-font-size: 2.25rem; --ifm-leading: 1; margin-bottom: 1.25rem; @@ -212,16 +314,6 @@ html[data-theme=dark] [class^=docMainContainer_] .breadcrumbs__link:hover { [class^=docMainContainer_] [class^=generatedIndexPage_] { --ifm-spacing-horizontal: 1.625rem; - position: relative; -} - -[class^=docMainContainer_] [class^=generatedIndexPage_]::before { - position: absolute; - background: url(/img/gems-bg.webp) no-repeat 0 0; - width: 1322px; - height: 1070px; - z-index: -1; - content: ''; } [class^=docMainContainer_] [class^=generatedIndexPage_] .row[class*=list_] > .col { @@ -236,13 +328,9 @@ html[data-theme=dark] [class^=docMainContainer_] .breadcrumbs__link:hover { margin-bottom: 2.5rem !important; } -html[data-theme='dark'] [class^=docMainContainer_] .card { - --ifm-heading-color: #041834; -} - .markdown { - --ifm-heading-font-family: "Beausite", sans-serif; - --ifm-heading-font-weight: 300; + --ifm-heading-font-family: var(--mod-font-family-heading), sans-serif; + --ifm-heading-font-weight: var(--mod-font-weight-medium); --ifm-link-decoration: underline; } @@ -255,106 +343,7 @@ html[data-theme='dark'] [class^=docMainContainer_] .card { font-size: 0.875rem; } -[class^=docMainContainer_] .pagination-nav { - --ifm-heading-color: #041834; - justify-content: center; -} - -[class^=docMainContainer_] .pagination-nav__link { - background: var(--ifm-color-white); - border-radius: 50px; - border: 1px solid var(--ifm-color-border); - backdrop-filter: blur(15px); - flex-basis: 50%; -} - -[class^=docMainContainer_] .pagination-nav__link--prev { - padding-inline: 27px 38px; -} -[class^=docMainContainer_] .pagination-nav__link .pagination-nav__sublabel { - transition: transform .25s ease-in-out; -} - -[class^=docMainContainer_] .pagination-nav__link--prev .pagination-nav__sublabel { - background-image: url("data:image/svg+xml, %3Csvg%20width=%2243%22%20height=%2216%22%20viewBox=%220%200%2043%2016%22%20fill=%22none%22%20xmlns=%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%3Cpath%20d=%22M0.292892%207.2929C-0.0976296%207.68342%20-0.0976295%208.31659%200.292893%208.70711L6.65685%2015.0711C7.04738%2015.4616%207.68054%2015.4616%208.07107%2015.0711C8.46159%2014.6805%208.46159%2014.0474%208.07107%2013.6569L2.41422%208L8.07107%202.34315C8.46159%201.95262%208.46159%201.31946%208.07107%200.928935C7.68054%200.538411%207.04738%200.538411%206.65685%200.928935L0.292892%207.2929ZM43%207L1%207L1%209L43%209L43%207Z%22%20fill=%22%23041834%22%2F%3E%0A%3C%2Fsvg%3E%0A"); - background-repeat: no-repeat; - width: 42px; - height: 16px; - flex: 0 0 42px; - -} - -[class^=docMainContainer_] .pagination-nav__link--prev:hover .pagination-nav__sublabel { - transform: translateX(-20%); -} - -[class^=docMainContainer_] .pagination-nav__link--next { - padding-inline: 38px 27px; - text-align: right; -} - -[class^=docMainContainer_] .pagination-nav__link--next .pagination-nav__sublabel { -background-image: url("data:image/svg+xml, %3Csvg%20width=%2243%22%20height=%2216%22%20viewBox=%220%200%2043%2016%22%20fill=%22none%22%20xmlns=%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%3Cpath%20d=%22M42.7071%208.70711C43.0976%208.31659%2043.0976%207.68343%2042.7071%207.2929L36.3431%200.928939C35.9526%200.538414%2035.3195%200.538414%2034.9289%200.928938C34.5384%201.31946%2034.5384%201.95263%2034.9289%202.34315L40.5858%208.00001L34.9289%2013.6569C34.5384%2014.0474%2034.5384%2014.6805%2034.9289%2015.0711C35.3195%2015.4616%2035.9526%2015.4616%2036.3431%2015.0711L42.7071%208.70711ZM-1.74846e-07%209L42%209.00001L42%207.00001L1.74846e-07%207L-1.74846e-07%209Z%22%20fill=%22%23041834%22%2F%3E%0A%3C%2Fsvg%3E%0A"); -background-repeat: no-repeat; - background-position: 100% 0; - width: 100%; - height: 16px; - flex: 0 0 42px; -} - -[class^=docMainContainer_] .pagination-nav__link--next:hover .pagination-nav__sublabel { - transform: translateX(20%); -} - -[class^=docMainContainer_] .pagination-nav__sublabel { - font-size: 0; - color: transparent; -} -[class^=docMainContainer_] .pagination-nav__label { - font-weight: 500; - color: var(--ifm-heading-color); - line-height: 1.375; - flex-grow: 1; -} - -[class^=docMainContainer_] .pagination-nav__label::before, -[class^=docMainContainer_] .pagination-nav__label::after { - content: none; -} - -.footer { - display: flex; - justify-content: flex-end; -} - -.footer > .container { - width: auto; - margin: 0; -} - -.footer .footer__bottom { - display: flex; - flex-flow: column; - align-items: center; - gap: 1.25rem; -} - -.footer .footer__bottom::before { - width: 47px; - height: 48px; - background-image: url("data:image/svg+xml, %3Csvg%20width=%2247%22%20height=%2248%22%20viewBox=%220%200%2047%2048%22%20fill=%22none%22%20xmlns=%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%3Cpath%20d=%22M46.2205%200.092002C45.777%20-0.0920021%2045.2677%200.00862519%2044.9272%200.350758L24.4951%2020.8816C23.1532%209.14558%2013.2189%200%201.18742%200C0.872678%200%200.572248%200.126503%200.349071%200.350758C0.125895%200.575013%200%200.876894%200%201.19315V23.5899V45.9895C0%2046.0097%200.00858372%2046.0269%200.00858372%2046.047C0.00858372%2046.1045%200.0228899%2046.1592%200.0343349%2046.2167C0.0457799%2046.277%200.0572248%2046.3374%200.0772535%2046.3949C0.082976%2046.4122%200.082976%2046.4294%200.0886985%2046.4467C0.103005%2046.4812%200.128756%2046.507%200.145923%2046.5415C0.174536%2046.5962%200.203148%2046.6508%200.240344%2046.6997C0.274679%2046.7457%200.311875%2046.7859%200.351933%2046.8262C0.39199%2046.8664%200.432047%2046.9067%200.480688%2046.9412C0.52933%2046.9786%200.580832%2047.0073%200.635195%2047.0361C0.66953%2047.0533%200.695282%2047.0792%200.729616%2047.0936C0.743923%2047.0993%200.758229%2047.0964%200.772535%2047.1022C0.901291%2047.1511%201.03863%2047.1856%201.18455%2047.1856H45.7627C46.4179%2047.1856%2046.9501%2046.6508%2046.9501%2045.9924V1.19315C46.9501%200.710141%2046.6611%200.276006%2046.2176%200.092002H46.2205ZM44.5781%204.07109V22.3968H26.3406L44.5781%204.07109ZM44.5781%2033.5951H24.661V24.7802H44.5781V33.5951ZM2.37483%2043.1087V24.7831H20.6124L11.4936%2033.9459L2.37483%2043.1087ZM2.37483%202.41793C13.0673%203.01594%2021.6625%2011.6526%2022.2576%2022.3968H2.37483V2.41793ZM13.1703%2035.6335L22.2891%2026.4707V44.7964H4.05152L13.1703%2035.6335ZM24.6639%2044.7964V35.9814H44.581V44.7964H24.6639Z%22%20fill=%22%23041834%22%2F%3E%0A%3C%2Fsvg%3E%0A"); - background-repeat: no-repeat; - content: ''; -} - -html[data-theme='dark'] .footer .footer__bottom::before { - background-image: url("data:image/svg+xml, %3Csvg%20width=%2247%22%20height=%2248%22%20viewBox=%220%200%2047%2048%22%20fill=%22none%22%20xmlns=%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%3Cpath%20d=%22M46.2205%200.092002C45.777%20-0.0920021%2045.2677%200.00862519%2044.9272%200.350758L24.4951%2020.8816C23.1532%209.14558%2013.2189%200%201.18742%200C0.872678%200%200.572248%200.126503%200.349071%200.350758C0.125895%200.575013%200%200.876894%200%201.19315V23.5899V45.9895C0%2046.0097%200.00858372%2046.0269%200.00858372%2046.047C0.00858372%2046.1045%200.0228899%2046.1592%200.0343349%2046.2167C0.0457799%2046.277%200.0572248%2046.3374%200.0772535%2046.3949C0.082976%2046.4122%200.082976%2046.4294%200.0886985%2046.4467C0.103005%2046.4812%200.128756%2046.507%200.145923%2046.5415C0.174536%2046.5962%200.203148%2046.6508%200.240344%2046.6997C0.274679%2046.7457%200.311875%2046.7859%200.351933%2046.8262C0.39199%2046.8664%200.432047%2046.9067%200.480688%2046.9412C0.52933%2046.9786%200.580832%2047.0073%200.635195%2047.0361C0.66953%2047.0533%200.695282%2047.0792%200.729616%2047.0936C0.743923%2047.0993%200.758229%2047.0964%200.772535%2047.1022C0.901291%2047.1511%201.03863%2047.1856%201.18455%2047.1856H45.7627C46.4179%2047.1856%2046.9501%2046.6508%2046.9501%2045.9924V1.19315C46.9501%200.710141%2046.6611%200.276006%2046.2176%200.092002H46.2205ZM44.5781%204.07109V22.3968H26.3406L44.5781%204.07109ZM44.5781%2033.5951H24.661V24.7802H44.5781V33.5951ZM2.37483%2043.1087V24.7831H20.6124L11.4936%2033.9459L2.37483%2043.1087ZM2.37483%202.41793C13.0673%203.01594%2021.6625%2011.6526%2022.2576%2022.3968H2.37483V2.41793ZM13.1703%2035.6335L22.2891%2026.4707V44.7964H4.05152L13.1703%2035.6335ZM24.6639%2044.7964V35.9814H44.581V44.7964H24.6639Z%22%20fill=%22white%22%2F%3E%0A%3C%2Fsvg%3E%0A"); - -} -.footer .footer__copyright { - font-size: 0.875rem; -} @media(min-width: 997px) { .navbar { @@ -374,25 +363,6 @@ html[data-theme='dark'] .footer .footer__bottom::before { --ifm-toc-border-color: var(--ifm-section-divider-color); } - [class^=docMainContainer_] .pagination-nav { - gap: calc(2 * var(--ifm-spacing-horizontal)); - margin-right: var(--ifm-spacing-horizontal); - display: flex; - } - - [class^=docMainContainer_] .pagination-nav__link { - width: 22.625rem; - display: flex; - flex-flow: row; - align-items: center; - gap: 1rem; - height: auto; - } - - [class^=docMainContainer_] .pagination-nav__link--next .pagination-nav__sublabel { - order: 99; - width: 42px; - } } /* Custom styles for desktop (screens wider than 768px) */ @@ -437,10 +407,225 @@ html[data-theme='dark'] { padding: 0 calc(var(--ifm-pre-padding) - 3px) 0 var(--ifm-pre-padding); } -/* The announcement bar is pinned, so the sticky navbar has to clear it by exactly the - bar's height. data-bar-visible is maintained in src/theme/AnnouncementBar/index.tsx. */ -@media (min-width: 997px) { - html[data-bar-visible='true'] .navbar--fixed-top { - top: var(--docusaurus-announcement-bar-height); - } +/* --------------------------------------------------------------------------- + Fixed header offsets. + + Navbar/Layout puts the announcement bar, the navbar and the section nav in + one fixed block, so nothing below it is pushed down automatically. These + rules reserve exactly that block's height. Previously a single rule nudged + the sticky navbar clear of the bar; a fixed two-row header needs the whole + stack accounted for instead. + --------------------------------------------------------------------------- */ + +/* Height of the section nav row. The row is desktop-only (see + SecondaryNav.module.css); below the breakpoint it is display:none, so this + value simply does not apply. */ +:root { + --ifm-secondary-nav-height: var(--mod-spacing_6); + + /* First-paint default only: 40px bar + 56px navbar + 48px section nav, i.e. + a desktop viewport with a one-line announcement bar. Navbar/Layout measures + the real header on mount and overwrites this, which is what makes mobile — + where the bar wraps to two or three lines — correct. Being wrong for one + frame beats being wrong permanently. + + A literal rather than a calc over --docusaurus-announcement-bar-height: + that property is `auto` below the breakpoint, and `auto` inside calc() + invalidates the whole expression, taking every offset below with it. */ + --docs-header-height: 144px; +} + +/* Anchor targets land below the header rather than behind it. */ +html { + scroll-padding-top: calc(var(--docs-header-height) + var(--mod-spacing_2)); +} + +.main-wrapper { + margin-top: var(--docs-header-height); + min-height: calc(100vh - var(--docs-header-height)); +} + +/* The table of contents sticks, but theme-classic derives its offset from + --ifm-navbar-height alone, which here is only the first of the header's three + rows. That put its top 72px behind the fixed header — entries slid under the + header on scroll — and made its max-height overshoot the viewport by the same + amount. Both are re-derived from the measured header instead. */ +.theme-doc-toc-desktop { + top: calc(var(--docs-header-height) + var(--mod-spacing_2)); + max-height: calc(100vh - var(--docs-header-height) - var(--mod-spacing_4)); +} + +.theme-doc-sidebar-container { + position: sticky; + top: var(--docs-header-height); + height: calc(100vh - var(--docs-header-height)); + align-self: flex-start; +} + +/* theme-classic pulls the sidebar container up by the navbar height and pads + the sidebar back down by the same amount, so the menu clears a navbar the + container sits beneath. The rule above positions the container below the + whole fixed header instead, which makes both halves of that trick dead + weight — and leaves the padding showing as a navbar-height gap above the + first item. */ +.theme-doc-sidebar-container { + margin-top: 0; +} + +/* Matches sidebar_ only: docSidebarContainer_ and sidebarViewport_ both + differ in case or the following character. */ +.theme-doc-sidebar-container [class*='sidebar_'] { + padding-top: 0; +} + +/* Section name above the scoped sidebar (the html item added in + DocSidebarItems/filterUtils.ts). */ +.theme-doc-sidebar-menu > li > strong { + display: block; + padding: var(--mod-spacing_1) 0 var(--mod-spacing_1_2); + font-size: var(--mod-font-size-default); + font-weight: var(--mod-font-weight-semi-bold); + color: var(--docs-color-active); +} + +/* Antialiased rendering. Without this, WebKit renders light-on-dark and + dark-on-light text at different apparent weights, so Geist headings look + inconsistent between modes. Grayscale AA normalises it. */ +body { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* Admonitions: rounded, hairline border all round, icon inline with the text. + Replaces Infima's thick coloured left stripe, matching docs.moderne.io. + With a title (`:::info[My title]`) the container stacks so the heading row + sits above the content. */ +.theme-admonition { + border: 1px solid; + border-radius: var(--mod-border-radius-card); + /* Hairline only, no elevation: Infima's .alert shadow reads wrong against + the warm canvas. */ + box-shadow: none; + padding: var(--mod-spacing_2); + /* Always stacked, whether or not a custom title was given. Laid out as a row, + an untitled admonition ran its body beside the type label, so every line + after the first was indented to a depth set by the width of the word + "info". The heading is a heading; the body starts on the next line. */ + display: flex; + flex-direction: column; + gap: var(--mod-spacing_1_2); + margin-top: var(--mod-spacing_3); + margin-bottom: var(--mod-spacing_3); +} + +/* Icon and label on one line, at the size of the heading they are. */ +.theme-admonition [class*='admonitionHeading_'] { + margin: 0; + display: flex; + align-items: center; + gap: var(--mod-spacing_1); + font-size: var(--mod-font-size-h5); + font-weight: var(--mod-font-weight-semi-bold); + line-height: 1.3; + text-transform: none; +} + +.theme-admonition [class*='admonitionHeading_'] [class*='admonitionIcon_'] { + margin-right: 0; + display: inline-flex; + align-items: center; + flex-shrink: 0; +} + +/* These icons are drawn as filled paths (fill-rule="evenodd"); forcing + fill:none + stroke turned each one into a hollow outline of its own edge. */ +.theme-admonition [class*='admonitionHeading_'] [class*='admonitionIcon_'] svg { + fill: currentColor; + stroke: none; + width: 1em; + height: 1em; +} + +/* A custom title lands inside the heading, so it inherits the size and weight + above and needs nothing of its own. */ + +.theme-admonition [class*='admonitionContent_'] { + /* Shrink below intrinsic width so nested blocks (code blocks especially) + scroll inside the box instead of overflowing it. */ + min-width: 0; +} + +.theme-admonition [class*='admonitionContent_'] > p:first-child { + margin-top: 0; +} + +.theme-admonition [class*='admonitionContent_'] > p:last-child { + margin-bottom: 0; +} + +/* --------------------------------------------------------------------------- + Introduction page (/). + + The entry point reads as a landing page. Its sidebar is removed natively by + `displayed_sidebar: null` in the doc's front matter, which also releases the + width Docusaurus reserves for the column — nothing to do here for that. + + data-route comes from src/theme/Root.tsx. Docusaurus's own docs-doc-id-* + classes exist only in a production build, so they cannot style a page + consistently between `yarn start` and `yarn build`. + --------------------------------------------------------------------------- */ + +/* Green accent in place of the site's blue. No single green clears AA against + both grounds — every one in the palette is light-usable or dark-usable — so + this is a mode-aware pair, the same shape as --docs-color-active. mint-700 is + 5.12:1 on the light ground; the brand mint is 14.54:1 on the dark ground and + 1.11:1 on the light, which is why it appears only in the dark block. */ +html[data-route='/'] { + --docs-intro-accent: var(--mod-mint-700); + --docs-intro-accent-hover: var(--mod-mint-800); + --ifm-link-color: var(--docs-intro-accent); + --ifm-link-hover-color: var(--docs-intro-accent-hover); + --ifm-color-primary: var(--docs-intro-accent); +} + +html[data-route='/'][data-theme='dark'] { + --docs-intro-accent: var(--ifm-color-mint); + --docs-intro-accent-hover: var(--mod-mint-400); +} + +/* Per-type admonition tints. The status tokens are mode-aware, so one rule per + type covers both light and dark. The accent drives border, icon (the icon + inherits currentColor) and heading; body copy stays neutral. Caution shares + the warning tint, Summary shares info — matching docs.moderne.io. */ +.theme-admonition-note { + background-color: var(--mod-color-status-neutral-bg); + border-color: var(--mod-color-text-tertiary); + color: var(--mod-color-text-tertiary); +} +.theme-admonition-tip { + background-color: var(--mod-color-feedback-success-bg); + border-color: var(--mod-color-feedback-success-fg); + color: var(--mod-color-feedback-success-fg); +} +.theme-admonition-info, +.theme-admonition-summary { + background-color: var(--mod-color-feedback-info-bg); + border-color: var(--mod-toast-info-fg); + color: var(--mod-toast-info-fg); +} +.theme-admonition-warning, +.theme-admonition-caution { + background-color: var(--mod-color-feedback-warning-bg); + border-color: var(--mod-color-feedback-warning-fg); + color: var(--mod-color-feedback-warning-fg); +} +.theme-admonition-danger { + background-color: var(--mod-color-feedback-error-bg); + border-color: var(--mod-color-feedback-error-fg); + color: var(--mod-color-feedback-error-fg); +} + +/* Body copy stays neutral; only the heading and icon carry the accent. */ +.theme-admonition [class*='admonitionContent_'] { + color: var(--mod-color-text-primary); } diff --git a/src/theme/Admonition/index.tsx b/src/theme/Admonition/index.tsx new file mode 100644 index 0000000000..609e57fc5f --- /dev/null +++ b/src/theme/Admonition/index.tsx @@ -0,0 +1,54 @@ +import { type FunctionComponent } from 'react'; +import Admonition from '@theme-original/Admonition'; +import type AdmonitionType from '@theme/Admonition'; +import type { WrapperProps } from '@docusaurus/types'; +import { processAdmonitionProps } from '@docusaurus/theme-common'; + +type Props = WrapperProps; + +/** + * Capitalises the generic admonition label — ":::info" renders "Info", not + * "info" — while leaving an author's own title exactly as written. + * + * Done here rather than the two more obvious ways, both of which fail: + * + * - A translation override in i18n/en/code.json is the documented route, but + * creating an i18n directory turns on Docusaurus's translation machinery for + * the whole site, and the generated recipe-catalog sidebar has duplicate + * category labels (Camel x3, Upgrade x2) that then fail the build. + * - `text-transform: capitalize` cannot be scoped to the generic label. In this + * version a custom title renders as a bare text node in the heading, exactly + * like the default one, so CSS cannot tell them apart and would re-case + * ":::info[Which filters require symbol data]" word by word. + * + * Only the props decide it: a title supplied by the author, whether as + * ":::info[Title]" or the legacy mdxAdmonitionTitle child, is passed through + * untouched. The label is injected only when there is none. + */ +const LABELS: Record = { + note: 'Note', + tip: 'Tip', + info: 'Info', + warning: 'Warning', + danger: 'Danger', + caution: 'Caution', + // Legacy aliases, which the theme titles with hardcoded lowercase strings. + secondary: 'Secondary', + important: 'Important', + success: 'Success', +}; + +const AdmonitionWrapper: FunctionComponent = (props) => { + // Same extraction the theme runs, so a legacy title in children is seen too. + const { title, type } = processAdmonitionProps(props); + if (title) { + return ; + } + + const label = LABELS[type as string]; + return label ? : ; +}; + +AdmonitionWrapper.displayName = 'AdmonitionWrapper'; + +export default AdmonitionWrapper; diff --git a/src/theme/AnnouncementBar/index.tsx b/src/theme/AnnouncementBar/index.tsx index badb58985e..b53ffc97a7 100644 --- a/src/theme/AnnouncementBar/index.tsx +++ b/src/theme/AnnouncementBar/index.tsx @@ -12,10 +12,22 @@ import styles from './styles.module.css'; // the bar to everyone. const NOTICE_BAR_STORAGE_KEY = 'code-genome-project-announcement-dismissed'; const NOTICE_BAR_DISMISSED_ATTRIBUTE = 'data-notice-bar-dismissed'; -// Read by custom.css to offset the sticky navbar while a bar is showing. -const BAR_VISIBLE_ATTRIBUTE = 'data-bar-visible'; +/** + * Renders nothing in Docusaurus's default slot. + * + * The header — bar, navbar, section nav — is one fixed block built in + * Navbar/Layout, and the bar is rendered there by AnnouncementBarInline. If + * this also rendered, two bars would mount: they would overlap at the top of + * the page while the default one still occupied its height in the document + * flow, leaving a gap the height of the bar below the header. + */ export default function AnnouncementBar(): JSX.Element | null { + return null; +} + +/** The bar itself, mounted inside the fixed header stack by Navbar/Layout. */ +export function AnnouncementBarInline(): JSX.Element | null { const {pathname} = useLocation(); const {announcementBar} = useThemeConfig(); const {isActive, close} = useAnnouncementBar(); @@ -24,7 +36,6 @@ export default function AnnouncementBar(): JSX.Element | null { // The recipe-catalog bar only belongs on /recipes; elsewhere we show the Code Genome // Project notice. Recipe pages already carry that guidance inline (see RunRecipe). const onRecipes = pathname.startsWith('/recipes'); - const barVisible = onRecipes ? isActive : !noticeClosed; // The server can't read localStorage, so a previous dismissal is picked up after // mount. Until then the head script's attribute keeps the first paint correct. @@ -38,17 +49,6 @@ export default function AnnouncementBar(): JSX.Element | null { } }, []); - // Client-side navigation doesn't re-run the head script, and the two bars live on - // different routes, so the navbar offset has to be re-evaluated on every route change. - useEffect(() => { - const html = document.documentElement; - if (barVisible) { - html.setAttribute(BAR_VISIBLE_ATTRIBUTE, 'true'); - } else { - html.removeAttribute(BAR_VISIBLE_ATTRIBUTE); - } - }, [barVisible]); - const closeNotice = useCallback(() => { try { localStorage.setItem(NOTICE_BAR_STORAGE_KEY, 'true'); diff --git a/src/theme/AnnouncementBar/styles.module.css b/src/theme/AnnouncementBar/styles.module.css index 242bee20e4..25f7a32507 100644 --- a/src/theme/AnnouncementBar/styles.module.css +++ b/src/theme/AnnouncementBar/styles.module.css @@ -83,20 +83,12 @@ html[data-notice-bar-dismissed='true'] .noticeBar { @media (min-width: 997px) { :root { - /* Must equal the navbar offset in custom.css: smaller and the navbar overlaps the - bar, larger and a gap opens. Holds one line of copy; two would need ~48px. */ + /* The bar's own height on desktop, where one line of copy always fits. + Below the breakpoint it stays `auto` and wraps. Nothing derives a layout + offset from this any more — Navbar/Layout measures the rendered header. */ --docusaurus-announcement-bar-height: 40px; } - /* Pin the bar instead of letting it scroll away. Desktop only: below the breakpoint - the height is content-driven, so there's no fixed value for the navbar to clear. */ - .announcementBar, - .noticeBar { - position: sticky; - top: 0; - z-index: calc(var(--ifm-z-index-fixed) + 1); - } - .announcementBarPlaceholder, .announcementBarClose { flex-basis: 50px; diff --git a/src/theme/DocCard/styles.module.css b/src/theme/DocCard/styles.module.css index e9d429925e..e2bb7464b8 100644 --- a/src/theme/DocCard/styles.module.css +++ b/src/theme/DocCard/styles.module.css @@ -1,18 +1,31 @@ .cardContainer { - --ifm-link-color: var(--ifm-color-emphasis-800); - --ifm-link-hover-color: var(--ifm-color-emphasis-700); + /* The card title is a link but should read as heading text, not as an action, + so it takes the text tokens rather than the link colour. */ + --ifm-link-color: var(--mod-color-text-primary); + --ifm-link-hover-color: var(--mod-color-text-secondary); --ifm-link-hover-decoration: none; box-shadow: none; - border: 1px solid var(--ifm-color-divider); + border: 1px solid var(--mod-color-border-primary); transition: all var(--ifm-transition-fast) ease; transition-property: border, box-shadow; width: 100%; } +/* Mint hover is the OpenRewrite accent and has no design-system token; it comes + through the --ifm-color-mint brand literal declared in custom.css. + + Text is pinned dark for the same reason as the announcement bar: the mint + ground is identical in both themes, so text that followed the colour mode + would go near-white on green — 1.11:1. Pinned it is 11.96:1. */ .cardContainer:hover { - box-shadow: 0px 2px 30px 0px rgba(0, 0, 0, 0.15); + box-shadow: var(--docs-shadow-dropdown); background-color: var(--ifm-color-mint); + + --ifm-link-color: var(--mod-ink-800); + --ifm-link-hover-color: var(--mod-ink-800); + --ifm-heading-color: var(--mod-ink-800); + color: var(--mod-ink-800); } .cardContainer *:last-child { @@ -20,15 +33,15 @@ } .cardTitle { - --ifm-heading-margin-bottom: 8px; - font-size: 1.25rem; - font-weight: 600; + --ifm-heading-margin-bottom: var(--mod-spacing_1); + font-size: var(--mod-font-size-h4); + font-weight: var(--mod-font-weight-semi-bold); line-height: 1.666; } .cardDescription { color: var(--ifm-heading-color); - font-size: 1rem; + font-size: var(--mod-font-size-default); display: -webkit-box; -webkit-line-clamp: 2; line-clamp: 2; diff --git a/src/theme/DocPaginator/index.tsx b/src/theme/DocPaginator/index.tsx new file mode 100644 index 0000000000..d56d3da55a --- /dev/null +++ b/src/theme/DocPaginator/index.tsx @@ -0,0 +1,42 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * EJECTED from @docusaurus/theme-classic. + * Changes from the original: plain text links with an inline arrow, replacing + * the default label/sublabel pair that this site had styled as pill buttons. + */ +import Link from '@docusaurus/Link'; +import { translate } from '@docusaurus/Translate'; +import type { Props } from '@theme/DocPaginator'; +import clsx from 'clsx'; +import { type FunctionComponent } from 'react'; +import styles from './styles.module.css'; + +const DocPaginator: FunctionComponent = ({ previous, next, className }) => ( + + {previous && ( + + ← {previous.title} + + )} + {next && ( + + {next.title} → + + )} + +); + +DocPaginator.displayName = 'DocPaginator'; + +export default DocPaginator; diff --git a/src/theme/DocPaginator/styles.module.css b/src/theme/DocPaginator/styles.module.css new file mode 100644 index 0000000000..4f7c3fdcbb --- /dev/null +++ b/src/theme/DocPaginator/styles.module.css @@ -0,0 +1,43 @@ +/** + * Previous/next links: plain text with an inline arrow, no ground and no + * border. They are a way out of the page, not a call to action, so they sit + * below the content in the muted text colour rather than as pill buttons. + */ + +.paginationNav { + display: flex; + justify-content: space-between; + gap: var(--mod-spacing_4); + margin-top: var(--mod-spacing_4); + padding: 0; +} + +.paginationLink { + padding: 0; + background: transparent; + border: none; + font-size: var(--mod-font-size-sm); + font-weight: var(--mod-font-weight-medium); + line-height: 1; + color: var(--mod-color-text-tertiary); + text-decoration: none; + transition: color 0.2s ease; +} + +.paginationLink:hover { + color: var(--mod-color-text-primary); + text-decoration: none; +} + +.prev { + text-align: left; +} + +.next { + text-align: right; +} + +/* Inherits the link colour, including on hover. */ +.arrow { + color: inherit; +} diff --git a/src/theme/DocSidebarItems/filterUtils.ts b/src/theme/DocSidebarItems/filterUtils.ts new file mode 100644 index 0000000000..dd9835b282 --- /dev/null +++ b/src/theme/DocSidebarItems/filterUtils.ts @@ -0,0 +1,76 @@ +import type { PropSidebarItem, PropSidebarItemCategory } from '@docusaurus/plugin-content-docs'; +import { ownerOf, selectableSections, type NavSection } from '@site/src/config/navSections'; + +/** Every section that can scope a sidebar, with hrefs normalised once. */ +const allSections: NavSection[] = selectableSections.map((section) => ({ + ...section, + href: section.href.toLowerCase().replace(/\/$/, ''), +})); + +/** + * The section the current path belongs to, or null at the root. Shared with + * SecondaryNav so the highlight and the scoped sidebar cannot disagree. + * + * The sidebar tree is asked first, because a prefix cannot answer this: Lists + * and Reference both serve documents from /reference, and Concepts is served at + * /concepts-explanations while its documents live at /concepts-and-explanations. + * The prefix pass that follows is what matches the index pages themselves. + */ +export function findSection(pathname: string): NavSection | null { + const owner = ownerOf(pathname); + if (owner) { + const owned = allSections.find((section) => section.href === owner); + if (owned) { + return owned; + } + } + + const path = pathname.toLowerCase(); + return allSections.find((section) => path.startsWith(section.href)) ?? null; +} + +/** The top-level category whose generated-index permalink is this section. + * Matched on href rather than label, so renaming a section in sidebars.ts + * cannot silently break the match. */ +function findCategoryForSection( + items: readonly PropSidebarItem[], + section: NavSection, +): PropSidebarItemCategory | null { + return ( + items + .filter((item): item is PropSidebarItemCategory => item.type === 'category') + .find((item) => (item.href ?? '').toLowerCase().replace(/\/$/, '') === section.href) ?? null + ); +} + +/** + * Scope the sidebar to the section the reader is in — the secondary nav already + * lists every section, so repeating them here would show each link twice. + * + * Falls back to the full list where scoping would not help: at the root, and for + * single-document sections, where the scoped view would be empty. + */ +export function filterSidebarItemsBySection( + items: readonly PropSidebarItem[], + pathname: string, +): PropSidebarItem[] { + const section = findSection(pathname); + if (!section) { + return items as PropSidebarItem[]; + } + + const category = findCategoryForSection(items, section); + if (!category?.items?.length) { + return items as PropSidebarItem[]; + } + + // Label comes from the category, not from navSections, so the heading always + // matches what sidebars.ts calls the section. + const header: PropSidebarItem = { + type: 'html', + value: `${category.label}`, + defaultStyle: true, + }; + + return [header, ...category.items]; +} diff --git a/src/theme/DocSidebarItems/index.tsx b/src/theme/DocSidebarItems/index.tsx new file mode 100644 index 0000000000..8198ca20ff --- /dev/null +++ b/src/theme/DocSidebarItems/index.tsx @@ -0,0 +1,41 @@ +import { type FunctionComponent, useMemo } from 'react'; +import DocSidebarItems from '@theme-original/DocSidebarItems'; +import type DocSidebarItemsType from '@theme/DocSidebarItems'; +import type { WrapperProps } from '@docusaurus/types'; +import { useLocation } from '@docusaurus/router'; +import { useWindowSize } from '@docusaurus/theme-common'; +import { filterSidebarItemsBySection } from './filterUtils'; + +type Props = WrapperProps; + +/** + * Scopes the sidebar to the section selected in the secondary nav. + * + * Desktop only: the section nav is hidden below 997px, so scoping there would + * strand a reader in one section with no way out. Both sidebars render through + * this component at level 1, so the viewport is the only thing separating them — + * useWindowSize is what Docusaurus itself uses to pick between them. + * + * Only the top level is filtered; nested items arrive at level 2+ already inside + * the chosen section. + */ +const DocSidebarItemsWrapper: FunctionComponent = (props) => { + const location = useLocation(); + const windowSize = useWindowSize(); + + const isTopLevel = (props.level ?? 0) <= 1; + const shouldScope = isTopLevel && windowSize !== 'mobile'; + + // Memoized: DocSidebarItems is memo()'d on items identity, so a fresh array + // each render would defeat it for every top-level category. + const items = useMemo( + () => (shouldScope ? filterSidebarItemsBySection(props.items, location.pathname) : props.items), + [shouldScope, props.items, location.pathname], + ); + + return ; +}; + +DocSidebarItemsWrapper.displayName = 'DocSidebarItemsWrapper'; + +export default DocSidebarItemsWrapper; diff --git a/src/theme/Footer/index.tsx b/src/theme/Footer/index.tsx new file mode 100644 index 0000000000..b0b4e2fc3b --- /dev/null +++ b/src/theme/Footer/index.tsx @@ -0,0 +1,76 @@ +import { type FunctionComponent } from 'react'; +import Link from '@docusaurus/Link'; +import styles from './styles.module.css'; + +/** + * Footer. + * + * One row: legal and reference links left, social right, over a hairline. + * Replaces the default Infima footer, which stacked its links above a centred + * copyright — matching the treatment on docs.moderne.io. + * + * Icons are inline rather than from an icon package: three glyphs used once + * each do not justify a dependency, and inline SVG inherits currentColor. + */ + +const XIcon: FunctionComponent = () => ( + + + +); + +const LinkedInIcon: FunctionComponent = () => ( + + + +); + +const YouTubeIcon: FunctionComponent = () => ( + + + +); + +const SOCIAL = [ + { label: 'X', href: 'https://x.com/moderneinc', Icon: XIcon }, + { label: 'LinkedIn', href: 'https://www.linkedin.com/company/moderneinc', Icon: LinkedInIcon }, + { label: 'YouTube', href: 'https://www.youtube.com/@moderne-and-openrewrite', Icon: YouTubeIcon }, +]; + +const LINKS = [ + { label: 'Contact us', href: 'mailto:support@moderne.io' }, + { label: 'Licensing', to: '/licensing/openrewrite-licensing' }, +]; + +const Footer: FunctionComponent = () => ( + +); + +Footer.displayName = 'Footer'; + +export default Footer; diff --git a/src/theme/Footer/styles.module.css b/src/theme/Footer/styles.module.css new file mode 100644 index 0000000000..97f555c09d --- /dev/null +++ b/src/theme/Footer/styles.module.css @@ -0,0 +1,77 @@ +.footer { + background-color: var(--ifm-background-color); + /* Hairline between page content and the footer, matching the one under the + section nav at the top of the page. */ + border-top: 1px solid var(--mod-color-border-primary); + padding: var(--ifm-footer-padding-vertical) var(--mod-spacing_4); +} + +.container { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--mod-spacing_4); + width: 100%; + max-width: 1100px; + margin-inline: auto; +} + +.links { + display: flex; + align-items: center; + gap: var(--mod-spacing_2_1_2); + font-size: var(--mod-font-size-caption); + white-space: nowrap; +} + +/* The copyright is the only non-link here, and reads as metadata rather than + something to act on. */ +.links span { + color: var(--mod-color-text-tertiary); +} + +.links a { + color: var(--mod-color-text-primary); + text-decoration: none; +} + +.links a:hover { + color: var(--mod-color-text-primary); + text-decoration: underline; +} + +.social { + display: flex; + align-items: center; + gap: var(--mod-spacing_2); +} + +.social a { + display: flex; + align-items: center; + color: var(--mod-color-text-primary); + transition: opacity 0.2s ease; +} + +.social a:hover { + opacity: 0.7; +} + +.social svg { + width: 18px; + height: 18px; +} + +/* Stack below the breakpoint: the two groups together need more width than a + phone has, and wrapping them mid-row reads as a broken line. */ +@media (max-width: 768px) { + .container { + flex-direction: column; + gap: var(--mod-spacing_3); + } + + .links { + flex-wrap: wrap; + justify-content: center; + } +} diff --git a/src/theme/Navbar/Content/index.tsx b/src/theme/Navbar/Content/index.tsx new file mode 100644 index 0000000000..402d61d11b --- /dev/null +++ b/src/theme/Navbar/Content/index.tsx @@ -0,0 +1,127 @@ +/** + * Navbar content. + * + * Three zones: logo left, search and colour-mode toggle centred, external links + * right. Matches the layout of docs.moderne.io. Section navigation lives in + * (see Navbar/Layout), not here. + */ +import Link from '@docusaurus/Link'; +import { type FunctionComponent, type ReactNode } from 'react'; +import clsx from 'clsx'; +import { useThemeConfig, useWindowSize, ThemeClassNames } from '@docusaurus/theme-common'; +import { splitNavbarItems } from '@docusaurus/theme-common/internal'; +import type { Props as NavbarItemConfig } from '@theme/NavbarItem'; +import NavbarColorModeToggle from '@theme/Navbar/ColorModeToggle'; +import NavbarMobileSidebarToggle from '@theme/Navbar/MobileSidebar/Toggle'; +import IconExternalLink from '@theme/Icon/ExternalLink'; +import SearchBar from '@theme/SearchBar'; +import NavbarLogo from '@theme/Navbar/Logo'; +import NavbarSearch from '@theme/Navbar/Search'; +import styles from './styles.module.css'; + +/** + * External destinations, desktop only. + * + * Read from themeConfig.navbar.items rather than a second list: the mobile + * drawer renders those same items through Docusaurus's own path, and two + * hand-synced copies would let the two navs disagree silently. + */ +type ExternalItem = { href: string; label: string }; + +/** themeConfig items are a union of many shapes; only plain href+label links + * belong in this row. */ +function isExternalItem(item: NavbarItemConfig): item is NavbarItemConfig & ExternalItem { + const candidate = item as Partial; + return typeof candidate.href === 'string' && typeof candidate.label === 'string'; +} + +const ExternalNavLinks: FunctionComponent<{ items: NavbarItemConfig[] }> = ({ items }) => ( + + {items.filter(isExternalItem).map((item) => ( + + {item.label} + + + ))} + +); + +ExternalNavLinks.displayName = 'ExternalNavLinks'; + +interface NavbarContentLayoutProps { + left: ReactNode; + center: ReactNode; + right: ReactNode; +} + +const NavbarContentLayout: FunctionComponent = ({ left, center, right }) => ( + + + {left} + + {center} + + {right} + + +); + +NavbarContentLayout.displayName = 'NavbarContentLayout'; + +const NavbarContent: FunctionComponent = () => { + // TODO from upstream theme-classic: temporary casting until ThemeConfig improves. + const items = useThemeConfig().navbar.items as NavbarItemConfig[]; + const [, rightItems] = splitNavbarItems(items); + const windowSize = useWindowSize(); + + // One SearchBar and one toggle, placed differently rather than rendered twice: + // each SearchBar mounts its own Algolia preconnect and window keydown listener, + // so a second instance makes Cmd+K fire two handlers. + const controls = ( + <> + + + + + > + ); + const isMobile = windowSize === 'mobile'; + + return ( + + + + + + > + } + center={isMobile ? null : controls} + right={ + <> + + {isMobile && {controls}} + > + } + /> + ); +}; + +NavbarContent.displayName = 'NavbarContent'; + +export default NavbarContent; diff --git a/src/theme/Navbar/Content/styles.module.css b/src/theme/Navbar/Content/styles.module.css new file mode 100644 index 0000000000..27a85ff7f6 --- /dev/null +++ b/src/theme/Navbar/Content/styles.module.css @@ -0,0 +1,93 @@ +/** + * Navbar content: three zones, logo left, search centred, external links right. + * + * The centre zone is absolutely positioned on desktop so the search field sits + * on the page's midline rather than the midpoint of whatever space the two side + * zones happen to leave. + */ + +.navbarInner { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + position: relative; +} + +.navbarLeft { + flex: 1; + display: flex; + align-items: center; + justify-content: flex-start; + min-width: 0; +} + +.navbarRight { + flex: 1; + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--mod-spacing_2); +} + +.navbarCenter { + flex: 0 0 auto; + display: flex; + align-items: center; +} + +@media (min-width: 997px) { + .navbarCenter { + position: absolute; + left: 50%; + transform: translateX(-50%); + gap: var(--mod-spacing_1); + } +} + +.logoGroup { + display: flex; + align-items: center; + gap: var(--mod-spacing_2); +} + +/* Search and toggle move here below the breakpoint, where the absolutely + centred zone has no room. Rendered in one place or the other, never both. */ +.mobileControls { + display: flex; + align-items: center; + gap: var(--mod-spacing_1); +} + +.externalLinks { + display: none; +} + +@media (min-width: 997px) { + .externalLinks { + display: flex; + align-items: center; + gap: var(--mod-spacing_3); + } +} + +.externalLink { + display: flex; + align-items: center; + gap: var(--mod-spacing_1_2); + font-size: var(--mod-font-size-default); + font-weight: var(--mod-font-weight-medium); + color: var(--mod-color-text-primary); + text-decoration: none; + white-space: nowrap; +} + +.externalLink:hover { + color: var(--mod-color-action-default); + text-decoration: none; +} + +.colorModeToggle { + display: flex; + align-items: center; +} diff --git a/src/theme/Navbar/Layout/index.tsx b/src/theme/Navbar/Layout/index.tsx new file mode 100644 index 0000000000..d8459ec984 --- /dev/null +++ b/src/theme/Navbar/Layout/index.tsx @@ -0,0 +1,110 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * EJECTED from @docusaurus/theme-classic. + * Changes from the original: + * - Wrapped the nav in a fixed .navbarWrapper holding the announcement bar, + * the navbar and the section nav as one block + * - Rendered below the primary navbar (desktop only) + */ +import { type ComponentProps, type FunctionComponent, useEffect, useRef } from 'react'; +import clsx from 'clsx'; +import { ThemeClassNames, useThemeConfig } from '@docusaurus/theme-common'; +import { useHideableNavbar, useNavbarMobileSidebar } from '@docusaurus/theme-common/internal'; +import { translate } from '@docusaurus/Translate'; +import NavbarMobileSidebar from '@theme/Navbar/MobileSidebar'; +import type { Props } from '@theme/Navbar/Layout'; +// Imported by path, not via @theme: the alias resolves to Docusaurus's own +// module declaration, which only knows about the default export. +import { AnnouncementBarInline } from '@site/src/theme/AnnouncementBar'; +import { SecondaryNav } from '@site/src/components/SecondaryNav'; +import { primarySections, releaseSections } from '@site/src/config/navSections'; +import styles from './styles.module.css'; + +function NavbarBackdrop(props: ComponentProps<'div'>) { + return ( + + ); +} + +export interface NavbarLayoutProps extends Props { + readonly className?: string; +} + +/** + * Publishes the header's real height as --docs-header-height. + * + * The header is position:fixed, so everything below it is offset by that + * variable (see the header offsets in custom.css). Its height is not a constant + * we can write down: the announcement bar has no fixed height on mobile, where + * its copy wraps to two or three lines depending on width and font loading. A + * hardcoded reserve is wrong for exactly the viewports hardest to check, so + * measure instead — this is correct at any width, any copy length, and any + * number of rows. + */ +function useHeaderHeight() { + const ref = useRef(null); + + useEffect(() => { + const el = ref.current; + if (!el) { + return; + } + const publish = () => + document.documentElement.style.setProperty('--docs-header-height', `${el.offsetHeight}px`); + + publish(); + const observer = new ResizeObserver(publish); + observer.observe(el); + return () => observer.disconnect(); + }, []); + + return ref; +} + +const NavbarLayout: FunctionComponent = ({ children, className }) => { + const { + navbar: { hideOnScroll, style }, + } = useThemeConfig(); + const mobileSidebar = useNavbarMobileSidebar(); + const { navbarRef, isNavbarVisible } = useHideableNavbar(hideOnScroll); + const wrapperRef = useHeaderHeight(); + + return ( + + + + {children} + + + + + + ); +}; + +NavbarLayout.displayName = 'NavbarLayout'; + +export default NavbarLayout; diff --git a/src/theme/Navbar/Layout/styles.module.css b/src/theme/Navbar/Layout/styles.module.css new file mode 100644 index 0000000000..59856933dc --- /dev/null +++ b/src/theme/Navbar/Layout/styles.module.css @@ -0,0 +1,35 @@ +/** + * Navbar layout. + * + * The whole header — announcement bar, primary navbar, section nav — is one + * fixed block. Making the block fixed rather than each row sticky is what lets + * the rows stack without any per-row `top` arithmetic; content below is pushed + * clear by the offsets in custom.css. + */ + +.navbarWrapper { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: var(--ifm-z-index-fixed); + /* Solid, so content scrolling underneath is fully occluded. The section nav's + bottom border draws the lower edge. */ + background-color: var(--ifm-background-color); +} + +/* Hide-on-scroll animation, from the Docusaurus original. */ +.navbarHideable { + transition: transform var(--ifm-transition-fast) ease; +} + +.navbarHidden { + transform: translate3d(0, calc(-100% - 2px), 0); +} + +@media (min-width: 997px) { + .navbar { + padding-left: var(--mod-spacing_4); + padding-right: var(--mod-spacing_4); + } +} diff --git a/src/theme/Root.tsx b/src/theme/Root.tsx new file mode 100644 index 0000000000..fe4bbbb10b --- /dev/null +++ b/src/theme/Root.tsx @@ -0,0 +1,20 @@ +import { type ReactNode, useEffect } from 'react'; +import { useLocation } from '@docusaurus/router'; + +/** + * Publishes the current route on as data-route. + * + * Docusaurus's own per-doc classes (docs-doc-id-*) only appear in a production + * build — the dev server ships a bare — so styling a single + * page through them looks right when deployed and wrong while developing. This + * gives one hook that behaves identically in both. + */ +export default function Root({ children }: { children: ReactNode }): ReactNode { + const { pathname } = useLocation(); + + useEffect(() => { + document.documentElement.setAttribute('data-route', pathname); + }, [pathname]); + + return <>{children}>; +} diff --git a/yarn.lock b/yarn.lock index 5c106a5aa0..857bd20905 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2261,6 +2261,11 @@ dependencies: langium "3.3.1" +"@moderneinc/design-system-tokens@7.4.0": + version "7.4.0" + resolved "https://registry.yarnpkg.com/@moderneinc/design-system-tokens/-/design-system-tokens-7.4.0.tgz#3aec47d9d4fa645ebb8dd9f67e99c04b16ce5b36" + integrity sha512-iBehSXP2053dshQk59dh78+af3/YZEHgTsgK4P14QrHPq9BAKAMF2C4v1EC3T8bJi0fY+KHwOdgzbkoCE9of1Q== + "@module-federation/error-codes@0.18.0": version "0.18.0" resolved "https://registry.npmjs.org/@module-federation/error-codes/-/error-codes-0.18.0.tgz"