From 021044ab112b3f7fb2eb4abf457828df95f6ad4e Mon Sep 17 00:00:00 2001 From: Sundram Gupta Date: Wed, 17 Jun 2026 03:09:15 +0530 Subject: [PATCH 01/27] feat(oc-docs): add sticky top navigation bar (BRU-3572) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a reusable, purely-presentational Topbar mounted into the OpenCollection desktop shell. Composed of small subcomponents: Brand (logo + collection name + version), an Open-in-Bruno CTA (restyled from FetchInBrunoButton, renders a real bruno:// deep link), and two slots — searchSlot (BRU-3573) and envSwitcherSlot (BRU-3186) — that render whatever node is passed and degrade gracefully when empty. Cross-lane contract (src/components/Topbar/Topbar.tsx) kept stable for BRU-3188: collectionName, version, logo, searchSlot, envSwitcherSlot, onOpenInBruno, onToggleSidebar — plus an optional openInBrunoHref so the CTA can render as an anchor with a real bruno:// href (backward-compatible additive prop). Responsive (no mobile/tablet Figma — designed here): - desktop (>=1024): full bar, centered search, inline env + CTA. - tablet (768-1023): sidebar inline (no hamburger), CTA icon-only, env switcher stays inline. - mobile (<768): hamburger + brand + spacer + search icon + overflow + CTA icon. Search icon expands a full-width search row; the overflow popover relocates the same envSwitcherSlot node. Styling maps every value to --oc-* theme tokens (no hardcoded hex): square bar with bottom border only, 6px rounded inner controls, Inter, honors light/dark. Remove the now-duplicate in-content "Fetch in Bruno" button from Docs (Topbar owns the CTA). Tests: vitest for buildBrunoDeepLink + layoutModeForWidth; Playwright for sticky behavior, the bruno:// CTA href, mobile condense/overflow, and search-expand (via a dev-only topbar harness page). Co-Authored-By: Claude Opus 4.8 --- packages/oc-docs/e2e/topbar.spec.ts | 98 ++++++++++ packages/oc-docs/src/components/Docs/Docs.tsx | 11 -- .../OpenCollection/OpenCollection.tsx | 50 +++-- .../oc-docs/src/components/Topbar/Brand.tsx | 35 ++++ .../src/components/Topbar/MobileOverflow.tsx | 60 ++++++ .../components/Topbar/OpenInBrunoButton.tsx | 100 ++++++++++ .../src/components/Topbar/StyledWrapper.ts | 172 ++++++++++++++++++ .../oc-docs/src/components/Topbar/Topbar.tsx | 116 ++++++++++++ .../Topbar/buildBrunoDeepLink.test.ts | 29 +++ .../components/Topbar/buildBrunoDeepLink.ts | 15 ++ .../oc-docs/src/components/Topbar/icons.tsx | 160 ++++++++++++++++ .../oc-docs/src/components/Topbar/index.ts | 13 ++ .../components/Topbar/useTopbarLayout.test.ts | 33 ++++ .../src/components/Topbar/useTopbarLayout.ts | 45 +++++ packages/oc-docs/src/topbarHarness.tsx | 59 ++++++ packages/oc-docs/topbar-harness.html | 12 ++ 16 files changed, 984 insertions(+), 24 deletions(-) create mode 100644 packages/oc-docs/e2e/topbar.spec.ts create mode 100644 packages/oc-docs/src/components/Topbar/Brand.tsx create mode 100644 packages/oc-docs/src/components/Topbar/MobileOverflow.tsx create mode 100644 packages/oc-docs/src/components/Topbar/OpenInBrunoButton.tsx create mode 100644 packages/oc-docs/src/components/Topbar/StyledWrapper.ts create mode 100644 packages/oc-docs/src/components/Topbar/Topbar.tsx create mode 100644 packages/oc-docs/src/components/Topbar/buildBrunoDeepLink.test.ts create mode 100644 packages/oc-docs/src/components/Topbar/buildBrunoDeepLink.ts create mode 100644 packages/oc-docs/src/components/Topbar/icons.tsx create mode 100644 packages/oc-docs/src/components/Topbar/index.ts create mode 100644 packages/oc-docs/src/components/Topbar/useTopbarLayout.test.ts create mode 100644 packages/oc-docs/src/components/Topbar/useTopbarLayout.ts create mode 100644 packages/oc-docs/src/topbarHarness.tsx create mode 100644 packages/oc-docs/topbar-harness.html diff --git a/packages/oc-docs/e2e/topbar.spec.ts b/packages/oc-docs/e2e/topbar.spec.ts new file mode 100644 index 0000000..6722c20 --- /dev/null +++ b/packages/oc-docs/e2e/topbar.spec.ts @@ -0,0 +1,98 @@ +import { test, expect } from '@playwright/test'; + +test.use({ colorScheme: 'light' }); + +const DESKTOP = { width: 1280, height: 900 }; +const TABLET = { width: 820, height: 1024 }; +const MOBILE = { width: 390, height: 800 }; + +test.describe('Topbar — mounted app', () => { + test('shows brand (name + version) and a pinned bar', async ({ page }) => { + await page.setViewportSize(DESKTOP); + await page.goto('/'); + + const header = page.locator('header.oc-topbar'); + await expect(header).toBeVisible(); + await expect(header.locator('.oc-topbar__brand-name')).toContainText('Bruno Testbench'); + await expect(header.locator('.oc-topbar__brand-version')).toContainText('1.0.0'); + + // Sticky: header stays at the top after the page scrolls. + await page.mouse.wheel(0, 600); + const box = await header.boundingBox(); + expect(box?.y ?? -1).toBeLessThanOrEqual(1); + }); + + test('Open-in-Bruno CTA deep-links via bruno://', async ({ page }) => { + await page.setViewportSize(DESKTOP); + await page.goto('/'); + + const cta = page.getByTestId('open-in-bruno'); + await expect(cta).toBeVisible(); + const href = await cta.getAttribute('href'); + expect(href).toMatch(/^bruno:\/\/app\/collection\/import\/git\?url=/); + }); + + test('mobile condenses: hamburger appears, CTA stays as an icon', async ({ page }) => { + await page.setViewportSize(MOBILE); + await page.goto('/'); + + await expect(page.getByRole('button', { name: /toggle sidebar/i })).toBeVisible(); + const cta = page.getByTestId('open-in-bruno'); + await expect(cta).toBeVisible(); + await expect(cta).toHaveClass(/is-icon/); + + // No horizontal overflow. + const scrollW = await page.evaluate(() => document.documentElement.scrollWidth); + expect(scrollW).toBeLessThanOrEqual(MOBILE.width + 1); + }); +}); + +test.describe('Topbar — harness (slots filled)', () => { + test('desktop renders search + env slots inline', async ({ page }) => { + await page.setViewportSize(DESKTOP); + await page.goto('/topbar-harness.html'); + + await expect(page.getByTestId('search-slot-input')).toBeVisible(); + await expect(page.getByTestId('env-switcher-slot')).toBeVisible(); + await expect(page.getByTestId('open-in-bruno')).toHaveClass(/is-full/); + }); + + test('tablet keeps env slot inline and CTA icon-only, no hamburger', async ({ page }) => { + await page.setViewportSize(TABLET); + await page.goto('/topbar-harness.html'); + + await expect(page.getByTestId('env-switcher-slot')).toBeVisible(); + await expect(page.getByTestId('open-in-bruno')).toHaveClass(/is-icon/); + await expect(page.getByRole('button', { name: /toggle sidebar/i })).toHaveCount(0); + }); + + test('mobile: search icon expands the search row', async ({ page }) => { + await page.setViewportSize(MOBILE); + await page.goto('/topbar-harness.html'); + + // Inline search is hidden on mobile until the toggle is pressed. + await expect(page.getByTestId('search-slot-input')).toHaveCount(0); + await page.getByRole('button', { name: /^search$/i }).click(); + await expect(page.getByTestId('search-slot-input')).toBeVisible(); + }); + + test('mobile: overflow popover hosts the env-switcher slot', async ({ page }) => { + await page.setViewportSize(MOBILE); + await page.goto('/topbar-harness.html'); + + await expect(page.getByTestId('env-switcher-slot')).toHaveCount(0); + await page.getByRole('button', { name: /more options/i }).click(); + await expect(page.getByTestId('env-switcher-slot')).toBeVisible(); + }); + + test('mobile: hamburger invokes onToggleSidebar', async ({ page }) => { + await page.setViewportSize(MOBILE); + await page.goto('/topbar-harness.html'); + + await page.getByRole('button', { name: /toggle sidebar/i }).click(); + const calls = await page.evaluate( + () => (window as unknown as { __toggleSidebarCalls?: number }).__toggleSidebarCalls + ); + expect(calls).toBe(1); + }); +}); diff --git a/packages/oc-docs/src/components/Docs/Docs.tsx b/packages/oc-docs/src/components/Docs/Docs.tsx index 03a2146..5813f7a 100644 --- a/packages/oc-docs/src/components/Docs/Docs.tsx +++ b/packages/oc-docs/src/components/Docs/Docs.tsx @@ -3,12 +3,10 @@ import type { OpenCollection as OpenCollectionCollection } from '@opencollection import type { StructuredText } from '@opencollection/types/common/description'; import Sidebar from './Sidebar/Sidebar'; import Item from './Item/Item'; -import FetchInBrunoButton from './Sidebar/FetchInBrunoButton'; import { getItemId, generateSafeId } from '../../utils/itemUtils'; import { isFolder, getItemName } from '../../utils/schemaHelpers'; import { useAppSelector, useAppDispatch } from '../../store/hooks'; import { selectSelectedItemId, selectItem } from '../../store/slices/docs'; -import { selectGitCollectionUrl } from '../../store/slices/app'; import { useMarkdownRenderer } from '../../hooks'; interface DocsProps { @@ -24,7 +22,6 @@ const Docs: React.FC = ({ }) => { const dispatch = useAppDispatch(); const selectedItemId = useAppSelector(selectSelectedItemId); - const gitCollectionUrl = useAppSelector(selectGitCollectionUrl); const md = useMarkdownRenderer(); const isInitialMount = useRef(true); @@ -141,14 +138,6 @@ const Docs: React.FC = ({ > {docsCollection.info.name} - {gitCollectionUrl && ( - - - - )} )} diff --git a/packages/oc-docs/src/components/OpenCollection/OpenCollection.tsx b/packages/oc-docs/src/components/OpenCollection/OpenCollection.tsx index 079ea74..c6f8b8d 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'; +import { buildBrunoDeepLink } from '../Topbar/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,29 @@ const DesktopLayout: React.FC = ({ }, []); return ( -
- + - setShowPlaygroundDrawer(false)} - collection={playgroundCollection} - selectedItem={playgroundItem} - onSelectItem={handlePlaygroundItemSelect} - /> +
+ + + setShowPlaygroundDrawer(false)} + collection={playgroundCollection} + selectedItem={playgroundItem} + onSelectItem={handlePlaygroundItemSelect} + /> +
); }; @@ -172,6 +191,7 @@ export interface OpenCollectionProps { const OpenCollectionContent: React.FC = ({ collection, + logo, gitCollectionUrl, }) => { const dispatch = useAppDispatch(); @@ -256,6 +276,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/Topbar/Brand.tsx b/packages/oc-docs/src/components/Topbar/Brand.tsx new file mode 100644 index 0000000..4b125f9 --- /dev/null +++ b/packages/oc-docs/src/components/Topbar/Brand.tsx @@ -0,0 +1,35 @@ +import React from 'react'; + +export interface BrandProps { + collectionName: string; + version?: string; + logo?: React.ReactNode; +} + +/** + * Renders an arbitrary logo node. A string is treated as an image src so + * callers can pass a URL (as `OpenCollection` does today); any other node is + * rendered verbatim. + */ +const renderLogo = (logo: React.ReactNode, collectionName: string): React.ReactNode => { + if (typeof logo === 'string') { + return {`${collectionName}; + } + return logo; +}; + +const Brand: React.FC = ({ collectionName, version, logo }) => ( +
+ {logo != null && logo !== '' && ( + {renderLogo(logo, collectionName)} + )} + + + {collectionName} + + {version && {version}} + +
+); + +export default Brand; diff --git a/packages/oc-docs/src/components/Topbar/MobileOverflow.tsx b/packages/oc-docs/src/components/Topbar/MobileOverflow.tsx new file mode 100644 index 0000000..09dc3b3 --- /dev/null +++ b/packages/oc-docs/src/components/Topbar/MobileOverflow.tsx @@ -0,0 +1,60 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { OverflowIcon } from './icons'; + +export interface MobileOverflowProps { + /** Secondary controls (the same envSwitcherSlot node, relocated here). */ + children: React.ReactNode; +} + +/** + * Overflow (⋯) trigger + popover for mobile. Hosts the SECONDARY controls + * (env switcher + show-vars) by relocating the passed slot node — it does not + * reimplement or duplicate them. Self-contained open/close with outside-click + * and Escape handling. + */ +const MobileOverflow: React.FC = ({ children }) => { + const [open, setOpen] = useState(false); + const containerRef = useRef(null); + + useEffect(() => { + if (!open) return; + + const onPointerDown = (event: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + setOpen(false); + } + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') setOpen(false); + }; + + document.addEventListener('mousedown', onPointerDown); + document.addEventListener('keydown', onKeyDown); + return () => { + document.removeEventListener('mousedown', onPointerDown); + document.removeEventListener('keydown', onKeyDown); + }; + }, [open]); + + return ( +
+ + {open && ( +
+ {children} +
+ )} +
+ ); +}; + +export default MobileOverflow; diff --git a/packages/oc-docs/src/components/Topbar/OpenInBrunoButton.tsx b/packages/oc-docs/src/components/Topbar/OpenInBrunoButton.tsx new file mode 100644 index 0000000..4d1d3f5 --- /dev/null +++ b/packages/oc-docs/src/components/Topbar/OpenInBrunoButton.tsx @@ -0,0 +1,100 @@ +import React from 'react'; +import styled from '@emotion/styled'; +import { BrunoGlyph } from './icons'; + +export interface OpenInBrunoButtonProps { + /** When provided, renders a real `bruno://` deep link (``). */ + href?: string; + /** Click handler; used when no href is given (renders a ` + )} + + + + {/* Inline search (tablet/desktop). On mobile it relocates to a row. */} + {hasSearch && !isMobile && ( +
+
{searchSlot}
+
+ )} + + {isMobile &&
} + + {/* Mobile search toggle reveals the full-width search row below. */} + {hasSearch && isMobile && ( + + )} + + {/* Secondary controls: inline on tablet/desktop, overflow popover on mobile. */} + {hasSecondary && !isMobile && ( +
{envSwitcherSlot}
+ )} + {hasSecondary && isMobile && {envSwitcherSlot}} + + {cta} +
+ + {hasSearch && isMobile && mobileSearchOpen && ( +
+
{searchSlot}
+
+ )} + + ); +}; + +export default Topbar; diff --git a/packages/oc-docs/src/components/Topbar/buildBrunoDeepLink.test.ts b/packages/oc-docs/src/components/Topbar/buildBrunoDeepLink.test.ts new file mode 100644 index 0000000..0ff79f1 --- /dev/null +++ b/packages/oc-docs/src/components/Topbar/buildBrunoDeepLink.test.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/components/Topbar/buildBrunoDeepLink.ts b/packages/oc-docs/src/components/Topbar/buildBrunoDeepLink.ts new file mode 100644 index 0000000..62ac68d --- /dev/null +++ b/packages/oc-docs/src/components/Topbar/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/components/Topbar/icons.tsx b/packages/oc-docs/src/components/Topbar/icons.tsx new file mode 100644 index 0000000..87ae459 --- /dev/null +++ b/packages/oc-docs/src/components/Topbar/icons.tsx @@ -0,0 +1,160 @@ +import React from 'react'; + +/** + * Line icons use `currentColor` so they inherit the control's themed text color. + * The Bruno mascot keeps its fixed brand fills — it is a brand mark, not a + * themeable UI surface (same treatment as the OpenCollection logo asset). + */ + +interface IconProps { + className?: string; +} + +export const SearchIcon: React.FC = ({ className }) => ( + +); + +export const HamburgerIcon: React.FC = ({ className }) => ( + +); + +export const OverflowIcon: React.FC = ({ className }) => ( + +); + +/** Bruno mascot — fixed brand colors, used inside the Open-in-Bruno CTA. */ +export const BrunoGlyph: React.FC = ({ className }) => ( + +); diff --git a/packages/oc-docs/src/components/Topbar/index.ts b/packages/oc-docs/src/components/Topbar/index.ts new file mode 100644 index 0000000..b90339d --- /dev/null +++ b/packages/oc-docs/src/components/Topbar/index.ts @@ -0,0 +1,13 @@ +export { default as Topbar } from './Topbar'; +export type { TopbarProps } from './Topbar'; +export { default as OpenInBrunoButton } from './OpenInBrunoButton'; +export type { OpenInBrunoButtonProps } from './OpenInBrunoButton'; +export { default as Brand } from './Brand'; +export type { BrandProps } from './Brand'; +export { buildBrunoDeepLink } from './buildBrunoDeepLink'; +export { + useTopbarLayout, + layoutModeForWidth, + showsHamburger, + type TopbarLayoutMode, +} from './useTopbarLayout'; diff --git a/packages/oc-docs/src/components/Topbar/useTopbarLayout.test.ts b/packages/oc-docs/src/components/Topbar/useTopbarLayout.test.ts new file mode 100644 index 0000000..0d37dff --- /dev/null +++ b/packages/oc-docs/src/components/Topbar/useTopbarLayout.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest'; +import { + layoutModeForWidth, + showsHamburger, + TOPBAR_TABLET_MIN, + TOPBAR_DESKTOP_MIN, +} from './useTopbarLayout'; + +describe('layoutModeForWidth', () => { + it('maps wide viewports to desktop (>=1024)', () => { + expect(layoutModeForWidth(TOPBAR_DESKTOP_MIN)).toBe('desktop'); + expect(layoutModeForWidth(1280)).toBe('desktop'); + }); + + it('maps the 768–1023 band to tablet', () => { + expect(layoutModeForWidth(TOPBAR_TABLET_MIN)).toBe('tablet'); + expect(layoutModeForWidth(1023)).toBe('tablet'); + }); + + it('maps narrow viewports to mobile (<768)', () => { + expect(layoutModeForWidth(TOPBAR_TABLET_MIN - 1)).toBe('mobile'); + expect(layoutModeForWidth(390)).toBe('mobile'); + expect(layoutModeForWidth(0)).toBe('mobile'); + }); +}); + +describe('showsHamburger', () => { + it('shows only on mobile (sidebar is inline on tablet/desktop)', () => { + expect(showsHamburger('mobile')).toBe(true); + expect(showsHamburger('tablet')).toBe(false); + expect(showsHamburger('desktop')).toBe(false); + }); +}); diff --git a/packages/oc-docs/src/components/Topbar/useTopbarLayout.ts b/packages/oc-docs/src/components/Topbar/useTopbarLayout.ts new file mode 100644 index 0000000..ad0db3d --- /dev/null +++ b/packages/oc-docs/src/components/Topbar/useTopbarLayout.ts @@ -0,0 +1,45 @@ +import { useEffect, useState } from 'react'; + +/** + * Responsive modes for the Topbar. + * - `mobile` (<768): single condensed row; hamburger + search-icon + overflow. + * - `tablet` (768–1023): sidebar is inline, search narrows, CTA is icon-only. + * - `desktop` (>=1024): full bar with centered search. + * + * Breakpoints mirror the theme tokens `--oc-breakpoint-tablet` (768) and + * `--oc-breakpoint-large` (1024). + */ +export type TopbarLayoutMode = 'mobile' | 'tablet' | 'desktop'; + +export const TOPBAR_TABLET_MIN = 768; +export const TOPBAR_DESKTOP_MIN = 1024; + +/** Pure width → mode mapping. Unit-testable without a DOM. */ +export const layoutModeForWidth = (width: number): TopbarLayoutMode => { + if (width >= TOPBAR_DESKTOP_MIN) return 'desktop'; + if (width >= TOPBAR_TABLET_MIN) return 'tablet'; + return 'mobile'; +}; + +/** Hamburger trigger only shows below the sidebar-inline breakpoint (mobile). */ +export const showsHamburger = (mode: TopbarLayoutMode): boolean => mode === 'mobile'; + +/** + * Tracks the current layout mode from the viewport width. + * SSR/no-window safe: defaults to `desktop` until the first client measure. + */ +export const useTopbarLayout = (): TopbarLayoutMode => { + const [mode, setMode] = useState(() => + typeof window === 'undefined' ? 'desktop' : layoutModeForWidth(window.innerWidth) + ); + + useEffect(() => { + if (typeof window === 'undefined') return; + const onResize = () => setMode(layoutModeForWidth(window.innerWidth)); + onResize(); + window.addEventListener('resize', onResize); + return () => window.removeEventListener('resize', onResize); + }, []); + + return mode; +}; diff --git a/packages/oc-docs/src/topbarHarness.tsx b/packages/oc-docs/src/topbarHarness.tsx new file mode 100644 index 0000000..15b0248 --- /dev/null +++ b/packages/oc-docs/src/topbarHarness.tsx @@ -0,0 +1,59 @@ +/* eslint-disable react-refresh/only-export-components -- dev-only render entry, not a component module */ +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import './styles/index.css'; +import { Topbar } from './components/Topbar'; + +/** + * Dev-only harness for e2e-testing the Topbar with both slots filled. + * NOT part of the shipped standalone build — it exists so Playwright can + * exercise the mobile search-expand and overflow popover, which require the + * search / env-switcher slot nodes that downstream tickets supply. + */ +const SearchSlot: React.FC = () => ( + +); + +const EnvSwitcherSlot: React.FC = () => ( +
+ Show vars + Development +
+); + +const HarnessApp: React.FC = () => ( +
+ HB} + searchSlot={} + envSwitcherSlot={} + openInBrunoHref="bruno://app/collection/import/git?url=https%3A%2F%2Fexample.com%2Frepo.git" + onToggleSidebar={() => { + (window as unknown as { __toggleSidebarCalls?: number }).__toggleSidebarCalls = + ((window as unknown as { __toggleSidebarCalls?: number }).__toggleSidebarCalls ?? 0) + 1; + }} + /> +
Scroll content to verify the bar stays pinned.
+
+); + +const container = document.getElementById('root'); +if (container) { + createRoot(container).render(); +} diff --git a/packages/oc-docs/topbar-harness.html b/packages/oc-docs/topbar-harness.html new file mode 100644 index 0000000..90d8b83 --- /dev/null +++ b/packages/oc-docs/topbar-harness.html @@ -0,0 +1,12 @@ + + + + + + Topbar Harness + + +
+ + + From f349f9ea4c2736d9103568180a65fdd9f8c17a6a Mon Sep 17 00:00:00 2001 From: Sundram Gupta Date: Wed, 17 Jun 2026 03:15:09 +0530 Subject: [PATCH 02/27] fix(oc-docs): pin Open-in-Bruno CTA to the right with empty slots (BRU-3572) With searchSlot/envSwitcherSlot undefined there was no flex-1 element between the brand and the right cluster, so the CTA collapsed next to the brand on the left. Always render a flex-1 middle (inline search when present, else a spacer) so the right-hand controls + CTA stay pinned to the right edge regardless of slot contents. Add a desktop e2e asserting the CTA hugs the right edge. Co-Authored-By: Claude Opus 4.8 --- packages/oc-docs/e2e/topbar.spec.ts | 17 +++++++++++++++++ .../oc-docs/src/components/Topbar/Topbar.tsx | 10 ++++++---- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/packages/oc-docs/e2e/topbar.spec.ts b/packages/oc-docs/e2e/topbar.spec.ts index 6722c20..9becf0d 100644 --- a/packages/oc-docs/e2e/topbar.spec.ts +++ b/packages/oc-docs/e2e/topbar.spec.ts @@ -22,6 +22,23 @@ test.describe('Topbar — mounted app', () => { expect(box?.y ?? -1).toBeLessThanOrEqual(1); }); + test('CTA stays pinned to the right with empty slots (desktop)', async ({ page }) => { + await page.setViewportSize(DESKTOP); + await page.goto('/'); + + const header = page.locator('header.oc-topbar'); + const cta = page.getByTestId('open-in-bruno'); + const brand = header.locator('.oc-topbar__brand'); + + const headerBox = await header.boundingBox(); + const ctaBox = await cta.boundingBox(); + const brandBox = await brand.boundingBox(); + + // CTA hugs the right edge (within the 20px bar padding), not the brand. + expect((headerBox!.x + headerBox!.width) - (ctaBox!.x + ctaBox!.width)).toBeLessThanOrEqual(24); + expect(ctaBox!.x).toBeGreaterThan(brandBox!.x + brandBox!.width + 100); + }); + test('Open-in-Bruno CTA deep-links via bruno://', async ({ page }) => { await page.setViewportSize(DESKTOP); await page.goto('/'); diff --git a/packages/oc-docs/src/components/Topbar/Topbar.tsx b/packages/oc-docs/src/components/Topbar/Topbar.tsx index 074b50d..3a6d90f 100644 --- a/packages/oc-docs/src/components/Topbar/Topbar.tsx +++ b/packages/oc-docs/src/components/Topbar/Topbar.tsx @@ -73,15 +73,17 @@ const Topbar: React.FC = ({ - {/* Inline search (tablet/desktop). On mobile it relocates to a row. */} - {hasSearch && !isMobile && ( + {/* Flex-1 middle: inline search on tablet/desktop, else a spacer that + keeps the right-hand controls + CTA pinned to the right edge — + including when the search/env slots are empty. */} + {hasSearch && !isMobile ? (
{searchSlot}
+ ) : ( +
)} - {isMobile &&
} - {/* Mobile search toggle reveals the full-width search row below. */} {hasSearch && isMobile && (
); From 4c9f8aa51d13eb85c67aed274c0476eb82596fa9 Mon Sep 17 00:00:00 2001 From: Sundram Gupta Date: Wed, 17 Jun 2026 12:01:47 +0530 Subject: [PATCH 05/27] refactor(oc-docs): address self-review on topbar (BRU-3572) Fixes from code review: Bugs: - Render the Open-in-Bruno CTA only when a deep link or onClick is provided, instead of a visible-but-inert button when neither is set. - Reset the mobile search row when leaving the mobile layout so it doesn't reappear with stale aria state on the next mobile resize. - MobileOverflow outside-dismiss now listens on pointerdown (covers touch) instead of mousedown. Optimization: - useTopbarLayout only setState on an actual breakpoint-band crossing (updater bail-out), and drop the redundant on-mount resize call. Dead code / clarity: - Delete the now-orphaned FetchInBrunoButton (superseded by OpenInBrunoButton). - Correct the StyledWrapper comment: layout switching is in JSX, not CSS; data-mode is for debugging/e2e. Token claim scoped to colors. - Use --oc-font-size-xs for the initials avatar instead of 11px. Reusability: - Extract a shared IconButton (hamburger, search toggle, overflow). - Deduplicate the search-inner wrapper in Topbar. - Collapse OpenInBrunoButton's anchor/button branches into one render. Co-Authored-By: Claude Opus 4.8 --- .../Docs/Sidebar/FetchInBrunoButton.tsx | 31 ------------ .../src/components/Topbar/InitialsAvatar.tsx | 2 +- .../src/components/Topbar/MobileOverflow.tsx | 16 +++---- .../components/Topbar/OpenInBrunoButton.tsx | 37 ++++---------- .../src/components/Topbar/StyledWrapper.ts | 9 ++-- .../oc-docs/src/components/Topbar/Topbar.tsx | 48 ++++++++----------- .../oc-docs/src/components/Topbar/icons.tsx | 16 +++++++ .../oc-docs/src/components/Topbar/index.ts | 2 + .../src/components/Topbar/useTopbarLayout.ts | 8 +++- 9 files changed, 66 insertions(+), 103 deletions(-) delete mode 100644 packages/oc-docs/src/components/Docs/Sidebar/FetchInBrunoButton.tsx 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/Topbar/InitialsAvatar.tsx b/packages/oc-docs/src/components/Topbar/InitialsAvatar.tsx index 5d3a55f..84e2317 100644 --- a/packages/oc-docs/src/components/Topbar/InitialsAvatar.tsx +++ b/packages/oc-docs/src/components/Topbar/InitialsAvatar.tsx @@ -22,7 +22,7 @@ const Badge = styled.span` background: linear-gradient(135deg, var(--oc-brand), var(--oc-primary-subtle)); color: var(--oc-button2-color-primary-text); font-family: var(--font-sans); - font-size: 11px; + font-size: var(--oc-font-size-xs); font-weight: 700; line-height: 1; letter-spacing: 0.02em; diff --git a/packages/oc-docs/src/components/Topbar/MobileOverflow.tsx b/packages/oc-docs/src/components/Topbar/MobileOverflow.tsx index 09dc3b3..5e50438 100644 --- a/packages/oc-docs/src/components/Topbar/MobileOverflow.tsx +++ b/packages/oc-docs/src/components/Topbar/MobileOverflow.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useRef, useState } from 'react'; -import { OverflowIcon } from './icons'; +import { OverflowIcon, IconButton } from './icons'; export interface MobileOverflowProps { /** Secondary controls (the same envSwitcherSlot node, relocated here). */ @@ -19,7 +19,7 @@ const MobileOverflow: React.FC = ({ children }) => { useEffect(() => { if (!open) return; - const onPointerDown = (event: MouseEvent) => { + const onPointerDown = (event: PointerEvent) => { if (containerRef.current && !containerRef.current.contains(event.target as Node)) { setOpen(false); } @@ -28,26 +28,24 @@ const MobileOverflow: React.FC = ({ children }) => { if (event.key === 'Escape') setOpen(false); }; - document.addEventListener('mousedown', onPointerDown); + document.addEventListener('pointerdown', onPointerDown); document.addEventListener('keydown', onKeyDown); return () => { - document.removeEventListener('mousedown', onPointerDown); + document.removeEventListener('pointerdown', onPointerDown); document.removeEventListener('keydown', onKeyDown); }; }, [open]); return (
- + {open && (
{children} diff --git a/packages/oc-docs/src/components/Topbar/OpenInBrunoButton.tsx b/packages/oc-docs/src/components/Topbar/OpenInBrunoButton.tsx index 4d1d3f5..a54688a 100644 --- a/packages/oc-docs/src/components/Topbar/OpenInBrunoButton.tsx +++ b/packages/oc-docs/src/components/Topbar/OpenInBrunoButton.tsx @@ -58,41 +58,22 @@ const OpenInBrunoButton: React.FC = ({ label = 'Open in Bruno', }) => { const className = iconOnly ? 'is-icon' : 'is-full'; - const ariaLabel = iconOnly ? label : undefined; - - const content = ( - <> - - {!iconOnly && {label}} - - ); - - if (href) { - return ( - - {content} - - ); - } + // A real deep link renders an anchor (right-click-copy, accessible); without + // one it falls back to a button driven by onClick. + const tagProps = href + ? ({ as: 'a' as const, href }) + : ({ as: 'button' as const, type: 'button' as const, onClick }); return ( - {content} + + {!iconOnly && {label}} ); }; diff --git a/packages/oc-docs/src/components/Topbar/StyledWrapper.ts b/packages/oc-docs/src/components/Topbar/StyledWrapper.ts index f9643d2..573a7cb 100644 --- a/packages/oc-docs/src/components/Topbar/StyledWrapper.ts +++ b/packages/oc-docs/src/components/Topbar/StyledWrapper.ts @@ -2,11 +2,12 @@ import styled from '@emotion/styled'; /** * Topbar styling. Square bar (radius 0, bottom border only), rounded inner - * controls (6px). All colors map to `--oc-*` theme tokens — no hardcoded hex — - * so the bar honors light/dark automatically. + * controls (6px). Color surfaces map to `--oc-*` theme tokens so the bar honors + * light/dark automatically; the popover elevation uses a neutral overlay shadow. * - * Responsiveness is driven by `data-mode` (mobile | tablet | desktop) set from - * `useTopbarLayout`, keeping the markup and CSS in sync from a single source. + * Which controls render per breakpoint is decided in JSX (`Topbar` + + * `useTopbarLayout`), not via CSS. `data-mode` (mobile | tablet | desktop) is + * exposed on the host element for debugging and e2e targeting. */ export const StyledWrapper = styled.header` position: sticky; diff --git a/packages/oc-docs/src/components/Topbar/Topbar.tsx b/packages/oc-docs/src/components/Topbar/Topbar.tsx index 3a6d90f..897bc2f 100644 --- a/packages/oc-docs/src/components/Topbar/Topbar.tsx +++ b/packages/oc-docs/src/components/Topbar/Topbar.tsx @@ -1,9 +1,9 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { StyledWrapper } from './StyledWrapper'; import Brand from './Brand'; import OpenInBrunoButton from './OpenInBrunoButton'; import MobileOverflow from './MobileOverflow'; -import { SearchIcon, HamburgerIcon } from './icons'; +import { SearchIcon, HamburgerIcon, IconButton } from './icons'; import { useTopbarLayout, showsHamburger } from './useTopbarLayout'; export interface TopbarProps { @@ -48,27 +48,23 @@ const Topbar: React.FC = ({ const isDesktop = mode === 'desktop'; const hasSearch = searchSlot != null; const hasSecondary = envSwitcherSlot != null; + const hasCta = openInBrunoHref != null || onOpenInBruno != null; - const cta = ( - - ); + // Collapse the revealed mobile search row when leaving the mobile layout, + // so it doesn't reappear (with stale aria state) on the next mobile resize. + useEffect(() => { + if (!isMobile) setMobileSearchOpen(false); + }, [isMobile]); + + const searchInner =
{searchSlot}
; return (
{showsHamburger(mode) && ( - + )} @@ -77,24 +73,20 @@ const Topbar: React.FC = ({ keeps the right-hand controls + CTA pinned to the right edge — including when the search/env slots are empty. */} {hasSearch && !isMobile ? ( -
-
{searchSlot}
-
+
{searchInner}
) : (
)} {/* Mobile search toggle reveals the full-width search row below. */} {hasSearch && isMobile && ( - + )} {/* Secondary controls: inline on tablet/desktop, overflow popover on mobile. */} @@ -103,13 +95,13 @@ const Topbar: React.FC = ({ )} {hasSecondary && isMobile && {envSwitcherSlot}} - {cta} + {hasCta && ( + + )}
{hasSearch && isMobile && mobileSearchOpen && ( -
-
{searchSlot}
-
+
{searchInner}
)} ); diff --git a/packages/oc-docs/src/components/Topbar/icons.tsx b/packages/oc-docs/src/components/Topbar/icons.tsx index 87ae459..6fff3e2 100644 --- a/packages/oc-docs/src/components/Topbar/icons.tsx +++ b/packages/oc-docs/src/components/Topbar/icons.tsx @@ -10,6 +10,22 @@ interface IconProps { className?: string; } +export interface IconButtonProps extends React.ButtonHTMLAttributes { + /** Accessible label — icon buttons have no visible text. */ + label: string; +} + +/** + * Square icon button used across the Topbar (hamburger, search toggle, overflow + * trigger). Styling lives in StyledWrapper under `.oc-topbar__icon-btn`; extra + * ARIA props (aria-expanded, aria-haspopup) pass through via spread. + */ +export const IconButton: React.FC = ({ label, children, ...rest }) => ( + +); + export const SearchIcon: React.FC = ({ className }) => ( { useEffect(() => { if (typeof window === 'undefined') return; - const onResize = () => setMode(layoutModeForWidth(window.innerWidth)); - onResize(); + // Bail out when the band is unchanged so dragging the window edge doesn't + // trigger a render on every resize tick (only on actual band crossings). + const onResize = () => { + const next = layoutModeForWidth(window.innerWidth); + setMode((prev) => (prev === next ? prev : next)); + }; window.addEventListener('resize', onResize); return () => window.removeEventListener('resize', onResize); }, []); From 3795aa7d483741993efad17124ee23b14f197488 Mon Sep 17 00:00:00 2001 From: Sundram Gupta Date: Wed, 17 Jun 2026 15:20:36 +0530 Subject: [PATCH 06/27] feat(oc-docs): Docs brand label, desktop-only CTA, hamburger below desktop (BRU-3572) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per updated mobile/tablet design: - Header brand now shows the fixed "Docs" label + initials avatar (HB), not the collection name. Version is no longer rendered in the header (it remains in the page body). collectionName still drives the avatar initials and the accessible title. - Open-in-Bruno is desktop-only — the Bruno desktop app isn't available on tablet/mobile, so the CTA is hidden below the desktop breakpoint. - Hamburger now shows on tablet too (sidebar is a drawer below desktop, persistent only on desktop). - Search collapses to an icon below desktop (tablet + mobile), expanding a full-width row; inline search input is desktop-only. Env switcher stays inline on tablet, in the overflow popover on mobile. `version` is kept on TopbarProps for cross-lane contract stability but is intentionally not rendered. Tests + harness updated for the new model. Co-Authored-By: Claude Opus 4.8 --- packages/oc-docs/e2e/topbar.spec.ts | 33 +++++++++---- .../oc-docs/src/components/Topbar/Brand.tsx | 20 +++----- .../src/components/Topbar/StyledWrapper.ts | 14 ------ .../oc-docs/src/components/Topbar/Topbar.tsx | 48 +++++++++++-------- .../components/Topbar/useTopbarLayout.test.ts | 4 +- .../src/components/Topbar/useTopbarLayout.ts | 7 ++- 6 files changed, 64 insertions(+), 62 deletions(-) diff --git a/packages/oc-docs/e2e/topbar.spec.ts b/packages/oc-docs/e2e/topbar.spec.ts index 38cd057..0b4b8d6 100644 --- a/packages/oc-docs/e2e/topbar.spec.ts +++ b/packages/oc-docs/e2e/topbar.spec.ts @@ -7,14 +7,16 @@ const TABLET = { width: 820, height: 1024 }; const MOBILE = { width: 390, height: 800 }; test.describe('Topbar — mounted app', () => { - test('shows brand (name + version) and a pinned bar', async ({ page }) => { + test('shows the "Docs" brand label (no version) and a pinned bar', async ({ page }) => { await page.setViewportSize(DESKTOP); await page.goto('/'); const header = page.locator('header.oc-topbar'); await expect(header).toBeVisible(); - await expect(header.locator('.oc-topbar__brand-name')).toContainText('Bruno Testbench'); - await expect(header.locator('.oc-topbar__brand-version')).toHaveText('v1.0.0'); + await expect(header.locator('.oc-topbar__brand-name')).toHaveText('Docs'); + // Collection name is not shown in the header; version is shown in the body. + await expect(header.locator('.oc-topbar__brand-name')).not.toContainText('Bruno Testbench'); + await expect(header.locator('.oc-topbar__brand-version')).toHaveCount(0); // Sticky: header stays at the top after the page scrolls. await page.mouse.wheel(0, 600); @@ -59,14 +61,13 @@ test.describe('Topbar — mounted app', () => { expect(href).toMatch(/^bruno:\/\/app\/collection\/import\/git\?url=/); }); - test('mobile condenses: hamburger appears, CTA stays as an icon', async ({ page }) => { + test('mobile condenses: hamburger appears, Open-in-Bruno is hidden', async ({ page }) => { await page.setViewportSize(MOBILE); await page.goto('/'); await expect(page.getByRole('button', { name: /toggle sidebar/i })).toBeVisible(); - const cta = page.getByTestId('open-in-bruno'); - await expect(cta).toBeVisible(); - await expect(cta).toHaveClass(/is-icon/); + // Open-in-Bruno is desktop-only (no Bruno desktop app on mobile). + await expect(page.getByTestId('open-in-bruno')).toHaveCount(0); // No horizontal overflow. const scrollW = await page.evaluate(() => document.documentElement.scrollWidth); @@ -84,13 +85,25 @@ test.describe('Topbar — harness (slots filled)', () => { await expect(page.getByTestId('open-in-bruno')).toHaveClass(/is-full/); }); - test('tablet keeps env slot inline and CTA icon-only, no hamburger', async ({ page }) => { + test('tablet: hamburger + inline env, search collapsed to icon, no CTA', async ({ page }) => { await page.setViewportSize(TABLET); await page.goto('/topbar-harness.html'); + await expect(page.getByRole('button', { name: /toggle sidebar/i })).toBeVisible(); await expect(page.getByTestId('env-switcher-slot')).toBeVisible(); - await expect(page.getByTestId('open-in-bruno')).toHaveClass(/is-icon/); - await expect(page.getByRole('button', { name: /toggle sidebar/i })).toHaveCount(0); + // Search is an icon (no inline input) and Open-in-Bruno is hidden below desktop. + await expect(page.getByTestId('search-slot-input')).toHaveCount(0); + await expect(page.getByRole('button', { name: /^search$/i })).toBeVisible(); + await expect(page.getByTestId('open-in-bruno')).toHaveCount(0); + }); + + test('tablet: search icon expands the search row', async ({ page }) => { + await page.setViewportSize(TABLET); + await page.goto('/topbar-harness.html'); + + await expect(page.getByTestId('search-slot-input')).toHaveCount(0); + await page.getByRole('button', { name: /^search$/i }).click(); + await expect(page.getByTestId('search-slot-input')).toBeVisible(); }); test('mobile: search icon expands the search row', async ({ page }) => { diff --git a/packages/oc-docs/src/components/Topbar/Brand.tsx b/packages/oc-docs/src/components/Topbar/Brand.tsx index b45b345..0c9b430 100644 --- a/packages/oc-docs/src/components/Topbar/Brand.tsx +++ b/packages/oc-docs/src/components/Topbar/Brand.tsx @@ -2,11 +2,14 @@ import React from 'react'; import InitialsAvatar from './InitialsAvatar'; export interface BrandProps { + /** Used for the initials avatar + accessible title; not shown as the label. */ collectionName: string; - version?: string; logo?: React.ReactNode; } +/** Fixed product label shown beside the avatar (collection name lives in the body). */ +const PRODUCT_LABEL = 'Docs'; + /** * Renders an arbitrary logo node. A string is treated as an image src so * callers can pass a URL (as `OpenCollection` does today); any other node is @@ -19,13 +22,7 @@ const renderLogo = (logo: React.ReactNode, collectionName: string): React.ReactN return logo; }; -/** Display version with a leading "v" (idempotent: "1.0.0" → "v1.0.0", "v2" → "v2"). */ -const formatVersion = (version: string): string => { - const trimmed = version.trim(); - return /^v/i.test(trimmed) ? trimmed : `v${trimmed}`; -}; - -const Brand: React.FC = ({ collectionName, version, logo }) => { +const Brand: React.FC = ({ collectionName, logo }) => { const hasLogo = logo != null && logo !== ''; return (
@@ -33,11 +30,8 @@ const Brand: React.FC = ({ collectionName, version, logo }) => { {hasLogo ? renderLogo(logo, collectionName) : } - - - {collectionName} - - {version && {formatVersion(version)}} + + {PRODUCT_LABEL}
); diff --git a/packages/oc-docs/src/components/Topbar/StyledWrapper.ts b/packages/oc-docs/src/components/Topbar/StyledWrapper.ts index 573a7cb..7edf419 100644 --- a/packages/oc-docs/src/components/Topbar/StyledWrapper.ts +++ b/packages/oc-docs/src/components/Topbar/StyledWrapper.ts @@ -55,13 +55,6 @@ export const StyledWrapper = styled.header` } } - .oc-topbar__brand-text { - display: flex; - flex-direction: column; - min-width: 0; - line-height: 1.1; - } - .oc-topbar__brand-name { font-size: var(--oc-font-size-md); font-weight: 600; @@ -71,13 +64,6 @@ export const StyledWrapper = styled.header` text-overflow: ellipsis; } - .oc-topbar__brand-version { - font-size: var(--oc-font-size-xs); - color: var(--oc-colors-text-muted, var(--oc-text)); - opacity: 0.7; - white-space: nowrap; - } - /* ---- Slots ---- */ .oc-topbar__search { display: flex; diff --git a/packages/oc-docs/src/components/Topbar/Topbar.tsx b/packages/oc-docs/src/components/Topbar/Topbar.tsx index 897bc2f..f8efcb6 100644 --- a/packages/oc-docs/src/components/Topbar/Topbar.tsx +++ b/packages/oc-docs/src/components/Topbar/Topbar.tsx @@ -27,13 +27,18 @@ export interface TopbarProps { * No routing, data fetching, or store access — everything arrives via props. * Composes small reusable subcomponents and exposes two slots (search, * env-switcher) that render whatever node is passed and degrade gracefully - * when empty. Responsive layout: full bar (desktop), condensed with icon-only - * CTA (tablet), single condensed row with hamburger + search-expand + overflow - * popover (mobile). + * when empty. Responsive layout: + * - desktop (>=1024): full bar — brand · centered search · env switcher · Open-in-Bruno. + * - tablet (768-1023): hamburger · brand · search icon · env switcher inline (no CTA). + * - mobile (<768): hamburger · brand · search icon · overflow popover (env) (no CTA). + * Below desktop the search collapses to an icon that expands a full-width row, + * and Open-in-Bruno is hidden (the Bruno desktop app only runs on desktop). + * + * Note: `version` is accepted for cross-lane contract stability but is not + * rendered in the header — the version is shown in the page body. */ const Topbar: React.FC = ({ collectionName, - version, logo, searchSlot, envSwitcherSlot, @@ -42,7 +47,7 @@ const Topbar: React.FC = ({ onToggleSidebar, }) => { const mode = useTopbarLayout(); - const [mobileSearchOpen, setMobileSearchOpen] = useState(false); + const [searchOpen, setSearchOpen] = useState(false); const isMobile = mode === 'mobile'; const isDesktop = mode === 'desktop'; @@ -50,11 +55,11 @@ const Topbar: React.FC = ({ const hasSecondary = envSwitcherSlot != null; const hasCta = openInBrunoHref != null || onOpenInBruno != null; - // Collapse the revealed mobile search row when leaving the mobile layout, - // so it doesn't reappear (with stale aria state) on the next mobile resize. + // Collapse the revealed search row when entering the desktop layout, so it + // doesn't reappear (with stale aria state) the next time search collapses. useEffect(() => { - if (!isMobile) setMobileSearchOpen(false); - }, [isMobile]); + if (isDesktop) setSearchOpen(false); + }, [isDesktop]); const searchInner =
{searchSlot}
; @@ -67,23 +72,23 @@ const Topbar: React.FC = ({ )} - + - {/* Flex-1 middle: inline search on tablet/desktop, else a spacer that - keeps the right-hand controls + CTA pinned to the right edge — - including when the search/env slots are empty. */} - {hasSearch && !isMobile ? ( + {/* Flex-1 middle: inline search on desktop, else a spacer that keeps the + right-hand controls pinned to the right edge (search collapses to an + icon below desktop, and may be empty). */} + {hasSearch && isDesktop ? (
{searchInner}
) : (
)} - {/* Mobile search toggle reveals the full-width search row below. */} - {hasSearch && isMobile && ( + {/* Below desktop: search toggle reveals the full-width search row below. */} + {hasSearch && !isDesktop && ( setMobileSearchOpen((prev) => !prev)} + aria-expanded={searchOpen} + onClick={() => setSearchOpen((prev) => !prev)} > @@ -95,12 +100,13 @@ const Topbar: React.FC = ({ )} {hasSecondary && isMobile && {envSwitcherSlot}} - {hasCta && ( - + {/* Open-in-Bruno is desktop-only — the Bruno desktop app isn't on tablet/mobile. */} + {isDesktop && hasCta && ( + )}
- {hasSearch && isMobile && mobileSearchOpen && ( + {hasSearch && !isDesktop && searchOpen && (
{searchInner}
)} diff --git a/packages/oc-docs/src/components/Topbar/useTopbarLayout.test.ts b/packages/oc-docs/src/components/Topbar/useTopbarLayout.test.ts index 0d37dff..eb8c941 100644 --- a/packages/oc-docs/src/components/Topbar/useTopbarLayout.test.ts +++ b/packages/oc-docs/src/components/Topbar/useTopbarLayout.test.ts @@ -25,9 +25,9 @@ describe('layoutModeForWidth', () => { }); describe('showsHamburger', () => { - it('shows only on mobile (sidebar is inline on tablet/desktop)', () => { + it('shows below desktop (sidebar is a drawer on tablet + mobile)', () => { expect(showsHamburger('mobile')).toBe(true); - expect(showsHamburger('tablet')).toBe(false); + expect(showsHamburger('tablet')).toBe(true); expect(showsHamburger('desktop')).toBe(false); }); }); diff --git a/packages/oc-docs/src/components/Topbar/useTopbarLayout.ts b/packages/oc-docs/src/components/Topbar/useTopbarLayout.ts index 90f75f9..baf93d2 100644 --- a/packages/oc-docs/src/components/Topbar/useTopbarLayout.ts +++ b/packages/oc-docs/src/components/Topbar/useTopbarLayout.ts @@ -21,8 +21,11 @@ export const layoutModeForWidth = (width: number): TopbarLayoutMode => { return 'mobile'; }; -/** Hamburger trigger only shows below the sidebar-inline breakpoint (mobile). */ -export const showsHamburger = (mode: TopbarLayoutMode): boolean => mode === 'mobile'; +/** + * Hamburger trigger shows below desktop (tablet + mobile): the sidebar is a + * drawer there, persistent only on desktop. + */ +export const showsHamburger = (mode: TopbarLayoutMode): boolean => mode !== 'desktop'; /** * Tracks the current layout mode from the viewport width. From 52e9fc44a5ac199d45d2a0b6e657c5ae1834de01 Mon Sep 17 00:00:00 2001 From: Sundram Gupta Date: Wed, 17 Jun 2026 15:22:35 +0530 Subject: [PATCH 07/27] style(oc-docs): borderless ghost icon-buttons in topbar (BRU-3572) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the 1px border on the shared topbar icon-button (hamburger, search toggle, overflow trigger) to match the design — ghost style with a hover/active background for affordance instead of a border. Co-Authored-By: Claude Opus 4.8 --- packages/oc-docs/src/components/Topbar/StyledWrapper.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/oc-docs/src/components/Topbar/StyledWrapper.ts b/packages/oc-docs/src/components/Topbar/StyledWrapper.ts index 7edf419..0442ffe 100644 --- a/packages/oc-docs/src/components/Topbar/StyledWrapper.ts +++ b/packages/oc-docs/src/components/Topbar/StyledWrapper.ts @@ -89,7 +89,8 @@ export const StyledWrapper = styled.header` flex: 1 1 auto; } - /* ---- Icon buttons (hamburger / search toggle / overflow) ---- */ + /* ---- Icon buttons (hamburger / search toggle / overflow) ---- + Ghost style: no border; a hover/active background provides the affordance. */ .oc-topbar__icon-btn { display: inline-flex; align-items: center; @@ -100,10 +101,10 @@ export const StyledWrapper = styled.header` flex: none; background: transparent; color: var(--oc-colors-text-muted, var(--oc-text)); - border: 1px solid var(--oc-border-border1); + border: none; border-radius: var(--oc-border-radius-base); cursor: pointer; - transition: background-color 0.12s ease, color 0.12s ease, border-color 0.12s ease; + transition: background-color 0.12s ease, color 0.12s ease; svg { width: 16px; @@ -118,7 +119,6 @@ export const StyledWrapper = styled.header` &[aria-expanded='true'] { background: var(--oc-background-surface0); color: var(--oc-text); - border-color: var(--oc-border-border2, var(--oc-border-border1)); } } From 64d5d583a212f3205e0b236040a16fe0205d6bb3 Mon Sep 17 00:00:00 2001 From: Sundram Gupta Date: Wed, 17 Jun 2026 16:26:47 +0530 Subject: [PATCH 08/27] feat(oc-docs): gate Open-in-Bruno on device capability, not width (BRU-3572) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iPad Pro is 1024–1366px wide, so the width-only desktop breakpoint showed the Open-in-Bruno CTA on it. Split "layout mode" (width) from "can run the Bruno desktop app" (capability): the CTA now needs the desktop layout AND a capable device, so the iPad Pro gets the desktop layout with no CTA. Capability (computeCanRunBrunoApp, pure + unit-tested): - Require any fine, hovering pointer — `(any-hover: hover) and (any-pointer: fine)` — so touchscreen laptops / 2-in-1s (touch is the primary pointer but a trackpad exists) still qualify. - Hard-exclude mobile/tablet OSes: UA match, plus maxTouchPoints > 1 on MacIntel to unmask iPadOS 13+ (which reports as Mac). A real Mac (maxTouchPoints === 0) is kept. The exclusion — not the media query — is what reliably catches an iPad with a trackpad folio. Also revert the header brand to collection name + version (per design); the "Docs"/no-version variant is dropped. Layout breakpoints unchanged: large tablets (>=1024) keep the desktop layout, 768–1023 keep hamburger + search icon. Add a dev-only topbar-device-check.html that prints the raw signals + the computed decision for verifying the device matrix (not shipped). Co-Authored-By: Claude Opus 4.8 --- packages/oc-docs/e2e/topbar.spec.ts | 8 +-- .../oc-docs/src/components/Topbar/Brand.tsx | 20 ++++-- .../src/components/Topbar/StyledWrapper.ts | 14 ++++ .../oc-docs/src/components/Topbar/Topbar.tsx | 18 +++-- .../oc-docs/src/components/Topbar/index.ts | 5 ++ .../Topbar/useCanRunBrunoApp.test.ts | 68 ++++++++++++++++++ .../components/Topbar/useCanRunBrunoApp.ts | 63 ++++++++++++++++ packages/oc-docs/src/deviceCheck.tsx | 72 +++++++++++++++++++ packages/oc-docs/topbar-device-check.html | 12 ++++ 9 files changed, 261 insertions(+), 19 deletions(-) create mode 100644 packages/oc-docs/src/components/Topbar/useCanRunBrunoApp.test.ts create mode 100644 packages/oc-docs/src/components/Topbar/useCanRunBrunoApp.ts create mode 100644 packages/oc-docs/src/deviceCheck.tsx create mode 100644 packages/oc-docs/topbar-device-check.html diff --git a/packages/oc-docs/e2e/topbar.spec.ts b/packages/oc-docs/e2e/topbar.spec.ts index 0b4b8d6..c268559 100644 --- a/packages/oc-docs/e2e/topbar.spec.ts +++ b/packages/oc-docs/e2e/topbar.spec.ts @@ -7,16 +7,14 @@ const TABLET = { width: 820, height: 1024 }; const MOBILE = { width: 390, height: 800 }; test.describe('Topbar — mounted app', () => { - test('shows the "Docs" brand label (no version) and a pinned bar', async ({ page }) => { + test('shows brand (name + version) and a pinned bar', async ({ page }) => { await page.setViewportSize(DESKTOP); await page.goto('/'); const header = page.locator('header.oc-topbar'); await expect(header).toBeVisible(); - await expect(header.locator('.oc-topbar__brand-name')).toHaveText('Docs'); - // Collection name is not shown in the header; version is shown in the body. - await expect(header.locator('.oc-topbar__brand-name')).not.toContainText('Bruno Testbench'); - await expect(header.locator('.oc-topbar__brand-version')).toHaveCount(0); + await expect(header.locator('.oc-topbar__brand-name')).toContainText('Bruno Testbench'); + await expect(header.locator('.oc-topbar__brand-version')).toHaveText('v1.0.0'); // Sticky: header stays at the top after the page scrolls. await page.mouse.wheel(0, 600); diff --git a/packages/oc-docs/src/components/Topbar/Brand.tsx b/packages/oc-docs/src/components/Topbar/Brand.tsx index 0c9b430..b45b345 100644 --- a/packages/oc-docs/src/components/Topbar/Brand.tsx +++ b/packages/oc-docs/src/components/Topbar/Brand.tsx @@ -2,14 +2,11 @@ import React from 'react'; import InitialsAvatar from './InitialsAvatar'; export interface BrandProps { - /** Used for the initials avatar + accessible title; not shown as the label. */ collectionName: string; + version?: string; logo?: React.ReactNode; } -/** Fixed product label shown beside the avatar (collection name lives in the body). */ -const PRODUCT_LABEL = 'Docs'; - /** * Renders an arbitrary logo node. A string is treated as an image src so * callers can pass a URL (as `OpenCollection` does today); any other node is @@ -22,7 +19,13 @@ const renderLogo = (logo: React.ReactNode, collectionName: string): React.ReactN return logo; }; -const Brand: React.FC = ({ collectionName, logo }) => { +/** Display version with a leading "v" (idempotent: "1.0.0" → "v1.0.0", "v2" → "v2"). */ +const formatVersion = (version: string): string => { + const trimmed = version.trim(); + return /^v/i.test(trimmed) ? trimmed : `v${trimmed}`; +}; + +const Brand: React.FC = ({ collectionName, version, logo }) => { const hasLogo = logo != null && logo !== ''; return (
@@ -30,8 +33,11 @@ const Brand: React.FC = ({ collectionName, logo }) => { {hasLogo ? renderLogo(logo, collectionName) : } - - {PRODUCT_LABEL} + + + {collectionName} + + {version && {formatVersion(version)}}
); diff --git a/packages/oc-docs/src/components/Topbar/StyledWrapper.ts b/packages/oc-docs/src/components/Topbar/StyledWrapper.ts index 0442ffe..0f241b2 100644 --- a/packages/oc-docs/src/components/Topbar/StyledWrapper.ts +++ b/packages/oc-docs/src/components/Topbar/StyledWrapper.ts @@ -55,6 +55,13 @@ export const StyledWrapper = styled.header` } } + .oc-topbar__brand-text { + display: flex; + flex-direction: column; + min-width: 0; + line-height: 1.1; + } + .oc-topbar__brand-name { font-size: var(--oc-font-size-md); font-weight: 600; @@ -64,6 +71,13 @@ export const StyledWrapper = styled.header` text-overflow: ellipsis; } + .oc-topbar__brand-version { + font-size: var(--oc-font-size-xs); + color: var(--oc-colors-text-muted, var(--oc-text)); + opacity: 0.7; + white-space: nowrap; + } + /* ---- Slots ---- */ .oc-topbar__search { display: flex; diff --git a/packages/oc-docs/src/components/Topbar/Topbar.tsx b/packages/oc-docs/src/components/Topbar/Topbar.tsx index f8efcb6..e83a593 100644 --- a/packages/oc-docs/src/components/Topbar/Topbar.tsx +++ b/packages/oc-docs/src/components/Topbar/Topbar.tsx @@ -5,6 +5,7 @@ import OpenInBrunoButton from './OpenInBrunoButton'; import MobileOverflow from './MobileOverflow'; import { SearchIcon, HamburgerIcon, IconButton } from './icons'; import { useTopbarLayout, showsHamburger } from './useTopbarLayout'; +import { useCanRunBrunoApp } from './useCanRunBrunoApp'; export interface TopbarProps { collectionName: string; @@ -31,14 +32,15 @@ export interface TopbarProps { * - desktop (>=1024): full bar — brand · centered search · env switcher · Open-in-Bruno. * - tablet (768-1023): hamburger · brand · search icon · env switcher inline (no CTA). * - mobile (<768): hamburger · brand · search icon · overflow popover (env) (no CTA). - * Below desktop the search collapses to an icon that expands a full-width row, - * and Open-in-Bruno is hidden (the Bruno desktop app only runs on desktop). + * Below desktop the search collapses to an icon that expands a full-width row. * - * Note: `version` is accepted for cross-lane contract stability but is not - * rendered in the header — the version is shown in the page body. + * Open-in-Bruno needs the desktop *layout* (>=1024) AND a device that can run + * the Bruno desktop app (capability check) — so a large touch tablet like the + * iPad Pro (1024–1366px) gets the desktop layout but no CTA. */ const Topbar: React.FC = ({ collectionName, + version, logo, searchSlot, envSwitcherSlot, @@ -47,6 +49,7 @@ const Topbar: React.FC = ({ onToggleSidebar, }) => { const mode = useTopbarLayout(); + const canRunBrunoApp = useCanRunBrunoApp(); const [searchOpen, setSearchOpen] = useState(false); const isMobile = mode === 'mobile'; @@ -72,7 +75,7 @@ const Topbar: React.FC = ({ )} - + {/* Flex-1 middle: inline search on desktop, else a spacer that keeps the right-hand controls pinned to the right edge (search collapses to an @@ -100,8 +103,9 @@ const Topbar: React.FC = ({ )} {hasSecondary && isMobile && {envSwitcherSlot}} - {/* Open-in-Bruno is desktop-only — the Bruno desktop app isn't on tablet/mobile. */} - {isDesktop && hasCta && ( + {/* Open-in-Bruno: desktop layout AND a device that can run Bruno desktop + (hidden on large touch tablets like iPad Pro despite their width). */} + {isDesktop && canRunBrunoApp && hasCta && ( )}
diff --git a/packages/oc-docs/src/components/Topbar/index.ts b/packages/oc-docs/src/components/Topbar/index.ts index e4d42d1..b73cf39 100644 --- a/packages/oc-docs/src/components/Topbar/index.ts +++ b/packages/oc-docs/src/components/Topbar/index.ts @@ -16,3 +16,8 @@ export { showsHamburger, type TopbarLayoutMode, } from './useTopbarLayout'; +export { + useCanRunBrunoApp, + computeCanRunBrunoApp, + type DeviceEnv, +} from './useCanRunBrunoApp'; diff --git a/packages/oc-docs/src/components/Topbar/useCanRunBrunoApp.test.ts b/packages/oc-docs/src/components/Topbar/useCanRunBrunoApp.test.ts new file mode 100644 index 0000000..6c473cf --- /dev/null +++ b/packages/oc-docs/src/components/Topbar/useCanRunBrunoApp.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from 'vitest'; +import { computeCanRunBrunoApp, type DeviceEnv } from './useCanRunBrunoApp'; + +const base: DeviceEnv = { + anyHoverFine: true, + userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Safari/605', + platform: 'MacIntel', + maxTouchPoints: 0, +}; + +describe('computeCanRunBrunoApp', () => { + it('allows a real desktop (fine pointer, no touch)', () => { + expect(computeCanRunBrunoApp(base)).toBe(true); + }); + + it('allows a touchscreen Windows laptop (any fine pointer present)', () => { + expect( + computeCanRunBrunoApp({ + anyHoverFine: true, + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120', + platform: 'Win32', + maxTouchPoints: 10, + }) + ).toBe(true); + }); + + it('excludes iPadOS masquerading as Mac (MacIntel + touch points)', () => { + expect( + computeCanRunBrunoApp({ + anyHoverFine: true, // iPad + trackpad folio can report this + userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Safari/605', + platform: 'MacIntel', + maxTouchPoints: 5, + }) + ).toBe(false); + }); + + it('excludes an iPad that still reports iPad in the UA', () => { + expect( + computeCanRunBrunoApp({ + anyHoverFine: true, + userAgent: 'Mozilla/5.0 (iPad; CPU OS 16_0 like Mac OS X) Safari/604', + platform: 'iPad', + maxTouchPoints: 5, + }) + ).toBe(false); + }); + + it('excludes Android / iPhone', () => { + expect(computeCanRunBrunoApp({ ...base, anyHoverFine: false, userAgent: 'Android 13', platform: 'Linux armv8l', maxTouchPoints: 5 })).toBe(false); + expect(computeCanRunBrunoApp({ ...base, anyHoverFine: false, userAgent: 'iPhone OS 16', platform: 'iPhone', maxTouchPoints: 5 })).toBe(false); + }); + + it('excludes a pure touch tablet (no fine pointer)', () => { + expect( + computeCanRunBrunoApp({ + anyHoverFine: false, + userAgent: 'Mozilla/5.0 (Linux; Android 13; Tab)', + platform: 'Linux armv8l', + maxTouchPoints: 10, + }) + ).toBe(false); + }); + + it('keeps a real Mac (MacIntel, zero touch points)', () => { + expect(computeCanRunBrunoApp({ ...base, maxTouchPoints: 0 })).toBe(true); + }); +}); diff --git a/packages/oc-docs/src/components/Topbar/useCanRunBrunoApp.ts b/packages/oc-docs/src/components/Topbar/useCanRunBrunoApp.ts new file mode 100644 index 0000000..a6caa50 --- /dev/null +++ b/packages/oc-docs/src/components/Topbar/useCanRunBrunoApp.ts @@ -0,0 +1,63 @@ +import { useEffect, useState } from 'react'; + +export interface DeviceEnv { + /** `(any-hover: hover) and (any-pointer: fine)` — any fine, hovering pointer present. */ + anyHoverFine: boolean; + userAgent: string; + /** navigator.platform (legacy but still the reliable iPad-unmask signal with maxTouchPoints). */ + platform: string; + maxTouchPoints: number; +} + +/** + * Whether this device can plausibly run the Bruno desktop app — the gate for + * showing the Open-in-Bruno CTA. Viewport width is the wrong signal (iPad Pro + * is 1024–1366px wide), so we look at input capability instead: + * + * 1. Require any fine, hovering pointer (`any-*` catches touchscreen laptops / + * 2-in-1s where the touch digitizer is the primary pointer but a trackpad + * exists). A pure touch tablet has neither → excluded. + * 2. Hard-exclude mobile/tablet OSes that can't run Bruno. iPadOS 13+ reports + * as `MacIntel`, so a real Mac (maxTouchPoints === 0) is kept while a touch + * iPad (maxTouchPoints > 1) is excluded. This exclusion — not the media + * query — is what reliably catches an iPad with a trackpad folio. + * + * Pure so it can be unit tested against a device matrix without a DOM. + */ +export const computeCanRunBrunoApp = ({ + anyHoverFine, + userAgent, + platform, + maxTouchPoints, +}: DeviceEnv): boolean => { + const isMobileOS = + /Android|iPhone|iPad|iPod/.test(userAgent) || + (platform === 'MacIntel' && maxTouchPoints > 1); + return anyHoverFine && !isMobileOS; +}; + +const readDeviceEnv = (): DeviceEnv => ({ + anyHoverFine: window.matchMedia('(any-hover: hover) and (any-pointer: fine)').matches, + userAgent: navigator.userAgent, + platform: navigator.platform, + maxTouchPoints: navigator.maxTouchPoints, +}); + +/** + * Hook form. Defaults to `false` until the first client measure (SSR/no-window + * safe and avoids flashing the CTA on touch devices). Re-evaluates on resize so + * attaching a trackpad / external mouse can light the CTA up. + */ +export const useCanRunBrunoApp = (): boolean => { + const [canRun, setCanRun] = useState(false); + + useEffect(() => { + if (typeof window === 'undefined') return; + const update = () => setCanRun(computeCanRunBrunoApp(readDeviceEnv())); + update(); + window.addEventListener('resize', update); + return () => window.removeEventListener('resize', update); + }, []); + + return canRun; +}; diff --git a/packages/oc-docs/src/deviceCheck.tsx b/packages/oc-docs/src/deviceCheck.tsx new file mode 100644 index 0000000..d42e045 --- /dev/null +++ b/packages/oc-docs/src/deviceCheck.tsx @@ -0,0 +1,72 @@ +/* eslint-disable react-refresh/only-export-components -- dev-only render entry, not a component module */ +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import { computeCanRunBrunoApp } from './components/Topbar/useCanRunBrunoApp'; + +/** + * Dev-only diagnostic page for verifying the Open-in-Bruno capability gate on + * real devices (iPad Pro, Surface, etc.). Prints the raw signals + the computed + * decision so the matrix can be checked before locking the logic. NOT shipped. + */ +const mq = (q: string) => (typeof window !== 'undefined' ? window.matchMedia(q).matches : false); + +const Row: React.FC<{ k: string; v: React.ReactNode }> = ({ k, v }) => ( + + {k} + {String(v)} + +); + +const DeviceCheck: React.FC = () => { + const signals = { + 'matchMedia (pointer: fine)': mq('(pointer: fine)'), + 'matchMedia (pointer: coarse)': mq('(pointer: coarse)'), + 'matchMedia (any-pointer: fine)': mq('(any-pointer: fine)'), + 'matchMedia (any-pointer: coarse)': mq('(any-pointer: coarse)'), + 'matchMedia (hover: hover)': mq('(hover: hover)'), + 'matchMedia (hover: none)': mq('(hover: none)'), + 'matchMedia (any-hover: hover)': mq('(any-hover: hover)'), + 'navigator.maxTouchPoints': navigator.maxTouchPoints, + 'navigator.platform': navigator.platform, + 'window.innerWidth': window.innerWidth, + 'navigator.userAgent': navigator.userAgent, + }; + + const anyHoverFine = mq('(any-hover: hover) and (any-pointer: fine)'); + const showCTA = computeCanRunBrunoApp({ + anyHoverFine, + userAgent: navigator.userAgent, + platform: navigator.platform, + maxTouchPoints: navigator.maxTouchPoints, + }); + + return ( +
+

Topbar device check

+

+ Open-in-Bruno CTA → {showCTA ? 'SHOWN' : 'HIDDEN'} +

+ + + {Object.entries(signals).map(([k, v]) => ( + + ))} + +
+
+ ); +}; + +const container = document.getElementById('root'); +if (container) { + createRoot(container).render(); +} diff --git a/packages/oc-docs/topbar-device-check.html b/packages/oc-docs/topbar-device-check.html new file mode 100644 index 0000000..4ba8419 --- /dev/null +++ b/packages/oc-docs/topbar-device-check.html @@ -0,0 +1,12 @@ + + + + + + Topbar Device Check + + +
+ + + From aeb4132104c0412be325937c57203198bc2585e7 Mon Sep 17 00:00:00 2001 From: Sundram Gupta Date: Wed, 17 Jun 2026 17:01:31 +0530 Subject: [PATCH 09/27] refactor(oc-docs): align Topbar to per-component folder convention (BRU-3572) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Structure + naming only — no behavior, props, or visual change. Adopts the house convention from PR #45 (each component a folder with .tsx + .spec.tsx + StyledWrapper.ts + index.ts; pure utils in src/utils; hooks in src/hooks; shared glyphs in src/assets/icons.tsx). Moves: - Promote shared primitives to top-level src/components/: IconButton/, InitialsAvatar/, OpenInBrunoButton/ (reusable across lanes). - Keep Topbar/, Topbar/Brand/, Topbar/MobileOverflow/ as their own folders. - Split the single Topbar/StyledWrapper.ts into per-component StyledWrapper.ts. - Pure utils -> src/utils: buildBrunoDeepLink, getInitials (+ .spec.ts). - Hooks -> src/hooks: useTopbarLayout, useCanRunBrunoApp (+ .spec.ts). - Raw glyphs -> src/assets/icons.tsx (SearchIcon, HamburgerIcon, OverflowIcon, BrunoGlyph). Matches PR #45's icons.tsx style/baseIconProps; see the file's reconciliation note for the (mechanical) merge with #45's glyph set. - *.test.ts -> *.spec.ts(x); add a colocated render spec per component. IconButton consolidation: - Single canonical src/components/IconButton/ (icon-only button primitive, aria-label required). Removed the duplicate, unused IconButton from ui/MinimalComponents.tsx (the other exports there are untouched) and the local copy in Topbar/icons.tsx. All callers import the shared path. Dev scaffolding removed from shipped src/ + package root: deleted src/topbarHarness.tsx, src/deviceCheck.tsx, topbar-harness.html, topbar-device-check.html. The e2e harness is now gated behind dev.tsx (?view=topbar-harness); e2e navigates there instead of the html. Public API unchanged: Topbar still exports from src/components/Topbar (index.ts re-export) for BRU-3188. OpenCollection updated to import buildBrunoDeepLink from src/utils. Co-Authored-By: Claude Opus 4.8 --- packages/oc-docs/e2e/topbar.spec.ts | 12 +- .../{components/Topbar => assets}/icons.tsx | 87 +++++-------- .../components/IconButton/IconButton.spec.tsx | 26 ++++ .../src/components/IconButton/IconButton.tsx | 20 +++ .../components/IconButton/StyledWrapper.ts | 37 ++++++ .../src/components/IconButton/index.ts | 2 + .../InitialsAvatar/InitialsAvatar.spec.tsx | 16 +++ .../InitialsAvatar/InitialsAvatar.tsx | 19 +++ .../InitialsAvatar/StyledWrapper.ts | 23 ++++ .../src/components/InitialsAvatar/index.ts | 2 + .../OpenCollection/OpenCollection.tsx | 2 +- .../OpenInBrunoButton.spec.tsx | 21 ++++ .../OpenInBrunoButton.tsx | 45 +------ .../OpenInBrunoButton/StyledWrapper.ts | 45 +++++++ .../src/components/OpenInBrunoButton/index.ts | 2 + .../components/Topbar/Brand/Brand.spec.tsx | 30 +++++ .../components/Topbar/{ => Brand}/Brand.tsx | 7 +- .../components/Topbar/Brand/StyledWrapper.ts | 51 ++++++++ .../src/components/Topbar/Brand/index.ts | 3 + .../src/components/Topbar/InitialsAvatar.tsx | 41 ------ .../MobileOverflow/MobileOverflow.spec.tsx | 18 +++ .../{ => MobileOverflow}/MobileOverflow.tsx | 8 +- .../Topbar/MobileOverflow/StyledWrapper.ts | 24 ++++ .../components/Topbar/MobileOverflow/index.ts | 3 + .../src/components/Topbar/StyledWrapper.ts | 117 +----------------- .../src/components/Topbar/Topbar.spec.tsx | 24 ++++ .../oc-docs/src/components/Topbar/Topbar.tsx | 9 +- .../oc-docs/src/components/Topbar/index.ts | 21 ---- packages/oc-docs/src/dev.tsx | 56 ++++++++- packages/oc-docs/src/deviceCheck.tsx | 72 ----------- packages/oc-docs/src/hooks/index.ts | 9 +- .../useCanRunBrunoApp.spec.ts} | 0 .../Topbar => hooks}/useCanRunBrunoApp.ts | 0 .../useTopbarLayout.spec.ts} | 0 .../Topbar => hooks}/useTopbarLayout.ts | 2 +- packages/oc-docs/src/topbarHarness.tsx | 58 --------- packages/oc-docs/src/ui/MinimalComponents.tsx | 24 ---- .../buildBrunoDeepLink.spec.ts} | 0 .../Topbar => utils}/buildBrunoDeepLink.ts | 0 .../getInitials.spec.ts} | 0 .../Topbar => utils}/getInitials.ts | 0 packages/oc-docs/topbar-device-check.html | 12 -- packages/oc-docs/topbar-harness.html | 12 -- 43 files changed, 490 insertions(+), 470 deletions(-) rename packages/oc-docs/src/{components/Topbar => assets}/icons.tsx (70%) create mode 100644 packages/oc-docs/src/components/IconButton/IconButton.spec.tsx create mode 100644 packages/oc-docs/src/components/IconButton/IconButton.tsx create mode 100644 packages/oc-docs/src/components/IconButton/StyledWrapper.ts create mode 100644 packages/oc-docs/src/components/IconButton/index.ts create mode 100644 packages/oc-docs/src/components/InitialsAvatar/InitialsAvatar.spec.tsx create mode 100644 packages/oc-docs/src/components/InitialsAvatar/InitialsAvatar.tsx create mode 100644 packages/oc-docs/src/components/InitialsAvatar/StyledWrapper.ts create mode 100644 packages/oc-docs/src/components/InitialsAvatar/index.ts create mode 100644 packages/oc-docs/src/components/OpenInBrunoButton/OpenInBrunoButton.spec.tsx rename packages/oc-docs/src/components/{Topbar => OpenInBrunoButton}/OpenInBrunoButton.tsx (53%) create mode 100644 packages/oc-docs/src/components/OpenInBrunoButton/StyledWrapper.ts create mode 100644 packages/oc-docs/src/components/OpenInBrunoButton/index.ts create mode 100644 packages/oc-docs/src/components/Topbar/Brand/Brand.spec.tsx rename packages/oc-docs/src/components/Topbar/{ => Brand}/Brand.tsx (89%) create mode 100644 packages/oc-docs/src/components/Topbar/Brand/StyledWrapper.ts create mode 100644 packages/oc-docs/src/components/Topbar/Brand/index.ts delete mode 100644 packages/oc-docs/src/components/Topbar/InitialsAvatar.tsx create mode 100644 packages/oc-docs/src/components/Topbar/MobileOverflow/MobileOverflow.spec.tsx rename packages/oc-docs/src/components/Topbar/{ => MobileOverflow}/MobileOverflow.tsx (87%) create mode 100644 packages/oc-docs/src/components/Topbar/MobileOverflow/StyledWrapper.ts create mode 100644 packages/oc-docs/src/components/Topbar/MobileOverflow/index.ts create mode 100644 packages/oc-docs/src/components/Topbar/Topbar.spec.tsx delete mode 100644 packages/oc-docs/src/deviceCheck.tsx rename packages/oc-docs/src/{components/Topbar/useCanRunBrunoApp.test.ts => hooks/useCanRunBrunoApp.spec.ts} (100%) rename packages/oc-docs/src/{components/Topbar => hooks}/useCanRunBrunoApp.ts (100%) rename packages/oc-docs/src/{components/Topbar/useTopbarLayout.test.ts => hooks/useTopbarLayout.spec.ts} (100%) rename packages/oc-docs/src/{components/Topbar => hooks}/useTopbarLayout.ts (95%) delete mode 100644 packages/oc-docs/src/topbarHarness.tsx rename packages/oc-docs/src/{components/Topbar/buildBrunoDeepLink.test.ts => utils/buildBrunoDeepLink.spec.ts} (100%) rename packages/oc-docs/src/{components/Topbar => utils}/buildBrunoDeepLink.ts (100%) rename packages/oc-docs/src/{components/Topbar/getInitials.test.ts => utils/getInitials.spec.ts} (100%) rename packages/oc-docs/src/{components/Topbar => utils}/getInitials.ts (100%) delete mode 100644 packages/oc-docs/topbar-device-check.html delete mode 100644 packages/oc-docs/topbar-harness.html diff --git a/packages/oc-docs/e2e/topbar.spec.ts b/packages/oc-docs/e2e/topbar.spec.ts index c268559..32a619b 100644 --- a/packages/oc-docs/e2e/topbar.spec.ts +++ b/packages/oc-docs/e2e/topbar.spec.ts @@ -76,7 +76,7 @@ test.describe('Topbar — mounted app', () => { test.describe('Topbar — harness (slots filled)', () => { test('desktop renders search + env slots inline', async ({ page }) => { await page.setViewportSize(DESKTOP); - await page.goto('/topbar-harness.html'); + await page.goto('/?view=topbar-harness'); await expect(page.getByTestId('search-slot-input')).toBeVisible(); await expect(page.getByTestId('env-switcher-slot')).toBeVisible(); @@ -85,7 +85,7 @@ test.describe('Topbar — harness (slots filled)', () => { test('tablet: hamburger + inline env, search collapsed to icon, no CTA', async ({ page }) => { await page.setViewportSize(TABLET); - await page.goto('/topbar-harness.html'); + await page.goto('/?view=topbar-harness'); await expect(page.getByRole('button', { name: /toggle sidebar/i })).toBeVisible(); await expect(page.getByTestId('env-switcher-slot')).toBeVisible(); @@ -97,7 +97,7 @@ test.describe('Topbar — harness (slots filled)', () => { test('tablet: search icon expands the search row', async ({ page }) => { await page.setViewportSize(TABLET); - await page.goto('/topbar-harness.html'); + await page.goto('/?view=topbar-harness'); await expect(page.getByTestId('search-slot-input')).toHaveCount(0); await page.getByRole('button', { name: /^search$/i }).click(); @@ -106,7 +106,7 @@ test.describe('Topbar — harness (slots filled)', () => { test('mobile: search icon expands the search row', async ({ page }) => { await page.setViewportSize(MOBILE); - await page.goto('/topbar-harness.html'); + await page.goto('/?view=topbar-harness'); // Inline search is hidden on mobile until the toggle is pressed. await expect(page.getByTestId('search-slot-input')).toHaveCount(0); @@ -116,7 +116,7 @@ test.describe('Topbar — harness (slots filled)', () => { test('mobile: overflow popover hosts the env-switcher slot', async ({ page }) => { await page.setViewportSize(MOBILE); - await page.goto('/topbar-harness.html'); + await page.goto('/?view=topbar-harness'); await expect(page.getByTestId('env-switcher-slot')).toHaveCount(0); await page.getByRole('button', { name: /more options/i }).click(); @@ -125,7 +125,7 @@ test.describe('Topbar — harness (slots filled)', () => { test('mobile: hamburger invokes onToggleSidebar', async ({ page }) => { await page.setViewportSize(MOBILE); - await page.goto('/topbar-harness.html'); + await page.goto('/?view=topbar-harness'); await page.getByRole('button', { name: /toggle sidebar/i }).click(); const calls = await page.evaluate( diff --git a/packages/oc-docs/src/components/Topbar/icons.tsx b/packages/oc-docs/src/assets/icons.tsx similarity index 70% rename from packages/oc-docs/src/components/Topbar/icons.tsx rename to packages/oc-docs/src/assets/icons.tsx index 6fff3e2..6c72105 100644 --- a/packages/oc-docs/src/components/Topbar/icons.tsx +++ b/packages/oc-docs/src/assets/icons.tsx @@ -1,78 +1,55 @@ import React from 'react'; -/** - * Line icons use `currentColor` so they inherit the control's themed text color. - * The Bruno mascot keeps its fixed brand fills — it is a brand mark, not a - * themeable UI surface (same treatment as the OpenCollection logo asset). +/* + * Shared icon glyphs. NOTE (reconciliation): PR #45 also introduces + * src/assets/icons.tsx with its own glyph set (GlobeIcon, BookIcon, …) and the + * same `baseIconProps` constant. When the two branches merge this file will + * conflict at the file level (both add it) — resolution is mechanical: keep one + * copy of `baseIconProps` and the union of both icon sets (names don't overlap). */ -interface IconProps { - className?: string; -} +/** Shared stroke styling. `currentColor` lets the icon inherit the surrounding + * theme colour, so it adapts when the theme changes. */ +const baseIconProps: React.SVGProps = { + width: 20, + height: 20, + viewBox: '0 0 24 24', + fill: 'none', + stroke: 'currentColor', + strokeWidth: 2, + strokeLinecap: 'round', + strokeLinejoin: 'round', + 'aria-hidden': true +}; -export interface IconButtonProps extends React.ButtonHTMLAttributes { - /** Accessible label — icon buttons have no visible text. */ - label: string; -} - -/** - * Square icon button used across the Topbar (hamburger, search toggle, overflow - * trigger). Styling lives in StyledWrapper under `.oc-topbar__icon-btn`; extra - * ARIA props (aria-expanded, aria-haspopup) pass through via spread. - */ -export const IconButton: React.FC = ({ label, children, ...rest }) => ( - -); - -export const SearchIcon: React.FC = ({ className }) => ( -
+ {/* Explicit logo overrides; otherwise fall back to the initials avatar. */} {hasLogo ? renderLogo(logo, collectionName) : } @@ -39,7 +40,7 @@ const Brand: React.FC = ({ collectionName, version, logo }) => { {version && {formatVersion(version)}} -
+
); }; diff --git a/packages/oc-docs/src/components/Topbar/Brand/StyledWrapper.ts b/packages/oc-docs/src/components/Topbar/Brand/StyledWrapper.ts new file mode 100644 index 0000000..be91856 --- /dev/null +++ b/packages/oc-docs/src/components/Topbar/Brand/StyledWrapper.ts @@ -0,0 +1,51 @@ +import styled from '@emotion/styled'; + +/** Brand cluster: logo/avatar + collection name + version. */ +export const StyledWrapper = styled.div` + display: flex; + align-items: center; + gap: 10px; + min-width: 0; /* allow truncation */ + flex-shrink: 0; + + .oc-topbar__brand-logo { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + flex: none; + overflow: hidden; + border-radius: var(--oc-border-radius-base); + + img, + svg { + width: 100%; + height: 100%; + object-fit: contain; + } + } + + .oc-topbar__brand-text { + display: flex; + flex-direction: column; + min-width: 0; + line-height: 1.1; + } + + .oc-topbar__brand-name { + font-size: var(--oc-font-size-md); + font-weight: 600; + color: var(--oc-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .oc-topbar__brand-version { + font-size: var(--oc-font-size-xs); + color: var(--oc-colors-text-muted, var(--oc-text)); + opacity: 0.7; + white-space: nowrap; + } +`; diff --git a/packages/oc-docs/src/components/Topbar/Brand/index.ts b/packages/oc-docs/src/components/Topbar/Brand/index.ts new file mode 100644 index 0000000..cfdf77b --- /dev/null +++ b/packages/oc-docs/src/components/Topbar/Brand/index.ts @@ -0,0 +1,3 @@ +export { default } from './Brand'; +export { default as Brand } from './Brand'; +export type { BrandProps } from './Brand'; diff --git a/packages/oc-docs/src/components/Topbar/InitialsAvatar.tsx b/packages/oc-docs/src/components/Topbar/InitialsAvatar.tsx deleted file mode 100644 index 84e2317..0000000 --- a/packages/oc-docs/src/components/Topbar/InitialsAvatar.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import React from 'react'; -import styled from '@emotion/styled'; -import { getInitials } from './getInitials'; - -export interface InitialsAvatarProps { - collectionName: string; -} - -/** - * Default brand mark: a 26×26 rounded badge showing the collection initials - * over the Bruno amber gradient. Both gradient stops are theme tokens - * (--oc-brand → --oc-primary-subtle), so it adapts to light/dark — no hex. - */ -const Badge = 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, var(--oc-brand), var(--oc-primary-subtle)); - color: var(--oc-button2-color-primary-text); - font-family: var(--font-sans); - font-size: var(--oc-font-size-xs); - font-weight: 700; - line-height: 1; - letter-spacing: 0.02em; - user-select: none; -`; - -const InitialsAvatar: React.FC = ({ collectionName }) => { - const initials = getInitials(collectionName); - return ( - - ); -}; - -export default InitialsAvatar; diff --git a/packages/oc-docs/src/components/Topbar/MobileOverflow/MobileOverflow.spec.tsx b/packages/oc-docs/src/components/Topbar/MobileOverflow/MobileOverflow.spec.tsx new file mode 100644 index 0000000..a6aacd2 --- /dev/null +++ b/packages/oc-docs/src/components/Topbar/MobileOverflow/MobileOverflow.spec.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, it, expect } from 'vitest'; +import MobileOverflow from './MobileOverflow'; + +describe('MobileOverflow', () => { + it('renders the trigger collapsed by default (popover hidden, slot not shown)', () => { + const html = renderToStaticMarkup( + +
+ + ); + expect(html).toContain('aria-label="More options"'); + expect(html).toContain('aria-expanded="false"'); + // Popover (and the relocated slot) only mount once opened. + expect(html).not.toContain('data-testid="env-switcher-slot"'); + }); +}); diff --git a/packages/oc-docs/src/components/Topbar/MobileOverflow.tsx b/packages/oc-docs/src/components/Topbar/MobileOverflow/MobileOverflow.tsx similarity index 87% rename from packages/oc-docs/src/components/Topbar/MobileOverflow.tsx rename to packages/oc-docs/src/components/Topbar/MobileOverflow/MobileOverflow.tsx index 5e50438..4b20865 100644 --- a/packages/oc-docs/src/components/Topbar/MobileOverflow.tsx +++ b/packages/oc-docs/src/components/Topbar/MobileOverflow/MobileOverflow.tsx @@ -1,5 +1,7 @@ import React, { useEffect, useRef, useState } from 'react'; -import { OverflowIcon, IconButton } from './icons'; +import { OverflowIcon } from '../../../assets/icons'; +import { IconButton } from '../../IconButton'; +import { StyledWrapper } from './StyledWrapper'; export interface MobileOverflowProps { /** Secondary controls (the same envSwitcherSlot node, relocated here). */ @@ -37,7 +39,7 @@ const MobileOverflow: React.FC = ({ children }) => { }, [open]); return ( -
+ = ({ children }) => { {children}
)} -
+ ); }; diff --git a/packages/oc-docs/src/components/Topbar/MobileOverflow/StyledWrapper.ts b/packages/oc-docs/src/components/Topbar/MobileOverflow/StyledWrapper.ts new file mode 100644 index 0000000..e6a2022 --- /dev/null +++ b/packages/oc-docs/src/components/Topbar/MobileOverflow/StyledWrapper.ts @@ -0,0 +1,24 @@ +import styled from '@emotion/styled'; + +/** Overflow trigger wrapper + the popover that hosts the relocated slot node. */ +export const StyledWrapper = styled.div` + position: relative; + flex: none; + + .oc-topbar__overflow-popover { + position: absolute; + top: calc(100% + 6px); + right: 0; + z-index: 40; + display: flex; + flex-direction: column; + gap: 10px; + min-width: 220px; + padding: 12px; + box-sizing: border-box; + background: var(--oc-background-base); + border: 1px solid var(--oc-border-border1); + border-radius: var(--oc-border-radius-base); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); + } +`; diff --git a/packages/oc-docs/src/components/Topbar/MobileOverflow/index.ts b/packages/oc-docs/src/components/Topbar/MobileOverflow/index.ts new file mode 100644 index 0000000..6c58c16 --- /dev/null +++ b/packages/oc-docs/src/components/Topbar/MobileOverflow/index.ts @@ -0,0 +1,3 @@ +export { default } from './MobileOverflow'; +export { default as MobileOverflow } from './MobileOverflow'; +export type { MobileOverflowProps } from './MobileOverflow'; diff --git a/packages/oc-docs/src/components/Topbar/StyledWrapper.ts b/packages/oc-docs/src/components/Topbar/StyledWrapper.ts index 0f241b2..6759f11 100644 --- a/packages/oc-docs/src/components/Topbar/StyledWrapper.ts +++ b/packages/oc-docs/src/components/Topbar/StyledWrapper.ts @@ -1,13 +1,13 @@ import styled from '@emotion/styled'; /** - * Topbar styling. Square bar (radius 0, bottom border only), rounded inner - * controls (6px). Color surfaces map to `--oc-*` theme tokens so the bar honors - * light/dark automatically; the popover elevation uses a neutral overlay shadow. + * Topbar bar styling. Square bar (radius 0, bottom border only). Color surfaces + * map to `--oc-*` theme tokens so the bar honors light/dark automatically. * * Which controls render per breakpoint is decided in JSX (`Topbar` + * `useTopbarLayout`), not via CSS. `data-mode` (mobile | tablet | desktop) is - * exposed on the host element for debugging and e2e targeting. + * exposed on the host element for debugging and e2e targeting. Brand, icon + * buttons and the overflow popover own their own styles in their components. */ export const StyledWrapper = styled.header` position: sticky; @@ -28,57 +28,6 @@ export const StyledWrapper = styled.header` box-sizing: border-box; } - /* ---- Brand ---- */ - .oc-topbar__brand { - display: flex; - align-items: center; - gap: 10px; - min-width: 0; /* allow truncation */ - flex-shrink: 0; - } - - .oc-topbar__brand-logo { - display: inline-flex; - align-items: center; - justify-content: center; - width: 26px; - height: 26px; - flex: none; - overflow: hidden; - border-radius: var(--oc-border-radius-base); - - img, - svg { - width: 100%; - height: 100%; - object-fit: contain; - } - } - - .oc-topbar__brand-text { - display: flex; - flex-direction: column; - min-width: 0; - line-height: 1.1; - } - - .oc-topbar__brand-name { - font-size: var(--oc-font-size-md); - font-weight: 600; - color: var(--oc-text); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .oc-topbar__brand-version { - font-size: var(--oc-font-size-xs); - color: var(--oc-colors-text-muted, var(--oc-text)); - opacity: 0.7; - white-space: nowrap; - } - - /* ---- Slots ---- */ .oc-topbar__search { display: flex; align-items: center; @@ -103,40 +52,7 @@ export const StyledWrapper = styled.header` flex: 1 1 auto; } - /* ---- Icon buttons (hamburger / search toggle / overflow) ---- - Ghost style: no border; a hover/active background provides the affordance. */ - .oc-topbar__icon-btn { - display: inline-flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - padding: 0; - flex: none; - background: transparent; - color: var(--oc-colors-text-muted, var(--oc-text)); - border: none; - border-radius: var(--oc-border-radius-base); - cursor: pointer; - transition: background-color 0.12s ease, color 0.12s ease; - - svg { - width: 16px; - height: 16px; - } - - &:hover { - background: var(--oc-background-surface0); - color: var(--oc-text); - } - - &[aria-expanded='true'] { - background: var(--oc-background-surface0); - color: var(--oc-text); - } - } - - /* ---- Mobile search row (revealed under the bar) ---- */ + /* Search row revealed under the bar when the icon is toggled (below desktop). */ .oc-topbar__search-row { display: flex; align-items: center; @@ -147,27 +63,4 @@ export const StyledWrapper = styled.header` max-width: none; } } - - /* ---- Mobile overflow popover ---- */ - .oc-topbar__overflow { - position: relative; - flex: none; - } - - .oc-topbar__overflow-popover { - position: absolute; - top: calc(100% + 6px); - right: 0; - z-index: 40; - display: flex; - flex-direction: column; - gap: 10px; - min-width: 220px; - padding: 12px; - box-sizing: border-box; - background: var(--oc-background-base); - border: 1px solid var(--oc-border-border1); - border-radius: var(--oc-border-radius-base); - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); - } `; diff --git a/packages/oc-docs/src/components/Topbar/Topbar.spec.tsx b/packages/oc-docs/src/components/Topbar/Topbar.spec.tsx new file mode 100644 index 0000000..f1bc184 --- /dev/null +++ b/packages/oc-docs/src/components/Topbar/Topbar.spec.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, it, expect } from 'vitest'; +import Topbar from './Topbar'; + +describe('Topbar', () => { + it('renders the brand name and version', () => { + const html = renderToStaticMarkup(); + expect(html).toContain('Hotel Booking API'); + expect(html).toContain('v1.0.0'); + }); + + it('renders the provided search slot node', () => { + const html = renderToStaticMarkup( + } /> + ); + expect(html).toContain('data-testid="search-slot-input"'); + }); + + it('degrades gracefully with no slots (still renders the bar)', () => { + const html = renderToStaticMarkup(); + expect(html).toContain('oc-topbar__bar'); + }); +}); diff --git a/packages/oc-docs/src/components/Topbar/Topbar.tsx b/packages/oc-docs/src/components/Topbar/Topbar.tsx index e83a593..a93e3d6 100644 --- a/packages/oc-docs/src/components/Topbar/Topbar.tsx +++ b/packages/oc-docs/src/components/Topbar/Topbar.tsx @@ -1,11 +1,12 @@ import React, { useEffect, useState } from 'react'; import { StyledWrapper } from './StyledWrapper'; import Brand from './Brand'; -import OpenInBrunoButton from './OpenInBrunoButton'; import MobileOverflow from './MobileOverflow'; -import { SearchIcon, HamburgerIcon, IconButton } from './icons'; -import { useTopbarLayout, showsHamburger } from './useTopbarLayout'; -import { useCanRunBrunoApp } from './useCanRunBrunoApp'; +import { OpenInBrunoButton } from '../OpenInBrunoButton'; +import { IconButton } from '../IconButton'; +import { SearchIcon, HamburgerIcon } from '../../assets/icons'; +import { useTopbarLayout, showsHamburger } from '../../hooks/useTopbarLayout'; +import { useCanRunBrunoApp } from '../../hooks/useCanRunBrunoApp'; export interface TopbarProps { collectionName: string; diff --git a/packages/oc-docs/src/components/Topbar/index.ts b/packages/oc-docs/src/components/Topbar/index.ts index b73cf39..f3d9fb4 100644 --- a/packages/oc-docs/src/components/Topbar/index.ts +++ b/packages/oc-docs/src/components/Topbar/index.ts @@ -1,23 +1,2 @@ export { default as Topbar } from './Topbar'; export type { TopbarProps } from './Topbar'; -export { default as OpenInBrunoButton } from './OpenInBrunoButton'; -export type { OpenInBrunoButtonProps } from './OpenInBrunoButton'; -export { default as Brand } from './Brand'; -export type { BrandProps } from './Brand'; -export { default as InitialsAvatar } from './InitialsAvatar'; -export type { InitialsAvatarProps } from './InitialsAvatar'; -export { IconButton } from './icons'; -export type { IconButtonProps } from './icons'; -export { getInitials } from './getInitials'; -export { buildBrunoDeepLink } from './buildBrunoDeepLink'; -export { - useTopbarLayout, - layoutModeForWidth, - showsHamburger, - type TopbarLayoutMode, -} from './useTopbarLayout'; -export { - useCanRunBrunoApp, - computeCanRunBrunoApp, - type DeviceEnv, -} from './useCanRunBrunoApp'; diff --git a/packages/oc-docs/src/dev.tsx b/packages/oc-docs/src/dev.tsx index c50b73f..3fbd2c4 100644 --- a/packages/oc-docs/src/dev.tsx +++ b/packages/oc-docs/src/dev.tsx @@ -1,3 +1,4 @@ +/* eslint-disable react-refresh/only-export-components -- dev-only entry, not a component module */ import React from 'react'; import { createRoot } from 'react-dom/client'; import { Provider } from 'react-redux'; @@ -12,6 +13,7 @@ import 'prismjs/components/prism-xml-doc'; import 'prismjs/components/prism-http'; import 'prismjs/components/prism-graphql'; import OpenCollection from './components/OpenCollection/OpenCollection'; +import { Topbar } from './components/Topbar'; import { createOpenCollectionStore } from './store/store'; import { sampleCollectionYaml } from './sampleCollection'; @@ -20,7 +22,6 @@ if (typeof window !== 'undefined') { (window as any).Prism = Prism; } - // Development App component const DevApp: React.FC = () => { const store = createOpenCollectionStore(); @@ -37,11 +38,62 @@ const DevApp: React.FC = () => { ); }; +/** + * Dev-only Topbar harness (gated behind `?view=topbar-harness`). Mounts the + * Topbar with both slots filled so the e2e suite can exercise the search-expand + * and overflow popover, which need the search / env-switcher slot nodes that + * downstream tickets supply. Not part of the shipped standalone build. + */ +const SearchSlot: React.FC = () => ( + +); + +const EnvSwitcherSlot: React.FC = () => ( +
+ Show vars + Development +
+); + +const TopbarHarness: React.FC = () => ( +
+ } + envSwitcherSlot={} + openInBrunoHref="bruno://app/collection/import/git?url=https%3A%2F%2Fexample.com%2Frepo.git" + onToggleSidebar={() => { + (window as unknown as { __toggleSidebarCalls?: number }).__toggleSidebarCalls = + ((window as unknown as { __toggleSidebarCalls?: number }).__toggleSidebarCalls ?? 0) + 1; + }} + /> +
Scroll content to verify the bar stays pinned.
+
+); + +const view = + typeof window !== 'undefined' ? new URLSearchParams(window.location.search).get('view') : null; + // Render the app const container = document.getElementById('root'); if (container) { const root = createRoot(container); - root.render(); + root.render(view === 'topbar-harness' ? : ); } else { console.error('Root container not found'); } diff --git a/packages/oc-docs/src/deviceCheck.tsx b/packages/oc-docs/src/deviceCheck.tsx deleted file mode 100644 index d42e045..0000000 --- a/packages/oc-docs/src/deviceCheck.tsx +++ /dev/null @@ -1,72 +0,0 @@ -/* eslint-disable react-refresh/only-export-components -- dev-only render entry, not a component module */ -import React from 'react'; -import { createRoot } from 'react-dom/client'; -import { computeCanRunBrunoApp } from './components/Topbar/useCanRunBrunoApp'; - -/** - * Dev-only diagnostic page for verifying the Open-in-Bruno capability gate on - * real devices (iPad Pro, Surface, etc.). Prints the raw signals + the computed - * decision so the matrix can be checked before locking the logic. NOT shipped. - */ -const mq = (q: string) => (typeof window !== 'undefined' ? window.matchMedia(q).matches : false); - -const Row: React.FC<{ k: string; v: React.ReactNode }> = ({ k, v }) => ( - - {k} - {String(v)} - -); - -const DeviceCheck: React.FC = () => { - const signals = { - 'matchMedia (pointer: fine)': mq('(pointer: fine)'), - 'matchMedia (pointer: coarse)': mq('(pointer: coarse)'), - 'matchMedia (any-pointer: fine)': mq('(any-pointer: fine)'), - 'matchMedia (any-pointer: coarse)': mq('(any-pointer: coarse)'), - 'matchMedia (hover: hover)': mq('(hover: hover)'), - 'matchMedia (hover: none)': mq('(hover: none)'), - 'matchMedia (any-hover: hover)': mq('(any-hover: hover)'), - 'navigator.maxTouchPoints': navigator.maxTouchPoints, - 'navigator.platform': navigator.platform, - 'window.innerWidth': window.innerWidth, - 'navigator.userAgent': navigator.userAgent, - }; - - const anyHoverFine = mq('(any-hover: hover) and (any-pointer: fine)'); - const showCTA = computeCanRunBrunoApp({ - anyHoverFine, - userAgent: navigator.userAgent, - platform: navigator.platform, - maxTouchPoints: navigator.maxTouchPoints, - }); - - return ( -
-

Topbar device check

-

- Open-in-Bruno CTA → {showCTA ? 'SHOWN' : 'HIDDEN'} -

- - - {Object.entries(signals).map(([k, v]) => ( - - ))} - -
-
- ); -}; - -const container = document.getElementById('root'); -if (container) { - createRoot(container).render(); -} diff --git a/packages/oc-docs/src/hooks/index.ts b/packages/oc-docs/src/hooks/index.ts index a9414cd..1867916 100644 --- a/packages/oc-docs/src/hooks/index.ts +++ b/packages/oc-docs/src/hooks/index.ts @@ -1 +1,8 @@ -export { useMarkdownRenderer } from './useMarkdownRenderer'; \ No newline at end of file +export { useMarkdownRenderer } from './useMarkdownRenderer'; +export { + useTopbarLayout, + layoutModeForWidth, + showsHamburger, + type TopbarLayoutMode, +} from './useTopbarLayout'; +export { useCanRunBrunoApp, computeCanRunBrunoApp, type DeviceEnv } from './useCanRunBrunoApp'; \ No newline at end of file diff --git a/packages/oc-docs/src/components/Topbar/useCanRunBrunoApp.test.ts b/packages/oc-docs/src/hooks/useCanRunBrunoApp.spec.ts similarity index 100% rename from packages/oc-docs/src/components/Topbar/useCanRunBrunoApp.test.ts rename to packages/oc-docs/src/hooks/useCanRunBrunoApp.spec.ts diff --git a/packages/oc-docs/src/components/Topbar/useCanRunBrunoApp.ts b/packages/oc-docs/src/hooks/useCanRunBrunoApp.ts similarity index 100% rename from packages/oc-docs/src/components/Topbar/useCanRunBrunoApp.ts rename to packages/oc-docs/src/hooks/useCanRunBrunoApp.ts diff --git a/packages/oc-docs/src/components/Topbar/useTopbarLayout.test.ts b/packages/oc-docs/src/hooks/useTopbarLayout.spec.ts similarity index 100% rename from packages/oc-docs/src/components/Topbar/useTopbarLayout.test.ts rename to packages/oc-docs/src/hooks/useTopbarLayout.spec.ts diff --git a/packages/oc-docs/src/components/Topbar/useTopbarLayout.ts b/packages/oc-docs/src/hooks/useTopbarLayout.ts similarity index 95% rename from packages/oc-docs/src/components/Topbar/useTopbarLayout.ts rename to packages/oc-docs/src/hooks/useTopbarLayout.ts index baf93d2..ccac0fc 100644 --- a/packages/oc-docs/src/components/Topbar/useTopbarLayout.ts +++ b/packages/oc-docs/src/hooks/useTopbarLayout.ts @@ -3,7 +3,7 @@ import { useEffect, useState } from 'react'; /** * Responsive modes for the Topbar. * - `mobile` (<768): single condensed row; hamburger + search-icon + overflow. - * - `tablet` (768–1023): sidebar is inline, search narrows, CTA is icon-only. + * - `tablet` (768–1023): hamburger, search collapses to an icon, env inline. * - `desktop` (>=1024): full bar with centered search. * * Breakpoints mirror the theme tokens `--oc-breakpoint-tablet` (768) and diff --git a/packages/oc-docs/src/topbarHarness.tsx b/packages/oc-docs/src/topbarHarness.tsx deleted file mode 100644 index 3cd8a08..0000000 --- a/packages/oc-docs/src/topbarHarness.tsx +++ /dev/null @@ -1,58 +0,0 @@ -/* eslint-disable react-refresh/only-export-components -- dev-only render entry, not a component module */ -import React from 'react'; -import { createRoot } from 'react-dom/client'; -import './styles/index.css'; -import { Topbar } from './components/Topbar'; - -/** - * Dev-only harness for e2e-testing the Topbar with both slots filled. - * NOT part of the shipped standalone build — it exists so Playwright can - * exercise the mobile search-expand and overflow popover, which require the - * search / env-switcher slot nodes that downstream tickets supply. - */ -const SearchSlot: React.FC = () => ( - -); - -const EnvSwitcherSlot: React.FC = () => ( -
- Show vars - Development -
-); - -const HarnessApp: React.FC = () => ( -
- } - envSwitcherSlot={} - openInBrunoHref="bruno://app/collection/import/git?url=https%3A%2F%2Fexample.com%2Frepo.git" - onToggleSidebar={() => { - (window as unknown as { __toggleSidebarCalls?: number }).__toggleSidebarCalls = - ((window as unknown as { __toggleSidebarCalls?: number }).__toggleSidebarCalls ?? 0) + 1; - }} - /> -
Scroll content to verify the bar stays pinned.
-
-); - -const container = document.getElementById('root'); -if (container) { - createRoot(container).render(); -} diff --git a/packages/oc-docs/src/ui/MinimalComponents.tsx b/packages/oc-docs/src/ui/MinimalComponents.tsx index 1cd621e..b0f7d71 100644 --- a/packages/oc-docs/src/ui/MinimalComponents.tsx +++ b/packages/oc-docs/src/ui/MinimalComponents.tsx @@ -104,30 +104,6 @@ export const StatusBadge: React.FC = ({ status, text }) => { ); }; -interface IconButtonProps { - icon: React.ReactNode; - onClick?: () => void; - tooltip?: string; - size?: 'small' | 'medium' | 'large'; -} - -export const IconButton: React.FC = ({ - icon, - onClick, - tooltip, - size = 'medium' -}) => { - return ( - - ); -}; - interface TabGroupProps { tabs: Array<{ id: string; label: string }>; defaultTab?: string; diff --git a/packages/oc-docs/src/components/Topbar/buildBrunoDeepLink.test.ts b/packages/oc-docs/src/utils/buildBrunoDeepLink.spec.ts similarity index 100% rename from packages/oc-docs/src/components/Topbar/buildBrunoDeepLink.test.ts rename to packages/oc-docs/src/utils/buildBrunoDeepLink.spec.ts diff --git a/packages/oc-docs/src/components/Topbar/buildBrunoDeepLink.ts b/packages/oc-docs/src/utils/buildBrunoDeepLink.ts similarity index 100% rename from packages/oc-docs/src/components/Topbar/buildBrunoDeepLink.ts rename to packages/oc-docs/src/utils/buildBrunoDeepLink.ts diff --git a/packages/oc-docs/src/components/Topbar/getInitials.test.ts b/packages/oc-docs/src/utils/getInitials.spec.ts similarity index 100% rename from packages/oc-docs/src/components/Topbar/getInitials.test.ts rename to packages/oc-docs/src/utils/getInitials.spec.ts diff --git a/packages/oc-docs/src/components/Topbar/getInitials.ts b/packages/oc-docs/src/utils/getInitials.ts similarity index 100% rename from packages/oc-docs/src/components/Topbar/getInitials.ts rename to packages/oc-docs/src/utils/getInitials.ts diff --git a/packages/oc-docs/topbar-device-check.html b/packages/oc-docs/topbar-device-check.html deleted file mode 100644 index 4ba8419..0000000 --- a/packages/oc-docs/topbar-device-check.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - Topbar Device Check - - -
- - - diff --git a/packages/oc-docs/topbar-harness.html b/packages/oc-docs/topbar-harness.html deleted file mode 100644 index 90d8b83..0000000 --- a/packages/oc-docs/topbar-harness.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - Topbar Harness - - -
- - - From 50709426cb619b0d29159d79aa3fec845915912b Mon Sep 17 00:00:00 2001 From: Sundram Gupta Date: Wed, 17 Jun 2026 17:22:56 +0530 Subject: [PATCH 10/27] =?UTF-8?q?feat(oc-docs):=20compact=20mobile=20brand?= =?UTF-8?q?=20=E2=80=94=20avatar=20+=20"Docs"=20only=20(BRU-3572)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On mobile the brand showed the full collection name + version like tablet/desktop. Per design, mobile shows just the initials avatar (HB) and a fixed "Docs" label — no collection name, no version (both remain in the page body). Add a `compact` prop to Brand and drive it from `isMobile` in Topbar. Tablet/desktop keep name + version. Co-Authored-By: Claude Opus 4.8 --- packages/oc-docs/e2e/topbar.spec.ts | 5 ++++ .../components/Topbar/Brand/Brand.spec.tsx | 10 ++++++++ .../src/components/Topbar/Brand/Brand.tsx | 25 ++++++++++++++----- .../oc-docs/src/components/Topbar/Topbar.tsx | 2 +- 4 files changed, 35 insertions(+), 7 deletions(-) diff --git a/packages/oc-docs/e2e/topbar.spec.ts b/packages/oc-docs/e2e/topbar.spec.ts index 32a619b..bbc8aa5 100644 --- a/packages/oc-docs/e2e/topbar.spec.ts +++ b/packages/oc-docs/e2e/topbar.spec.ts @@ -67,6 +67,11 @@ test.describe('Topbar — mounted app', () => { // Open-in-Bruno is desktop-only (no Bruno desktop app on mobile). await expect(page.getByTestId('open-in-bruno')).toHaveCount(0); + // Compact brand: avatar + "Docs" only — no full name, no version. + await expect(page.locator('.oc-topbar__brand-name')).toHaveText('Docs'); + await expect(page.locator('.oc-topbar__brand-version')).toHaveCount(0); + await expect(page.locator('header.oc-topbar')).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/components/Topbar/Brand/Brand.spec.tsx b/packages/oc-docs/src/components/Topbar/Brand/Brand.spec.tsx index 7c5e45f..fb3ced4 100644 --- a/packages/oc-docs/src/components/Topbar/Brand/Brand.spec.tsx +++ b/packages/oc-docs/src/components/Topbar/Brand/Brand.spec.tsx @@ -27,4 +27,14 @@ describe('Brand', () => { expect(html).toContain('src="/logo.svg"'); expect(html).not.toContain('data-testid="brand-initials"'); }); + + it('compact (mobile): shows the avatar + "Docs", hides name and version', () => { + const html = renderToStaticMarkup( + + ); + expect(html).toContain('data-testid="brand-initials"'); + expect(html).toContain('Docs'); + expect(html).not.toContain('Hotel Booking API'); + expect(html).not.toContain('v1.0.0'); + }); }); diff --git a/packages/oc-docs/src/components/Topbar/Brand/Brand.tsx b/packages/oc-docs/src/components/Topbar/Brand/Brand.tsx index 120c0be..8a74d79 100644 --- a/packages/oc-docs/src/components/Topbar/Brand/Brand.tsx +++ b/packages/oc-docs/src/components/Topbar/Brand/Brand.tsx @@ -6,8 +6,17 @@ export interface BrandProps { collectionName: string; version?: string; logo?: React.ReactNode; + /** + * Compact (mobile) brand: avatar + a fixed "Docs" label, with the collection + * name and version hidden. The avatar initials still derive from + * `collectionName`; the full name + version are shown in the page body. + */ + compact?: boolean; } +/** Fixed product label shown beside the avatar in compact (mobile) mode. */ +const PRODUCT_LABEL = 'Docs'; + /** * Renders an arbitrary logo node. A string is treated as an image src so * callers can pass a URL (as `OpenCollection` does today); any other node is @@ -26,7 +35,7 @@ const formatVersion = (version: string): string => { return /^v/i.test(trimmed) ? trimmed : `v${trimmed}`; }; -const Brand: React.FC = ({ collectionName, version, logo }) => { +const Brand: React.FC = ({ collectionName, version, logo, compact = false }) => { const hasLogo = logo != null && logo !== ''; return ( @@ -34,12 +43,16 @@ const Brand: React.FC = ({ collectionName, version, logo }) => { {hasLogo ? renderLogo(logo, collectionName) : } - - - {collectionName} + {compact ? ( + {PRODUCT_LABEL} + ) : ( + + + {collectionName} + + {version && {formatVersion(version)}} - {version && {formatVersion(version)}} - + )} ); }; diff --git a/packages/oc-docs/src/components/Topbar/Topbar.tsx b/packages/oc-docs/src/components/Topbar/Topbar.tsx index a93e3d6..867e706 100644 --- a/packages/oc-docs/src/components/Topbar/Topbar.tsx +++ b/packages/oc-docs/src/components/Topbar/Topbar.tsx @@ -76,7 +76,7 @@ const Topbar: React.FC = ({ )} - + {/* Flex-1 middle: inline search on desktop, else a spacer that keeps the right-hand controls pinned to the right edge (search collapses to an From b5a73348fe64194c994cca51d0cfad3b49b4f3f5 Mon Sep 17 00:00:00 2001 From: Sundram Gupta Date: Wed, 17 Jun 2026 19:26:40 +0530 Subject: [PATCH 11/27] fix(oc-docs): address self-review on topbar capability + shadow (BRU-3572) - useCanRunBrunoApp: re-detect pointer capability via the media query's own `change` event instead of window `resize` (a resize event does not fire when a trackpad / external mouse is attached, so the documented dynamic re-detection never ran). Initial-load detection is unchanged. - MobileOverflow popover: use the --oc-shadow-md theme token instead of a hardcoded rgba shadow, so the elevation adapts in dark mode. Co-Authored-By: Claude Opus 4.8 --- .../Topbar/MobileOverflow/StyledWrapper.ts | 2 +- packages/oc-docs/src/hooks/useCanRunBrunoApp.ts | 15 ++++++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/oc-docs/src/components/Topbar/MobileOverflow/StyledWrapper.ts b/packages/oc-docs/src/components/Topbar/MobileOverflow/StyledWrapper.ts index e6a2022..843c02f 100644 --- a/packages/oc-docs/src/components/Topbar/MobileOverflow/StyledWrapper.ts +++ b/packages/oc-docs/src/components/Topbar/MobileOverflow/StyledWrapper.ts @@ -19,6 +19,6 @@ export const StyledWrapper = styled.div` background: var(--oc-background-base); border: 1px solid var(--oc-border-border1); border-radius: var(--oc-border-radius-base); - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); + box-shadow: var(--oc-shadow-md); } `; diff --git a/packages/oc-docs/src/hooks/useCanRunBrunoApp.ts b/packages/oc-docs/src/hooks/useCanRunBrunoApp.ts index a6caa50..bf44cfe 100644 --- a/packages/oc-docs/src/hooks/useCanRunBrunoApp.ts +++ b/packages/oc-docs/src/hooks/useCanRunBrunoApp.ts @@ -36,8 +36,10 @@ export const computeCanRunBrunoApp = ({ return anyHoverFine && !isMobileOS; }; +const POINTER_QUERY = '(any-hover: hover) and (any-pointer: fine)'; + const readDeviceEnv = (): DeviceEnv => ({ - anyHoverFine: window.matchMedia('(any-hover: hover) and (any-pointer: fine)').matches, + anyHoverFine: window.matchMedia(POINTER_QUERY).matches, userAgent: navigator.userAgent, platform: navigator.platform, maxTouchPoints: navigator.maxTouchPoints, @@ -45,18 +47,21 @@ const readDeviceEnv = (): DeviceEnv => ({ /** * Hook form. Defaults to `false` until the first client measure (SSR/no-window - * safe and avoids flashing the CTA on touch devices). Re-evaluates on resize so - * attaching a trackpad / external mouse can light the CTA up. + * safe and avoids flashing the CTA on touch devices). Re-evaluates when the + * pointer capability changes (e.g. attaching a trackpad / external mouse) by + * listening to the media query itself — a `resize` event would NOT fire on + * device attach. */ export const useCanRunBrunoApp = (): boolean => { const [canRun, setCanRun] = useState(false); useEffect(() => { if (typeof window === 'undefined') return; + const mql = window.matchMedia(POINTER_QUERY); const update = () => setCanRun(computeCanRunBrunoApp(readDeviceEnv())); update(); - window.addEventListener('resize', update); - return () => window.removeEventListener('resize', update); + mql.addEventListener('change', update); + return () => mql.removeEventListener('change', update); }, []); return canRun; From 2d538b16d9d94ada0fc0f07e2260b98bea58d709 Mon Sep 17 00:00:00 2001 From: Sundram Gupta Date: Fri, 19 Jun 2026 01:37:09 +0530 Subject: [PATCH 12/27] =?UTF-8?q?fix(oc-docs):=20align=20topbar=20header?= =?UTF-8?q?=20to=20design=20=E2=80=94=20hamburger,=20spacing,=20avatar=20(?= =?UTF-8?q?BRU-3572)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matched the Claude design reference (chrome.jsx): - IconButton glyph now uses the base text token (--oc-text, = design's #343434 fg-base in light) instead of the muted token, and renders at 18px — the hamburger/search/overflow glyphs read solid, not faded. - Mobile bar tightened to match: padding 0 12px + gap 8 (from 20/12), and the hamburger gets margin-left -4 so the brand isn't over-indented. - Brand logo↔text gap 10 -> 8. - Initials avatar: gradient is now a fixed brand mark (#d37f17 -> #dc9741, identical in light/dark like the Bruno mascot), white text, JetBrains Mono with -0.02em tracking — matches the design letterforms (was Inter, theme-tokened, +0.02em). Co-Authored-By: Claude Opus 4.8 --- .../src/components/IconButton/StyledWrapper.ts | 10 ++++++---- .../src/components/InitialsAvatar/StyledWrapper.ts | 14 ++++++++------ .../src/components/Topbar/Brand/StyledWrapper.ts | 2 +- .../oc-docs/src/components/Topbar/StyledWrapper.ts | 11 +++++++++++ packages/oc-docs/src/components/Topbar/Topbar.tsx | 2 +- 5 files changed, 27 insertions(+), 12 deletions(-) diff --git a/packages/oc-docs/src/components/IconButton/StyledWrapper.ts b/packages/oc-docs/src/components/IconButton/StyledWrapper.ts index a3b5166..127e0bc 100644 --- a/packages/oc-docs/src/components/IconButton/StyledWrapper.ts +++ b/packages/oc-docs/src/components/IconButton/StyledWrapper.ts @@ -3,7 +3,9 @@ import styled from '@emotion/styled'; /** * Ghost icon button: no border; a hover/active background provides the * affordance. Colours map to `--oc-*` theme tokens. The `svg` is sized here so - * any glyph passed as a child renders at a consistent 16px. + * any glyph passed as a child renders at a consistent 18px. Glyph colour is the + * base text token (matches the design's solid icon buttons); callers can + * override per-glyph (e.g. a muted search icon) via inline style on the child. */ export const StyledButton = styled.button` display: inline-flex; @@ -14,15 +16,15 @@ export const StyledButton = styled.button` padding: 0; flex: none; background: transparent; - color: var(--oc-colors-text-muted, var(--oc-text)); + color: var(--oc-text); border: none; border-radius: var(--oc-border-radius-base); cursor: pointer; transition: background-color 0.12s ease, color 0.12s ease; svg { - width: 16px; - height: 16px; + width: 18px; + height: 18px; } &:hover { diff --git a/packages/oc-docs/src/components/InitialsAvatar/StyledWrapper.ts b/packages/oc-docs/src/components/InitialsAvatar/StyledWrapper.ts index ff9a87c..0df1fa4 100644 --- a/packages/oc-docs/src/components/InitialsAvatar/StyledWrapper.ts +++ b/packages/oc-docs/src/components/InitialsAvatar/StyledWrapper.ts @@ -1,8 +1,10 @@ import styled from '@emotion/styled'; /** - * 26×26 rounded badge over the Bruno amber gradient. Both gradient stops are - * theme tokens (--oc-brand → --oc-primary-subtle), so it adapts to light/dark. + * 26×26 rounded badge with the collection initials. The amber gradient is a + * FIXED brand mark (same treatment as the Bruno mascot) — it stays identical in + * light and dark, matching the design, so it is intentionally not theme-tokened. + * Mono font + tight tracking match the design's letterforms. */ export const Badge = styled.span` display: inline-flex; @@ -12,12 +14,12 @@ export const Badge = styled.span` height: 26px; flex: none; border-radius: var(--oc-border-radius-base); - background: linear-gradient(135deg, var(--oc-brand), var(--oc-primary-subtle)); - color: var(--oc-button2-color-primary-text); - font-family: var(--font-sans); + 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; + letter-spacing: -0.02em; user-select: none; `; diff --git a/packages/oc-docs/src/components/Topbar/Brand/StyledWrapper.ts b/packages/oc-docs/src/components/Topbar/Brand/StyledWrapper.ts index be91856..47d7d4d 100644 --- a/packages/oc-docs/src/components/Topbar/Brand/StyledWrapper.ts +++ b/packages/oc-docs/src/components/Topbar/Brand/StyledWrapper.ts @@ -4,7 +4,7 @@ import styled from '@emotion/styled'; export const StyledWrapper = styled.div` display: flex; align-items: center; - gap: 10px; + gap: 8px; min-width: 0; /* allow truncation */ flex-shrink: 0; diff --git a/packages/oc-docs/src/components/Topbar/StyledWrapper.ts b/packages/oc-docs/src/components/Topbar/StyledWrapper.ts index 6759f11..f811006 100644 --- a/packages/oc-docs/src/components/Topbar/StyledWrapper.ts +++ b/packages/oc-docs/src/components/Topbar/StyledWrapper.ts @@ -28,6 +28,17 @@ export const StyledWrapper = styled.header` box-sizing: border-box; } + /* Mobile tightens the horizontal inset + gap (matches design). */ + &[data-mode='mobile'] .oc-topbar__bar { + gap: 8px; + padding: 0 12px; + } + + /* Pull the hamburger toward the edge so the brand isn't over-indented. */ + .oc-topbar__menu { + margin-left: -4px; + } + .oc-topbar__search { display: flex; align-items: center; diff --git a/packages/oc-docs/src/components/Topbar/Topbar.tsx b/packages/oc-docs/src/components/Topbar/Topbar.tsx index 867e706..5d07999 100644 --- a/packages/oc-docs/src/components/Topbar/Topbar.tsx +++ b/packages/oc-docs/src/components/Topbar/Topbar.tsx @@ -71,7 +71,7 @@ const Topbar: React.FC = ({
{showsHamburger(mode) && ( - + )} From 8e0675f9aeb8d328e9d1ec228c742371b179fa9a Mon Sep 17 00:00:00 2001 From: Sundram Gupta Date: Fri, 19 Jun 2026 11:07:34 +0530 Subject: [PATCH 13/27] test(oc-docs): use data-testid selectors in topbar e2e (BRU-3572) Per team convention, drop tag/class-based Playwright selectors (page.locator('header.oc-topbar'), '.oc-topbar__brand-name', etc.) in favor of data-testid. Adds data-testid to the Topbar root ("topbar"), Brand ("brand"), brand name ("brand-name") and version ("brand-version") and switches the e2e to getByTestId. Also replaces the emotion-class assertion on the CTA (toHaveClass(/is-full/)) with a visibility check. Co-Authored-By: Claude Opus 4.8 --- packages/oc-docs/e2e/topbar.spec.ts | 19 ++++++++++--------- .../src/components/Topbar/Brand/Brand.tsx | 12 ++++++++---- .../oc-docs/src/components/Topbar/Topbar.tsx | 2 +- 3 files changed, 19 insertions(+), 14 deletions(-) diff --git a/packages/oc-docs/e2e/topbar.spec.ts b/packages/oc-docs/e2e/topbar.spec.ts index bbc8aa5..8928d76 100644 --- a/packages/oc-docs/e2e/topbar.spec.ts +++ b/packages/oc-docs/e2e/topbar.spec.ts @@ -11,10 +11,10 @@ test.describe('Topbar — mounted app', () => { await page.setViewportSize(DESKTOP); await page.goto('/'); - const header = page.locator('header.oc-topbar'); + const header = page.getByTestId('topbar'); await expect(header).toBeVisible(); - await expect(header.locator('.oc-topbar__brand-name')).toContainText('Bruno Testbench'); - await expect(header.locator('.oc-topbar__brand-version')).toHaveText('v1.0.0'); + await expect(page.getByTestId('brand-name')).toContainText('Bruno Testbench'); + await expect(page.getByTestId('brand-version')).toHaveText('v1.0.0'); // Sticky: header stays at the top after the page scrolls. await page.mouse.wheel(0, 600); @@ -26,9 +26,9 @@ test.describe('Topbar — mounted app', () => { await page.setViewportSize(DESKTOP); await page.goto('/'); - const header = page.locator('header.oc-topbar'); + const header = page.getByTestId('topbar'); const cta = page.getByTestId('open-in-bruno'); - const brand = header.locator('.oc-topbar__brand'); + const brand = page.getByTestId('brand'); const headerBox = await header.boundingBox(); const ctaBox = await cta.boundingBox(); @@ -68,9 +68,9 @@ test.describe('Topbar — mounted app', () => { await expect(page.getByTestId('open-in-bruno')).toHaveCount(0); // Compact brand: avatar + "Docs" only — no full name, no version. - await expect(page.locator('.oc-topbar__brand-name')).toHaveText('Docs'); - await expect(page.locator('.oc-topbar__brand-version')).toHaveCount(0); - await expect(page.locator('header.oc-topbar')).not.toContainText('Bruno Testbench'); + await expect(page.getByTestId('brand-name')).toHaveText('Docs'); + await expect(page.getByTestId('brand-version')).toHaveCount(0); + await expect(page.getByTestId('topbar')).not.toContainText('Bruno Testbench'); // No horizontal overflow. const scrollW = await page.evaluate(() => document.documentElement.scrollWidth); @@ -85,7 +85,8 @@ test.describe('Topbar — harness (slots filled)', () => { await expect(page.getByTestId('search-slot-input')).toBeVisible(); await expect(page.getByTestId('env-switcher-slot')).toBeVisible(); - await expect(page.getByTestId('open-in-bruno')).toHaveClass(/is-full/); + // CTA shows on the desktop layout (always the full label variant). + await expect(page.getByTestId('open-in-bruno')).toBeVisible(); }); test('tablet: hamburger + inline env, search collapsed to icon, no CTA', async ({ page }) => { diff --git a/packages/oc-docs/src/components/Topbar/Brand/Brand.tsx b/packages/oc-docs/src/components/Topbar/Brand/Brand.tsx index 8a74d79..e3335c7 100644 --- a/packages/oc-docs/src/components/Topbar/Brand/Brand.tsx +++ b/packages/oc-docs/src/components/Topbar/Brand/Brand.tsx @@ -38,19 +38,23 @@ const formatVersion = (version: string): string => { const Brand: React.FC = ({ collectionName, version, logo, compact = false }) => { const hasLogo = logo != null && logo !== ''; return ( - + {/* Explicit logo overrides; otherwise fall back to the initials avatar. */} {hasLogo ? renderLogo(logo, collectionName) : } {compact ? ( - {PRODUCT_LABEL} + {PRODUCT_LABEL} ) : ( - + {collectionName} - {version && {formatVersion(version)}} + {version && ( + + {formatVersion(version)} + + )} )} diff --git a/packages/oc-docs/src/components/Topbar/Topbar.tsx b/packages/oc-docs/src/components/Topbar/Topbar.tsx index 5d07999..73ecdcb 100644 --- a/packages/oc-docs/src/components/Topbar/Topbar.tsx +++ b/packages/oc-docs/src/components/Topbar/Topbar.tsx @@ -68,7 +68,7 @@ const Topbar: React.FC = ({ const searchInner =
{searchSlot}
; return ( - +
{showsHamburger(mode) && ( From dfeded38fd2892c0b67af8b86747a756ca6ae9af Mon Sep 17 00:00:00 2001 From: Sundram Gupta Date: Sat, 20 Jun 2026 13:00:46 +0530 Subject: [PATCH 14/27] refactor(oc-docs): drop per-component index.ts barrels per review (BRU-3572) Team standard (reversed from the earlier 4-file pattern): component folders have no index.ts barrel; consumers import the concrete file directly. - Delete the 6 barrels this PR added: Topbar/, Topbar/Brand/, Topbar/MobileOverflow/, IconButton/, InitialsAvatar/, OpenInBrunoButton/. - Repoint every importer at the concrete module (e.g. '../Topbar' -> '../Topbar/Topbar', '../IconButton' -> '../IconButton/IconButton'), switching the barrel's named re-exports back to the components' default exports. Touches Topbar.tsx, Brand.tsx, MobileOverflow.tsx, OpenCollection.tsx (mount) and dev.tsx (harness). Behavior/visual unchanged. icons split + e2e locators restructure deferred to mirror PR #45 once it lands (icons.tsx already notes the reconciliation). Co-Authored-By: Claude Opus 4.8 --- packages/oc-docs/src/components/IconButton/index.ts | 2 -- packages/oc-docs/src/components/InitialsAvatar/index.ts | 2 -- .../src/components/OpenCollection/OpenCollection.tsx | 2 +- .../oc-docs/src/components/OpenInBrunoButton/index.ts | 2 -- packages/oc-docs/src/components/Topbar/Brand/Brand.tsx | 2 +- packages/oc-docs/src/components/Topbar/Brand/index.ts | 3 --- .../components/Topbar/MobileOverflow/MobileOverflow.tsx | 2 +- .../oc-docs/src/components/Topbar/MobileOverflow/index.ts | 3 --- packages/oc-docs/src/components/Topbar/Topbar.tsx | 8 ++++---- packages/oc-docs/src/components/Topbar/index.ts | 2 -- packages/oc-docs/src/dev.tsx | 2 +- 11 files changed, 8 insertions(+), 22 deletions(-) delete mode 100644 packages/oc-docs/src/components/IconButton/index.ts delete mode 100644 packages/oc-docs/src/components/InitialsAvatar/index.ts delete mode 100644 packages/oc-docs/src/components/OpenInBrunoButton/index.ts delete mode 100644 packages/oc-docs/src/components/Topbar/Brand/index.ts delete mode 100644 packages/oc-docs/src/components/Topbar/MobileOverflow/index.ts delete mode 100644 packages/oc-docs/src/components/Topbar/index.ts diff --git a/packages/oc-docs/src/components/IconButton/index.ts b/packages/oc-docs/src/components/IconButton/index.ts deleted file mode 100644 index 8a059b8..0000000 --- a/packages/oc-docs/src/components/IconButton/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default as IconButton } from './IconButton'; -export type { IconButtonProps } from './IconButton'; diff --git a/packages/oc-docs/src/components/InitialsAvatar/index.ts b/packages/oc-docs/src/components/InitialsAvatar/index.ts deleted file mode 100644 index beb5123..0000000 --- a/packages/oc-docs/src/components/InitialsAvatar/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default as InitialsAvatar } from './InitialsAvatar'; -export type { InitialsAvatarProps } from './InitialsAvatar'; diff --git a/packages/oc-docs/src/components/OpenCollection/OpenCollection.tsx b/packages/oc-docs/src/components/OpenCollection/OpenCollection.tsx index 8344ea2..b1d537a 100644 --- a/packages/oc-docs/src/components/OpenCollection/OpenCollection.tsx +++ b/packages/oc-docs/src/components/OpenCollection/OpenCollection.tsx @@ -6,7 +6,7 @@ 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'; +import Topbar from '../Topbar/Topbar'; import { buildBrunoDeepLink } from '../../utils/buildBrunoDeepLink'; import { parseYaml } from '../../utils/yamlUtils'; import { hydrateWithUUIDs } from '../../utils/items'; diff --git a/packages/oc-docs/src/components/OpenInBrunoButton/index.ts b/packages/oc-docs/src/components/OpenInBrunoButton/index.ts deleted file mode 100644 index f5c1d47..0000000 --- a/packages/oc-docs/src/components/OpenInBrunoButton/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default as OpenInBrunoButton } from './OpenInBrunoButton'; -export type { OpenInBrunoButtonProps } from './OpenInBrunoButton'; diff --git a/packages/oc-docs/src/components/Topbar/Brand/Brand.tsx b/packages/oc-docs/src/components/Topbar/Brand/Brand.tsx index e3335c7..8b065b7 100644 --- a/packages/oc-docs/src/components/Topbar/Brand/Brand.tsx +++ b/packages/oc-docs/src/components/Topbar/Brand/Brand.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { InitialsAvatar } from '../../InitialsAvatar'; +import InitialsAvatar from '../../InitialsAvatar/InitialsAvatar'; import { StyledWrapper } from './StyledWrapper'; export interface BrandProps { diff --git a/packages/oc-docs/src/components/Topbar/Brand/index.ts b/packages/oc-docs/src/components/Topbar/Brand/index.ts deleted file mode 100644 index cfdf77b..0000000 --- a/packages/oc-docs/src/components/Topbar/Brand/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { default } from './Brand'; -export { default as Brand } from './Brand'; -export type { BrandProps } from './Brand'; diff --git a/packages/oc-docs/src/components/Topbar/MobileOverflow/MobileOverflow.tsx b/packages/oc-docs/src/components/Topbar/MobileOverflow/MobileOverflow.tsx index 4b20865..1ff4066 100644 --- a/packages/oc-docs/src/components/Topbar/MobileOverflow/MobileOverflow.tsx +++ b/packages/oc-docs/src/components/Topbar/MobileOverflow/MobileOverflow.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useRef, useState } from 'react'; import { OverflowIcon } from '../../../assets/icons'; -import { IconButton } from '../../IconButton'; +import IconButton from '../../IconButton/IconButton'; import { StyledWrapper } from './StyledWrapper'; export interface MobileOverflowProps { diff --git a/packages/oc-docs/src/components/Topbar/MobileOverflow/index.ts b/packages/oc-docs/src/components/Topbar/MobileOverflow/index.ts deleted file mode 100644 index 6c58c16..0000000 --- a/packages/oc-docs/src/components/Topbar/MobileOverflow/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { default } from './MobileOverflow'; -export { default as MobileOverflow } from './MobileOverflow'; -export type { MobileOverflowProps } from './MobileOverflow'; diff --git a/packages/oc-docs/src/components/Topbar/Topbar.tsx b/packages/oc-docs/src/components/Topbar/Topbar.tsx index 73ecdcb..8942a05 100644 --- a/packages/oc-docs/src/components/Topbar/Topbar.tsx +++ b/packages/oc-docs/src/components/Topbar/Topbar.tsx @@ -1,9 +1,9 @@ import React, { useEffect, useState } from 'react'; import { StyledWrapper } from './StyledWrapper'; -import Brand from './Brand'; -import MobileOverflow from './MobileOverflow'; -import { OpenInBrunoButton } from '../OpenInBrunoButton'; -import { IconButton } from '../IconButton'; +import Brand from './Brand/Brand'; +import MobileOverflow from './MobileOverflow/MobileOverflow'; +import OpenInBrunoButton from '../OpenInBrunoButton/OpenInBrunoButton'; +import IconButton from '../IconButton/IconButton'; import { SearchIcon, HamburgerIcon } from '../../assets/icons'; import { useTopbarLayout, showsHamburger } from '../../hooks/useTopbarLayout'; import { useCanRunBrunoApp } from '../../hooks/useCanRunBrunoApp'; diff --git a/packages/oc-docs/src/components/Topbar/index.ts b/packages/oc-docs/src/components/Topbar/index.ts deleted file mode 100644 index f3d9fb4..0000000 --- a/packages/oc-docs/src/components/Topbar/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default as Topbar } from './Topbar'; -export type { TopbarProps } from './Topbar'; diff --git a/packages/oc-docs/src/dev.tsx b/packages/oc-docs/src/dev.tsx index 3fbd2c4..9b8b3ee 100644 --- a/packages/oc-docs/src/dev.tsx +++ b/packages/oc-docs/src/dev.tsx @@ -13,7 +13,7 @@ import 'prismjs/components/prism-xml-doc'; import 'prismjs/components/prism-http'; import 'prismjs/components/prism-graphql'; import OpenCollection from './components/OpenCollection/OpenCollection'; -import { Topbar } from './components/Topbar'; +import Topbar from './components/Topbar/Topbar'; import { createOpenCollectionStore } from './store/store'; import { sampleCollectionYaml } from './sampleCollection'; From 41c871114252c6ee1d9c84a0b3eebce64170a368 Mon Sep 17 00:00:00 2001 From: Sundram Gupta Date: Wed, 24 Jun 2026 15:49:53 +0530 Subject: [PATCH 15/27] test(oc-docs): scope header e2e to shipped behavior + adopt e2e structure (BRU-3572) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure the header Playwright suite onto the project's e2e folder layout. Add the harness infra (as per the established e2e structure): e2e/config/app.config.ts, e2e/components/base.component.ts, e2e/playwright/{index,pages.fixture}.ts, e2e/tsconfig.json; point playwright.config.ts at appConfig. - New e2e/components/page-header.component.ts — a PageHeader layout component exposed as the `pageHeader` fixture, so specs read `pageHeader.brandName` directly (the model a future `sidebar` fixture will follow). It owns only the header's shipped chrome: brand cluster, Open-in-Bruno CTA, and the menu (hamburger) trigger. - Move e2e/topbar.spec.ts -> e2e/tests/layout/page-header.spec.ts. Scope the suite to what the mounted app actually renders for this ticket: brand name/version, initials avatar, Open-in-Bruno (bruno:// href, desktop-only, pinned right), and mobile condense (hamburger shows, CTA hidden, compact "Docs" brand). Dropped the slot-fill tests (search expand, overflow env, hamburger callback): those need the search input (BRU-3573), env switcher (BRU-3186) and sidebar drawer (BRU-3574), so they belong with those tickets — and removed the now-orphaned dev harness from dev.tsx. All gates green: 4 header e2e + full suite (69) + build:standalone. Co-Authored-By: Claude Opus 4.8 --- .../oc-docs/e2e/components/base.component.ts | 13 ++ .../e2e/components/page-header.component.ts | 30 ++++ packages/oc-docs/e2e/config/app.config.ts | 11 ++ packages/oc-docs/e2e/playwright/index.ts | 12 ++ .../oc-docs/e2e/playwright/pages.fixture.ts | 17 +++ .../e2e/tests/layout/page-header.spec.ts | 72 +++++++++ packages/oc-docs/e2e/topbar.spec.ts | 142 ------------------ packages/oc-docs/e2e/tsconfig.json | 7 + packages/oc-docs/playwright.config.ts | 12 +- packages/oc-docs/src/dev.tsx | 54 +------ 10 files changed, 169 insertions(+), 201 deletions(-) create mode 100644 packages/oc-docs/e2e/components/base.component.ts create mode 100644 packages/oc-docs/e2e/components/page-header.component.ts create mode 100644 packages/oc-docs/e2e/config/app.config.ts create mode 100644 packages/oc-docs/e2e/playwright/index.ts create mode 100644 packages/oc-docs/e2e/playwright/pages.fixture.ts create mode 100644 packages/oc-docs/e2e/tests/layout/page-header.spec.ts delete mode 100644 packages/oc-docs/e2e/topbar.spec.ts create mode 100644 packages/oc-docs/e2e/tsconfig.json diff --git a/packages/oc-docs/e2e/components/base.component.ts b/packages/oc-docs/e2e/components/base.component.ts new file mode 100644 index 0000000..2a69dda --- /dev/null +++ b/packages/oc-docs/e2e/components/base.component.ts @@ -0,0 +1,13 @@ +import type { Page, Locator } from '@playwright/test'; + +export abstract class BaseComponent { + /** + * The element this component is scoped to. Section components receive their + * container; page-wide controls omit it and default to the whole page. + */ + readonly root: Locator; + + constructor(protected readonly page: Page, root?: Locator) { + this.root = root ?? page.locator(':root'); + } +} diff --git a/packages/oc-docs/e2e/components/page-header.component.ts b/packages/oc-docs/e2e/components/page-header.component.ts new file mode 100644 index 0000000..92c3026 --- /dev/null +++ b/packages/oc-docs/e2e/components/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, BRU-3572). A layout component — + * present on every screen — so tests get it handed over directly + * (`pageHeader.brandName`), the same way the sidebar will be. + * + * 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 drawer it opens is + // BRU-3574; the header only renders the button. + readonly menuButton = this.root.getByRole('button', { name: /toggle sidebar/i }); +} diff --git a/packages/oc-docs/e2e/config/app.config.ts b/packages/oc-docs/e2e/config/app.config.ts new file mode 100644 index 0000000..e77e909 --- /dev/null +++ b/packages/oc-docs/e2e/config/app.config.ts @@ -0,0 +1,11 @@ +export interface AppConfig { + /** Base URL the docs app is served from (used as Playwright's `baseURL`). */ + baseURL: string; + /** Command Playwright runs to start the app (the `webServer`). */ + webServerCommand: string; +} + +export const appConfig: AppConfig = { + baseURL:'http://127.0.0.1:3001', + webServerCommand: 'npm run dev' +}; diff --git a/packages/oc-docs/e2e/playwright/index.ts b/packages/oc-docs/e2e/playwright/index.ts new file mode 100644 index 0000000..26cbd13 --- /dev/null +++ b/packages/oc-docs/e2e/playwright/index.ts @@ -0,0 +1,12 @@ +import { mergeTests } from '@playwright/test'; +import { test as pagesTest } from './pages.fixture'; + +/** + * Entry point for the test harness — specs import everything (`test`, `expect`) + * from here. + * + * `mergeTests` combines the fixtures from every `*.fixture.ts` file in this folder + * into one `test`. + */ +export const test = mergeTests(pagesTest); +export { expect } from '@playwright/test'; diff --git a/packages/oc-docs/e2e/playwright/pages.fixture.ts b/packages/oc-docs/e2e/playwright/pages.fixture.ts new file mode 100644 index 0000000..a0c6a12 --- /dev/null +++ b/packages/oc-docs/e2e/playwright/pages.fixture.ts @@ -0,0 +1,17 @@ +import { test as base } from '@playwright/test'; +import { PageHeaderComponent } from '../components/page-header.component'; + +/** + * Layout components the app uses on every screen are handed to tests directly, + * e.g. `pageHeader` (and later `sidebar`), so a test writes + * `pageHeader.openSearch()` rather than `layout.header.openSearch()`. + */ +type Fixtures = { + pageHeader: PageHeaderComponent; +}; + +export const test = base.extend({ + pageHeader: async ({ page }, use) => { + await use(new PageHeaderComponent(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..a9e6eaa --- /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 for BRU-3572 — + * the search and env-switcher slots ship empty here (their content is BRU-3573 + * / BRU-3186), 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(); + expect(box?.y ?? -1).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 (the drawer itself is BRU-3574). + 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/e2e/topbar.spec.ts b/packages/oc-docs/e2e/topbar.spec.ts deleted file mode 100644 index 8928d76..0000000 --- a/packages/oc-docs/e2e/topbar.spec.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { test, expect } from '@playwright/test'; - -test.use({ colorScheme: 'light' }); - -const DESKTOP = { width: 1280, height: 900 }; -const TABLET = { width: 820, height: 1024 }; -const MOBILE = { width: 390, height: 800 }; - -test.describe('Topbar — mounted app', () => { - test('shows brand (name + version) and a pinned bar', async ({ page }) => { - await page.setViewportSize(DESKTOP); - await page.goto('/'); - - const header = page.getByTestId('topbar'); - await expect(header).toBeVisible(); - await expect(page.getByTestId('brand-name')).toContainText('Bruno Testbench'); - await expect(page.getByTestId('brand-version')).toHaveText('v1.0.0'); - - // Sticky: header stays at the top after the page scrolls. - await page.mouse.wheel(0, 600); - const box = await header.boundingBox(); - expect(box?.y ?? -1).toBeLessThanOrEqual(1); - }); - - test('CTA stays pinned to the right with empty slots (desktop)', async ({ page }) => { - await page.setViewportSize(DESKTOP); - await page.goto('/'); - - const header = page.getByTestId('topbar'); - const cta = page.getByTestId('open-in-bruno'); - const brand = page.getByTestId('brand'); - - const headerBox = await header.boundingBox(); - const ctaBox = await cta.boundingBox(); - const brandBox = await brand.boundingBox(); - - // CTA hugs the right edge (within the 20px bar padding), not the brand. - expect((headerBox!.x + headerBox!.width) - (ctaBox!.x + ctaBox!.width)).toBeLessThanOrEqual(24); - expect(ctaBox!.x).toBeGreaterThan(brandBox!.x + brandBox!.width + 100); - }); - - test('brand shows the initials avatar derived from the collection name', async ({ page }) => { - await page.setViewportSize(DESKTOP); - await page.goto('/'); - - // sampleCollection name is "Bruno Testbench" → "BT". - const avatar = page.getByTestId('brand-initials'); - await expect(avatar).toBeVisible(); - await expect(avatar).toHaveText('BT'); - }); - - test('Open-in-Bruno CTA deep-links via bruno://', async ({ page }) => { - await page.setViewportSize(DESKTOP); - await page.goto('/'); - - const cta = page.getByTestId('open-in-bruno'); - await expect(cta).toBeVisible(); - const href = await cta.getAttribute('href'); - expect(href).toMatch(/^bruno:\/\/app\/collection\/import\/git\?url=/); - }); - - test('mobile condenses: hamburger appears, Open-in-Bruno is hidden', async ({ page }) => { - await page.setViewportSize(MOBILE); - await page.goto('/'); - - await expect(page.getByRole('button', { name: /toggle sidebar/i })).toBeVisible(); - // Open-in-Bruno is desktop-only (no Bruno desktop app on mobile). - await expect(page.getByTestId('open-in-bruno')).toHaveCount(0); - - // Compact brand: avatar + "Docs" only — no full name, no version. - await expect(page.getByTestId('brand-name')).toHaveText('Docs'); - await expect(page.getByTestId('brand-version')).toHaveCount(0); - await expect(page.getByTestId('topbar')).not.toContainText('Bruno Testbench'); - - // No horizontal overflow. - const scrollW = await page.evaluate(() => document.documentElement.scrollWidth); - expect(scrollW).toBeLessThanOrEqual(MOBILE.width + 1); - }); -}); - -test.describe('Topbar — harness (slots filled)', () => { - test('desktop renders search + env slots inline', async ({ page }) => { - await page.setViewportSize(DESKTOP); - await page.goto('/?view=topbar-harness'); - - await expect(page.getByTestId('search-slot-input')).toBeVisible(); - await expect(page.getByTestId('env-switcher-slot')).toBeVisible(); - // CTA shows on the desktop layout (always the full label variant). - await expect(page.getByTestId('open-in-bruno')).toBeVisible(); - }); - - test('tablet: hamburger + inline env, search collapsed to icon, no CTA', async ({ page }) => { - await page.setViewportSize(TABLET); - await page.goto('/?view=topbar-harness'); - - await expect(page.getByRole('button', { name: /toggle sidebar/i })).toBeVisible(); - await expect(page.getByTestId('env-switcher-slot')).toBeVisible(); - // Search is an icon (no inline input) and Open-in-Bruno is hidden below desktop. - await expect(page.getByTestId('search-slot-input')).toHaveCount(0); - await expect(page.getByRole('button', { name: /^search$/i })).toBeVisible(); - await expect(page.getByTestId('open-in-bruno')).toHaveCount(0); - }); - - test('tablet: search icon expands the search row', async ({ page }) => { - await page.setViewportSize(TABLET); - await page.goto('/?view=topbar-harness'); - - await expect(page.getByTestId('search-slot-input')).toHaveCount(0); - await page.getByRole('button', { name: /^search$/i }).click(); - await expect(page.getByTestId('search-slot-input')).toBeVisible(); - }); - - test('mobile: search icon expands the search row', async ({ page }) => { - await page.setViewportSize(MOBILE); - await page.goto('/?view=topbar-harness'); - - // Inline search is hidden on mobile until the toggle is pressed. - await expect(page.getByTestId('search-slot-input')).toHaveCount(0); - await page.getByRole('button', { name: /^search$/i }).click(); - await expect(page.getByTestId('search-slot-input')).toBeVisible(); - }); - - test('mobile: overflow popover hosts the env-switcher slot', async ({ page }) => { - await page.setViewportSize(MOBILE); - await page.goto('/?view=topbar-harness'); - - await expect(page.getByTestId('env-switcher-slot')).toHaveCount(0); - await page.getByRole('button', { name: /more options/i }).click(); - await expect(page.getByTestId('env-switcher-slot')).toBeVisible(); - }); - - test('mobile: hamburger invokes onToggleSidebar', async ({ page }) => { - await page.setViewportSize(MOBILE); - await page.goto('/?view=topbar-harness'); - - await page.getByRole('button', { name: /toggle sidebar/i }).click(); - const calls = await page.evaluate( - () => (window as unknown as { __toggleSidebarCalls?: number }).__toggleSidebarCalls - ); - expect(calls).toBe(1); - }); -}); diff --git a/packages/oc-docs/e2e/tsconfig.json b/packages/oc-docs/e2e/tsconfig.json new file mode 100644 index 0000000..6238ba7 --- /dev/null +++ b/packages/oc-docs/e2e/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["**/*.ts"] +} diff --git a/packages/oc-docs/playwright.config.ts b/packages/oc-docs/playwright.config.ts index 63f6ce4..851a281 100644 --- a/packages/oc-docs/playwright.config.ts +++ b/packages/oc-docs/playwright.config.ts @@ -1,4 +1,5 @@ import { defineConfig, devices } from '@playwright/test'; +import { appConfig } from './e2e/config/app.config'; export default defineConfig({ testDir: './e2e', @@ -6,14 +7,13 @@ export default defineConfig({ forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 1 : undefined, - outputDir: './test-results', reporter: [ ['list'], - ['html', { outputFolder: './playwright-report', open: 'never' }], - ['json', { outputFile: './test-results/results.json' }], + ['html'], + ['json', { outputFile: 'playwright-report/results.json' }], ], use: { - baseURL: 'http://127.0.0.1:3001', + baseURL: appConfig.baseURL, trace: 'on-first-retry', }, projects: [ @@ -23,8 +23,8 @@ export default defineConfig({ }, ], webServer: { - command: 'npm run dev', - url: 'http://127.0.0.1:3001', + command: appConfig.webServerCommand, + url: appConfig.baseURL, reuseExistingServer: !process.env.CI, }, }); diff --git a/packages/oc-docs/src/dev.tsx b/packages/oc-docs/src/dev.tsx index 9b8b3ee..ff2e9c5 100644 --- a/packages/oc-docs/src/dev.tsx +++ b/packages/oc-docs/src/dev.tsx @@ -13,7 +13,6 @@ import 'prismjs/components/prism-xml-doc'; import 'prismjs/components/prism-http'; import 'prismjs/components/prism-graphql'; import OpenCollection from './components/OpenCollection/OpenCollection'; -import Topbar from './components/Topbar/Topbar'; import { createOpenCollectionStore } from './store/store'; import { sampleCollectionYaml } from './sampleCollection'; @@ -38,62 +37,11 @@ const DevApp: React.FC = () => { ); }; -/** - * Dev-only Topbar harness (gated behind `?view=topbar-harness`). Mounts the - * Topbar with both slots filled so the e2e suite can exercise the search-expand - * and overflow popover, which need the search / env-switcher slot nodes that - * downstream tickets supply. Not part of the shipped standalone build. - */ -const SearchSlot: React.FC = () => ( - -); - -const EnvSwitcherSlot: React.FC = () => ( -
- Show vars - Development -
-); - -const TopbarHarness: React.FC = () => ( -
- } - envSwitcherSlot={} - openInBrunoHref="bruno://app/collection/import/git?url=https%3A%2F%2Fexample.com%2Frepo.git" - onToggleSidebar={() => { - (window as unknown as { __toggleSidebarCalls?: number }).__toggleSidebarCalls = - ((window as unknown as { __toggleSidebarCalls?: number }).__toggleSidebarCalls ?? 0) + 1; - }} - /> -
Scroll content to verify the bar stays pinned.
-
-); - -const view = - typeof window !== 'undefined' ? new URLSearchParams(window.location.search).get('view') : null; - // Render the app const container = document.getElementById('root'); if (container) { const root = createRoot(container); - root.render(view === 'topbar-harness' ? : ); + root.render(); } else { console.error('Root container not found'); } From a91a5885d28988a101b86fd1d5e911735b324832 Mon Sep 17 00:00:00 2001 From: Sundram Gupta Date: Wed, 24 Jun 2026 16:23:41 +0530 Subject: [PATCH 16/27] refactor(oc-docs): eager capability check + note slot/toggle gaps (BRU-3572) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-push review fixes: - useCanRunBrunoApp measures the device capability eagerly on first render (SSR/no-window safe), so the Open-in-Bruno CTA's visibility is correct on first paint — removes a one-tick flash and makes the desktop e2e deterministic rather than retry-dependent. - Comment at the Topbar mount noting searchSlot (BRU-3573), envSwitcherSlot (BRU-3186) and onToggleSidebar (BRU-3574) are wired by their own tickets. Co-Authored-By: Claude Opus 4.8 --- .../components/OpenCollection/OpenCollection.tsx | 3 +++ packages/oc-docs/src/hooks/useCanRunBrunoApp.ts | 14 ++++++++------ 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/oc-docs/src/components/OpenCollection/OpenCollection.tsx b/packages/oc-docs/src/components/OpenCollection/OpenCollection.tsx index b1d537a..00310ee 100644 --- a/packages/oc-docs/src/components/OpenCollection/OpenCollection.tsx +++ b/packages/oc-docs/src/components/OpenCollection/OpenCollection.tsx @@ -154,6 +154,9 @@ const DesktopLayout: React.FC = ({ return (
+ {/* searchSlot (BRU-3573), envSwitcherSlot (BRU-3186) and onToggleSidebar + (BRU-3574) are wired by their own tickets; the header renders the + empty slots / inert hamburger until then. */} ({ }); /** - * Hook form. Defaults to `false` until the first client measure (SSR/no-window - * safe and avoids flashing the CTA on touch devices). Re-evaluates when the - * pointer capability changes (e.g. attaching a trackpad / external mouse) by - * listening to the media query itself — a `resize` event would NOT fire on - * device attach. + * Hook form. Measured eagerly on first render (SSR/no-window safe → `false`), + * so the CTA's visibility is correct on first paint — no flash. Re-evaluates + * when the pointer capability changes (e.g. attaching a trackpad / external + * mouse) by listening to the media query itself — a `resize` event would NOT + * fire on device attach. */ export const useCanRunBrunoApp = (): boolean => { - const [canRun, setCanRun] = useState(false); + const [canRun, setCanRun] = useState(() => + typeof window === 'undefined' ? false : computeCanRunBrunoApp(readDeviceEnv()) + ); useEffect(() => { if (typeof window === 'undefined') return; From b3bd08a7e01febb88104d4fc6be6838cd76b3505 Mon Sep 17 00:00:00 2001 From: Sundram Gupta Date: Wed, 24 Jun 2026 17:10:22 +0530 Subject: [PATCH 17/27] test(oc-docs): migrate request-errors e2e to the page-object structure (BRU-3408) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The try-it failure-message spec was the last flat e2e file. Move it onto the page-object structure and locate by test id / role instead of CSS classes: - New e2e/components/request-playground.component.ts (RequestPlaygroundComponent) — open try-it for an endpoint, send, edit URL, read the failure banner / Response tab — exposed as the `requestPlayground` fixture. - Move e2e/request-errors.spec.ts -> e2e/tests/request/request-errors.spec.ts; it imports { test, expect } from the playwright harness and drives the request playground. Assertions unchanged. - Add data-testid to the error banner (error-banner / error-title / error-message / error-hint) and the endpoint section so the spec no longer relies on .error-title / .error-message / .endpoint-section class selectors. BRU-3408 shipped in the merged PR #40; this test-structure cleanup rides along with the topbar PR (#44) per request. Gates green: 88 unit, 17 e2e, build:standalone. Co-Authored-By: Claude Opus 4.8 --- .../request-playground.component.ts | 39 +++++++++++ .../oc-docs/e2e/playwright/pages.fixture.ts | 5 ++ packages/oc-docs/e2e/request-errors.spec.ts | 70 ------------------- .../e2e/tests/request/request-errors.spec.ts | 61 ++++++++++++++++ packages/oc-docs/src/components/Docs/Docs.tsx | 1 + .../src/ui/ErrorBanner/ErrorBanner.tsx | 8 +-- 6 files changed, 110 insertions(+), 74 deletions(-) create mode 100644 packages/oc-docs/e2e/components/request-playground.component.ts delete mode 100644 packages/oc-docs/e2e/request-errors.spec.ts create mode 100644 packages/oc-docs/e2e/tests/request/request-errors.spec.ts diff --git a/packages/oc-docs/e2e/components/request-playground.component.ts b/packages/oc-docs/e2e/components/request-playground.component.ts new file mode 100644 index 0000000..e24499d --- /dev/null +++ b/packages/oc-docs/e2e/components/request-playground.component.ts @@ -0,0 +1,39 @@ +import type { Locator } from '@playwright/test'; +import { BaseComponent } from './base.component'; + +/** + * The try-it request playground: open it from an endpoint, send the request, + * edit the URL, and read the failure banner. A page-wide component (the + * playground opens over the current screen), handed to tests as `requestPlayground`. + * + * Elements are found by test id or accessible name, never by class. + */ +export class RequestPlaygroundComponent extends BaseComponent { + readonly sendButton = this.page.getByRole('button', { name: 'SEND' }); + readonly urlInput = this.page.getByPlaceholder('Enter request URL'); + readonly responseTab = this.page.getByRole('button', { name: 'Response', exact: true }); + + // Failure banner (BRU-3408) + readonly errorTitle = this.page.getByTestId('error-title'); + readonly errorMessage = this.page.getByTestId('error-message'); + + /** An endpoint section, located by its h1 title. */ + section(name: string): Locator { + return this.page.getByTestId('endpoint-section').filter({ + has: this.page.getByRole('heading', { name, level: 1, exact: true }) + }); + } + + /** Open the try-it playground for an endpoint via its "Try" button. */ + async open(endpoint: string): Promise { + await this.section(endpoint).getByRole('button', { name: 'Try' }).click(); + } + + async setUrl(url: string): Promise { + await this.urlInput.fill(url); + } + + async send(): Promise { + await this.sendButton.click(); + } +} diff --git a/packages/oc-docs/e2e/playwright/pages.fixture.ts b/packages/oc-docs/e2e/playwright/pages.fixture.ts index 3f81047..8fdd52a 100644 --- a/packages/oc-docs/e2e/playwright/pages.fixture.ts +++ b/packages/oc-docs/e2e/playwright/pages.fixture.ts @@ -2,6 +2,7 @@ import { test as base } from '@playwright/test'; import { CollectionPage } from '../pages/collection.page'; import { PageHeaderComponent } from '../components/page-header.component'; import { ThemeToggleComponent } from '../components/theme-toggle.component'; +import { RequestPlaygroundComponent } from '../components/request-playground.component'; /** * Each page object gets a fixture, and so do the layout components the app uses @@ -12,6 +13,7 @@ type Fixtures = { collectionPage: CollectionPage; pageHeader: PageHeaderComponent; themeToggle: ThemeToggleComponent; + requestPlayground: RequestPlaygroundComponent; }; export const test = base.extend({ @@ -23,5 +25,8 @@ export const test = base.extend({ }, themeToggle: async ({ page }, use) => { await use(new ThemeToggleComponent(page)); + }, + requestPlayground: async ({ page }, use) => { + await use(new RequestPlaygroundComponent(page)); } }); diff --git a/packages/oc-docs/e2e/request-errors.spec.ts b/packages/oc-docs/e2e/request-errors.spec.ts deleted file mode 100644 index 38cdcfe..0000000 --- a/packages/oc-docs/e2e/request-errors.spec.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { test, expect, type Page } from '@playwright/test'; - -/** - * Locate an endpoint section by its h1 title (mirrors requests.spec.ts). - */ -function endpointSection(page: Page, name: string) { - return page.locator('.endpoint-section').filter({ - has: page.getByRole('heading', { name, level: 1, exact: true }), - }); -} - -/** Open the try-it playground for an endpoint. */ -async function openTryIt(page: Page, endpoint: string) { - await endpointSection(page, endpoint).getByRole('button', { name: 'Try' }).click(); -} - -test.describe('Try-it request failure messages (BRU-3408)', () => { - test.beforeEach(async ({ page }) => { - await page.goto('/'); - await page.waitForSelector('.endpoint-section'); - }); - - test('cross-origin failure is classified as browser-blocked (CORS), inside the Response tab', async ({ page }) => { - // The sample collection targets localhost:8081 — a different origin than the - // docs page (127.0.0.1:3001). Aborting reproduces the opaque failure. - await page.route('**/api/users**', (route) => route.abort()); - - await openTryIt(page, 'get users'); - await page.getByRole('button', { name: 'SEND' }).click(); - - await expect(page.locator('.error-title')).toHaveText('Request blocked'); - await expect(page.locator('.error-message')).toContainText('usually CORS'); - await expect(page.locator('.error-message')).toContainText('Bruno desktop app'); - - // Banner renders inside the Response tab shell, not as a full-pane replacement. - await expect(page.getByRole('button', { name: 'Response', exact: true })).toBeVisible(); - }); - - test('same-origin failure is "unreachable" and never mentions CORS', async ({ page }) => { - // Point the request at the docs page's own origin, then fail it. - await page.route('**/same-origin-fail**', (route) => route.abort()); - - await openTryIt(page, 'get users'); - await page.getByPlaceholder('Enter request URL').fill('http://127.0.0.1:3001/same-origin-fail'); - await page.getByRole('button', { name: 'SEND' }).click(); - - await expect(page.locator('.error-title')).toHaveText("Couldn't reach the server"); - const message = (await page.locator('.error-message').innerText()).toLowerCase(); - expect(message).toContain('may be down'); - expect(message).not.toContain('cors'); - }); - - test('a 4xx response is NOT treated as a failure (renders normally)', async ({ page }) => { - await page.route('**/api/users**', (route) => - route.fulfill({ - status: 404, - headers: { 'access-control-allow-origin': '*' }, - contentType: 'application/json', - body: JSON.stringify({ error: 'not found' }), - }) - ); - - await openTryIt(page, 'get users'); - await page.getByRole('button', { name: 'SEND' }).click(); - - // No error banner; a 404 is a normal response (status shown). - await expect(page.locator('.error-title')).toHaveCount(0); - await expect(page.getByText('404 Not Found')).toBeVisible(); - }); -}); diff --git a/packages/oc-docs/e2e/tests/request/request-errors.spec.ts b/packages/oc-docs/e2e/tests/request/request-errors.spec.ts new file mode 100644 index 0000000..e7cdf62 --- /dev/null +++ b/packages/oc-docs/e2e/tests/request/request-errors.spec.ts @@ -0,0 +1,61 @@ +import { test, expect } from '../../playwright'; + +/** + * A failed try-it request shows a contextual error banner (BRU-3408): a + * cross-origin abort is classified as browser-blocked (CORS), a same-origin + * abort as unreachable, and a 4xx is a normal response (no banner). + */ +test.describe('Try-it request failure messages', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await page.getByTestId('endpoint-section').first().waitFor(); + }); + + test('cross-origin failure is classified as browser-blocked (CORS), inside the Response tab', async ({ page, requestPlayground }) => { + // The sample collection targets localhost:8081 — a different origin than the + // docs page (127.0.0.1:3001). Aborting reproduces the opaque failure. + await page.route('**/api/users**', (route) => route.abort()); + + await requestPlayground.open('get users'); + await requestPlayground.send(); + + await expect(requestPlayground.errorTitle).toHaveText('Request blocked'); + await expect(requestPlayground.errorMessage).toContainText('usually CORS'); + await expect(requestPlayground.errorMessage).toContainText('Bruno desktop app'); + + // Banner renders inside the Response tab shell, not as a full-pane replacement. + await expect(requestPlayground.responseTab).toBeVisible(); + }); + + test('same-origin failure is "unreachable" and never mentions CORS', async ({ page, requestPlayground }) => { + // Point the request at the docs page's own origin, then fail it. + await page.route('**/same-origin-fail**', (route) => route.abort()); + + await requestPlayground.open('get users'); + await requestPlayground.setUrl('http://127.0.0.1:3001/same-origin-fail'); + await requestPlayground.send(); + + await expect(requestPlayground.errorTitle).toHaveText("Couldn't reach the server"); + const message = (await requestPlayground.errorMessage.innerText()).toLowerCase(); + expect(message).toContain('may be down'); + expect(message).not.toContain('cors'); + }); + + test('a 4xx response is NOT treated as a failure (renders normally)', async ({ page, requestPlayground }) => { + await page.route('**/api/users**', (route) => + route.fulfill({ + status: 404, + headers: { 'access-control-allow-origin': '*' }, + contentType: 'application/json', + body: JSON.stringify({ error: 'not found' }), + }) + ); + + await requestPlayground.open('get users'); + await requestPlayground.send(); + + // No error banner; a 404 is a normal response (status shown). + await expect(requestPlayground.errorTitle).toHaveCount(0); + await expect(page.getByText('404 Not Found')).toBeVisible(); + }); +}); diff --git a/packages/oc-docs/src/components/Docs/Docs.tsx b/packages/oc-docs/src/components/Docs/Docs.tsx index 7782c27..ca50333 100644 --- a/packages/oc-docs/src/components/Docs/Docs.tsx +++ b/packages/oc-docs/src/components/Docs/Docs.tsx @@ -179,6 +179,7 @@ const Docs: React.FC = ({
= ({ title, message, hint, className = '' }) => ( - -
{title}
-
{message}
- {hint ?
{hint}
: null} + +
{title}
+
{message}
+ {hint ?
{hint}
: null}
); From dbb954fb48404ce17694e310eefe90bca8a047f3 Mon Sep 17 00:00:00 2001 From: Sundram Gupta Date: Wed, 24 Jun 2026 23:51:09 +0530 Subject: [PATCH 18/27] refactor(oc-docs): align topbar to the team folder conventions (BRU-3572) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adopt the structure conventions from the EM review (Bijin): - Icons: split src/assets/icons.tsx into one file per icon under src/assets/icons/ (SearchIcon/HamburgerIcon/OverflowIcon/BrunoGlyph) + shared baseIconProps.ts + an index.ts aggregator (importable individually or from the barrel). Import paths unchanged. - ui/ vs components/: IconButton is a generic presentational primitive → moved to src/ui/IconButton/. Feature components (Topbar/Brand/ MobileOverflow) stay under src/components/Topbar/. - StyledWrapper naming: every per-component styled root export is now named StyledWrapper (was StyledButton / Base / Badge in IconButton / OpenInBrunoButton / InitialsAvatar). - Default testId prop: Topbar, Brand, InitialsAvatar and OpenInBrunoButton take an optional `testId` (defaults equal the previous hardcoded ids, so existing selectors keep matching); Brand derives `${testId}-name` / `${testId}-version`. - E2E components grouped by area: e2e/components/{layout,request}/*. Structure/naming only — no visual or behavior change. Gates green: 88 unit, 17 e2e, build:standalone. Co-Authored-By: Claude Opus 4.8 --- .../{ => layout}/page-header.component.ts | 2 +- .../request-playground.component.ts | 2 +- .../oc-docs/e2e/playwright/pages.fixture.ts | 4 +- .../{icons.tsx => icons/BrunoGlyph.tsx} | 46 ------------------- .../src/assets/icons/HamburgerIcon.tsx | 9 ++++ .../oc-docs/src/assets/icons/OverflowIcon.tsx | 10 ++++ .../oc-docs/src/assets/icons/SearchIcon.tsx | 10 ++++ .../oc-docs/src/assets/icons/baseIconProps.ts | 15 ++++++ packages/oc-docs/src/assets/icons/index.ts | 4 ++ .../InitialsAvatar/InitialsAvatar.tsx | 12 +++-- .../InitialsAvatar/StyledWrapper.ts | 2 +- .../OpenInBrunoButton/OpenInBrunoButton.tsx | 10 ++-- .../OpenInBrunoButton/StyledWrapper.ts | 2 +- .../src/components/Topbar/Brand/Brand.tsx | 18 ++++++-- .../Topbar/MobileOverflow/MobileOverflow.tsx | 2 +- .../oc-docs/src/components/Topbar/Topbar.tsx | 6 ++- .../IconButton/IconButton.spec.tsx | 0 .../IconButton/IconButton.tsx | 6 +-- .../IconButton/StyledWrapper.ts | 2 +- 19 files changed, 90 insertions(+), 72 deletions(-) rename packages/oc-docs/e2e/components/{ => layout}/page-header.component.ts (95%) rename packages/oc-docs/e2e/components/{ => request}/request-playground.component.ts (96%) rename packages/oc-docs/src/assets/{icons.tsx => icons/BrunoGlyph.tsx} (74%) create mode 100644 packages/oc-docs/src/assets/icons/HamburgerIcon.tsx create mode 100644 packages/oc-docs/src/assets/icons/OverflowIcon.tsx create mode 100644 packages/oc-docs/src/assets/icons/SearchIcon.tsx create mode 100644 packages/oc-docs/src/assets/icons/baseIconProps.ts create mode 100644 packages/oc-docs/src/assets/icons/index.ts rename packages/oc-docs/src/{components => ui}/IconButton/IconButton.spec.tsx (100%) rename packages/oc-docs/src/{components => ui}/IconButton/IconButton.tsx (82%) rename packages/oc-docs/src/{components => ui}/IconButton/StyledWrapper.ts (95%) diff --git a/packages/oc-docs/e2e/components/page-header.component.ts b/packages/oc-docs/e2e/components/layout/page-header.component.ts similarity index 95% rename from packages/oc-docs/e2e/components/page-header.component.ts rename to packages/oc-docs/e2e/components/layout/page-header.component.ts index 92c3026..8408e9c 100644 --- a/packages/oc-docs/e2e/components/page-header.component.ts +++ b/packages/oc-docs/e2e/components/layout/page-header.component.ts @@ -1,5 +1,5 @@ import type { Page } from '@playwright/test'; -import { BaseComponent } from './base.component'; +import { BaseComponent } from '../base.component'; /** * The page header (sticky top navigation bar, BRU-3572). A layout component — diff --git a/packages/oc-docs/e2e/components/request-playground.component.ts b/packages/oc-docs/e2e/components/request/request-playground.component.ts similarity index 96% rename from packages/oc-docs/e2e/components/request-playground.component.ts rename to packages/oc-docs/e2e/components/request/request-playground.component.ts index e24499d..84a9f44 100644 --- a/packages/oc-docs/e2e/components/request-playground.component.ts +++ b/packages/oc-docs/e2e/components/request/request-playground.component.ts @@ -1,5 +1,5 @@ import type { Locator } from '@playwright/test'; -import { BaseComponent } from './base.component'; +import { BaseComponent } from '../base.component'; /** * The try-it request playground: open it from an endpoint, send the request, diff --git a/packages/oc-docs/e2e/playwright/pages.fixture.ts b/packages/oc-docs/e2e/playwright/pages.fixture.ts index 8fdd52a..0647f15 100644 --- a/packages/oc-docs/e2e/playwright/pages.fixture.ts +++ b/packages/oc-docs/e2e/playwright/pages.fixture.ts @@ -1,8 +1,8 @@ import { test as base } from '@playwright/test'; import { CollectionPage } from '../pages/collection.page'; -import { PageHeaderComponent } from '../components/page-header.component'; +import { PageHeaderComponent } from '../components/layout/page-header.component'; import { ThemeToggleComponent } from '../components/theme-toggle.component'; -import { RequestPlaygroundComponent } from '../components/request-playground.component'; +import { RequestPlaygroundComponent } from '../components/request/request-playground.component'; /** * Each page object gets a fixture, and so do the layout components the app uses diff --git a/packages/oc-docs/src/assets/icons.tsx b/packages/oc-docs/src/assets/icons/BrunoGlyph.tsx similarity index 74% rename from packages/oc-docs/src/assets/icons.tsx rename to packages/oc-docs/src/assets/icons/BrunoGlyph.tsx index 6c72105..2f3eaba 100644 --- a/packages/oc-docs/src/assets/icons.tsx +++ b/packages/oc-docs/src/assets/icons/BrunoGlyph.tsx @@ -1,51 +1,5 @@ import React from 'react'; -/* - * Shared icon glyphs. NOTE (reconciliation): PR #45 also introduces - * src/assets/icons.tsx with its own glyph set (GlobeIcon, BookIcon, …) and the - * same `baseIconProps` constant. When the two branches merge this file will - * conflict at the file level (both add it) — resolution is mechanical: keep one - * copy of `baseIconProps` and the union of both icon sets (names don't overlap). - */ - -/** Shared stroke styling. `currentColor` lets the icon inherit the surrounding - * theme colour, so it adapts when the theme changes. */ -const baseIconProps: React.SVGProps = { - width: 20, - height: 20, - viewBox: '0 0 24 24', - fill: 'none', - stroke: 'currentColor', - strokeWidth: 2, - strokeLinecap: 'round', - strokeLinejoin: 'round', - 'aria-hidden': true -}; - -/** Magnifying glass — Topbar search toggle. */ -export const SearchIcon: React.FC = () => ( - - - - -); - -/** Three bars — Topbar sidebar (hamburger) toggle. */ -export const HamburgerIcon: React.FC = () => ( - - - -); - -/** Vertical ellipsis — Topbar mobile overflow trigger. */ -export const OverflowIcon: React.FC = () => ( - -); - /** 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 new file mode 100644 index 0000000..4e96f1c --- /dev/null +++ b/packages/oc-docs/src/assets/icons/baseIconProps.ts @@ -0,0 +1,15 @@ +import type { SVGProps } from 'react'; + +/** 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, + viewBox: '0 0 24 24', + fill: 'none', + stroke: 'currentColor', + strokeWidth: 2, + strokeLinecap: 'round', + strokeLinejoin: 'round', + 'aria-hidden': true +}; diff --git a/packages/oc-docs/src/assets/icons/index.ts b/packages/oc-docs/src/assets/icons/index.ts new file mode 100644 index 0000000..fa94056 --- /dev/null +++ b/packages/oc-docs/src/assets/icons/index.ts @@ -0,0 +1,4 @@ +export * from './SearchIcon'; +export * from './HamburgerIcon'; +export * from './OverflowIcon'; +export * from './BrunoGlyph'; diff --git a/packages/oc-docs/src/components/InitialsAvatar/InitialsAvatar.tsx b/packages/oc-docs/src/components/InitialsAvatar/InitialsAvatar.tsx index f4be6d1..ee24bf6 100644 --- a/packages/oc-docs/src/components/InitialsAvatar/InitialsAvatar.tsx +++ b/packages/oc-docs/src/components/InitialsAvatar/InitialsAvatar.tsx @@ -1,19 +1,23 @@ import React from 'react'; import { getInitials } from '../../utils/getInitials'; -import { Badge } from './StyledWrapper'; +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 }) => ( - +
); export default InitialsAvatar; diff --git a/packages/oc-docs/src/components/InitialsAvatar/StyledWrapper.ts b/packages/oc-docs/src/components/InitialsAvatar/StyledWrapper.ts index 0df1fa4..b1fbded 100644 --- a/packages/oc-docs/src/components/InitialsAvatar/StyledWrapper.ts +++ b/packages/oc-docs/src/components/InitialsAvatar/StyledWrapper.ts @@ -6,7 +6,7 @@ import styled from '@emotion/styled'; * light and dark, matching the design, so it is intentionally not theme-tokened. * Mono font + tight tracking match the design's letterforms. */ -export const Badge = styled.span` +export const StyledWrapper = styled.span` display: inline-flex; align-items: center; justify-content: center; diff --git a/packages/oc-docs/src/components/OpenInBrunoButton/OpenInBrunoButton.tsx b/packages/oc-docs/src/components/OpenInBrunoButton/OpenInBrunoButton.tsx index c5d416e..d0c77e0 100644 --- a/packages/oc-docs/src/components/OpenInBrunoButton/OpenInBrunoButton.tsx +++ b/packages/oc-docs/src/components/OpenInBrunoButton/OpenInBrunoButton.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { BrunoGlyph } from '../../assets/icons'; -import { Base } from './StyledWrapper'; +import { StyledWrapper } from './StyledWrapper'; export interface OpenInBrunoButtonProps { /** When provided, renders a real `bruno://` deep link (`
`). */ @@ -10,6 +10,7 @@ export interface OpenInBrunoButtonProps { /** Collapse to a square icon-only control. */ iconOnly?: boolean; label?: string; + testId?: string; } const OpenInBrunoButton: React.FC = ({ @@ -17,6 +18,7 @@ const OpenInBrunoButton: React.FC = ({ onClick, iconOnly = false, label = 'Open in Bruno', + testId = 'open-in-bruno', }) => { const className = iconOnly ? 'is-icon' : 'is-full'; // A real deep link renders an anchor (right-click-copy, accessible); without @@ -26,16 +28,16 @@ const OpenInBrunoButton: React.FC = ({ : ({ as: 'button' as const, type: 'button' as const, onClick }); return ( - {!iconOnly && {label}} - + ); }; diff --git a/packages/oc-docs/src/components/OpenInBrunoButton/StyledWrapper.ts b/packages/oc-docs/src/components/OpenInBrunoButton/StyledWrapper.ts index 8c9ecca..e1f366b 100644 --- a/packages/oc-docs/src/components/OpenInBrunoButton/StyledWrapper.ts +++ b/packages/oc-docs/src/components/OpenInBrunoButton/StyledWrapper.ts @@ -5,7 +5,7 @@ import styled from '@emotion/styled'; * link) or a `