diff --git a/packages/oc-docs/e2e/components/layout/page-header.component.ts b/packages/oc-docs/e2e/components/layout/page-header.component.ts new file mode 100644 index 0000000..971d390 --- /dev/null +++ b/packages/oc-docs/e2e/components/layout/page-header.component.ts @@ -0,0 +1,30 @@ +import type { Page } from '@playwright/test'; +import { BaseComponent } from '../base.component'; + +/** + * The page header (sticky top navigation bar). A layout component — present on + * every screen — so tests get it handed over directly (`pageHeader.brandName`), + * the same way the sidebar is. + * + * Scoped to the `topbar` test id. Exposes the header's own shipped chrome: + * brand cluster, the Open-in-Bruno CTA, and the menu (hamburger) trigger. + * Parts are found by test id or accessible name, never by class. + */ +export class PageHeaderComponent extends BaseComponent { + constructor(page: Page) { + super(page, page.getByTestId('topbar')); + } + + // Brand cluster + readonly brand = this.root.getByTestId('brand'); + readonly brandName = this.root.getByTestId('brand-name'); + readonly brandVersion = this.root.getByTestId('brand-version'); + readonly brandInitials = this.root.getByTestId('brand-initials'); + + // Open-in-Bruno CTA + readonly openInBruno = this.root.getByTestId('open-in-bruno'); + + // Sidebar (hamburger) trigger — shown below desktop. The header only renders + // the button; the drawer it opens lives elsewhere. + readonly menuButton = this.root.getByTestId('topbar-menu'); +} diff --git a/packages/oc-docs/e2e/playwright/pages.fixture.ts b/packages/oc-docs/e2e/playwright/pages.fixture.ts index 065ea12..c73dd71 100644 --- a/packages/oc-docs/e2e/playwright/pages.fixture.ts +++ b/packages/oc-docs/e2e/playwright/pages.fixture.ts @@ -1,13 +1,16 @@ import { test as base } from '@playwright/test'; import { OverviewPage } from '../pages/overview.page'; +import { PageHeaderComponent } from '../components/layout/page-header.component'; import { ThemeToggleComponent } from '../components/theme-toggle.component'; /** - * Each page object gets a fixture, and so do the common components specs drive - * directly — the theme switch today, the sidebar and page header when they're added. + * Registers the page objects and shared components as Playwright fixtures, so a + * spec receives a ready instance by destructuring (e.g. `{ pageHeader }`) and + * calls `pageHeader.brandName` directly instead of constructing it. */ type Fixtures = { overviewPage: OverviewPage; + pageHeader: PageHeaderComponent; themeToggle: ThemeToggleComponent; }; @@ -15,6 +18,9 @@ export const test = base.extend({ overviewPage: async ({ page }, use) => { await use(new OverviewPage(page)); }, + pageHeader: async ({ page }, use) => { + await use(new PageHeaderComponent(page)); + }, themeToggle: async ({ page }, use) => { await use(new ThemeToggleComponent(page)); } diff --git a/packages/oc-docs/e2e/tests/layout/page-header.spec.ts b/packages/oc-docs/e2e/tests/layout/page-header.spec.ts new file mode 100644 index 0000000..650613b --- /dev/null +++ b/packages/oc-docs/e2e/tests/layout/page-header.spec.ts @@ -0,0 +1,72 @@ +import { test, expect } from '../../playwright'; + +/** + * The page header (sticky top navigation bar): brand cluster + Open-in-Bruno + * CTA. These tests cover what the mounted app actually renders — the search and + * env-switcher slots ship empty here, so they aren't exercised in this suite. + */ +test.use({ colorScheme: 'light' }); + +const DESKTOP = { width: 1280, height: 900 }; +const MOBILE = { width: 390, height: 800 }; + +test.describe('Page header', () => { + test('shows brand (name + version) and a pinned bar', async ({ page, pageHeader }) => { + await page.setViewportSize(DESKTOP); + await page.goto('/'); + + await expect(pageHeader.root).toBeVisible(); + await expect(pageHeader.brandName).toContainText('Bruno Testbench'); + await expect(pageHeader.brandVersion).toHaveText('v1.0.0'); + + // Sticky: header stays at the top after the page scrolls. + await page.mouse.wheel(0, 600); + const box = await pageHeader.root.boundingBox(); + if (!box) throw new Error('header has no bounding box'); + expect(box.y).toBeLessThanOrEqual(1); + }); + + test('shows the initials avatar derived from the collection name', async ({ page, pageHeader }) => { + await page.setViewportSize(DESKTOP); + await page.goto('/'); + + // sampleCollection name is "Bruno Testbench" → "BT". + await expect(pageHeader.brandInitials).toBeVisible(); + await expect(pageHeader.brandInitials).toHaveText('BT'); + }); + + test('Open-in-Bruno CTA deep-links via bruno:// and is pinned right', async ({ page, pageHeader }) => { + await page.setViewportSize(DESKTOP); + await page.goto('/'); + + await expect(pageHeader.openInBruno).toBeVisible(); + const href = await pageHeader.openInBruno.getAttribute('href'); + expect(href).toMatch(/^bruno:\/\/app\/collection\/import\/git\?url=/); + + // CTA hugs the right edge (within the 20px bar padding), not the brand. + const headerBox = await pageHeader.root.boundingBox(); + const ctaBox = await pageHeader.openInBruno.boundingBox(); + const brandBox = await pageHeader.brand.boundingBox(); + expect((headerBox!.x + headerBox!.width) - (ctaBox!.x + ctaBox!.width)).toBeLessThanOrEqual(24); + expect(ctaBox!.x).toBeGreaterThan(brandBox!.x + brandBox!.width + 100); + }); + + test('mobile condenses: hamburger shows, CTA hidden, brand compact', async ({ page, pageHeader }) => { + await page.setViewportSize(MOBILE); + await page.goto('/'); + + // Below desktop the sidebar trigger appears. + await expect(pageHeader.menuButton).toBeVisible(); + // Open-in-Bruno is desktop-only (no Bruno desktop app on mobile). + await expect(pageHeader.openInBruno).toHaveCount(0); + + // Compact brand: avatar + "Docs" only — no full name, no version. + await expect(pageHeader.brandName).toHaveText('Docs'); + await expect(pageHeader.brandVersion).toHaveCount(0); + await expect(pageHeader.root).not.toContainText('Bruno Testbench'); + + // No horizontal overflow. + const scrollW = await page.evaluate(() => document.documentElement.scrollWidth); + expect(scrollW).toBeLessThanOrEqual(MOBILE.width + 1); + }); +}); diff --git a/packages/oc-docs/src/assets/icons/BrunoGlyph.tsx b/packages/oc-docs/src/assets/icons/BrunoGlyph.tsx new file mode 100644 index 0000000..2f3eaba --- /dev/null +++ b/packages/oc-docs/src/assets/icons/BrunoGlyph.tsx @@ -0,0 +1,107 @@ +import React from 'react'; + +/** Bruno mascot — fixed brand colours (a brand mark, not a themeable surface). + * Used inside the Open-in-Bruno CTA. */ +export const BrunoGlyph: React.FC = () => ( + +); diff --git a/packages/oc-docs/src/assets/icons/HamburgerIcon.tsx b/packages/oc-docs/src/assets/icons/HamburgerIcon.tsx new file mode 100644 index 0000000..7765242 --- /dev/null +++ b/packages/oc-docs/src/assets/icons/HamburgerIcon.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +import { baseIconProps } from './baseIconProps'; + +/** Three bars — Topbar sidebar (hamburger) toggle. */ +export const HamburgerIcon: React.FC = () => ( + + + +); diff --git a/packages/oc-docs/src/assets/icons/OverflowIcon.tsx b/packages/oc-docs/src/assets/icons/OverflowIcon.tsx new file mode 100644 index 0000000..d105d0a --- /dev/null +++ b/packages/oc-docs/src/assets/icons/OverflowIcon.tsx @@ -0,0 +1,10 @@ +import React from 'react'; + +/** Vertical ellipsis — Topbar mobile overflow trigger. */ +export const OverflowIcon: React.FC = () => ( + +); diff --git a/packages/oc-docs/src/assets/icons/SearchIcon.tsx b/packages/oc-docs/src/assets/icons/SearchIcon.tsx new file mode 100644 index 0000000..4a31c17 --- /dev/null +++ b/packages/oc-docs/src/assets/icons/SearchIcon.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import { baseIconProps } from './baseIconProps'; + +/** Magnifying glass — Topbar search toggle. */ +export const SearchIcon: React.FC = () => ( + + + + +); diff --git a/packages/oc-docs/src/assets/icons/baseIconProps.ts b/packages/oc-docs/src/assets/icons/baseIconProps.ts index a408ea4..4e96f1c 100644 --- a/packages/oc-docs/src/assets/icons/baseIconProps.ts +++ b/packages/oc-docs/src/assets/icons/baseIconProps.ts @@ -1,7 +1,7 @@ import type { SVGProps } from 'react'; -/** Shared stroke styling for the empty-state icons. `currentColor` lets the icon - * inherit the surrounding theme colour, so it adapts when the theme changes. */ +/** Shared stroke styling. `currentColor` lets the icon inherit the surrounding + * theme colour, so it adapts when the theme changes. */ export const baseIconProps: SVGProps = { width: 20, height: 20, diff --git a/packages/oc-docs/src/assets/icons/index.ts b/packages/oc-docs/src/assets/icons/index.ts index 9ab99fe..531c277 100644 --- a/packages/oc-docs/src/assets/icons/index.ts +++ b/packages/oc-docs/src/assets/icons/index.ts @@ -1,2 +1,6 @@ +export * from './SearchIcon'; +export * from './HamburgerIcon'; +export * from './OverflowIcon'; +export * from './BrunoGlyph'; export * from './GlobeIcon'; export * from './BookIcon'; diff --git a/packages/oc-docs/src/components/Docs/Sidebar/FetchInBrunoButton.tsx b/packages/oc-docs/src/components/Docs/Sidebar/FetchInBrunoButton.tsx deleted file mode 100644 index aedfef3..0000000 --- a/packages/oc-docs/src/components/Docs/Sidebar/FetchInBrunoButton.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import React from 'react'; - -const FetchInBrunoButton: React.FC = () => ( - - - - - - - - - - - - - - - - - - - - - - - - Fetch In Bruno - -); - -export default FetchInBrunoButton; diff --git a/packages/oc-docs/src/components/InitialsAvatar/InitialsAvatar.spec.tsx b/packages/oc-docs/src/components/InitialsAvatar/InitialsAvatar.spec.tsx new file mode 100644 index 0000000..8bf45f9 --- /dev/null +++ b/packages/oc-docs/src/components/InitialsAvatar/InitialsAvatar.spec.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, it, expect } from 'vitest'; +import InitialsAvatar from './InitialsAvatar'; + +describe('InitialsAvatar', () => { + it('renders the initials for a multi-word collection name', () => { + const html = renderToStaticMarkup(); + expect(html).toContain('HB'); + }); + + it('renders a single letter for a one-word name', () => { + const html = renderToStaticMarkup(); + expect(html).toContain('>E<'); + }); +}); diff --git a/packages/oc-docs/src/components/InitialsAvatar/InitialsAvatar.tsx b/packages/oc-docs/src/components/InitialsAvatar/InitialsAvatar.tsx new file mode 100644 index 0000000..7f1d38f --- /dev/null +++ b/packages/oc-docs/src/components/InitialsAvatar/InitialsAvatar.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { getInitials } from '../../utils/common'; +import { StyledWrapper } from './StyledWrapper'; + +export interface InitialsAvatarProps { + collectionName: string; + testId?: string; +} + +/** + * Default brand mark: a rounded badge showing the collection initials over the + * Bruno amber gradient. + */ +const InitialsAvatar: React.FC = ({ + collectionName, + testId = 'brand-initials', +}) => ( + +); + +export default InitialsAvatar; diff --git a/packages/oc-docs/src/components/InitialsAvatar/StyledWrapper.ts b/packages/oc-docs/src/components/InitialsAvatar/StyledWrapper.ts new file mode 100644 index 0000000..d25eb47 --- /dev/null +++ b/packages/oc-docs/src/components/InitialsAvatar/StyledWrapper.ts @@ -0,0 +1,19 @@ +import styled from '@emotion/styled'; + +export const StyledWrapper = styled.span` + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + flex: none; + border-radius: var(--oc-border-radius-base); + background: linear-gradient(135deg, #d37f17 0%, #dc9741 100%); + color: #fff; + font-family: var(--font-mono); + font-size: var(--oc-font-size-xs); + font-weight: 700; + line-height: 1; + letter-spacing: -0.02em; + user-select: none; +`; diff --git a/packages/oc-docs/src/components/OpenCollection/OpenCollection.tsx b/packages/oc-docs/src/components/OpenCollection/OpenCollection.tsx index 079ea74..4d3c397 100644 --- a/packages/oc-docs/src/components/OpenCollection/OpenCollection.tsx +++ b/packages/oc-docs/src/components/OpenCollection/OpenCollection.tsx @@ -6,6 +6,8 @@ import type { HttpRequest } from '@opencollection/types/requests/http'; import type { OpenCollection as IOpenCollection } from '@opencollection/types'; import PlaygroundDrawer from '../PlaygroundDrawer/PlaygroundDrawer'; import Docs from '../Docs/Docs'; +import Topbar from '../Topbar/Topbar'; +import { buildBrunoDeepLink } from '../../utils/buildBrunoDeepLink'; import { parseYaml } from '../../utils/yamlUtils'; import { hydrateWithUUIDs } from '../../utils/items'; import { getItemType, isFolder } from '../../utils/schemaHelpers'; @@ -79,6 +81,10 @@ interface DesktopLayoutProps { docsCollection: OpenCollectionCollection | null; playgroundCollection: OpenCollectionCollection | null; filteredCollectionItems: OpenCollectionItem[]; + collectionName: string; + version?: string; + logo?: React.ReactNode; + openInBrunoHref?: string; children?: React.ReactNode; } @@ -101,7 +107,11 @@ const findItemByUuid = (items: OpenCollectionItem[] | undefined, uuid: string): const DesktopLayout: React.FC = ({ docsCollection, playgroundCollection, - filteredCollectionItems + filteredCollectionItems, + collectionName, + version, + logo, + openInBrunoHref }) => { const selectedItemId = useAppSelector(selectSelectedItemId); const [playgroundItem, setPlaygroundItem] = useState(null); @@ -143,20 +153,32 @@ const DesktopLayout: React.FC = ({ }, []); return ( -
- + {/* searchSlot, envSwitcherSlot and onToggleSidebar are wired up + separately; the header renders empty slots / an inert hamburger + until then. */} + - setShowPlaygroundDrawer(false)} - collection={playgroundCollection} - selectedItem={playgroundItem} - onSelectItem={handlePlaygroundItemSelect} - /> +
+ + + setShowPlaygroundDrawer(false)} + collection={playgroundCollection} + selectedItem={playgroundItem} + onSelectItem={handlePlaygroundItemSelect} + /> +
); }; @@ -172,6 +194,7 @@ export interface OpenCollectionProps { const OpenCollectionContent: React.FC = ({ collection, + logo, gitCollectionUrl, }) => { const dispatch = useAppDispatch(); @@ -256,6 +279,10 @@ const OpenCollectionContent: React.FC = ({ docsCollection, playgroundCollection, filteredCollectionItems, + collectionName: docsCollection?.info?.name ?? '', + version: docsCollection?.info?.version, + logo, + openInBrunoHref: buildBrunoDeepLink(gitCollectionUrl), }; return ( diff --git a/packages/oc-docs/src/components/OpenInBrunoButton/OpenInBrunoButton.spec.tsx b/packages/oc-docs/src/components/OpenInBrunoButton/OpenInBrunoButton.spec.tsx new file mode 100644 index 0000000..d88dc7a --- /dev/null +++ b/packages/oc-docs/src/components/OpenInBrunoButton/OpenInBrunoButton.spec.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, it, expect } from 'vitest'; +import OpenInBrunoButton from './OpenInBrunoButton'; + +describe('OpenInBrunoButton', () => { + it('renders an anchor with the bruno:// href and the label', () => { + const html = renderToStaticMarkup( + + ); + expect(html).toContain('href="bruno://app/collection/import/git?url=x"'); + expect(html).toContain('Open in Bruno'); + }); + + it('renders a button (no href) and hides the label in icon-only mode', () => { + const html = renderToStaticMarkup(); + expect(html).toContain(''); + }); +}); diff --git a/packages/oc-docs/src/components/OpenInBrunoButton/OpenInBrunoButton.tsx b/packages/oc-docs/src/components/OpenInBrunoButton/OpenInBrunoButton.tsx new file mode 100644 index 0000000..d0c77e0 --- /dev/null +++ b/packages/oc-docs/src/components/OpenInBrunoButton/OpenInBrunoButton.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { BrunoGlyph } from '../../assets/icons'; +import { StyledWrapper } from './StyledWrapper'; + +export interface OpenInBrunoButtonProps { + /** When provided, renders a real `bruno://` deep link (``). */ + href?: string; + /** Click handler; used when no href is given (renders a ` - ); -}; - interface TabGroupProps { tabs: Array<{ id: string; label: string }>; defaultTab?: string; diff --git a/packages/oc-docs/src/utils/buildBrunoDeepLink.spec.ts b/packages/oc-docs/src/utils/buildBrunoDeepLink.spec.ts new file mode 100644 index 0000000..0ff79f1 --- /dev/null +++ b/packages/oc-docs/src/utils/buildBrunoDeepLink.spec.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from 'vitest'; +import { buildBrunoDeepLink } from './buildBrunoDeepLink'; + +describe('buildBrunoDeepLink', () => { + it('builds a bruno:// git-import deep link', () => { + expect(buildBrunoDeepLink('https://github.com/usebruno/bruno-testbench.git')).toBe( + 'bruno://app/collection/import/git?url=https%3A%2F%2Fgithub.com%2Fusebruno%2Fbruno-testbench.git' + ); + }); + + it('url-encodes query-string-breaking characters', () => { + expect(buildBrunoDeepLink('https://host/repo.git?token=a&b=c')).toContain( + encodeURIComponent('https://host/repo.git?token=a&b=c') + ); + }); + + it('returns undefined for nullish / empty / whitespace input', () => { + expect(buildBrunoDeepLink(undefined)).toBeUndefined(); + expect(buildBrunoDeepLink(null)).toBeUndefined(); + expect(buildBrunoDeepLink('')).toBeUndefined(); + expect(buildBrunoDeepLink(' ')).toBeUndefined(); + }); + + it('trims surrounding whitespace before encoding', () => { + expect(buildBrunoDeepLink(' https://h/r.git ')).toBe( + 'bruno://app/collection/import/git?url=https%3A%2F%2Fh%2Fr.git' + ); + }); +}); diff --git a/packages/oc-docs/src/utils/buildBrunoDeepLink.ts b/packages/oc-docs/src/utils/buildBrunoDeepLink.ts new file mode 100644 index 0000000..62ac68d --- /dev/null +++ b/packages/oc-docs/src/utils/buildBrunoDeepLink.ts @@ -0,0 +1,15 @@ +/** + * Builds a `bruno://` deep link that opens the desktop app and imports the + * collection straight from its git remote. + * + * Pure + framework-agnostic so it can be unit tested and reused by any CTA. + * Returns `undefined` when there is no usable git URL, letting callers omit + * the link (render a disabled / hidden CTA) instead of producing a dead href. + */ +export const buildBrunoDeepLink = (gitUrl?: string | null): string | undefined => { + const trimmed = gitUrl?.trim(); + if (!trimmed) { + return undefined; + } + return `bruno://app/collection/import/git?url=${encodeURIComponent(trimmed)}`; +}; diff --git a/packages/oc-docs/src/utils/common.spec.ts b/packages/oc-docs/src/utils/common.spec.ts index ef9365e..e722318 100644 --- a/packages/oc-docs/src/utils/common.spec.ts +++ b/packages/oc-docs/src/utils/common.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { formatCollectionVersion, DEFAULT_COLLECTION_VERSION } from './common'; +import { formatCollectionVersion, DEFAULT_COLLECTION_VERSION, getInitials } from './common'; describe('formatCollectionVersion', () => { it('pads numeric versions to a full major.minor.patch with a "v" prefix', () => { @@ -33,3 +33,33 @@ describe('formatCollectionVersion', () => { expect(formatCollectionVersion(' ')).toBe(DEFAULT_COLLECTION_VERSION); }); }); + +describe('getInitials', () => { + it('uses the first letter of the first two words', () => { + expect(getInitials('Hotel Booking API')).toBe('HB'); + expect(getInitials('Bruno Testbench')).toBe('BT'); + }); + + it('uses only the first letter for a single word', () => { + expect(getInitials('Echo')).toBe('E'); + expect(getInitials('payments')).toBe('P'); + }); + + it('uppercases the result', () => { + expect(getInitials('hotel booking')).toBe('HB'); + }); + + it('collapses extra whitespace', () => { + expect(getInitials(' Hotel Booking ')).toBe('HB'); + }); + + it('returns empty string for nullish / blank input', () => { + expect(getInitials(undefined)).toBe(''); + expect(getInitials(null)).toBe(''); + expect(getInitials(' ')).toBe(''); + }); + + it('handles non-letter first characters by taking them verbatim', () => { + expect(getInitials('1Password Vault')).toBe('1V'); + }); +}); diff --git a/packages/oc-docs/src/utils/common.ts b/packages/oc-docs/src/utils/common.ts index 3cc4a2f..872bef1 100644 --- a/packages/oc-docs/src/utils/common.ts +++ b/packages/oc-docs/src/utils/common.ts @@ -33,3 +33,22 @@ export const formatCollectionVersion = (version?: string | number | null): strin return `v${segments.join('.')}`; }; + +/** + * Derives the brand-avatar initials from a collection name. + * - Two or more words → first letter of the first two words ("Hotel Booking API" → "HB"). + * - Single word → just its first letter ("Echo" → "E"). + * - Empty / nullish → "" (caller decides the fallback). + * + * Pure + DOM-free so it can be unit tested directly. + */ +export const getInitials = (collectionName?: string | null): string => { + const words = (collectionName ?? '').trim().split(/\s+/).filter(Boolean); + if (words.length === 0) { + return ''; + } + if (words.length === 1) { + return words[0].charAt(0).toUpperCase(); + } + return (words[0].charAt(0) + words[1].charAt(0)).toUpperCase(); +};