diff --git a/AGENTS.md b/AGENTS.md index 7a1e3066f..995a78e3d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,10 +17,12 @@ Vocs-powered documentation site for Tempo protocol. Use this guidance when creat ## Documentation IA and agent exports -- `src/lib/docs-sections.ts` owns the section labels, landing routes, and route ownership; `vocs.config.ts` owns the sidebar within each section. -- Primary sections: Get Started (`/get-started`), Accounts (`/docs/accounts`), Earn (`/docs/earn`), Routes (`/docs/routes`), Zones (`/docs/zones`), and Machine Payments (`/docs/agents`). Get Started also owns the SDK, CLI, and wallet CLI pages (`/docs/sdk`, `/docs/cli`, `/docs/wallet`) as its Tempo CLI and Tempo SDK sections. -- Tempo EVM (`/docs/development`) covers wallets and signing keys, transactions, fees, TIP-20 Tokens, contracts, exchange, network details, nodes, protocol specifications, and changelog. -- API reference (`/docs/api`) is its own top-nav link, matching Console's "API Docs" link. Partners (`/docs/partners`) contains third-party integrations. The old APIs & SDKs hub (`/docs/tools`) redirects to `/docs/sdk`. +- `src/lib/docs-sections.ts` owns the section labels, landing routes, and route ownership, plus `docsResourceSectionIds`, the sections in the top bar's Resources menu; `vocs.config.ts` owns the sidebar within each section. +- Top bar: the TEMPO DEVELOPERS lockup (Home), the products as plain links (Get started, Accounts, Earn, Routes, Zones, Machine Payments, Network), then Resources, the only menu (Guides, Partners, SDKs & CLI), search and Get API key, the one primary button. There is no second row. The sidebar lists only the current section's pages, and each product's sidebar ends with an API reference group that links its endpoint pages; the API reference itself keeps the full sidebar generated from the spec. Below 1200px the products move into the menu drawer (products, Resources, then the section's pages); the Vocs sidebar shows from 1080px. Each docs page opens with a header row: Section › Page breadcrumbs, Ask AI and Copy page. Docs pages end at Previous / Next with no footer; Home and Not found keep the full footer. +- Sections: Get started (`/get-started`, onboarding plus the testnet faucet, network details, and Build with AI), Accounts (`/docs/accounts`), Earn (`/docs/earn`), Routes (`/docs/routes`), Zones (`/docs/zones`), Machine Payments (`/docs/agents`), Network (`/docs/development`, plus `/docs/guide/node`, `/docs/changelog`, and `/docs/protocol/upgrades`), API (`/docs/api`), SDKs & CLI (`/docs/sdk`, `/docs/cli`, `/docs/wallet`), Guides (`/docs/resources`) and Partners (`/docs/partners`). +- Network merges the former Tempo EVM and Operate sections under the `developers` id. Its sidebar lists the Tempo EVM groups (wallets and signing keys, transactions, fees, TIP-20 Tokens, contracts, exchange, network details, and protocol specifications), then Run a node, then Changelog (network upgrades, node releases, and operator updates). +- The header has no product switcher, agent popover, or sign-in link. Their links live in context: Tempo Wallet in the Accounts sidebar, the MPP spec in Machine Payments, the Explorer in Network, agent setup in Get started › Build with AI, and Console behind Get API key (it reads "Console" when signed in). +- API reference matches Console's "API Docs" link. Partners contains third-party integrations. The old APIs & SDKs hub (`/docs/tools`) redirects to `/docs/sdk`. - Keep Earn and Routes beta notices and the Zones availability notice (live on Mainnet, set up with Tempo) on their overview pages. The earlier `/docs/guide/private-zones` sandbox is a separate integration. - `public/SKILL.md` is the canonical Tempo docs skill, published at `/SKILL.md`. The `tempoxyz/plugins` copy is generated from it by that repo's skill sync, so edit only `public/SKILL.md` here. - `src/lib/markdown-output-transform.ts` uses `src/lib/ai-docs.ts` to add section context before Vocs validates and writes Markdown exports. `SKILL.md` links to `public/SKILL.md`, the source Vite serves and copies. Never edit `dist` or `.vercel` copies by hand. diff --git a/e2e/blog.test.ts b/e2e/blog.test.ts index e3b268e39..ef80a4984 100644 --- a/e2e/blog.test.ts +++ b/e2e/blog.test.ts @@ -51,24 +51,34 @@ for (const width of [390, 1440]) { await expect( page.locator('header').getByRole('link', { name: 'Blog', exact: true, includeHidden: true }), ).toHaveCount(0) - await expect( - page - .locator('.docs-section-utilities') - .getByRole('link', { name: 'Blog', exact: true, includeHidden: true }), - ).toHaveCount(0) await expect(page.locator('#blog, .tempo-blog')).toHaveCount(0) - if (width < 1080) { + if (width < 1200) { await page.getByRole('button', { name: 'Open menu', exact: true }).click() - const menu = page.getByRole('dialog', { name: 'Documentation navigation', exact: true }) + const menu = page.getByRole('dialog', { name: 'Tempo Developers menu', exact: true }) await expect(menu).toBeVisible() await expect( menu.getByRole('link', { name: 'Blog', exact: true, includeHidden: true }), ).toHaveCount(0) - await expect(menu.getByRole('link', { name: 'Get Started', exact: true })).toBeVisible() + await expect(menu.getByRole('link', { name: 'Get started', exact: true })).toBeVisible() } else { + const products = page + .locator('header.docs-site-header') + .getByRole('navigation', { name: 'Products', exact: true }) + await expect(products).toBeVisible() + await expect(products.getByRole('link')).toHaveText([ + 'Get started', + 'Accounts', + 'Earn', + 'Routes', + 'Zones', + 'Machine Payments', + 'Network', + ]) + // Resources, the bar's only menu, opens outside the header. + await products.getByRole('button', { name: 'Resources', exact: true }).click() await expect( - page.getByRole('navigation', { name: 'Documentation sections', exact: true }), - ).toBeVisible() + page.getByRole('menu', { name: 'Resources', exact: true }).getByRole('menuitem'), + ).toHaveText(['Guides', 'Partners', 'SDKs & CLI']) } }) } diff --git a/e2e/developers-homepage.test.ts b/e2e/developers-homepage.test.ts index 94282b058..0951b9ede 100644 --- a/e2e/developers-homepage.test.ts +++ b/e2e/developers-homepage.test.ts @@ -16,8 +16,14 @@ test('serves the docs homepage directly at the root URL', async ({ page, request expect((await request.get('/', { maxRedirects: 0 })).status()).toBe(200) await page.goto('/') await expect(page).toHaveURL((url) => url.pathname === '/') - await expect(page.getByRole('heading', { level: 1, name: 'Documentation' })).toBeVisible() - await expect(page.getByRole('navigation', { name: 'Developer navigation' })).toBeVisible() + await expect( + page.getByRole('heading', { level: 1, name: 'Build stablecoin payments on Tempo' }), + ).toBeVisible() + await expect( + page + .locator('header.docs-site-header') + .getByRole('navigation', { name: 'Products', exact: true }), + ).toBeVisible() }) test('keeps legacy docs queries and anchors when opening the new landing', async ({ page }) => { @@ -40,7 +46,7 @@ test('serves getting started directly with its overview sidebar', async ({ page, ).toBeVisible() const sidebar = page.locator('[data-v-gutter-left] [data-v-sidebar-container]') await expect(sidebar).toBeVisible() - await expect(sidebar.getByRole('link', { name: 'Start building', exact: true })).toHaveAttribute( + await expect(sidebar.getByRole('link', { name: 'Build on Tempo', exact: true })).toHaveAttribute( 'href', '/get-started', ) @@ -84,24 +90,30 @@ for (const entry of ['direct', 'client navigation'] as const) { } else { await page.goto('/docs/accounts') await page - .getByRole('navigation', { name: 'Documentation sections' }) - .getByRole('link', { name: 'Get Started', exact: true }) + .locator('header.docs-site-header') + .getByRole('navigation', { name: 'Products', exact: true }) + .getByRole('link', { name: 'Get started', exact: true }) .click() await page .locator('article[data-v-content]') - .getByRole('link', { name: 'interactive quickstart', exact: true }) + .getByRole('link', { name: 'Send your first payment', exact: true }) .click() } await expect(page).toHaveURL((url) => url.pathname === '/get-started/quickstart') await expect(page.locator('#try-a-payment')).toBeAttached() await expect(page.locator('h2#try-a-payment')).toBeVisible() - const pageTools = page.getByRole('navigation', { name: 'Page tools' }) - await expect(pageTools).toHaveCount(1) - await expect(pageTools.getByRole('link', { name: 'View Markdown' })).toHaveAttribute( - 'href', - '/assets/md/get-started/quickstart.md', - ) + // One Copy page split button above the H1; its menu links the page's Markdown. + const pageActions = page.locator('.docs-page-actions') + await expect(pageActions).toHaveCount(1) + await expect(pageActions.getByRole('button', { name: 'Copy page', exact: true })).toBeVisible() + await pageActions.getByRole('button', { name: 'More page options', exact: true }).click() + const pageMenu = page.getByRole('menu', { name: 'More page options', exact: true }) + await expect( + pageMenu.getByRole('menuitem', { name: 'View as Markdown', exact: true }), + ).toHaveAttribute('href', '/assets/md/get-started/quickstart.md') + await page.keyboard.press('Escape') + await expect(pageMenu).toHaveCount(0) const signIn = getDemoStep(page, 'Create an account, or use an existing one.').getByRole( 'button', { name: 'Sign in', exact: true }, @@ -163,14 +175,18 @@ for (const entry of ['direct', 'client navigation'] as const) { }) => { await page.setViewportSize({ width: 1440, height: 1000 }) const sidebar = page.locator('[data-v-gutter-left] [data-v-sidebar-container]') - const sections = page.getByRole('navigation', { name: 'Documentation sections' }) + const products = page + .locator('header.docs-site-header') + .getByRole('navigation', { name: 'Products', exact: true }) if (entry === 'direct') { expect((await request.get('/get-started/quickstart', { maxRedirects: 0 })).status()).toBe(200) await page.goto('/get-started/quickstart') } else { await page.goto('/docs/accounts') - await sections.getByRole('link', { name: 'Get Started', exact: true }).click() + // Get started opens its overview. + await products.getByRole('link', { name: 'Get started', exact: true }).click() + await expect(page).toHaveURL(/\/get-started\/?$/) await sidebar.getByRole('link', { name: 'Quickstart', exact: true }).click() } @@ -178,18 +194,21 @@ for (const entry of ['direct', 'client navigation'] as const) { await expect( page.getByRole('heading', { level: 1, name: 'Quickstart', exact: true }), ).toBeVisible() - await expect(page.getByRole('navigation', { name: 'Developer navigation' })).toBeVisible() - await expect(sections).toBeVisible() - await expect(sections.locator('[aria-current="page"]')).toHaveText('Get Started') + await expect(products).toBeVisible() + await expect(products.locator('[aria-current="page"]')).toHaveText('Get started') + await expect(products.getByRole('link', { name: 'Get started', exact: true })).toHaveAttribute( + 'aria-current', + 'page', + ) await expect(sidebar).toBeVisible() await expect(sidebar.locator('a[data-active]')).toHaveAttribute( 'href', '/get-started/quickstart', ) - await sidebar.getByRole('link', { name: 'Start building', exact: true }).click() + await sidebar.getByRole('link', { name: 'Build on Tempo', exact: true }).click() await expect(page).toHaveURL(/\/get-started\/?$/) - await expect(sections).toBeVisible() + await expect(products.locator('[aria-current="page"]')).toHaveText('Get started') await expect(sidebar.locator('a[data-active]')).toHaveAttribute('href', '/get-started') }) } diff --git a/e2e/docs-workspace.test.ts b/e2e/docs-workspace.test.ts index 8ebeeb07f..b210ea0ca 100644 --- a/e2e/docs-workspace.test.ts +++ b/e2e/docs-workspace.test.ts @@ -1,67 +1,139 @@ -import { expect, type Locator, test } from '@playwright/test' +import { expect, type Locator, type Page, test } from '@playwright/test' -const sectionLabels = [ - 'Get Started', +// From 1200px the top bar lists the products as plain links, then Resources, its only menu. +const productLabels = [ + 'Get started', 'Accounts', 'Earn', 'Routes', 'Zones', 'Machine Payments', - 'Tempo EVM', + 'Network', ] -const utilityLabels = ['API reference', 'Partners'] -const externalTools = [ - ['MPP', 'https://mpp.dev/'], - ['Mercator', 'https://mercator.sh/'], - ['Tempo Console', 'https://console.tempo.xyz/'], - ['Tempo Wallet', 'https://wallet.tempo.xyz/'], +const resourceSections = [ + ['Guides', '/docs/resources/guides'], + ['Partners', '/docs/partners'], + ['SDKs & CLI', '/docs/sdk'], ] as const - -const homeAgentSetups = [ - { - label: 'Codex', - command: - 'codex plugin marketplace add tempoxyz/plugins --ref main\ncodex plugin add docs@tempo', - instruction: null, - install: /^Install Codex CLI/, - }, - { - label: 'Claude Code', - command: 'claude plugin marketplace add tempoxyz/plugins\nclaude plugin install docs@tempo', - instruction: null, - install: /^Install Claude Code/, - }, - { - label: 'Amp', - command: 'amp mcp add tempo https://mcp.tempo.xyz', - instruction: /MCP/, - install: /^Install Amp/, - }, - { - label: 'Skills', - command: 'npx skills add tempoxyz/plugins --skill docs', - instruction: /skill/i, - install: null, - }, - { - label: 'MCP', - command: 'https://mcp.tempo.xyz', - instruction: /HTTP MCP server/i, - install: null, - }, +const resourceLabels: string[] = resourceSections.map(([label]) => label) +// Below 1200px the menu drawer lists the products, then Resources' sections under its label. +const menuLinks = [...productLabels, ...resourceLabels] +// The other Tempo products are linked from the full footer on Home; docs pages have no footer. +const footerTools = [ + ['tempo.xyz', 'https://tempo.xyz'], + ['Console', 'https://console.tempo.xyz'], + ['Wallet', 'https://wallet.tempo.xyz'], + ['Explorer', 'https://explore.tempo.xyz'], + ['MPP', 'https://mpp.dev'], + ['Mercator', 'https://mercator.sh'], + ['GitHub', 'https://github.com/tempoxyz'], + ['X', 'https://twitter.com/tempo'], ] as const -test('seven sections expose product tasks and expandable developer chapters', async ({ page }) => { +// Vocs marks the desktop sidebar's current page with data-active; the drawer's DS items use +// aria-current and set data-active="false" on every other page. +const activeSidebarLink = 'a[data-active="true"], a[aria-current="page"]' + +/** The products in the top bar (1200px and up). */ +function productNav(page: Page) { + return page + .locator('header.docs-site-header') + .getByRole('navigation', { name: 'Products', exact: true }) +} + +/** The Resources menu button after the products. */ +function resourcesButton(page: Page) { + return productNav(page).getByRole('button', { name: 'Resources', exact: true }) +} + +/** The Resources menu, named after its button. */ +function resourcesMenu(page: Page) { + return page.getByRole('menu', { name: 'Resources', exact: true }) +} + +/** Below 1200px, Open menu opens the menu drawer. */ +function menuButton(page: Page) { + return page.getByRole('button', { name: 'Open menu', exact: true }) +} + +/** The menu drawer: the products and Resources, then the current section's pages. */ +function menuDrawer(page: Page) { + return page.getByRole('dialog', { name: 'Tempo Developers menu', exact: true }) +} + +/** The current section's pages in the menu drawer, named after the section. */ +function sectionPages(page: Page, section: string) { + return menuDrawer(page).getByRole('navigation', { name: `${section} pages`, exact: true }) +} + +/** The breadcrumb above a docs page's H1: the section, then the current page. */ +function breadcrumbItems(page: Page) { + return page + .locator('.docs-page-header') + .getByRole('navigation', { name: 'Breadcrumb', exact: true }) + .getByRole('listitem') +} + +/** The Copy page split button's menu, named after its More page options button. */ +function pageOptionsMenu(page: Page) { + return page.getByRole('menu', { name: 'More page options', exact: true }) +} + +/** + * The current section: its product in the top bar from 1200px, the breadcrumb's section below. + * Guides, Partners and SDKs & CLI mark the Resources button instead; the API reference has no + * top-bar entry, so only the breadcrumb names it. + */ +async function expectSection(page: Page, section: string, mobile = false) { + if (mobile) { + await expect(breadcrumbItems(page).first()).toHaveText(section) + return + } + const current = productNav(page).locator('[aria-current="page"]') + if (productLabels.includes(section)) { + await expect(current).toBeVisible() + await expect(current).toHaveText(section) + return + } + await expect(current).toHaveCount(0) + await expect(breadcrumbItems(page).first()).toHaveText(section) + if (resourceLabels.includes(section)) + await expect(resourcesButton(page)).toHaveAttribute('data-active', '') + else await expect(resourcesButton(page)).not.toHaveAttribute('data-active') +} + +test('the top bar lists the products and Resources, and products expose their tasks and developer chapters', async ({ + page, +}) => { await page.setViewportSize({ width: 1440, height: 1000 }) await page.goto('/') - const navigation = page.getByRole('navigation', { name: 'Documentation sections', exact: true }) - const utilities = page.locator('.docs-section-utilities') + const products = productNav(page) const sidebar = page.locator('[data-v-gutter-left] [data-v-sidebar-container]') - await expect(navigation.getByRole('link')).toHaveText(sectionLabels) - await expect(utilities.getByRole('link')).toHaveText(utilityLabels) + await expect(products.getByRole('link')).toHaveText(productLabels) + // Products are plain links; Resources is the only menu. + await expect(products.getByRole('button')).toHaveText(['Resources']) + // Home belongs to no section. + await expect(products.locator('[aria-current]')).toHaveCount(0) + await expect(resourcesButton(page)).not.toHaveAttribute('data-active') + // Clicks before hydration do nothing; the header search enables once hydrated. + await expect( + page.getByRole('button', { name: 'Search documentation', exact: true }), + ).toBeEnabled() + for (const [label, href] of resourceSections) { + await resourcesButton(page).click() + await expect(resourcesMenu(page).getByRole('menuitem')).toHaveText(resourceLabels) + await resourcesMenu(page).getByRole('menuitem', { name: label, exact: true }).click() + await expect(page).toHaveURL(new RegExp(`${href}/?$`)) + await expect(resourcesMenu(page)).toBeHidden() + await expectSection(page, label) + } + await resourcesButton(page).click() + await expect(resourcesMenu(page).locator('[aria-current="page"]')).toHaveText('SDKs & CLI') + await page.keyboard.press('Escape') + await expect(resourcesMenu(page)).toBeHidden() for (const section of [ { - label: 'Get Started', + label: 'Get started', path: '/get-started', links: ['/get-started/quickstart', '/docs/guide/using-tempo-with-ai'], }, @@ -129,17 +201,21 @@ test('seven sections expose product tasks and expandable developer chapters', as ], }, { - label: 'Tempo EVM', + // Network merges Tempo EVM and Operate: building on the chain, nodes, upgrades and releases. + label: 'Network', path: '/docs/development', - links: ['/docs/development'], + links: [ + '/docs/development', + '/docs/guide/node', + '/docs/protocol/upgrades', + '/docs/changelog', + ], }, ]) { - await navigation.getByRole('link', { name: section.label, exact: true }).click() + await products.getByRole('link', { name: section.label, exact: true }).click() await expect(page).toHaveURL(new RegExp(`${section.path}/?$`)) - await expect(navigation.locator('[aria-current="page"]')).toHaveText( - section.label.replace(/ \(.*\)$/, ''), - ) - await expect(utilities.locator('[aria-current]')).toHaveCount(0) + // Only the section's own product is marked current in the top bar. + await expect(products.locator('[aria-current]')).toHaveText([section.label]) await expect(page.locator('#related-documentation')).toHaveCount(0) for (const href of section.links) await expect(sidebar.locator(`a[href="${href}"]`), `${section.label}: ${href}`).toBeVisible() @@ -150,18 +226,19 @@ test('seven sections expose product tasks and expandable developer chapters', as await expect(specifications).toBeVisible() await specifications.click() await expect(page).toHaveURL(/\/docs\/protocol\/?$/) - await expect(navigation.locator('[aria-current="page"]')).toHaveText('Tempo EVM') + await expectSection(page, 'Network') await expect(page.getByRole('heading', { level: 1 })).toBeVisible() }) for (const section of [ { - label: 'Get Started', + label: 'Get started', overview: '/get-started', paths: [ '/get-started/quickstart', '/docs/guide/using-tempo-with-ai', '/docs/quickstart/faucet', + '/docs/quickstart/connection-details', ], }, { @@ -196,7 +273,7 @@ for (const section of [ ], }, { - label: 'Tempo EVM', + label: 'Network', overview: '/docs/development', paths: [ '/docs/network/transactions', @@ -205,13 +282,11 @@ for (const section of [ '/docs/guide/payments/transfer-memos', '/docs/guide/payments/send-a-payment/examples', '/docs/guide/getting-funds', - '/docs/quickstart/connection-details', '/docs/quickstart/wallet-developers', '/docs/protocol/rpc', '/docs/guide/issuance/create-a-stablecoin', '/docs/guide/stablecoin-dex/executing-swaps', '/docs/guide/issuance/manage-stablecoin', - '/docs/guide/node/installation', ], }, { @@ -226,8 +301,8 @@ for (const section of [ ], }, { - label: 'Get Started (SDKs and CLI)', - overview: '/get-started', + label: 'SDKs & CLI', + overview: '/docs/sdk', paths: ['/docs/sdk', '/docs/cli', '/docs/cli/api', '/docs/sdk/typescript/api-client'], }, { @@ -240,19 +315,24 @@ for (const section of [ ], }, { - label: 'API reference', + label: 'API', overview: '/docs/api/console', paths: ['/docs/api/console', '/docs/api/console/api-keys', '/docs/api/team'], }, { - label: 'Tempo EVM (Changelog)', + label: 'Network (node operators)', + overview: '/docs/guide/node', + paths: ['/docs/guide/node/installation'], + }, + { + label: 'Network (Changelog)', overview: '/docs/protocol/upgrades', paths: ['/docs/protocol/upgrades/t11', '/docs/changelog'], }, ]) { test(`existing deep links select ${section.label} and retain their sidebar`, async ({ page }) => { await page.setViewportSize({ width: 1440, height: 1000 }) - const navigation = page.locator('.docs-section-nav') + const label = section.label.replace(/ \(.*\)$/, '') const sidebar = page.locator('[data-v-gutter-left] [data-v-sidebar-container]') for (const path of section.paths) { const [pathname, anchor] = path.split('#') @@ -262,15 +342,11 @@ for (const section of [ ) if (anchor) await expect(page.locator(`[id="${anchor}"]`)).toHaveCount(1) await expect(page.getByRole('heading', { level: 1 })).toBeVisible() - await expect(navigation.locator('[aria-current="page"]')).toHaveText( - section.label.replace(/ \(.*\)$/, ''), - ) + await expectSection(page, label) await expect(sidebar.locator(`a[href="${section.overview}"]`).first()).toBeVisible() } expect((await page.reload())?.status()).toBe(200) - await expect(navigation.locator('[aria-current="page"]')).toHaveText( - section.label.replace(/ \(.*\)$/, ''), - ) + await expectSection(page, label) }) } @@ -280,9 +356,9 @@ test('Get started recommends a test payment and offers build paths and tool link await page.goto('/get-started') const article = page.locator('article[data-v-content]') await expect(article.getByRole('heading', { level: 1 })).toHaveText('Build on Tempo') - const quickstart = article.getByRole('link', { name: 'interactive quickstart', exact: true }) + const quickstart = article.getByRole('link', { name: 'Send your first payment', exact: true }) await expect(quickstart).toHaveCount(1) - await expect(quickstart).toHaveAccessibleName('interactive quickstart') + await expect(quickstart).toHaveAttribute('href', '/get-started/quickstart') await expect(quickstart).toBeVisible() for (const [label, href] of [ ['Embed stablecoins in your app', '/docs/accounts'], @@ -326,15 +402,11 @@ test('product availability lives in overview callouts, not navigation labels', a await expect(article.getByRole('heading', { level: 1 })).not.toContainText(/beta|preview/i) await expect(article).toContainText(status) await expect(article.locator('a[href="https://tempo.xyz/contact"]').first()).toBeVisible() - await expect( - page - .getByRole('navigation', { name: 'Documentation sections', exact: true }) - .getByRole('link'), - ).toHaveText(sectionLabels) + await expect(productNav(page).getByRole('link')).toHaveText(productLabels) } }) -test('Earn task pages and API reference remain distinct', async ({ page }) => { +test('Earn task pages and the API reference remain distinct', async ({ page }) => { await page.goto('/docs/earn') const sidebar = page.locator('[data-v-gutter-left] [data-v-sidebar-container]') for (const [label, path] of [ @@ -347,224 +419,234 @@ test('Earn task pages and API reference remain distinct', async ({ page }) => { await expect(page).toHaveURL(new RegExp(`${path}/?$`)) await expect(sidebar.locator('a[data-active]')).toHaveAttribute('href', path) } - await page - .locator('.docs-section-utilities') - .getByRole('link', { name: 'API reference', exact: true }) - .click() - await expect(page).toHaveURL(/\/docs\/api\/?$/) - await expect(page.locator('.docs-section-nav [aria-current="page"]')).toHaveText('API reference') + // Earn's sidebar ends with its API reference, which belongs to the API section, not Earn. + await sidebar.getByRole('link', { name: 'Earn API', exact: true }).click() + await expect(page).toHaveURL(/\/docs\/api\/earn\/?$/) + await expectSection(page, 'API') + // A generic Overview takes its group's name. + await expect(breadcrumbItems(page)).toHaveText(['API', 'Earn API']) }) -test('Changelog lives inside Tempo EVM and retains upgrade URLs', async ({ page }) => { +test('node guides and the Changelog live in Network and retain upgrade URLs', async ({ page }) => { await page.goto('/docs/development') const sidebar = page.locator('[data-v-gutter-left] [data-v-sidebar-container]') - await expect(page.locator('.docs-section-utilities').getByText('Changelog')).toHaveCount(0) - const upgrades = sidebar.locator('a[href="/docs/protocol/upgrades"]') - // A click before hydration does nothing, so retry until the chapter opens. - await expect(async () => { - if (!(await upgrades.isVisible())) - await sidebar.getByRole('button', { name: 'Changelog', exact: true }).click() - await expect(upgrades).toBeVisible({ timeout: 1000 }) - }).toPass() - await upgrades.click() - await expect(page.locator('.docs-section-nav [aria-current="page"]')).toHaveText('Tempo EVM') + const current = productNav(page).locator('[aria-current="page"]') + await expect(productNav(page).getByText('Changelog')).toHaveCount(0) + // One Network sidebar carries the chain, the node guides and the Changelog. + await sidebar.locator('a[href="/docs/guide/node"]').click() + await expect(page).toHaveURL(/\/docs\/guide\/node\/?$/) + await expect(current).toHaveText('Network') + await sidebar.locator('a[href="/docs/protocol/upgrades"]').click() + await expect(page).toHaveURL(/\/docs\/protocol\/upgrades\/?$/) + await expect(current).toHaveText('Network') await sidebar.locator('a[href="/docs/protocol/upgrades/t11"]').click() await expect(page.getByRole('heading', { level: 1 })).toHaveText('T11 Network Upgrade') expect((await page.reload())?.status()).toBe(200) - await expect(page.locator('.docs-section-nav [aria-current="page"]')).toHaveText('Tempo EVM') + await expect(current).toHaveText('Network') await sidebar.getByRole('link', { name: 'Node releases', exact: true }).click() await expect(page).toHaveURL(/\/docs\/changelog\/?$/) - await sidebar.locator('a[href="/docs/development"]').click() + await expect(current).toHaveText('Network') + await productNav(page).getByRole('link', { name: 'Network', exact: true }).click() await expect(page).toHaveURL(/\/docs\/development\/?$/) }) -test('API reference is a keyboard-accessible utility and the old tools hub redirects', async ({ +test('Resources is a keyboard-accessible menu and the old tools hub redirects', async ({ page, }) => { await page.goto('/') - const api = page - .locator('.docs-section-utilities') - .getByRole('link', { name: 'API reference', exact: true }) - await api.focus() + // Keys pressed before hydration do nothing; the header search enables once hydrated. + await expect( + page.getByRole('button', { name: 'Search documentation', exact: true }), + ).toBeEnabled() + const trigger = resourcesButton(page) + const items = resourcesMenu(page).getByRole('menuitem') + await trigger.focus() + await page.keyboard.press('Enter') + await expect(items).toHaveText(resourceLabels) + await expect(items.first()).toBeFocused() + await page.keyboard.press('End') + await expect(items.last()).toBeFocused() + await page.keyboard.press('Home') + await expect(items.first()).toBeFocused() await page.keyboard.press('Enter') - await expect(page).toHaveURL(/\/docs\/api\/?$/) - await expect(api).toHaveAttribute('aria-current', 'page') + await expect(page).toHaveURL(/\/docs\/resources\/guides\/?$/) + await expectSection(page, 'Guides') await expect(page.locator('#related-documentation')).toHaveCount(0) expect((await page.goto('/docs/tools'))?.status()).toBe(200) await expect(page).toHaveURL(/\/docs\/sdk\/?$/) - await expect(page.locator('.docs-section-nav [aria-current="page"]')).toHaveText('Get Started') + await expectSection(page, 'SDKs & CLI') }) for (const path of ['/', '/docs/accounts']) { - test(`mobile navigation separates products, utilities, and external tools on ${path}`, async ({ + test(`mobile menu lists the products and Resources, and the footer links the products on ${path}`, async ({ page, }) => { await page.setViewportSize({ width: 390, height: 844 }) await page.goto(path) - await page.getByRole('button', { name: 'Open menu', exact: true }).click() - const menu = page.getByRole('dialog', { name: 'Documentation navigation', exact: true }) - const sections = menu.getByRole('navigation', { name: 'All documentation sections' }) - await expect(sections.getByRole('link')).toHaveCount(sectionLabels.length) - for (const label of sectionLabels) - await expect(sections.getByRole('link', { name: label, exact: true })).toBeVisible() - for (const label of utilityLabels) - await expect(menu.getByRole('link', { name: label, exact: true })).toBeVisible() - const tools = menu.getByRole('navigation', { name: 'External tools', exact: true }) - for (const [label, href] of externalTools) { - const link = tools.getByRole('link', { name: label, exact: true }) - await expect(link).toHaveAttribute('href', href) - await expect(link).toHaveAttribute('target', '_blank') - await expect(link).toHaveAttribute('rel', /noopener/) + const footer = page.locator('.docs-site-footer') + if (path === '/') { + await expect(footer).toHaveAttribute('data-variant', 'full') + await expect( + footer.getByRole('navigation', { name: 'Footer', exact: true }).locator('p'), + ).toHaveText(['Products', 'Resources', 'Tempo']) + // Products and Resources repeat the top bar; the API reference, which has no top-bar + // entry, closes Resources. + await expect(footer.locator('a[href^="/"]')).toHaveText([...menuLinks, 'API reference']) + for (const [label, href] of footerTools) { + const link = footer.getByRole('link', { + name: `${label} (opens in a new tab)`, + exact: true, + }) + await expect(link).toHaveAttribute('href', href) + await expect(link).toHaveAttribute('target', '_blank') + await expect(link).toHaveAttribute('rel', /noopener/) + } + } else { + // Docs pages end at Previous / Next, without a footer. + await expect(page.getByRole('heading', { level: 1 })).toBeVisible() + await expect(footer).toHaveCount(0) } - await menu.getByRole('link', { name: 'Tempo EVM', exact: true }).click() + await menuButton(page).click() + const menu = menuDrawer(page) + const areas = menu.getByRole('navigation', { name: 'Products', exact: true }) + await expect(areas.getByRole('link')).toHaveText(menuLinks) + for (const label of menuLinks) + await expect(areas.getByRole('link', { name: label, exact: true })).toBeVisible() + // Resources is a label above its sections, not a link. + await expect(areas.getByText('Resources', { exact: true })).toBeVisible() + await expect(areas.getByRole('link', { name: 'Resources', exact: true })).toHaveCount(0) + if (path === '/') await expect(areas.locator('[aria-current="page"]')).toHaveCount(0) + else await expect(areas.locator('[aria-current="page"]')).toHaveText('Accounts') + await expect(menu.getByRole('link', { name: 'Get API key', exact: true })).toHaveAttribute( + 'href', + 'https://console.tempo.xyz', + ) + await areas.getByRole('link', { name: 'Network', exact: true }).click() await expect(page).toHaveURL(/\/docs\/development\/?$/) await expect(menu).toBeHidden() await expect(page.locator('body')).not.toHaveCSS('overflow', 'hidden') - await page.getByRole('button', { name: 'Open docs navigation', exact: true }).click() - const drawer = page.getByRole('dialog', { name: 'Documentation', exact: true }) - await drawer - .locator('summary') - .filter({ hasText: /^Changelog$/ }) - .click() + await expect(breadcrumbItems(page)).toHaveText(['Network', 'Introduction']) + await menuButton(page).click() + const drawer = sectionPages(page, 'Network') + // Changelog is an always-open Network chapter, so its upgrades list without a toggle. + await expect(drawer.getByRole('group', { name: 'Changelog', exact: true })).toBeVisible() await drawer.locator('a[href="/docs/protocol/upgrades/t12"]').click() await expect(page).toHaveURL(/\/docs\/protocol\/upgrades\/t12\/?$/) - await expect(drawer).toBeHidden() + await expect(menu).toBeHidden() }) } for (const path of ['/', '/docs/accounts']) { - for (const width of [1080, 1440]) { - test(`desktop header destinations and agent setup stay usable on ${path} at ${width}px`, async ({ + for (const width of [1080, 1200, 1440]) { + test(`desktop top bar products, search and Get API key stay usable on ${path} at ${width}px`, async ({ page, }) => { await page.setViewportSize({ width, height: 1000 }) await page.goto(path) - const navigation = page.getByRole('navigation', { name: 'Developer navigation' }) + const header = page.locator('header.docs-site-header') await expect( - navigation.getByRole('combobox', { name: 'Color theme', includeHidden: true }), + header.getByRole('combobox', { name: 'Color theme', includeHidden: true }), ).toHaveCount(0) - await expect(navigation.getByRole('link', { name: 'Blog', exact: true })).toHaveCount(0) - const utilities = page.locator('.docs-section-utilities') - await expect(utilities.getByRole('link')).toHaveText(utilityLabels) - await expect(utilities.getByRole('link', { name: 'Blog', exact: true })).toHaveCount(0) - const search = navigation.getByRole('button', { name: 'Search documentation', exact: true }) - const agents = navigation.getByRole('button', { name: 'Agent setup', exact: true }) - await expect(agents).toBeEnabled() + await expect(header.getByRole('link', { name: 'Blog', exact: true })).toHaveCount(0) + // The products take the bar from 1200px; below it they move into the menu drawer. + const wide = width >= 1200 + const products = productNav(page) + const menu = menuButton(page) + if (wide) { + await expect(products.getByRole('link')).toHaveText(productLabels) + await expect(products.getByRole('button')).toHaveText(['Resources']) + if (path === '/') await expect(products.locator('[aria-current]')).toHaveCount(0) + else await expect(products.locator('[aria-current]')).toHaveText(['Accounts']) + await expect(menu).toBeHidden() + } else { + await expect(products).toBeHidden() + await expect(menu).toBeEnabled() + } + // Get API key is the one primary action; agent setup and Log in left the top bar. + await expect(header.getByRole('button', { name: 'Agent setup', exact: true })).toHaveCount(0) + await expect(header.getByRole('link', { name: 'Log in', exact: true })).toHaveCount(0) + const logo = header.getByRole('link', { name: 'Tempo Developers', exact: true }) + const search = header.getByRole('button', { name: 'Search documentation', exact: true }) + const apiKey = header.getByRole('link', { name: 'Get API key', exact: true }) + await expect(logo).toHaveAttribute('href', '/') + await expect(search).toBeEnabled() + await expect(apiKey).toHaveAttribute('href', 'https://console.tempo.xyz') await page.evaluate(() => document.fonts.ready) + // The lockup, products, search, Get API key and menu sit left to right, inside the + // viewport, without overlap. + const controls = wide + ? [logo, ...(await products.getByRole('link').all()), resourcesButton(page), search, apiKey] + : [logo, search, apiKey, menu] const boxes = await Promise.all( - [search, agents].map(async (control) => { + controls.map(async (control) => { await expect(control).toBeVisible() const box = await control.boundingBox() if (!box) throw new Error('Header control has no visible bounds') return box }), ) - const [searchBox, agentsBox] = boxes - expect(searchBox.x).toBeGreaterThan(agentsBox.x + agentsBox.width) - expect(searchBox.x + searchBox.width).toBeLessThanOrEqual(width) - for (const [index, a] of boxes.entries()) { - for (const b of boxes.slice(index + 1)) { - expect(a.x + a.width <= b.x || b.x + b.width <= a.x).toBe(true) - } + expect(boxes).toHaveLength(wide ? productLabels.length + 4 : 4) + for (const [index, box] of boxes.entries()) { + expect(box.x + box.width).toBeLessThanOrEqual(width) + const previous = boxes[index - 1] + if (previous) expect(box.x).toBeGreaterThanOrEqual(previous.x + previous.width) } - - await agents.click() - const panel = page.locator('#docs-agent-tools') - await expect(panel).toBeVisible() - await expect(agents).toHaveAttribute('aria-expanded', 'true') - const sections = page.getByRole('navigation', { name: 'Documentation sections', exact: true }) - await expect(sections).toBeVisible() - // Visibility alone does not detect another stacking context covering the panel. - const overlap = await panel.evaluate((element) => { - const sectionNav = document.querySelector('.docs-section-nav') - const panelRect = element.getBoundingClientRect() - const navRect = sectionNav?.getBoundingClientRect() ?? panelRect - const left = Math.max(panelRect.left, navRect.left) - const right = Math.min(panelRect.right, navRect.right) - const top = Math.max(panelRect.top, navRect.top) - const bottom = Math.min(panelRect.bottom, navRect.bottom) - return { - width: right - left, - height: bottom - top, - panelReceivesPointer: element.contains( - document.elementFromPoint((left + right) / 2, (top + bottom) / 2), - ), - } - }) - expect(overlap.width).toBeGreaterThan(0) - expect(overlap.height).toBeGreaterThan(0) - expect(overlap.panelReceivesPointer).toBe(true) - - await page.keyboard.press('Escape') - await expect(panel).toBeHidden() - await expect(agents).toBeFocused() - await agents.click() - await expect(panel).toBeVisible() - await page.getByRole('heading', { level: 1 }).click({ position: { x: 4, y: 4 } }) - await expect(panel).toBeHidden() - await expect(agents).toHaveAttribute('aria-expanded', 'false') }) } } -test('agent-first entry page offers setup and payment guides', async ({ +test('entry page offers one start button, three guides, the products and reference links', async ({ page, - context, request, }) => { - await context.grantPermissions(['clipboard-read', 'clipboard-write']) await page.setViewportSize({ width: 1440, height: 1000 }) await page.goto('/') - await expect(page.getByRole('heading', { level: 1 })).toHaveText('Documentation') + await expect(page.getByRole('heading', { level: 1 })).toHaveText( + 'Build stablecoin payments on Tempo', + ) await expect(page.locator('[data-v-gutter-left]')).toBeHidden() - await expect(page.locator('.tempo-docs-home-product-group')).toHaveCount(6) + const home = page.locator('.tempo-docs-home') + // One button in the hero. + const start = home.locator('.tempo-docs-home-actions').getByRole('link') + await expect(start).toHaveText(['Send your first payment']) + await expect(start).toHaveAttribute('href', '/get-started/quickstart') + // Three guides; Build with AI is the way in to agent setup. + const guides = home.locator('.tempo-docs-home-guide') + await expect(guides.getByRole('heading', { level: 3 })).toHaveText([ + 'Send your first payment', + 'Create an account', + 'Build with AI', + ]) + for (const [label, href] of [ + ['Run the quickstart', '/get-started/quickstart'], + ['Choose a signing method', '/docs/accounts/create'], + ['Set up your agent', '/docs/guide/using-tempo-with-ai'], + ]) { + await expect(guides.getByRole('link', { name: label, exact: true })).toHaveAttribute( + 'href', + href, + ) + } + await expect(home.locator('.tempo-docs-home-product-group')).toHaveCount(6) for (const product of ['accounts', 'earn', 'routes', 'zones', 'agents', 'development']) { await expect( - page.locator(`.tempo-docs-home-product-group h3 a[href="/docs/${product}"]`), + home.locator(`.tempo-docs-home-product-group h3 a[href="/docs/${product}"]`), ).toHaveCount(1) } - const setup = page.locator('.tempo-agent-start') - const installStatus = setup.locator('.tempo-agent-start-install').getByRole('status') - await expect(setup.getByRole('heading', { level: 3 })).toHaveText('Connect Tempo docs') - await expect(setup.getByRole('button', { name: /Copy prompt/ })).toHaveCount(0) - await expect( - setup.getByRole('link', { name: 'All setup options', exact: false }), - ).toHaveAttribute('href', '/docs/guide/using-tempo-with-ai') - for (const { label, command, instruction, install } of homeAgentSetups) { - const multipleCommands = command.includes('\n') - const commandSuccess = - label === 'MCP' - ? 'URL copied. Add it as an HTTP MCP server in your agent’s settings.' - : multipleCommands - ? 'Commands copied. Paste them into your terminal and run both commands.' - : 'Command copied. Paste it into your terminal and run it.' - await setup.getByRole('radio', { name: label, exact: true }).click() - await expect(installStatus).toHaveText('') - const instructionText = setup.locator( - '.tempo-agent-start-install .tempo-agent-start-instruction', - ) - if (instruction) await expect(instructionText).toContainText(instruction) - else await expect(instructionText).toHaveCount(0) - // The install link sits on the Connect Tempo docs heading row. - const installLink = setup.locator('.tempo-agent-start-heading .tempo-agent-start-prerequisite') - if (install) await expect(installLink).toContainText(install) - else await expect(installLink).toHaveCount(0) - await setup - .getByRole('button', { - name: - label === 'MCP' - ? 'Copy URL for MCP' - : `Copy command${multipleCommands ? 's' : ''} for ${label}`, - exact: true, - }) - .click() - await expect(installStatus).toHaveText(commandSuccess) - expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(command) - } - await setup.getByRole('radio', { name: 'Codex', exact: true }).click() - await expect(installStatus).toHaveText('') - await expect(page.locator('.tempo-docs-home')).not.toContainText('Bring your existing EVM app') + const reference = home.locator('.tempo-docs-home-reference') + await expect(reference.getByRole('heading', { level: 2 })).toHaveText('Reference & tools') + expect( + await reference + .locator('.tempo-docs-home-quick-link a') + .evaluateAll((links) => links.map((link) => link.getAttribute('href'))), + ).toEqual([ + '/docs/quickstart/connection-details', + '/docs/quickstart/faucet', + '/docs/api', + '/docs/sdk', + ]) + await expect(home).not.toContainText('Bring your existing EVM app') for (const anchor of [ 'start-here', 'choose-a-build-path', @@ -583,19 +665,15 @@ test('agent-first entry page offers setup and payment guides', async ({ expect((await request.get(destination)).status(), destination).toBe(200) } - await page.getByRole('link', { name: 'Send your first payment', exact: false }).first().click() + await start.click() await page.waitForURL(/\/get-started\/quickstart\/?$/) await expect(page.getByRole('heading', { level: 1 })).toHaveText('Quickstart') await expect( - page - .getByRole('navigation', { name: 'Documentation sections' }) - .getByRole('link', { name: 'Get Started', exact: true }), + productNav(page).getByRole('link', { name: 'Get started', exact: true }), ).toHaveAttribute('aria-current', 'page') }) -test('agent setup offers manual copy recovery without overflowing a narrow viewport', async ({ - page, -}) => { +test('Copy page offers manual recovery without overflowing a narrow viewport', async ({ page }) => { await page.setViewportSize({ width: 320, height: 844 }) await page.addInitScript(() => { Object.defineProperty(navigator, 'clipboard', { @@ -607,51 +685,40 @@ test('agent setup offers manual copy recovery without overflowing a narrow viewp }, }) }) - await page.goto('/') - const setup = page.locator('.tempo-agent-start') - const installStatus = setup.locator('.tempo-agent-start-install').getByRole('status') - const commands = setup.locator('.tempo-agent-start-command code') - for (const { label, command } of homeAgentSetups) { - const multipleCommands = command.includes('\n') - const commandFailure = - label === 'MCP' - ? 'Copy failed. Select and copy the server URL above.' - : multipleCommands - ? 'Copy failed. Select and copy both commands above.' - : 'Copy failed. Select and copy the command above.' - await setup.getByRole('radio', { name: label, exact: true }).click() - await expect(installStatus).toHaveText('') - await setup - .getByRole('button', { - name: - label === 'MCP' - ? 'Copy URL for MCP' - : `Copy command${multipleCommands ? 's' : ''} for ${label}`, - exact: true, - }) - .click() - await expect(installStatus).toHaveText(commandFailure) - await expect(commands).toBeVisible() - expect(await commands.innerText()).toBe(command) - expect( - await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), - label, - ).toBe(true) - } + await page.goto('/docs/cli') + const actions = page.locator('.docs-page-actions') + // Below 768px Ask AI and Copy page show only their icons and keep their names. + for (const name of ['Ask AI', 'Copy page']) + await expect(actions.getByRole('button', { name, exact: true })).toHaveText('') + await actions.getByRole('button', { name: 'Copy page', exact: true }).click() + await expect(actions.getByRole('status')).toHaveText( + 'Could not copy. Open View as Markdown to select the page text.', + ) + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true) + await actions.getByRole('button', { name: 'More page options', exact: true }).click() + const markdown = pageOptionsMenu(page).getByRole('menuitem', { + name: 'View as Markdown', + exact: true, + }) + await expect(markdown).toBeVisible() + await expect(markdown).toHaveAttribute('href', '/assets/md/docs/cli.md') + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true) }) -test('mobile documentation menu restores focus and navigates to another section', async ({ - page, -}) => { +test('mobile menu restores focus and navigates to another section', async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }) await page.goto('/') - await expect(page.getByRole('heading', { level: 1 })).toHaveText('Documentation') + await expect(page.getByRole('heading', { level: 1 })).toHaveText( + 'Build stablecoin payments on Tempo', + ) expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true) - const trigger = page.getByRole('button', { name: 'Open menu', exact: true }) + const trigger = menuButton(page) await trigger.click() - const menu = page.getByRole('dialog', { name: 'Documentation navigation', exact: true }) + const menu = menuDrawer(page) await expect(menu).toBeVisible() - await expect(menu.getByRole('button', { name: 'Close menu', exact: true })).toBeFocused() + // Focus moves into the drawer, which has its own Close button. + await expect(menu).toBeFocused() + await expect(menu.getByRole('button', { name: 'Close', exact: true })).toBeVisible() await page.keyboard.press('Escape') await expect(menu).not.toBeVisible() await expect(trigger).toBeFocused() @@ -659,53 +726,56 @@ test('mobile documentation menu restores focus and navigates to another section' ['Earn', '/docs/earn'], ['Accounts', '/docs/accounts'], ['Machine Payments', '/docs/agents'], - ['Tempo EVM', '/docs/development'], + ['Network', '/docs/development'], ]) { await trigger.click() await menu - .getByRole('navigation', { name: 'All documentation sections' }) + .getByRole('navigation', { name: 'Products', exact: true }) .getByRole('link', { name: label, exact: true }) .click() await expect(page).toHaveURL(new RegExp(`${path}/?$`)) await expect(menu).toBeHidden() await expect(page.locator('body')).not.toHaveCSS('overflow', 'hidden') expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true) - await page.getByRole('button', { name: 'Open docs navigation', exact: true }).click() - const drawer = page.getByRole('dialog', { name: 'Documentation', exact: true }) - await expect(drawer.locator(`a[href="${path}"]`)).toBeVisible() + await expectSection(page, label, true) + await trigger.click() + await expect( + menu + .getByRole('navigation', { name: 'Products', exact: true }) + .locator('[aria-current="page"]'), + ).toHaveText(label) + await expect(sectionPages(page, label).locator(`a[href="${path}"]`)).toBeVisible() await page.keyboard.press('Escape') - await expect(drawer).toBeHidden() + await expect(menu).toBeHidden() } }) test('mobile sidebars keep useful product guides visible', async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }) - for (const [path, destination] of [ - ['/docs/earn', '/docs/earn/how-it-works'], - ['/docs/routes', '/docs/routes/quotes'], - ['/docs/zones', '/docs/zones/connect'], + for (const [path, section, destination] of [ + ['/docs/earn', 'Earn', '/docs/earn/how-it-works'], + ['/docs/routes', 'Routes', '/docs/routes/quotes'], + ['/docs/zones', 'Zones', '/docs/zones/connect'], ]) { await page.goto(path) - await page.getByRole('button', { name: 'Open docs navigation', exact: true }).click() - const drawer = page.getByRole('dialog', { name: 'Documentation', exact: true }) + await menuButton(page).click() + const drawer = sectionPages(page, section) await expect(drawer.locator(`a[href="${destination}"]`)).toBeVisible() - await expect( - drawer.locator('summary').filter({ hasText: /More about|Earlier testnet/ }), - ).toHaveCount(0) + await expect(drawer.getByRole('button', { name: /More about|Earlier testnet/ })).toHaveCount(0) await page.keyboard.press('Escape') } await page.goto('/docs/accounts') - await page.getByRole('button', { name: 'Open docs navigation', exact: true }).click() - const drawer = page.getByRole('dialog', { name: 'Documentation', exact: true }) + await menuButton(page).click() + const drawer = sectionPages(page, 'Accounts') for (const href of ['/docs/accounts/create', '/docs/accounts/fund']) await expect(drawer.locator(`a[href="${href}"]`)).toBeVisible() - // Each Features and Build group opens to its own overview page and guides. + // Each Features and Build submenu opens to its own overview page and guides. for (const [group, href] of [ ['Account Policies', '/docs/accounts/policies'], ['Access Keys', '/docs/accounts/access-keys'], ['Wallets', '/docs/accounts/wallets'], ]) { - const disclosure = drawer.locator('summary').filter({ hasText: new RegExp(`^${group}$`) }) + const disclosure = drawer.getByRole('button', { name: group, exact: true }) await expect(drawer.locator(`a[href="${href}"]`)).toBeHidden() await disclosure.click() await expect(drawer.locator(`a[href="${href}"]`)).toBeVisible() @@ -716,7 +786,7 @@ test('mobile sidebars keep useful product guides visible', async ({ page }) => { await expect(page).toHaveURL(/\/docs\/accounts\/create\/?$/) await expect(drawer).toBeHidden() await expect(page.getByRole('heading', { level: 1 })).toHaveText('Create an Account') - await expect(page.locator('.docs-section-nav [aria-current="page"]')).toHaveText('Accounts') + await expect(breadcrumbItems(page)).toHaveText(['Accounts', 'Create an Account']) }) test('sandbox guides stay collapsed under Testnet Sandbox until a sandbox page opens', async ({ @@ -724,123 +794,227 @@ test('sandbox guides stay collapsed under Testnet Sandbox until a sandbox page o }) => { await page.setViewportSize({ width: 390, height: 844 }) await page.goto('/docs/zones') - await page.getByRole('button', { name: 'Open docs navigation', exact: true }).click() - const drawer = page.getByRole('dialog', { name: 'Documentation', exact: true }) - const sandbox = drawer.locator('summary').filter({ hasText: /^Testnet Sandbox$/ }) + await menuButton(page).click() + const drawer = sectionPages(page, 'Zones') + const sandbox = drawer.getByRole('button', { name: 'Testnet Sandbox', exact: true }) await expect(sandbox).toBeVisible() await expect(drawer.locator('a[href="/docs/guide/private-zones/deposit-to-a-zone"]')).toBeHidden() await sandbox.click() await drawer.locator('a[href="/docs/guide/private-zones"]').click() await expect(page).toHaveURL(/\/docs\/guide\/private-zones\/?$/) - await page.getByRole('button', { name: 'Open docs navigation', exact: true }).click() + await menuButton(page).click() await expect(drawer.locator('a[href="/docs/zones/privacy"]')).toBeVisible() await expect( drawer.locator('a[href="/docs/guide/private-zones/deposit-to-a-zone"]'), ).toBeVisible() await drawer.locator('a[href="/docs/guide/private-zones/deposit-to-a-zone"]').click() await expect(page).toHaveURL(/\/docs\/guide\/private-zones\/deposit-to-a-zone\/?$/) - await page.getByRole('button', { name: 'Open docs navigation', exact: true }).click() + await menuButton(page).click() await expect( drawer.locator('a[href="/docs/guide/private-zones/withdraw-from-a-zone"]'), ).toBeVisible() await expect(drawer.locator('a[href="/docs/zones/deposit"]')).toBeVisible() }) -test('mobile article drawer traps focus and survives client navigation', async ({ page }) => { +test('mobile menu drawer traps focus and survives client navigation', async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }) await page.goto('/docs/network') - const trigger = page.getByRole('button', { name: 'Open docs navigation', exact: true }) + // Below Desktop the menu drawer holds the section's pages, so Vocs' outline row stays hidden. + const outline = page + .locator('[data-v-outline-mobile]') + .getByRole('button', { name: 'On this page', exact: true, includeHidden: true }) + await expect(outline).toHaveCount(1) + await expect(outline).toBeHidden() + await expectSection(page, 'Network', true) + const trigger = menuButton(page) await trigger.click() - const drawer = page.getByRole('dialog', { name: 'Documentation', exact: true }) - const close = drawer.getByRole('button', { name: 'Close docs navigation', exact: true }) - await expect(close).toBeFocused() - const specifications = drawer.locator('summary').filter({ hasText: /^Changelog$/ }) + const drawer = menuDrawer(page) + const search = drawer.getByRole('button', { name: 'Search docs', exact: true }) + const close = drawer.getByRole('button', { name: 'Close', exact: true }) + await expect(drawer).toBeFocused() + // Focus wraps between the drawer's last control, Close, and its first, Search docs. await page.keyboard.press('Shift+Tab') - await expect(specifications).toBeFocused() + await expect(close).toBeFocused() await page.keyboard.press('Tab') + await expect(search).toBeFocused() + await page.keyboard.press('Shift+Tab') await expect(close).toBeFocused() + await page.keyboard.press('Tab') + await expect(search).toBeFocused() + // The products come straight after search. + await page.keyboard.press('Tab') + await expect( + drawer + .getByRole('navigation', { name: 'Products', exact: true }) + .getByRole('link', { name: 'Get started', exact: true }), + ).toBeFocused() await page.keyboard.press('Escape') await expect(drawer).toBeHidden() await expect(trigger).toBeFocused() - await page.getByRole('button', { name: 'Open menu', exact: true }).click() - const menu = page.getByRole('dialog', { name: 'Documentation navigation', exact: true }) - await menu.getByRole('link', { name: 'API reference', exact: true }).click() - await expect(page).toHaveURL(/\/docs\/api\/?$/) - await expect(menu).toBeHidden() + // The API reference has no top-bar entry; each product's pages end with its API reference. await trigger.click() - await expect(drawer).toBeVisible() + await sectionPages(page, 'Network').getByRole('link', { name: 'JSON-RPC', exact: true }).click() + await expect(page).toHaveURL(/\/docs\/api\/rpc\/?$/) + await expect(drawer).toBeHidden() + await trigger.click() + // The drawer follows the page to the API section's pages. + const api = sectionPages(page, 'API') + await expect(api).toBeVisible() await page.setViewportSize({ width: 1440, height: 1000 }) await expect(drawer).toBeHidden() await expect(page.locator('body')).not.toHaveCSS('overflow', 'hidden') }) -test('mobile pages without an outline still expose the documentation drawer', async ({ page }) => { +test('mobile pages without an outline still expose the menu drawer', async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }) await page.goto('/docs/sdk') await expect(page.locator('article[data-v-content] h2')).toHaveCount(0) - const trigger = page.getByRole('button', { name: 'Open docs navigation', exact: true }) + // The page's generic Overview label takes its group's name. + await expect(breadcrumbItems(page)).toHaveText(['SDKs & CLI', 'Tempo SDK']) + const trigger = menuButton(page) await expect(trigger).toBeVisible() await trigger.click() - const drawer = page.getByRole('dialog', { name: 'Documentation', exact: true }) + const drawer = menuDrawer(page) await expect(drawer).toBeVisible() - // Tempo SDK is an always-open section, so it renders as a heading above its pages. - await expect(drawer.locator('p').filter({ hasText: /^Tempo SDK$/ })).toBeVisible() - await drawer.getByRole('link', { name: 'Python', exact: true }).click() + await expect( + drawer + .getByRole('navigation', { name: 'Products', exact: true }) + .locator('[aria-current="page"]'), + ).toHaveText('SDKs & CLI') + const pages = sectionPages(page, 'SDKs & CLI') + // Tempo SDK is an always-open section, so it renders as a labelled group of its pages. + await expect(pages.getByRole('group', { name: 'Tempo SDK', exact: true })).toBeVisible() + await pages.getByRole('link', { name: 'Python', exact: true }).click() await expect(page).toHaveURL(/\/docs\/sdk\/python\/?$/) await expect(drawer).toBeHidden() await expect(page.locator('body')).not.toHaveCSS('overflow', 'hidden') await expect(trigger).toBeVisible() }) -test('agent setup closes when keyboard focus leaves and when resizing to mobile', async ({ +test('the Copy page menu is keyboard operable, closes on Escape and outside presses, and connects your agent', async ({ page, }) => { await page.setViewportSize({ width: 1440, height: 1000 }) await page.goto('/docs/accounts') - const trigger = page.getByRole('button', { name: 'Agent setup', exact: true }) - const panel = page.locator('#docs-agent-tools') + const actions = page.locator('.docs-page-actions') + const trigger = actions.getByRole('button', { name: 'More page options', exact: true }) + const menu = pageOptionsMenu(page) + const items = menu.getByRole('menuitem') + await expect(trigger).toHaveAttribute('aria-haspopup', 'menu') + await expect(trigger).toHaveAttribute('aria-expanded', 'false') + await trigger.focus() + await page.keyboard.press('Enter') + await expect(menu).toBeVisible() + await expect(trigger).toHaveAttribute('aria-expanded', 'true') + // Copy page is the split button's own action, so the menu holds the other page options. + await expect(items).toHaveText(['View as Markdown', 'Connect your agent']) + await expect( + menu.getByRole('menuitem', { name: 'View as Markdown', exact: true }), + ).toHaveAttribute('href', '/assets/md/docs/accounts.md') + await expect(items.nth(0)).toBeFocused() + await page.keyboard.press('End') + await expect(items.nth(1)).toBeFocused() + await page.keyboard.press('ArrowDown') + await expect(items.nth(0)).toBeFocused() + await page.keyboard.press('ArrowUp') + await expect(items.nth(1)).toBeFocused() + await page.keyboard.press('Home') + await expect(items.nth(0)).toBeFocused() + await page.keyboard.press('ArrowDown') + await expect(items.nth(1)).toBeFocused() + // Visibility alone does not detect another stacking context covering the menu. + const receivesPointer = await menu.evaluate((element) => { + const rect = element.getBoundingClientRect() + return element.contains( + document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2), + ) + }) + expect(receivesPointer).toBe(true) + + await page.keyboard.press('Escape') + await expect(menu).toBeHidden() + await expect(trigger).toBeFocused() + await expect(trigger).toHaveAttribute('aria-expanded', 'false') await trigger.click() - await expect(panel).toBeVisible() - await panel.locator('a[href], button:not([disabled])').last().focus() - await page.keyboard.press('Tab') - await expect(panel).toBeHidden() + await expect(menu).toBeVisible() + // While the menu is open, its backdrop takes presses outside it, such as on the H1. + const heading = await page.getByRole('heading', { level: 1 }).boundingBox() + if (!heading) throw new Error('The page heading has no visible bounds') + await page.mouse.click(heading.x + 4, heading.y + 4) + await expect(menu).toBeHidden() await expect(trigger).toHaveAttribute('aria-expanded', 'false') + // Agent setup moved from the header into this menu and Get started › Build with AI. await trigger.click() - await expect(panel).toBeVisible() - await page.setViewportSize({ width: 390, height: 844 }) - // Wait for the menu state to close, beyond CSS hiding the desktop header. - await expect(panel).toHaveCount(0) + await menu.getByRole('menuitem', { name: 'Connect your agent', exact: true }).click() + await expect(page).toHaveURL(/\/docs\/guide\/using-tempo-with-ai\/?$/) + await expect(page.getByRole('heading', { level: 1 })).toHaveText('Build with AI') + await expectSection(page, 'Get started') +}) + +test('the page header links its section and Ask AI opens the page in ChatGPT or Claude', async ({ + page, +}) => { await page.setViewportSize({ width: 1440, height: 1000 }) - await expect(trigger).toBeVisible() - await expect(trigger).toHaveAttribute('aria-expanded', 'false') - await expect(panel).toBeHidden() + await page.goto('/docs/accounts/create') + const askAi = page + .locator('.docs-page-actions') + .getByRole('button', { name: 'Ask AI', exact: true }) + const menu = page.getByRole('menu', { name: 'Ask AI', exact: true }) + // Ask AI moved from Vocs' floating field into the page header. + await expect(page.locator('[data-v-ask-ai-container]')).toBeHidden() + await askAi.click() + await expect(menu.getByRole('menuitem')).toHaveText(['Ask ChatGPT', 'Ask Claude']) + for (const [name, href] of [ + ['Ask ChatGPT', /^https:\/\/chatgpt\.com\?hints=search&q=\S+$/], + ['Ask Claude', /^https:\/\/claude\.ai\/new\?q=\S+$/], + ] as const) { + const item = menu.getByRole('menuitem', { name, exact: true }) + await expect(item).toHaveAttribute('href', href) + await expect(item).toHaveAttribute('target', '_blank') + } + await page.keyboard.press('Escape') + await expect(menu).toBeHidden() + await expect(askAi).toBeFocused() + // Cmd/Ctrl+I toggles it. + await page.keyboard.press('ControlOrMeta+i') + await expect(menu).toBeVisible() + await page.keyboard.press('ControlOrMeta+i') + await expect(menu).toBeHidden() + // The breadcrumb's section links its overview. + await expect(breadcrumbItems(page)).toHaveText(['Accounts', 'Create an Account']) + await breadcrumbItems(page).getByRole('link', { name: 'Accounts', exact: true }).click() + await expect(page).toHaveURL(/\/docs\/accounts\/?$/) + await expect(breadcrumbItems(page)).toHaveText(['Accounts', 'Introduction']) }) -test('article tools copy readable Markdown and survive client navigation', async ({ +test('Copy page copies readable Markdown and survives client navigation', async ({ page, context, }) => { await context.grantPermissions(['clipboard-read', 'clipboard-write']) await page.goto('/docs/cli') - const actions = page.getByRole('navigation', { name: 'Page tools' }) - await actions.getByRole('button', { name: 'Copy for agent' }).click() + const actions = page.locator('.docs-page-actions') + const options = actions.getByRole('button', { name: 'More page options', exact: true }) + const markdown = pageOptionsMenu(page).getByRole('menuitem', { + name: 'View as Markdown', + exact: true, + }) + await actions.getByRole('button', { name: 'Copy page', exact: true }).click() await expect(actions.getByRole('status')).toHaveText('Page Markdown copied to clipboard.') expect(await page.evaluate(() => navigator.clipboard.readText())).toContain('# Tempo CLI') - await expect(actions.getByRole('link', { name: 'View Markdown' })).toHaveAttribute( - 'href', - '/assets/md/docs/cli.md', - ) - await page - .locator('.docs-section-utilities') - .getByRole('link', { name: 'API reference', exact: true }) - .click() + await options.click() + await expect(markdown).toHaveAttribute('href', '/assets/md/docs/cli.md') + await page.keyboard.press('Escape') + await expect(markdown).toBeHidden() + // The API reference has no top-bar entry; Get started's sidebar ends with its overview. + await productNav(page).getByRole('link', { name: 'Get started', exact: true }).click() + await page.waitForURL(/\/get-started\/?$/) + await page.locator('[data-v-gutter-left] [data-v-sidebar-container] a[href="/docs/api"]').click() await page.waitForURL(/\/docs\/api\/?$/) await expect(actions).toHaveCount(1) - await expect(actions.getByRole('link', { name: 'View Markdown' })).toHaveAttribute( - 'href', - '/assets/md/docs/api.md', - ) + await options.click() + await expect(markdown).toHaveAttribute('href', '/assets/md/docs/api.md') }) test('mobile docs search works and appearance persists through navigation and reload', async ({ @@ -848,11 +1022,11 @@ test('mobile docs search works and appearance persists through navigation and re }) => { await page.setViewportSize({ width: 390, height: 844 }) await page.goto('/docs/accounts') - const menu = page.getByRole('dialog', { name: 'Documentation navigation', exact: true }) - await page.getByRole('button', { name: 'Open menu', exact: true }).click() + const menu = menuDrawer(page) + await menuButton(page).click() await menu.getByRole('combobox', { name: 'Color theme' }).selectOption('dark') await expect(page.locator('html')).toHaveAttribute('data-vocs-theme', 'dark') - await menu.getByRole('button', { name: 'Search documentation', exact: true }).click() + await menu.getByRole('button', { name: 'Search docs', exact: true }).click() await expect(menu).toBeHidden() const search = page.getByRole('dialog', { name: 'Search documentation', exact: true }) await expect(search).toBeVisible() @@ -865,7 +1039,7 @@ test('mobile docs search works and appearance persists through navigation and re await expect(page.locator('html')).toHaveAttribute('data-vocs-theme', 'dark') await page.reload() await expect(page.locator('html')).toHaveAttribute('data-vocs-theme', 'dark') - await page.getByRole('button', { name: 'Open menu', exact: true }).click() + await menuButton(page).click() await menu.getByRole('combobox', { name: 'Color theme' }).selectOption('light') await expect(page.locator('html')).toHaveAttribute('data-vocs-theme', 'light') await page.keyboard.press('Escape') @@ -874,12 +1048,12 @@ test('mobile docs search works and appearance persists through navigation and re function developerDisclosure(sidebar: Locator, label: string, mobile: boolean) { return mobile - ? sidebar.locator('summary:visible').filter({ hasText: new RegExp(`^${label}$`) }) + ? sidebar.getByRole('button', { name: label, exact: true }) : sidebar.getByRole('button', { name: new RegExp(`^(?:Toggle )?${label}(?: section)?$`) }) } async function expectOnlyActiveLeaf(sidebar: Locator, path: string) { - const active = sidebar.locator('a[data-active], a[aria-current="page"]') + const active = sidebar.locator(activeSidebarLink) await expect(active).toHaveCount(1) await expect(active).toHaveAttribute('href', path) const hrefs = await sidebar @@ -950,7 +1124,7 @@ for (const mobile of [false, true]) { for (const entry of [ { name: 'token introduction', - section: 'Tempo EVM', + section: 'Network', path: '/docs/network/tokens', group: 'TIP-20 Tokens', sibling: '/docs/guide/issuance/manage-stablecoin', @@ -958,7 +1132,7 @@ for (const mobile of [false, true]) { }, { name: 'token management', - section: 'Tempo EVM', + section: 'Network', path: '/docs/guide/issuance/manage-stablecoin', group: 'TIP-20 Tokens', sibling: '/docs/guide/issuance/mint-stablecoins', @@ -966,7 +1140,7 @@ for (const mobile of [false, true]) { }, { name: 'wallet integration', - section: 'Tempo EVM', + section: 'Network', path: '/docs/quickstart/wallet-developers', group: 'Accounts and keys', sibling: '/docs/development/account-keychain', @@ -974,7 +1148,7 @@ for (const mobile of [false, true]) { }, { name: 'transaction specification', - section: 'Tempo EVM', + section: 'Network', path: '/docs/protocol/transactions/spec-tempo-transaction', group: 'Transactions', sibling: '/docs/guide/tempo-transaction', @@ -983,7 +1157,7 @@ for (const mobile of [false, true]) { }, { name: 'fee specification', - section: 'Tempo EVM', + section: 'Network', path: '/docs/protocol/fees/spec-fee', group: 'Fees', sibling: '/docs/network/fees', @@ -992,7 +1166,7 @@ for (const mobile of [false, true]) { }, { name: 'token specification', - section: 'Tempo EVM', + section: 'Network', path: '/docs/protocol/tip20/spec', group: 'TIP-20 Tokens', sibling: '/docs/network/tokens', @@ -1001,7 +1175,7 @@ for (const mobile of [false, true]) { }, { name: 'account keychain reference', - section: 'Tempo EVM', + section: 'Network', path: '/docs/protocol/transactions/AccountKeychain', group: 'Accounts and keys', sibling: '/docs/development/account-keychain', @@ -1010,7 +1184,7 @@ for (const mobile of [false, true]) { }, { name: 'receive policy reference', - section: 'Tempo EVM', + section: 'Network', path: '/docs/protocol/tip403/receive-policies', group: 'Receive payments', sibling: '/docs/guide/payments/transfer-memos', @@ -1031,13 +1205,10 @@ for (const mobile of [false, true]) { }) => { await page.setViewportSize(viewport) expect((await page.goto(entry.path))?.status()).toBe(200) - await expect(page.locator('.docs-section-nav [aria-current="page"]')).toHaveText( - entry.section, - ) - if (mobile) - await page.getByRole('button', { name: 'Open docs navigation', exact: true }).click() + await expectSection(page, entry.section, mobile) + if (mobile) await menuButton(page).click() const sidebar = mobile - ? page.getByRole('dialog', { name: 'Documentation', exact: true }) + ? sectionPages(page, entry.section) : page.locator('[data-v-gutter-left] [data-v-sidebar-container]') const disclosure = developerDisclosure(sidebar, entry.group, mobile) await expect(disclosure).toBeVisible() @@ -1064,47 +1235,37 @@ for (const mobile of [false, true]) { await expect(sibling).toBeVisible() if (mobile) await page.keyboard.press('Escape') expect((await page.reload())?.status()).toBe(200) - if (mobile) - await page.getByRole('button', { name: 'Open docs navigation', exact: true }).click() + if (mobile) await menuButton(page).click() await expect(sidebar.locator(`a[href="${entry.sibling}"]`)).toBeVisible() await expectOnlyActiveLeaf(sidebar, entry.path) if ('guide' in entry && entry.guide) { - // Guides and their references keep the same tab and chapter through client navigation. + // Guides and their references keep the same section and chapter through client navigation. await sidebar.locator(`a[href="${entry.guide}"]`).click() await expect(page).toHaveURL(new RegExp(`${entry.guide}/?$`)) - await expect(page.locator('.docs-section-nav [aria-current="page"]')).toHaveText( - entry.section, - ) - if (mobile) - await page.getByRole('button', { name: 'Open docs navigation', exact: true }).click() + await expectSection(page, entry.section, mobile) + if (mobile) await menuButton(page).click() await expectOnlyActiveLeaf(sidebar, entry.guide) await expect(disclosure).toBeVisible() await expect(sidebar.locator(`a[href="${entry.path}"]`)).toBeVisible() await sidebar.locator(`a[href="${entry.path}"]`).click() await expect(page).toHaveURL(new RegExp(`${entry.path}/?$`)) - await expect(page.locator('.docs-section-nav [aria-current="page"]')).toHaveText( - entry.section, - ) - if (mobile) - await page.getByRole('button', { name: 'Open docs navigation', exact: true }).click() + await expectSection(page, entry.section, mobile) + if (mobile) await menuButton(page).click() await expectOnlyActiveLeaf(sidebar, entry.path) await expect(sidebar.locator(`a[href="${entry.guide}"]`)).toBeVisible() } }) } - test(`${surface} MPP deep links navigate within Machine payments`, async ({ page }) => { + test(`${surface} MPP deep links navigate within Machine Payments`, async ({ page }) => { await page.setViewportSize(viewport) expect((await page.goto('/docs/guide/machine-payments/pay-as-you-go'))?.status()).toBe(200) - await expect(page.locator('.docs-section-nav [aria-current="page"]')).toHaveText( - 'Machine Payments', - ) - if (mobile) - await page.getByRole('button', { name: 'Open docs navigation', exact: true }).click() + await expectSection(page, 'Machine Payments', mobile) + if (mobile) await menuButton(page).click() const sidebar = mobile - ? page.getByRole('dialog', { name: 'Documentation', exact: true }) + ? sectionPages(page, 'Machine Payments') : page.locator('[data-v-gutter-left] [data-v-sidebar-container]') - const active = sidebar.locator('a[data-active], a[aria-current="page"]') + const active = sidebar.locator(activeSidebarLink) await expect(active).toHaveCount(1) await expect(active).toHaveAttribute('href', '/docs/guide/machine-payments/pay-as-you-go') await expect(sidebar.getByRole('link', { name: 'Use Mercator', exact: true })).toBeVisible() @@ -1113,30 +1274,25 @@ for (const mobile of [false, true]) { await expect(page).toHaveURL(/\/docs\/guide\/machine-payments\/server\/?$/) if (mobile) await expect(sidebar).toBeHidden() expect((await page.reload())?.status()).toBe(200) - await expect(page.locator('.docs-section-nav [aria-current="page"]')).toHaveText( - 'Machine Payments', - ) - if (mobile) - await page.getByRole('button', { name: 'Open docs navigation', exact: true }).click() + await expectSection(page, 'Machine Payments', mobile) + if (mobile) await menuButton(page).click() await expect(active).toHaveCount(1) await expect(active).toHaveAttribute('href', '/docs/guide/machine-payments/server') }) } -test('the developer guide catalog opens Browser payments in its Tempo EVM home', async ({ - page, -}) => { +test('the developer guide catalog opens Browser payments in its Network home', async ({ page }) => { await page.setViewportSize({ width: 1440, height: 1000 }) await page.goto('/docs/development') const article = page.locator('article[data-v-content]') await expect(article.getByRole('heading', { level: 1 })).toHaveText('Tempo EVM') - await expect(page.locator('.docs-section-nav [aria-current="page"]')).toHaveText('Tempo EVM') + await expectSection(page, 'Network') const browserPayments = article.locator('a[href="/docs/guide/payments/send-a-payment/browser"]') await expect(browserPayments).toHaveCount(1) await expect(browserPayments).toHaveText('Browser payments') await browserPayments.click() await expect(page).toHaveURL(/\/docs\/guide\/payments\/send-a-payment\/browser\/?$/) - await expect(page.locator('.docs-section-nav [aria-current="page"]')).toHaveText('Tempo EVM') + await expectSection(page, 'Network') await expect(article.getByRole('heading', { level: 1 })).toHaveText('Browser payments') const sidebar = page.locator('[data-v-gutter-left] [data-v-sidebar-container]') await expect(sidebar.getByRole('link', { name: 'Send payments', exact: true })).toBeVisible() diff --git a/e2e/document-semantics.test.ts b/e2e/document-semantics.test.ts index 59462c610..37c0f62ab 100644 --- a/e2e/document-semantics.test.ts +++ b/e2e/document-semantics.test.ts @@ -82,17 +82,20 @@ test('keeps one route title through docs and OpenAPI client navigation', async ( await page.goto('/docs/quickstart/integrate-tempo', { waitUntil: 'networkidle' }) await expectSingleTitle(page, 'Integrate Tempo with Apps, Wallets, and Services | Tempo Docs') - await page - .getByRole('navigation', { name: 'Documentation sections', exact: true }) - .getByRole('link', { name: 'Tempo EVM', exact: true }) - .click() + const products = page + .locator('header.docs-site-header') + .getByRole('navigation', { name: 'Products', exact: true }) + // Tempo EVM is now the Network product; its landing page keeps the Tempo EVM title. + await products.getByRole('link', { name: 'Network', exact: true }).click() await page.waitForURL(/\/docs\/development\/?$/) await expectSingleTitle(page, 'Tempo EVM | Tempo Docs') - await page - .locator('.docs-section-utilities') - .getByRole('link', { name: 'API reference', exact: true }) - .click() + // The API reference has no top-bar entry; Get started's sidebar ends with its overview. + await products.getByRole('link', { name: 'Get started', exact: true }).click() + await page.waitForURL(/\/get-started\/?$/) + await expectSingleTitle(page, 'Get Started with Tempo | Tempo Docs') + + await page.locator('[data-v-gutter-left] a[href="/docs/api"]:visible').first().click() await page.waitForURL(/\/docs\/api\/?$/) await expectSingleTitle(page, 'Tempo API: Sponsor Gas Fees and Route Stablecoins | Tempo Docs') diff --git a/e2e/production-mount.test.ts b/e2e/production-mount.test.ts index eea3134e3..ad096faa2 100644 --- a/e2e/production-mount.test.ts +++ b/e2e/production-mount.test.ts @@ -15,10 +15,20 @@ test('production navigation preserves the mount through history and page tools', const page = await context.newPage() await page.setViewportSize({ width: 1440, height: 1000 }) await page.goto(`${productionDocsUrl}/get-started/quickstart`) - const sections = page.getByRole('navigation', { name: 'Documentation sections', exact: true }) + const products = page + .locator('header.docs-site-header') + .getByRole('navigation', { name: 'Products', exact: true }) const sidebar = page.locator('[data-v-gutter-left] nav[data-v-sidebar]') - await expect(sections.locator('[aria-current]')).toHaveText('Get Started') - await sections.getByRole('link', { name: 'Accounts', exact: true }).click() + await expect(products.locator('[aria-current]')).toHaveText('Get started') + await expect(products.getByRole('link', { name: 'Get started', exact: true })).toHaveAttribute( + 'href', + '/developers/get-started', + ) + // Wait for the header to hydrate (search enables) so the product link is a client navigation. + await expect( + page.getByRole('button', { name: 'Search documentation', exact: true }), + ).toBeEnabled() + await products.getByRole('link', { name: 'Accounts', exact: true }).click() await expect(page).toHaveURL(`${productionDocsUrl}/docs/accounts`) await sidebar.locator('a[href$="/docs/accounts/create"]').click() await expect(page).toHaveURL(`${productionDocsUrl}/docs/accounts/create`) @@ -38,12 +48,15 @@ test('production navigation preserves the mount through history and page tools', 'href', '/developers/docs/accounts/create', ) - const tools = page.getByRole('navigation', { name: 'Page tools' }) - await expect(tools).toHaveCount(1) - await expect(tools.getByRole('link', { name: 'View Markdown' })).toHaveAttribute( - 'href', - '/developers/assets/md/docs/accounts/create.md', - ) + // One Copy page split button; its menu links the page's Markdown inside the mount. + const actions = page.locator('.docs-page-actions') + await expect(actions).toHaveCount(1) + await actions.getByRole('button', { name: 'More page options', exact: true }).click() + await expect( + page + .getByRole('menu', { name: 'More page options', exact: true }) + .getByRole('menuitem', { name: 'View as Markdown', exact: true }), + ).toHaveAttribute('href', '/developers/assets/md/docs/accounts/create.md') expect(audit.errors).toEqual([]) expect(audit.failedRequests).toEqual([]) }) @@ -67,7 +80,12 @@ test('production docs search opens the passkey guide with its interactive demo', `${productionDocsUrl}/docs/accounts/authentication/passkeys#create-a-passkey`, ) await expect(dialog).toBeHidden() - await expect(page.locator('.docs-section-nav a[aria-current]')).toHaveText('Accounts') + await expect( + page + .locator('header.docs-site-header') + .getByRole('navigation', { name: 'Products', exact: true }) + .locator('a[aria-current]'), + ).toHaveText('Accounts') const demo = page.getByTestId('passkey-account-demo') await expect(demo).toBeVisible() await expect( @@ -84,25 +102,37 @@ test('production mobile menus keep the docs mount and current sidebar selection' const page = await context.newPage() await page.setViewportSize({ width: 390, height: 844 }) await page.goto(`${productionDocsUrl}/docs/accounts`) - await page.getByRole('button', { name: 'Open menu', exact: true }).click() - const menu = page.getByRole('dialog', { name: 'Documentation navigation', exact: true }) - await menu.getByRole('link', { name: 'Get Started', exact: true }).click() + const trigger = page.getByRole('button', { name: 'Open menu', exact: true }) + // The menu drawer lists the products above the current section's pages. + const menu = page.getByRole('dialog', { name: 'Tempo Developers menu', exact: true }) + const products = menu.getByRole('navigation', { name: 'Products', exact: true }) + const pages = menu.getByRole('navigation', { name: 'Get started pages', exact: true }) + await trigger.click() + await products.getByRole('link', { name: 'Get started', exact: true }).click() await expect(page).toHaveURL(`${productionDocsUrl}/get-started`) await expect(menu).toBeHidden() await expect(page.locator('body')).not.toHaveCSS('overflow', 'hidden') - const trigger = page.getByRole('button', { name: 'Open docs navigation', exact: true }) - const drawer = page.getByRole('dialog', { name: 'Documentation', exact: true }) await trigger.click() - await drawer.getByRole('link', { name: 'Quickstart', exact: true }).click() + await pages.getByRole('link', { name: 'Quickstart', exact: true }).click() await expect(page).toHaveURL(`${productionDocsUrl}/get-started/quickstart`) - await expect(drawer).toBeHidden() + await expect(menu).toBeHidden() + await expect( + page + .locator('.docs-page-header') + .getByRole('navigation', { name: 'Breadcrumb', exact: true }) + .getByRole('listitem'), + ).toHaveText(['Get started', 'Quickstart']) await trigger.click() - await expect(drawer.locator('a[aria-current]')).toHaveAttribute( + await expect(products.locator('a[aria-current="page"]')).toHaveAttribute( + 'href', + '/developers/get-started', + ) + await expect(pages.locator('a[aria-current]')).toHaveAttribute( 'href', '/developers/get-started/quickstart', ) await page.keyboard.press('Escape') - await expect(drawer).toBeHidden() + await expect(menu).toBeHidden() expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true) expect(audit.errors).toEqual([]) expect(audit.failedRequests).toEqual([]) diff --git a/e2e/sidebar-scroll.test.ts b/e2e/sidebar-scroll.test.ts index 21ad8dbc3..bd4e10b88 100644 --- a/e2e/sidebar-scroll.test.ts +++ b/e2e/sidebar-scroll.test.ts @@ -40,7 +40,8 @@ test('desktop sidebar keeps the current task active through nested steps and cli await expect(active).toHaveAttribute('href', '/docs/routes') await page - .getByRole('navigation', { name: 'Documentation sections', exact: true }) + .locator('header.docs-site-header') + .getByRole('navigation', { name: 'Products', exact: true }) .getByRole('link', { name: 'Accounts', exact: true }) .click() await expect(page).toHaveURL(/\/docs\/accounts\/?$/) @@ -112,26 +113,31 @@ test('mobile drawer retains the current task after scrolling and follows task na }) => { await page.setViewportSize({ width: 390, height: 844 }) await page.goto(`${transfer}#track-destination-delivery`) - const trigger = page.getByRole('button', { name: 'Open docs navigation', exact: true }) - const drawer = page.getByRole('dialog', { name: 'Documentation', exact: true }) + const trigger = page.getByRole('button', { name: 'Open menu', exact: true }) + // The menu drawer lists the products above the current section's pages. + const drawer = page.getByRole('dialog', { name: 'Tempo Developers menu', exact: true }) + const products = drawer.getByRole('navigation', { name: 'Products', exact: true }) + const pages = drawer.getByRole('navigation', { name: 'Routes pages', exact: true }) + const active = pages.locator('a[aria-current]') await trigger.click() - await expect(drawer.locator('a[aria-current]')).toHaveCount(1) - await expect(drawer.locator('a[aria-current]')).toHaveAttribute('href', transfer) - await expect(drawer.locator('a[aria-current]')).toHaveAttribute('aria-current', 'page') - await drawer.locator(`a[href="${deposits}"]`).click() + await expect(products.locator('a[aria-current="page"]')).toHaveText('Routes') + await expect(active).toHaveCount(1) + await expect(active).toHaveAttribute('href', transfer) + await expect(active).toHaveAttribute('aria-current', 'page') + await pages.locator(`a[href="${deposits}"]`).click() await expect(drawer).toBeHidden() await expect(page).toHaveURL(new RegExp(`${deposits}/?$`)) await page .locator('[id="track-each-deposit"]') .evaluate((element) => element.scrollIntoView({ behavior: 'instant' })) await trigger.click() - await expect(drawer.locator('a[aria-current]')).toHaveCount(1) - await expect(drawer.locator('a[aria-current]')).toHaveAttribute('href', deposits) - await drawer.locator('a[href="/docs/routes"]').click() + await expect(active).toHaveCount(1) + await expect(active).toHaveAttribute('href', deposits) + await pages.locator('a[href="/docs/routes"]').click() await expect(drawer).toBeHidden() await trigger.click() - await expect(drawer.locator('a[aria-current]')).toHaveAttribute('href', '/docs/routes') - await expect(drawer.locator('a[aria-current]')).toHaveAttribute('aria-current', 'page') + await expect(active).toHaveAttribute('href', '/docs/routes') + await expect(active).toHaveAttribute('aria-current', 'page') }) test('OpenAPI endpoints retain their own active state after client navigation from a task', async ({ @@ -154,10 +160,12 @@ test('OpenAPI endpoints retain their own active state after client navigation fr await expect(sidebar.locator('a[data-active]')).toHaveCount(1) await expect(sidebar.locator('a[data-active]')).toHaveAttribute('href', href) } - await page - .getByRole('navigation', { name: 'Documentation sections', exact: true }) - .getByRole('link', { name: 'Routes', exact: true }) - .click() + // The API reference has no top-bar entry, so no product is current; Routes opens from the bar. + const products = page + .locator('header.docs-site-header') + .getByRole('navigation', { name: 'Products', exact: true }) + await expect(products.locator('[aria-current]')).toHaveCount(0) + await products.getByRole('link', { name: 'Routes', exact: true }).click() await expect(page).toHaveURL(/\/docs\/routes\/?$/) await expect(sidebar.locator('a[data-active]')).toHaveCount(1) await expect(sidebar.locator('a[data-active]')).toHaveAttribute('href', '/docs/routes') diff --git a/e2e/zyzz-styles.test.ts b/e2e/zyzz-styles.test.ts index b3f73d112..9e11260bd 100644 --- a/e2e/zyzz-styles.test.ts +++ b/e2e/zyzz-styles.test.ts @@ -47,7 +47,8 @@ for (const width of [390, 1440]) { .poll(() => page.locator('body').evaluate((body) => { const style = getComputedStyle(body) - return { font: style.fontFamily.includes('Pilat'), margin: style.margin } + // Docs pages set copy in Inter (DS Body); titles keep Pilat. + return { font: style.fontFamily.includes('Inter'), margin: style.margin } }), ) .toEqual({ font: true, margin: '0px' }) @@ -61,7 +62,7 @@ for (const width of [390, 1440]) { .trim(), ), ) - .toBe('65px') + .toBe('56px') await expect(page.locator('.docs-site-header')).toHaveCSS('position', 'fixed') await expect(page.locator('html')).toHaveCSS('color-scheme', theme) await expect @@ -96,7 +97,8 @@ test('compiled homepage layout responds without losing its brand typography', as guides.evaluate((element) => getComputedStyle(element).gridTemplateColumns.split(' ').length), ) .toBe(1) - await expect(page.locator('.tempo-docs-home h1')).toHaveCSS('font-weight', '500') + // Headings are regular weight (system-v2). + await expect(page.locator('.tempo-docs-home h1')).toHaveCSS('font-weight', '400') }) test('component recipes preserve badges and keyboard-accessible docs buttons', async ({ page }) => { @@ -104,7 +106,8 @@ test('component recipes preserve badges and keyboard-accessible docs buttons', a const badge = page.locator('.docs-specification-meta span').first() // Inline flex is blockified inside the specification's flex link. await expect(badge).toHaveCSS('display', 'flex') - await expect(badge).toHaveCSS('border-radius', '8px') + // Badges take radius 4 (system-v2). + await expect(badge).toHaveCSS('border-radius', '4px') await page.goto('/docs/guide/using-tempo-with-ai') await selectTab(page.getByRole('tab', { name: 'Cursor', exact: true })) @@ -170,21 +173,22 @@ test('closed mobile navigation stays outside the viewport and restores focus on await page.setViewportSize({ width: 390, height: 844 }) await page.emulateMedia({ reducedMotion: 'reduce' }) await page.goto('/docs/guide/issuance/create-a-stablecoin') - const trigger = page.getByRole('button', { name: 'Open docs navigation', exact: true }) - // Include the closed dialog: an ARIA-hidden element can still cover the page visually. + const trigger = page.getByRole('button', { name: 'Open menu', exact: true }) + // Include a closed dialog: an ARIA-hidden element can still cover the page visually. The + // closed menu drawer unmounts, so nothing is left to cover the page. const panel = page.getByRole('dialog', { - name: 'Documentation', + name: 'Tempo Developers menu', exact: true, includeHidden: true, }) - const rightEdge = () => panel.evaluate((element) => element.getBoundingClientRect().right) await expect(trigger).toHaveAttribute('aria-expanded', 'false') - await expect.poll(rightEdge).toBeLessThanOrEqual(0) + await expect(panel).toHaveCount(0) await trigger.click() await expect(panel).toBeInViewport() - await expect(panel).toHaveAttribute('aria-modal', 'true') + // The drawer is modal: it hides the page behind it, top bar included, from assistive technology. + await expect(page.locator('header.docs-site-header')).toHaveAttribute('aria-hidden', 'true') await page.keyboard.press('Escape') - await expect.poll(rightEdge).toBeLessThanOrEqual(0) + await expect(panel).toHaveCount(0) await expect(trigger).toBeFocused() // Activation need not focus the button (for example, on touch browsers). @@ -193,8 +197,8 @@ test('closed mobile navigation stays outside the viewport and restores focus on element.click() }) await expect(panel).toBeInViewport() - await panel.getByRole('button', { name: 'Close docs navigation', exact: true }).click() - await expect.poll(rightEdge).toBeLessThanOrEqual(0) + await panel.getByRole('button', { name: 'Close', exact: true }).click() + await expect(panel).toHaveCount(0) await expect(trigger).toBeFocused() }) diff --git a/public/SKILL.md b/public/SKILL.md index cc6f688da..d77ec4a0e 100644 --- a/public/SKILL.md +++ b/public/SKILL.md @@ -16,23 +16,25 @@ All links below use the canonical docs mount at `https://tempo.xyz/developers`. | Section | Start here when the reader wants to… | | --- | --- | -| [Get Started](https://tempo.xyz/developers/get-started.md) | Choose an integration, send a first test payment, get testnet funds, connect a coding agent, or find the SDKs and CLI. | +| [Get started](https://tempo.xyz/developers/get-started.md) | Choose an integration, send a first test payment, get testnet funds, or connect a coding agent. | | [Accounts](https://tempo.xyz/developers/docs/accounts.md) | Create accounts, set up sign-in, security policies, access keys, and fees, or follow guides for wallet providers, SSO, self-custody, agents, automations, spending, and treasuries. | | [Earn](https://tempo.xyz/developers/docs/earn.md) | Choose a vault, deposit stablecoins, read positions, or withdraw. Earn is in beta; check access on the introduction page. | | [Routes](https://tempo.xyz/developers/docs/routes.md) | Accept cross-network deposits, quote transfers, or track delivery. Routes is in beta; check access on the introduction page. | | [Zones](https://tempo.xyz/developers/docs/zones.md) | Connect to a Zone and work with private balances, deposits, or withdrawals. Zones is live on Mainnet; check how to set up a Zone on the introduction page. | | [Machine Payments](https://tempo.xyz/developers/docs/agents.md) | Pay for APIs, accept API payments, discover services, or integrate MPP. | -| [Tempo EVM](https://tempo.xyz/developers/docs/development.md) | Build wallet support, manage signing keys, send or receive payments and reconcile deposits, use transactions and fees, issue TIP-20 Tokens, deploy contracts, exchange stablecoins, run nodes, or read protocol specifications and changelog. | -| [API reference](https://tempo.xyz/developers/docs/api.md) | Find Tempo API endpoints by product, authentication, Console management endpoints, JSON-RPC, and the indexer. | +| [Network](https://tempo.xyz/developers/docs/development.md) | Build wallet support, manage signing keys, send or receive payments and reconcile deposits, use transactions and fees, issue TIP-20 Tokens, deploy contracts, exchange stablecoins, or read protocol specifications; run RPC, standby, or validator nodes, upgrade node software, or follow network upgrades, activation timelines, and node releases. | +| [API](https://tempo.xyz/developers/docs/api.md) | Find Tempo API endpoints by product, authentication, Console management endpoints, JSON-RPC, and the indexer. | +| [SDKs & CLI](https://tempo.xyz/developers/docs/sdk.md) | Install a Tempo SDK for TypeScript, Rust, Go, Python, or Foundry, use the Tempo API client or server handlers, or run the Tempo CLI. | +| [Guides](https://tempo.xyz/developers/docs/resources/guides.md) | Plan payment infrastructure and workflows, liquidity and issuance, machine payments, wallets and funding, or compliance controls. | | [Partners](https://tempo.xyz/developers/docs/partners.md) | Find third-party wallets, bridges, RPC infrastructure, data services, and integrations. | -Accounts covers account setup and its guides; Tempo EVM covers payments and the chain capabilities behind them. Routes covers cross-network movement. The API reference supplements product guides with exact endpoints and schemas. SDKs and the CLI are under Get Started. +Accounts covers account setup and its guides; Network covers payments, the chain capabilities behind them, and running nodes. Routes covers cross-network movement. The API section supplements product guides with exact endpoints and schemas. SDKs & CLI covers the client libraries and the Tempo CLI. Choose providers through the task guides for [wallets](https://tempo.xyz/developers/docs/resources/guides/embedded-wallets.md), [funding and payouts](https://tempo.xyz/developers/docs/resources/guides/funding-and-payouts.md), [payment screening](https://tempo.xyz/developers/docs/resources/guides/payment-screening.md), and [reconciliation](https://tempo.xyz/developers/docs/resources/guides/payment-reconciliation.md). Use [node providers](https://tempo.xyz/developers/docs/partners/rpc-and-nodes.md) for hosted RPC access. For application checkout and returns, use [payment links](https://tempo.xyz/developers/docs/resources/guides/payment-links.md) and [refunds](https://tempo.xyz/developers/docs/resources/guides/refunds.md). These workflows connect trusted request records and amount reservations to the maintained payment primitives. -Within Tempo EVM, node operators start at [Run a node](https://tempo.xyz/developers/docs/guide/node.md). For network upgrades and activation timelines, read [Changelog](https://tempo.xyz/developers/docs/protocol/upgrades.md); for node software versions, read [Node releases](https://tempo.xyz/developers/docs/changelog.md). +Within Network, node operators start at [Run a node](https://tempo.xyz/developers/docs/guide/node.md). For network upgrades and activation timelines, read [Changelog](https://tempo.xyz/developers/docs/protocol/upgrades.md); for node software versions, read [Node releases](https://tempo.xyz/developers/docs/changelog.md). Use current Zones pages before the earlier `/docs/guide/private-zones` sandbox walkthroughs. That sandbox uses a separate integration and does not establish which operations are available in current Mainnet deployments. diff --git a/src/components/Badge.tsx b/src/components/Badge.tsx index 67c79c3ea..79a945043 100644 --- a/src/components/Badge.tsx +++ b/src/components/Badge.tsx @@ -13,11 +13,12 @@ export function Badge({ const badge = variants({ base: { display: 'inline-flex', - minHeight: '24px', + minHeight: '20px', alignItems: 'center', justifyContent: 'center', - borderRadius: tokens.radius.md, - paddingInline: tokens.spacing['2'], + // Badges (20 high) take radius 4. + borderRadius: tokens.radius.sm, + paddingInline: tokens.spacing['1_5'], textAlign: 'center', fontWeight: tokens.fontWeight.medium, fontSize: tokens.fontSize.xs, @@ -52,9 +53,8 @@ const badge = variants({ minWidth: '80px', minHeight: '28px', paddingInline: tokens.spacing['2'], - // design-exception: TDS Badge small radius (6px), between the 4px and 8px steps. - borderRadius: '6px !custom', - '--corner-radius': '6px', + borderRadius: tokens.radius.sm, + '--corner-radius': tokens.radius.sm, boxSizing: 'border-box', // A real border (not TDS's overlay) so the smooth-corner fallback can stroke it. borderWidth: tokens.borderWidth.hairline, diff --git a/src/components/DocsCards.styles.ts b/src/components/DocsCards.styles.ts index cd1ff3b85..32189d227 100644 --- a/src/components/DocsCards.styles.ts +++ b/src/components/DocsCards.styles.ts @@ -2,15 +2,18 @@ import { global } from 'zyzz/web' import { inherited } from '../styles/inherited' import { vars as tokens } from '../styles/theme' -// Vocs boxes the Previous/Next pagination links below its sm breakpoint. Keep them plain links -// at every width; this layer follows vocs_utilities so it outranks the max-sm utilities. +// Vocs boxes the Previous/Next pagination links below its sm breakpoint. Keep them as the same +// next-step cards at every width; this layer follows vocs_utilities so it outranks max-sm. global({ '@layer utilities': { '@media (width < 640px)': { '[data-v-pagination] > a': { - padding: 0, - border: 0, - borderRadius: 0, + paddingBlock: tokens.spacing['4'], + paddingInline: tokens.spacing['5'], + borderWidth: tokens.borderWidth.hairline, + borderStyle: 'solid', + borderColor: tokens.color.hairline, + borderRadius: tokens.radius.lg, }, }, }, @@ -20,13 +23,9 @@ global({ // Vocs pagination links share the card markup, so they are excluded explicitly. global({ ':root': { - '--tempo-card-radius': tokens.radius.panel, - '--tempo-card-padding': '28px', - }, - '@media (width < 700px)': { - ':root': { - '--tempo-card-padding': '24px', - }, + // Cards are surfaces: radius 12, padding 20 (system-v2). + '--tempo-card-radius': tokens.radius.lg, + '--tempo-card-padding': '20px', }, 'article[data-v-content] a[class~="vocs:flex-col"]:not([data-v-pagination] > a):has(> [class~="vocs:text-heading"]):has( > [class~="vocs:text-secondary"] )': { @@ -34,16 +33,20 @@ global({ gridTemplateColumns: 'minmax(0, 1fr)', gridTemplateRows: 'auto 1fr', alignContent: 'start', - // design-exception: Preserve this component’s existing geometry; it is not a shared scale step. - gap: '12px 10px !custom', + // Title to description 4; the icon column sits 8 from the title. + rowGap: tokens.spacing['1'], + columnGap: tokens.spacing['2'], minWidth: 0, // design-exception: Preserve the inherited component/framework scope at the point of use. padding: 'var(--tempo-card-padding) !custom', - border: 0, + // Surfaces sit on the page background with a 1px hairline. + borderWidth: tokens.borderWidth.hairline, + borderStyle: 'solid', + borderColor: tokens.color.hairline, // design-exception: Preserve the inherited component/framework scope at the point of use. borderRadius: 'var(--tempo-card-radius) !custom', '--corner-radius': 'var(--tempo-card-radius)', - backgroundColor: tokens.color.panel, + backgroundColor: tokens.color.background, color: inherited.color.vocsTextColorPrimary, textDecoration: 'none', @@ -77,11 +80,11 @@ global({ ':is(article[data-v-content] a[class~="vocs:flex-col"]:not([data-v-pagination] > a):has(> [class~="vocs:text-heading"]):has( > [class~="vocs:text-secondary"] )) > [class~="vocs:text-heading"]': { gridRow: 1, - fontSize: tokens.fontSize.lead, + fontSize: tokens.fontSize.body, fontWeight: tokens.fontWeight.medium, - letterSpacing: tokens.letterSpacing.tight, - lineHeight: tokens.lineHeight.snug, + letterSpacing: tokens.letterSpacing.normal, + lineHeight: tokens.lineHeight.body, }, ':is(article[data-v-content] a[class~="vocs:flex-col"]:not([data-v-pagination] > a):has(> [class~="vocs:text-heading"]):has( > [class~="vocs:text-secondary"] )) > [class~="vocs:text-secondary"]': { @@ -90,7 +93,7 @@ global({ color: inherited.color.vocsTextColorSecondary, fontSize: tokens.fontSize.sm, - lineHeight: tokens.lineHeight.relaxed, + lineHeight: tokens.lineHeight.control, }, ':is(article[data-v-content] a[class~="vocs:flex-col"]:not([data-v-pagination] > a):has(> [class~="vocs:text-heading"]):has( > [class~="vocs:text-secondary"] )):focus-visible': { diff --git a/src/components/DocsFooter.recipes.ts b/src/components/DocsFooter.recipes.ts new file mode 100644 index 000000000..7170fc42b --- /dev/null +++ b/src/components/DocsFooter.recipes.ts @@ -0,0 +1,139 @@ +import { style } from '../styles/recipes' +import { vars as tokens } from '../styles/theme' + +// The footer (Home and Not found) sits under one top hairline, with its text on the page +// gutter (24, 16 below Tablet). The fill hides the outline rail where the footer runs under it. +export const root = style({ + paddingInline: tokens.spacing['4'], + borderTopWidth: tokens.borderWidth.hairline, + borderTopStyle: 'solid', + borderTopColor: tokens.color.hairline, + backgroundColor: tokens.color.background, + fontFamily: tokens.fontFamily.ui, + letterSpacing: tokens.letterSpacing.normal, + color: tokens.color.muted, + '@media (width >= 768px)': { + paddingInline: tokens.spacing['6'], + }, +}) + +// Full (Home and Not found) sits on the panel fill (DS background/primary), as in the Figma. +export const full = style({ + backgroundColor: tokens.color.panel, +}) + +export const inlineList = style({ + display: 'flex', + flexWrap: 'wrap', + alignItems: 'center', + columnGap: tokens.spacing['4'], + rowGap: tokens.spacing['2'], +}) + +// The T mark and the tagline, then three columns of 14/20 links, then the 12/16 bottom row. +export const brand = style({ + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + gap: tokens.spacing['4'], + paddingBlockStart: tokens.spacing['12'], +}) + +export const mark = style({ + width: '17px', + height: '18px', + color: tokens.color.foreground, +}) + +export const tagline = style({ + maxWidth: '320px', + margin: tokens.spacing['0'], + fontSize: tokens.fontSize.sm, + lineHeight: tokens.lineHeight.control, + color: tokens.color.muted, +}) + +export const columns = style({ + display: 'grid', + gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', + columnGap: tokens.spacing['6'], + rowGap: tokens.spacing['8'], + paddingBlockStart: tokens.spacing['8'], + paddingBlockEnd: tokens.spacing['12'], + '@media (width >= 768px)': { + gridTemplateColumns: 'repeat(3, minmax(0, 160px))', + }, +}) + +export const column = style({ + display: 'flex', + flexDirection: 'column', + gap: tokens.spacing['4'], + minWidth: 0, +}) + +export const columnHeader = style({ + fontSize: tokens.fontSize.sm, + fontWeight: tokens.fontWeight.medium, + lineHeight: tokens.lineHeight.control, + color: tokens.color.foreground, +}) + +export const list = style({ + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + gap: tokens.spacing['3'], + fontSize: tokens.fontSize.sm, + lineHeight: tokens.lineHeight.control, +}) + +export const bottom = style({ + display: 'flex', + flexWrap: 'wrap', + alignItems: 'center', + columnGap: tokens.spacing['4'], + rowGap: tokens.spacing['2'], + paddingBlock: tokens.spacing['6'], + borderTopWidth: tokens.borderWidth.hairline, + borderTopStyle: 'solid', + borderTopColor: tokens.color.hairline, + fontSize: tokens.fontSize.xs, + lineHeight: tokens.lineHeight.caption, +}) + +// Links are secondary and turn primary on hover. +export const link = style({ + display: 'inline-flex', + alignItems: 'center', + gap: tokens.spacing['0_5'], + color: tokens.color.muted, + textDecoration: 'none', + transitionProperty: 'color, background-color, border-color, opacity, transform', + transitionDuration: 'var(--tempo-exit)', + transitionTimingFunction: 'var(--tempo-ease)', + selectors: { + '&:hover': { + '@media (hover: hover)': { + color: tokens.color.foreground, + transitionDuration: 'var(--tempo-enter)', + }, + }, + }, + '@media (prefers-reduced-motion: reduce)': { + transitionProperty: 'none', + }, +}) + +export const externalIcon = style({ + flexShrink: 0, +}) + +export const visuallyHidden = style({ + position: 'absolute', + width: '1px', + height: '1px', + overflow: 'hidden', + clipPath: 'inset(50%)', + whiteSpace: 'nowrap', +}) diff --git a/src/components/DocsFooter.tsx b/src/components/DocsFooter.tsx new file mode 100644 index 000000000..7c132bbd7 --- /dev/null +++ b/src/components/DocsFooter.tsx @@ -0,0 +1,113 @@ +'use client' + +import { MdxPageContext } from 'vocs' +import { Link, useRouter } from 'waku' +import ArrowUpRightIcon from '~icons/lucide/arrow-up-right' +import { docsResourceSections, docsSections } from '../lib/docs-sections' +import * as ui from './DocsFooter.recipes' +import { TempoMark } from './ToolLogos' + +type FooterLink = { label: string; href: string } +type FooterColumn = { header: string; links: FooterLink[] } + +const githubLink: FooterLink = { label: 'GitHub', href: 'https://github.com/tempoxyz' } +const xLink: FooterLink = { label: 'X', href: 'https://twitter.com/tempo' } +const tempoLink: FooterLink = { label: 'tempo.xyz', href: 'https://tempo.xyz' } +const consoleLink: FooterLink = { label: 'Console', href: 'https://console.tempo.xyz' } +const explorerLink: FooterLink = { label: 'Explorer', href: 'https://explore.tempo.xyz' } + +const socialLinks = [githubLink, xLink] + +// Products and Resources repeat the top bar, with its labels and landing routes; the API +// reference, which has no top-bar entry, closes Resources. +const columns: FooterColumn[] = [ + { + header: 'Products', + links: docsSections.map((section) => ({ label: section.label, href: section.href })), + }, + { + header: 'Resources', + links: [ + ...docsResourceSections.map((section) => ({ label: section.label, href: section.href })), + { label: 'API reference', href: '/docs/api' }, + ], + }, + { + header: 'Tempo', + links: [ + tempoLink, + consoleLink, + { label: 'Wallet', href: 'https://wallet.tempo.xyz' }, + explorerLink, + { label: 'MPP', href: 'https://mpp.dev' }, + { label: 'Mercator', href: 'https://mercator.sh' }, + ], + }, +] + +function FooterLinkItem({ link }: { link: FooterLink }) { + if (link.href.startsWith('/')) + return ( + + {link.label} + + ) + return ( + + {link.label} + + ) +} + +function LinkList({ links, className }: { links: FooterLink[]; className: string }) { + return ( + + ) +} + +/** Home and Not found (its frontmatter sets `footer: full`) have a footer; docs pages end at Previous / Next. */ +function useShowsFooter() { + const { path } = useRouter() + const { frontmatter } = MdxPageContext.use() + const pathname = (path ?? '/').replace(/^\/developers(?=\/|$)/, '').replace(/\/+$/, '') || '/' + return pathname === '/' || frontmatter?.footer === 'full' +} + +/** The site footer on Home and Not found, rendered through the Vocs Footer slot. */ +export default function DocsFooter() { + const showsFooter = useShowsFooter() + if (!showsFooter) return null + const copyright = © {new Date().getFullYear()} Tempo + + return ( +
+
+
+ +
+ {copyright} + +
+
+ ) +} diff --git a/src/components/DocsHeader.recipes.ts b/src/components/DocsHeader.recipes.ts deleted file mode 100644 index c8c47e7b2..000000000 --- a/src/components/DocsHeader.recipes.ts +++ /dev/null @@ -1,440 +0,0 @@ -import { inherited } from '../styles/inherited' -import { style } from '../styles/recipes' -import { style as instanceStyle } from '../styles/scoped' -import { vars as tokens } from '../styles/theme' -export const tempoLogoText = style({ - display: 'block', - backgroundColor: 'currentcolor !custom', -}) -export const activeSquareIcon = style({ - width: '11px', - height: '11px', - flexShrink: 0, - - color: inherited.color.colorMixInOklabForeground70Transparent, -}) -export const searchIconIcon = style({ - flexShrink: 0, -}) -export const chevronIcon = style({ - flexShrink: 0, - opacity: '60%', - transitionProperty: 'transform, translate, scale, rotate', - transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)', - transitionDuration: '200ms', -}) -export const chevronIcon2 = style({ - rotate: '180deg', -}) -export const codexLogo = style({ - width: tokens.spacing['3_5'], - height: tokens.spacing['3_5'], - flexShrink: 0, -}) -export const commandTabsLayout = style({ - display: 'flex', - flexWrap: 'wrap', - gap: tokens.spacing['1_5'], -}) -export const commandTabsButton = style({ - display: 'inline-flex', - alignItems: 'center', - gap: tokens.spacing['1_5'], - borderRadius: tokens.radius.sm, - paddingInline: tokens.spacing['2_5'], - paddingBlock: tokens.spacing['1_5'], - fontFamily: tokens.fontFamily.book, - fontSize: tokens.fontSize.xs, - letterSpacing: tokens.letterSpacing.normal, - transitionProperty: - 'color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tempo-style-gradient-from, --tempo-style-gradient-via, --tempo-style-gradient-to', - transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)', - transitionDuration: '150ms', -}) -export const commandTabsButton2 = style({ - backgroundColor: inherited.color.colorMixInOklabForeground6Transparent, - color: tokens.color.foreground, -}) -export const commandTabsButton3 = style({ - color: inherited.color.colorMixInOklabForeground60Transparent, - selectors: { - '&:hover': { - '@media (hover: hover)': { - backgroundColor: inherited.color.colorMixInOklabForeground3Transparent, - - color: inherited.color.colorMixInOklabForeground70Transparent, - }, - }, - }, -}) -export const commandSnippetButton = style({ - display: 'flex', - minHeight: '48px', - width: '100%', - alignItems: 'flex-start', - gap: tokens.spacing['3'], - borderRadius: tokens.radius.sm, - - backgroundColor: inherited.color.colorMixInOklabForeground35000000000000004Transparent, - paddingInline: tokens.spacing['3'], - paddingBlock: tokens.spacing['2_5'], - textAlign: 'left', - transitionProperty: - 'color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tempo-style-gradient-from, --tempo-style-gradient-via, --tempo-style-gradient-to', - transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)', - transitionDuration: '150ms', - selectors: { - '&:hover': { - '@media (hover: hover)': { - backgroundColor: inherited.color.colorMixInOklabForeground6Transparent, - }, - }, - }, -}) -export const code = style({ - display: 'grid', - minWidth: '0', - flex: '1 1 0%', - gridTemplateColumns: 'auto minmax(0,1fr)', - gap: tokens.spacing['2'], - fontFamily: tokens.fontFamily.code, - fontSize: tokens.fontSize.xs, - - lineHeight: tokens.lineHeight.normal, - overflowWrap: 'break-word', - whiteSpace: 'pre-wrap', - color: tokens.color.foreground, -}) -export const commandSnippetText = style({ - color: inherited.color.colorMixInOklabForeground55Transparent, - WebkitUserSelect: 'none', - userSelect: 'none', -}) -export const commandSnippetText2 = style({ - minWidth: '0', -}) -export const commandSnippetText3 = style({ - marginTop: tokens.spacing['1'], - flexShrink: 0, - transitionProperty: - 'color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tempo-style-gradient-from, --tempo-style-gradient-via, --tempo-style-gradient-to', - transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)', - transitionDuration: '150ms', -}) -export const commandSnippetText4 = style({ - color: tokens.color.foreground, -}) -export const commandSnippetText5 = style({ - color: inherited.color.colorMixInOklabForeground55Transparent, - selectors: { - '&:is(:where(.group\\/copy):hover *)': { - '@media (hover: hover)': { - color: inherited.color.colorMixInOklabForeground70Transparent, - }, - }, - }, -}) -export const agentCommandSectionLayout = style({ - borderRadius: tokens.radius.sm, - paddingInline: tokens.spacing['3'], - paddingBlock: tokens.spacing['2_5'], - transitionProperty: - 'color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tempo-style-gradient-from, --tempo-style-gradient-via, --tempo-style-gradient-to', - transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)', - transitionDuration: '150ms', - selectors: { - '&:hover': { - '@media (hover: hover)': { - backgroundColor: inherited.color.colorMixInOklabForeground4Transparent, - }, - }, - }, -}) -export const agentCommandSectionLayout2 = style({ - display: 'flex', - alignItems: 'flex-start', - gap: tokens.spacing['3'], -}) -export const agentCommandSectionText = style({ - display: 'grid', - width: '34px', - height: '34px', - flexShrink: 0, - placeItems: 'center', - - backgroundColor: inherited.color.surfaceInput, - color: tokens.color.foreground, -}) -export const anchor = style({ - position: 'relative', - display: 'flex', - minWidth: '0', - flexDirection: 'column', - gap: tokens.spacing['0_5'], - paddingInlineEnd: tokens.spacing['5'], -}) -export const arrowUpRight = style({ - position: 'absolute', - top: tokens.spacing['0_5'], - insetInlineEnd: '0', - width: tokens.spacing['3'], - height: tokens.spacing['3'], - - color: inherited.color.colorMixInOklabForeground55Transparent, - transitionProperty: - 'color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tempo-style-gradient-from, --tempo-style-gradient-via, --tempo-style-gradient-to', - transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)', - transitionDuration: '150ms', - selectors: { - '&:is(:where(.group\\/item):hover *)': { - '@media (hover: hover)': { - color: inherited.color.colorMixInOklabForeground60Transparent, - }, - }, - }, -}) -export const agentCommandSectionText2 = style({ - fontFamily: tokens.fontFamily.book, - fontSize: tokens.fontSize.sm, - letterSpacing: tokens.letterSpacing.normal, - color: tokens.color.foreground, -}) -export const agentCommandSectionText3 = style({ - fontFamily: tokens.fontFamily.book, - fontSize: tokens.fontSize.compact, - lineHeight: tokens.lineHeight.snug, - letterSpacing: tokens.letterSpacing.normal, - - color: inherited.color.colorMixInOklabForeground60Transparent, -}) -export const agentCommandSectionLayout3 = style({ - marginTop: tokens.spacing['3'], - - marginInlineStart: tokens.spacing['12'], - selectors: { - ':where(& > :not(:last-child))': { - '--tempo-style-space-y-reverse': '0', - - marginBlockStart: 0, - - marginBlockEnd: tokens.spacing['3'], - }, - }, -}) -export const agentsPanelLayout = style({ - width: '520px', - padding: tokens.spacing['3'], -}) -export const agentsPanelLayout2 = style({ - paddingBottom: tokens.spacing['4'], - paddingInlineStart: tokens.spacing['3'], -}) -export const agentsPanelDescription = style({ - paddingInline: tokens.spacing['3'], - paddingTop: tokens.spacing['2'], - paddingBottom: tokens.spacing['1_5'], - fontFamily: tokens.fontFamily.book, - fontSize: tokens.fontSize.compact, - letterSpacing: tokens.letterSpacing.normal, - - color: inherited.color.colorMixInOklabForeground55Transparent, -}) -export const agentsPanelLayout3 = style({ - selectors: { - ':where(& > :not(:last-child))': { - '--tempo-style-space-y-reverse': '0', - - marginBlockStart: 0, - - marginBlockEnd: tokens.spacing['1'], - }, - }, -}) -export const agentsPanelDescription2 = style({ - paddingInline: tokens.spacing['2_5'], - - paddingBlock: tokens.spacing['1'], - fontFamily: tokens.fontFamily.book, - fontSize: tokens.fontSize.xs, - - color: inherited.color.colorMixInOklabForeground60Transparent, -}) -export const anchor2 = style({ - // design-exception: Derive this layout value from the existing responsive CSS variables. - marginInline: 'calc(var(--spacing) * -2) !custom', - display: 'flex', - minHeight: tokens.spacing['8'], - alignItems: 'center', - gap: tokens.spacing['2'], - // SB3 in the drawer: the desktop sidebar's smoothed radius and hover motion. - borderRadius: tokens.radius.md, - '--corner-radius': tokens.radius.md, - paddingInline: tokens.spacing['2'], - - paddingBlock: tokens.spacing['1'], - fontFamily: tokens.fontFamily.book, - fontSize: tokens.fontSize.sm, - letterSpacing: tokens.letterSpacing.normal, - transitionProperty: 'color, background-color, border-color, opacity, transform', - transitionTimingFunction: 'var(--tempo-ease)', - transitionDuration: 'var(--tempo-exit)', - selectors: { - '&:hover': { transitionDuration: 'var(--tempo-enter)' }, - }, -}) -// SB1 in the drawer: the current page does not change on hover. -export const anchor3 = style({ - fontWeight: tokens.fontWeight.medium, - color: tokens.color.foreground, -}) -// SB2 in the drawer: entries use the primary color. -export const anchor4 = style({ - color: tokens.color.foreground, - selectors: { - '&:hover': { - '@media (hover: hover)': { - backgroundColor: inherited.color.colorMixInOklabForeground4Transparent, - }, - }, - }, -}) -export const arrowUpRight2 = style({ - marginTop: tokens.spacing['0_5'], - width: tokens.spacing['3'], - height: tokens.spacing['3'], -}) -export const sidebarDisclosureDetails = style({ - marginTop: tokens.spacing['1'], -}) -export const sidebarDisclosureSummary = style({ - // design-exception: Derive this layout value from the existing responsive CSS variables. - marginInline: 'calc(var(--spacing) * -2) !custom', - display: 'flex', - minHeight: tokens.spacing['8'], - cursor: 'pointer', - listStyleType: 'none', - alignItems: 'center', - justifyContent: 'space-between', - borderRadius: tokens.radius.md, - '--corner-radius': tokens.radius.md, - paddingInline: tokens.spacing['2'], - - paddingBlock: tokens.spacing['1'], - fontFamily: tokens.fontFamily.book, - fontSize: tokens.fontSize.sm, - letterSpacing: tokens.letterSpacing.normal, - - color: tokens.color.foreground, - transitionProperty: 'color, background-color, border-color, opacity, transform', - transitionTimingFunction: 'var(--tempo-ease)', - transitionDuration: 'var(--tempo-exit)', - selectors: { - '&:hover': { - transitionDuration: 'var(--tempo-enter)', - '@media (hover: hover)': { - backgroundColor: inherited.color.colorMixInOklabForeground4Transparent, - }, - }, - '&::-webkit-details-marker': { - display: 'none', - }, - }, -}) -export const sidebarDisclosureLayout = style({ - marginTop: tokens.spacing['1'], - marginInlineStart: tokens.spacing['2'], - display: 'flex', - flexDirection: 'column', - gap: tokens.spacing['0'], - borderInlineStartStyle: 'solid', - borderInlineStartWidth: tokens.borderWidth.hairline, - borderColor: tokens.color.line, - paddingInlineStart: tokens.spacing['3'], -}) -export const sidebarNodesLayout = style({ - marginTop: tokens.spacing['5'], - selectors: { - '&:first-child': { - marginTop: tokens.spacing['0'], - }, - }, -}) -export const sidebarNodesLayout2 = style({ - marginTop: tokens.spacing['3'], -}) -// SB2 in the drawer: group labels take the entry type in the secondary color. -export const sidebarNodesDescription = style({ - // design-exception: Derive this layout value from the existing responsive CSS variables. - marginInline: 'calc(var(--spacing) * -2) !custom', - paddingInline: tokens.spacing['2'], - paddingBlock: tokens.spacing['1'], - fontFamily: tokens.fontFamily.book, - fontSize: tokens.fontSize.sm, - lineHeight: tokens.lineHeight.normal, - fontWeight: tokens.fontWeight.normal, - letterSpacing: tokens.letterSpacing.normal, - - color: tokens.color.muted, -}) -// SB2 in the drawer: a single-section sidebar's header is the title, set like "On this page". -export const sidebarNodesTitle = style({ - // design-exception: Derive this layout value from the existing responsive CSS variables. - marginInline: 'calc(var(--spacing) * -2) !custom', - paddingInline: tokens.spacing['2'], - paddingBottom: tokens.spacing['2'], - fontFamily: tokens.fontFamily.book, - fontSize: tokens.fontSize.compact, - lineHeight: tokens.lineHeight.compact, - fontWeight: tokens.fontWeight.medium, - letterSpacing: tokens.letterSpacing.normal, - - color: tokens.color.muted, -}) -export const sidebarNodesLayout3 = style({ - marginInlineStart: tokens.spacing['2'], - display: 'flex', - flexDirection: 'column', - gap: tokens.spacing['0'], - borderInlineStartStyle: 'solid', - borderInlineStartWidth: tokens.borderWidth.hairline, - borderColor: tokens.color.line, - paddingInlineStart: tokens.spacing['3'], -}) -export const sidebarNodesLayout4 = style({ - display: 'flex', - flexDirection: 'column', - gap: tokens.spacing['0'], -}) -export const tempoLogo = style({ - height: '18px', - width: '80px', -}) -export const arrowUpRight3 = style({ - width: tokens.spacing['4'], - height: tokens.spacing['4'], -}) -export const tempoLogoTextAppearance = instanceStyle( - (values: { value0: string; value1: string }) => ({ - aspectRatio: '102.461 / 23.2394', - '--tempo-maskImage': values.value0, - maskImage: 'var(--tempo-maskImage)', - maskRepeat: 'no-repeat', - maskSize: 'contain', - maskPosition: 'center', - '--tempo-WebkitMaskImage': values.value1, - WebkitMaskImage: 'var(--tempo-WebkitMaskImage)', - WebkitMaskRepeat: 'no-repeat', - WebkitMaskSize: 'contain', - WebkitMaskPosition: 'center', - }), -) -export const sidebarLeafAnchorAppearance = instanceStyle((values: { value0: string }) => ({ - '--tempo-paddingLeft': values.value0, - // design-exception: Preserve the inherited component/framework scope at the point of use. - paddingInlineStart: 'var(--tempo-paddingLeft) !custom', -})) -export const sidebarDisclosureDetailsAppearance = instanceStyle((values: { value0: string }) => ({ - '--tempo-paddingLeft': values.value0, - // design-exception: Preserve the inherited component/framework scope at the point of use. - paddingInlineStart: 'var(--tempo-paddingLeft) !custom', -})) diff --git a/src/components/DocsHeader.test.ts b/src/components/DocsHeader.test.ts index 08e68f449..d33b57e61 100644 --- a/src/components/DocsHeader.test.ts +++ b/src/components/DocsHeader.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest' -import { docsSections, getActiveDocsSection } from '../lib/docs-sections' +import { + docsResourceSections, + docsSections, + docsUtilitySections, + getActiveDocsSection, +} from '../lib/docs-sections' import { normalizeDocsPath, resolveSidebarItems } from '../lib/docs-sidebar' const sidebar = { @@ -41,19 +46,34 @@ describe('resolveSidebarItems', () => { }) describe('getActiveDocsSection', () => { - it('presents seven product and developer tabs without availability badges', () => { + it('lists the products in the top bar, then Resources as its only menu', () => { expect(docsSections.map(({ label }) => label)).toEqual([ - 'Get Started', + 'Get started', 'Accounts', 'Earn', 'Routes', 'Zones', 'Machine Payments', - 'Tempo EVM', + 'Network', ]) + expect(docsResourceSections.map(({ label }) => label)).toEqual([ + 'Guides', + 'Partners', + 'SDKs & CLI', + ]) + expect( + [...docsSections, ...docsUtilitySections] + .filter(({ id }) => ['api', 'tools', 'resources', 'ecosystem'].includes(id)) + .map(({ label }) => label), + ).toEqual(['API', 'SDKs & CLI', 'Guides', 'Partners']) + expect(getActiveDocsSection('/docs/earn/balances')?.id).toBe('earn') + expect(getActiveDocsSection('/docs/cli/request')?.id).toBe('tools') + expect(getActiveDocsSection('/docs/partners/wallets')?.id).toBe('ecosystem') + expect(getActiveDocsSection('/')).toBeUndefined() + expect(getActiveDocsSection('/docs/apiary')).toBeUndefined() }) - it('opens the guide library from Tempo EVM and the MPP introduction from Machine payments', () => { + it('opens Network on the Tempo EVM introduction and Machine Payments on the MPP introduction', () => { expect(docsSections.find(({ id }) => id === 'developers')?.href).toBe('/docs/development') expect(docsSections.find(({ id }) => id === 'machine-payments')?.href).toBe('/docs/agents') }) @@ -62,7 +82,7 @@ describe('getActiveDocsSection', () => { expect(getActiveDocsSection('/developers/docs/api/transfers/')?.id).toBe('api') expect(getActiveDocsSection('/developers/get-started/')?.id).toBe('overview') expect(getActiveDocsSection('/developers/docs/protocol/upgrades/t12/')?.id).toBe('developers') - expect(getActiveDocsSection('/developers/docs/protocol/')?.label).toBe('Tempo EVM') + expect(getActiveDocsSection('/developers/docs/protocol/')?.label).toBe('Network') }) it.each([ @@ -125,7 +145,7 @@ describe('getActiveDocsSection', () => { ['/docs/guide/ousd', 'ecosystem'], ['/docs/partners/join', 'ecosystem'], ['/docs/quickstart/integrate-tempo', 'developers'], - ['/docs/quickstart/connection-details', 'developers'], + ['/docs/quickstart/connection-details', 'overview'], ['/docs/guide/issuance/create-a-stablecoin', 'developers'], ['/docs/guide/issuance/manage-stablecoin', 'developers'], ['/docs/guide/issuance/migrate-erc20-to-tip20', 'developers'], @@ -150,11 +170,13 @@ describe('getActiveDocsSection', () => { ['/docs/changelog', 'developers'], ['/docs/api/indexer-api', 'api'], ['/docs/protocol/rpc/eth_getBalance', 'developers'], - ['/docs/sdk/typescript', 'overview'], - ['/docs/cli/request', 'overview'], - ['/docs/wallet/recipes', 'overview'], - ['/docs/tools', 'overview'], + ['/docs/sdk/typescript', 'tools'], + ['/docs/cli/request', 'tools'], + ['/docs/wallet/recipes', 'tools'], + ['/docs/tools', 'tools'], ['/docs/quickstart/developer-tools', 'overview'], + ['/docs/resources/guides', 'resources'], + ['/docs/resources/guides/payroll', 'resources'], ])('assigns %s to the %s section', (pathname, expected) => { expect(getActiveDocsSection(pathname)?.id).toBe(expected) expect(getActiveDocsSection(`/developers${pathname}/`)?.id).toBe(expected) diff --git a/src/components/DocsHeader.tsx b/src/components/DocsHeader.tsx index f1a5f9e74..1ea12dffa 100644 --- a/src/components/DocsHeader.tsx +++ b/src/components/DocsHeader.tsx @@ -1,66 +1,38 @@ 'use client' -import { type ReactNode, useEffect, useLayoutEffect, useRef, useState } from 'react' +import { Button, Kbd, Menu } from '@tempoxyz/ds' +import { useEffect, useLayoutEffect, useState } from 'react' import { useRouter, Link as WakuLink } from 'waku' -import { cx as composeStyles } from 'zyzz' import ChevronDownIcon from '~icons/lucide/chevron-down' -import SquareTerminalIcon from '~icons/lucide/square-terminal' -import { tempoAgentSetupCommands } from '../lib/ai-install-commands' -import { normalizeDocsPath, type SidebarNode } from '../lib/docs-sidebar' - -export { normalizeDocsPath, resolveSidebarItems } from '../lib/docs-sidebar' - import { DOCS_SEARCH_PARAM } from '../lib/docs-search' -import { publicAssetPath } from '../lib/public-asset-path' -import { navActiveSquare } from '../styles/surfaces.styles' -import { AmpLogo, ClaudeLogo, CodexLogo } from './AgentLogos' -import { AgentSetupCommand } from './AgentSetupCommand' -import { CopyIconSwap } from './CopyIconSwap' -import * as ui from './DocsHeader.recipes' +import { docsResourceSections, docsSections, getActiveDocsSection } from '../lib/docs-sections' +import { normalizeDocsPath } from '../lib/docs-sidebar' +import { DocsLockup } from './DocsLockup' +import DocsMenuDrawer from './DocsMenuDrawer' import { - docsHeaderAccountEmail, docsHeaderActions, - docsHeaderAgentMenu, - docsHeaderAgentPanel, - docsHeaderAgentTrigger, - docsHeaderBrand, - docsHeaderIconButton, - docsHeaderLoginPrimary, + docsHeaderGetKey, + docsHeaderKbd, + docsHeaderLeft, docsHeaderLogo, - docsHeaderMenuButton, - docsHeaderMobileActions, - docsHeaderMobileAgents, - docsHeaderMobileBody, - docsHeaderMobileDialog, - docsHeaderMobileLabel, - docsHeaderMobileResources, - docsHeaderMobileSearch, - docsHeaderMobileSections, - docsHeaderMobileTheme, - docsHeaderMobileTop, - docsHeaderMobileUtilityLink, - docsHeaderMobileWebsite, docsHeaderNav, + docsHeaderSearchField, + docsHeaderSearchLabel, + docsHeaderWordmark, + docsProductChevron, + docsProductLink, + docsProductNav, docsSiteHeader, } from './DocsNavigation.styles' -import { - DocsApiDropdown, - DocsResourceLinks, - docsSections, - docsUtilitySections, - getActiveDocsSection, -} from './DocsSectionNav' import { HeadingAnchorFeedback } from './HeadingAnchorFeedback' -import { iconButton } from './IconButton.recipes' +import { RouterLink } from './RouterLink' import { SearchDialogMotion } from './SearchDialogMotion' import { SmoothCorners } from './SmoothCorners' -const DOCS_BASE_PATH = '/docs' -const TEMPO_AI_GUIDE_URL = `${DOCS_BASE_PATH}/guide/using-tempo-with-ai` -const TEMPO_PLUGIN_URL = `${TEMPO_AI_GUIDE_URL}#install-tempo-plugins` +export { normalizeDocsPath, resolveSidebarItems } from '../lib/docs-sidebar' /** Tempo Console. This host forwards to developers.tempo.xyz/console once the console moves there. */ -const CONSOLE_URL = 'https://console.tempo.xyz' +export const CONSOLE_URL = 'https://console.tempo.xyz' /** Console session lookup, proxied by this host; its cookie is scoped to `/console` so docs pages never see it. */ const CONSOLE_ME_PATH = '/console/api/v1/me' @@ -83,10 +55,6 @@ function useConsoleEmail() { return email } -function isExternal(href: string) { - return !href.startsWith('/') && !href.startsWith('#') -} - export function usePathname() { const { path } = useRouter() const [pathname, setPathname] = useState(() => normalizeDocsPath(path ?? '/')) @@ -109,108 +77,11 @@ export function usePathname() { return pathname } -function Anchor({ href, children, ...props }: React.AnchorHTMLAttributes) { - if (!href) return {children} - if (isExternal(href)) { - return ( - - {children} - - ) - } - return ( - - {children} - - ) -} - -function ArrowUpRight({ className }: { className?: string }) { - return ( - - ) -} - -function TempoLogo({ className }: { className?: string }) { - return ( -