diff --git a/sidebars.js b/sidebars.js index ba42163641..2ade4fd228 100644 --- a/sidebars.js +++ b/sidebars.js @@ -2209,3 +2209,88 @@ module.exports = { 'tctl-v1/workflow', ], }; + +// --------------------------------------------------------------------------- +// Secondary navigation bar (proof of concept) +// +// Moves four sections out of the single `documentation` sidebar and gives each +// one its own sidebar, so a reader inside a section sees only that section's +// tree. src/components/SecondaryNav renders the tabs and works out which +// section the reader is in from the URL. +// +// Every other section stays in `documentation` and keeps today's sidebar. +// To undo the proof of concept, delete this block and the SecondaryNav import +// in src/theme/Navbar/index.js. +// --------------------------------------------------------------------------- + +const NAV_TAB_SECTIONS = [ + { sidebar: 'develop', category: 'Develop' }, + { sidebar: 'cloud', category: 'Temporal Cloud' }, + { sidebar: 'guides', category: 'Guides' }, + // The AI tab carries the Durable AI tree plus the standalone "Develop with + // AI" page, which lives at the top level of the documentation sidebar today. + { sidebar: 'ai', category: 'Durable AI', extraDocs: ['with-ai'] }, +]; + +// The tab label already names the section, so drop the wrapping category and +// promote its overview page to the first sidebar entry. +function flattenSection(category) { + const overview = category.link && category.link.type === 'doc' ? [category.link.id] : []; + return [...overview, ...category.items]; +} + +(function buildSectionSidebars(sidebars) { + const mainSidebar = sidebars.documentation; + const moved = new Set(); + + for (const section of NAV_TAB_SECTIONS) { + const category = mainSidebar.find((item) => item && item.type === 'category' && item.label === section.category); + + if (!category) { + throw new Error( + `Secondary nav: no "${section.category}" category found in the documentation sidebar. ` + + `Update NAV_TAB_SECTIONS in sidebars.js if the section was renamed or removed.` + ); + } + + const extraDocs = section.extraDocs || []; + moved.add(category); + extraDocs.forEach((id) => moved.add(id)); + + sidebars[section.sidebar] = [...flattenSection(category), ...extraDocs]; + } + + sidebars.documentation = mainSidebar.filter((item) => !moved.has(item)); +})(module.exports); + +// Develop splits one level further: each SDK gets its own sidebar, so a reader +// on /develop/go sees the Go tree instead of all eight. src/components/SdkNav +// renders the SDKs as a third row, shown only inside Develop. What is left in +// `develop` is the language-agnostic material. +(function buildSdkSidebars(sidebars) { + const developSidebar = sidebars.develop; + const moved = new Set(); + const sdkLinks = []; + + for (const { id } of SDKS) { + const category = developSidebar.find( + (item) => item && item.type === 'category' && item.link && item.link.id === `develop/${id}/index` + ); + + if (!category) { + throw new Error(`Secondary nav: no sidebar category found for the ${id} SDK (expected link develop/${id}/index).`); + } + + moved.add(category); + sidebars[`develop-${id}`] = flattenSection(category); + + // The SDKs stay listed in Develop as plain links. A doc can belong to only + // one sidebar, and its pages now live in the per-SDK sidebar above. + sdkLinks.push({ type: 'link', label: category.label, href: `/develop/${id}` }); + } + + const remaining = developSidebar.filter((item) => !moved.has(item)); + const afterOverview = remaining.indexOf('develop/index') + 1; + + sidebars.develop = [...remaining.slice(0, afterOverview), ...sdkLinks, ...remaining.slice(afterOverview)]; +})(module.exports); diff --git a/src/components/SdkNav/activeSdk.js b/src/components/SdkNav/activeSdk.js new file mode 100644 index 0000000000..9d17547dbc --- /dev/null +++ b/src/components/SdkNav/activeSdk.js @@ -0,0 +1,17 @@ +import { SDKS } from '@site/src/constants/sdks'; + +export const DEVELOP_SECTION = '/develop'; + +export function normalizePath(pathname) { + return pathname.replace(/\/+$/, '') || '/'; +} + +// The SDK row and the section tabs are mutually exclusive, so both rows read +// this to decide which one renders. +export function findActiveSdk(pathname) { + const path = normalizePath(pathname); + return SDKS.find(({ id }) => { + const base = `${DEVELOP_SECTION}/${id}`; + return path === base || path.startsWith(`${base}/`); + }); +} diff --git a/src/components/SdkNav/index.js b/src/components/SdkNav/index.js new file mode 100644 index 0000000000..aaac681556 --- /dev/null +++ b/src/components/SdkNav/index.js @@ -0,0 +1,45 @@ +import React from 'react'; +import Link from '@docusaurus/Link'; +import { useLocation } from '@docusaurus/router'; +import { SDKS } from '@site/src/constants/sdks'; +import { StylePickers, useNavStyle } from '@site/src/components/navStyle'; +import styles from '@site/src/components/SecondaryNav/styles.module.css'; +import { DEVELOP_SECTION, findActiveSdk } from './activeSdk'; +import sdkStyles from './styles.module.css'; + +export default function SdkNav() { + const activeSdk = findActiveSdk(useLocation().pathname); + const navStyle = useNavStyle(); + + if (!activeSdk) return null; + + const tabs = SDKS.map(({ id, label, icon: Icon }) => { + const active = id === activeSdk.id; + return ( + + + {label} + + ); + }); + + return ( + + ); +} diff --git a/src/components/SdkNav/styles.module.css b/src/components/SdkNav/styles.module.css new file mode 100644 index 0000000000..85d34cb296 --- /dev/null +++ b/src/components/SdkNav/styles.module.css @@ -0,0 +1,5 @@ +.icon { + width: 1.125rem; + height: 1.125rem; + flex-shrink: 0; +} diff --git a/src/components/SecondaryNav/index.js b/src/components/SecondaryNav/index.js new file mode 100644 index 0000000000..ad2fd4f9d0 --- /dev/null +++ b/src/components/SecondaryNav/index.js @@ -0,0 +1,54 @@ +import React from 'react'; +import Link from '@docusaurus/Link'; +import { useLocation } from '@docusaurus/router'; +import { findActiveSdk } from '@site/src/components/SdkNav/activeSdk'; +import { StylePickers, useNavStyle } from '@site/src/components/navStyle'; +import styles from './styles.module.css'; + +const TABS = [ + { label: 'Develop', to: '/develop', match: ['/develop'] }, + { label: 'Cloud', to: '/cloud', match: ['/cloud'] }, + { label: 'Guides', to: '/guides', match: ['/guides'] }, + { label: 'AI agents', to: '/ai', match: ['/ai', '/with-ai'] }, +]; + +function isActive(pathname, prefixes) { + const path = pathname.replace(/\/+$/, '') || '/'; + return prefixes.some((prefix) => path === prefix || path.startsWith(`${prefix}/`)); +} + +export default function SecondaryNav() { + const { pathname } = useLocation(); + const navStyle = useNavStyle(); + + // Inside an SDK guide the SDK row takes this row's place. + if (findActiveSdk(pathname)) return null; + + const current = TABS.find((tab) => isActive(pathname, tab.match)); + + const tabs = TABS.map((tab) => ( + + {tab.label} + + )); + + return ( + + ); +} diff --git a/src/components/SecondaryNav/styles.module.css b/src/components/SecondaryNav/styles.module.css new file mode 100644 index 0000000000..c098238941 --- /dev/null +++ b/src/components/SecondaryNav/styles.module.css @@ -0,0 +1,187 @@ +.secondaryNav { + --secondary-nav-accent: var(--interactive-primary-surface, #444ce7); + --secondary-nav-gradient: linear-gradient(255.4deg, #444ce7 0%, #b664ff 100%); + --secondary-nav-surface: var(--ifm-navbar-background-color); + + position: sticky; + top: var(--navbar-primary-height); + z-index: 150; + height: var(--secondary-nav-height); + border-bottom: 1px solid var(--ifm-toc-border-color); + font-size: 0.875rem; +} + +.inner { + display: flex; + align-items: center; + height: 100%; + padding: 0 var(--ifm-navbar-padding-horizontal); + overflow-x: auto; + scrollbar-width: none; +} + +.inner::-webkit-scrollbar { + display: none; +} + +.tabs { + display: flex; + align-items: center; +} + +.tab { + display: inline-flex; + align-items: center; + gap: 0.5rem; + white-space: nowrap; + font-weight: 500; + color: var(--ifm-navbar-link-color); + text-decoration: none; + transition: + color 120ms ease, + background-color 120ms ease, + border-color 120ms ease; +} + +.tab:hover { + text-decoration: none; +} + +/* POC scaffolding. Delete with the unused variants. */ +.pickers { + display: flex; + gap: 0.375rem; + margin-left: auto; + padding-left: 1rem; +} + +.picker { + padding: 0.25rem 0.5rem; + font-size: 0.75rem; + font-family: inherit; + color: var(--ifm-color-emphasis-600); + background: transparent; + border: 1px solid var(--ifm-color-emphasis-300); + border-radius: 6px; + cursor: pointer; +} + +/* A. Underline ----------------------------------------------------------- */ + +.underline .tabs { + gap: 1.75rem; +} + +.underline .tab { + height: var(--secondary-nav-height); + margin-bottom: -1px; + border-bottom: 2px solid transparent; +} + +.underline .tab:hover { + color: var(--ifm-navbar-link-hover-color); +} + +.underline .tabActive, +.underline .tabActive:hover { + color: var(--ifm-color-primary); + border-bottom-color: var(--ifm-color-primary); +} + +/* B. Segmented. Keep the two-background ring: a masked pseudo-element fills + * solid in production, autoprefixer drops mask-composite. */ + +.segmented .group { + display: inline-flex; + gap: 0.5rem; + padding: 0.3125rem; + border: 1px solid transparent; + border-radius: 999px; + background: + linear-gradient(var(--secondary-nav-surface), var(--secondary-nav-surface)) padding-box, + var(--secondary-nav-gradient) border-box; +} + +.segmented .tab { + padding: 0.4375rem 1.5rem; + border-radius: 999px; +} + +.segmented .tab:hover { + color: var(--ifm-navbar-link-hover-color); +} + +.segmented .tabActive, +.segmented .tabActive:hover { + color: #fff; + background: var(--secondary-nav-accent); +} + +/* Surfaces --------------------------------------------------------------- */ + +/* Surfaces stay opaque: the bar is sticky over the doc sidebar's right border + * and the page content. Each sets --secondary-nav-surface so the segmented + * ring's inner fill matches the bar. */ + +.surfaceFlush { + --secondary-nav-surface: var(--ifm-navbar-background-color); + + background: var(--secondary-nav-surface); +} + +.surfaceRaised { + --secondary-nav-surface: #f4f5f7; + + background: var(--secondary-nav-surface); +} + +.surfaceTint { + --secondary-nav-surface: #f6f6fe; + + background: var(--secondary-nav-surface); +} + +.surfaceGradient { + --secondary-nav-surface: #ecedfd; + + background: linear-gradient(90deg, #ecedfd 0%, #fbf5ff 100%); +} + +.surfaceInk { + --secondary-nav-surface: #0d0f1c; + + background: var(--secondary-nav-surface); + border-bottom-color: transparent; +} + +.surfaceInk .tab { + color: rgb(255 255 255 / 72%); +} + +.surfaceInk .tab:hover, +.surfaceInk.underline .tabActive { + color: #fff; +} + +.surfaceInk.underline .tabActive { + border-bottom-color: #fff; +} + +.surfaceInk .picker { + color: rgb(255 255 255 / 60%); + border-color: rgb(255 255 255 / 20%); +} + +[data-theme='dark'] .surfaceRaised { + --secondary-nav-surface: #14141a; +} + +[data-theme='dark'] .surfaceTint { + --secondary-nav-surface: #0b0c25; +} + +[data-theme='dark'] .surfaceGradient { + --secondary-nav-surface: #131541; + + background: linear-gradient(90deg, #131541 0%, #1a0f26 100%); +} diff --git a/src/components/navStyle.js b/src/components/navStyle.js new file mode 100644 index 0000000000..48b98ec74c --- /dev/null +++ b/src/components/navStyle.js @@ -0,0 +1,65 @@ +import React, { useEffect, useState } from 'react'; + +// POC scaffolding. Delete with the pickers once a treatment is chosen. +export const VARIANTS = [ + { id: 'underline', label: 'A. Underline' }, + { id: 'segmented', label: 'B. Segmented' }, +]; + +export const SURFACES = [ + { id: 'surfaceFlush', label: '1. Flush' }, + { id: 'surfaceRaised', label: '2. Raised' }, + { id: 'surfaceTint', label: '3. Indigo tint' }, + { id: 'surfaceGradient', label: '4. Gradient wash' }, + { id: 'surfaceInk', label: '5. Ink' }, +]; + +// Read after mount so the server markup and first client render agree. +function useChoice(storageKey, options, fallback) { + const [value, setValue] = useState(fallback); + + useEffect(() => { + try { + const stored = window.localStorage.getItem(storageKey); + if (stored && options.some((option) => option.id === stored)) setValue(stored); + } catch { + // Blocked storage. Keep the default. + } + }, [storageKey, options]); + + const choose = (next) => { + setValue(next); + try { + window.localStorage.setItem(storageKey, next); + } catch { + // Not worth surfacing in a POC control. + } + }; + + return [value, choose]; +} + +export function useNavStyle() { + const [variant, chooseVariant] = useChoice('poc-secondary-nav-variant', VARIANTS, 'underline'); + const [surface, chooseSurface] = useChoice('poc-secondary-nav-surface', SURFACES, 'surfaceFlush'); + return { variant, chooseVariant, surface, chooseSurface }; +} + +export function StylePickers({ styles, variant, chooseVariant, surface, chooseSurface }) { + const picker = (value, options, onChange, label) => ( + + ); + + return ( +