diff --git a/web/platform/app-brand.test.tsx b/web/platform/app-brand.test.tsx new file mode 100644 index 000000000..2747bb3b4 --- /dev/null +++ b/web/platform/app-brand.test.tsx @@ -0,0 +1,39 @@ +import type { WebLinkProps } from "./navigation.js"; +import { WebNavigationProvider } from "./navigation.js"; +import { AppBrand } from "./app-brand.js"; +import { cleanup, render, screen } from "./test/rtl.js"; +import { afterEach, expect, it } from "vitest"; + +afterEach(cleanup); + +it("names the home link without reading the decorative monogram", () => { + render(Stovepipe); + expect(screen.getByRole("link", { name: "Stovepipe" })).toHaveAttribute("href", "/queue"); + expect(screen.getByText("SP")).toHaveAttribute("aria-hidden", "true"); +}); + +it("preserves host navigation and an explicit home label", () => { + function HostLink(props: WebLinkProps) { + return ; + } + render( + SubmitQueue + ); + const link = screen.getByRole("link", { name: "SubmitQueue queues" }); + expect(link).toHaveAttribute("href", "/sq"); + expect(link).toHaveAttribute("data-host-link", "true"); +}); + +it("omits an empty monogram while preserving custom branding", () => { + render(My queue host); + const link = screen.getByRole("link", { name: "My queue host" }); + expect(link.querySelector("[aria-hidden]")).toBeNull(); + expect(link.querySelector("strong")).toHaveTextContent("My queue host"); +}); + +it("keeps a custom symbol decorative without adding a monogram badge", () => { + render(}>Stovepipe); + const link = screen.getByRole("link", { name: "Stovepipe" }); + expect(link.querySelector("svg")?.parentElement).toHaveAttribute("aria-hidden", "true"); + expect(link).toHaveTextContent("Stovepipe"); +}); diff --git a/web/platform/app-brand.tsx b/web/platform/app-brand.tsx new file mode 100644 index 000000000..5949f6f10 --- /dev/null +++ b/web/platform/app-brand.tsx @@ -0,0 +1,54 @@ +"use client"; + +import type { ReactNode } from "react"; +import { styled } from "baseui"; +import { StyledLink } from "baseui/link"; +import { useWebNavigation } from "./navigation.js"; + +const BrandLink = styled(StyledLink, ({ $theme }) => ({ + display: "inline-flex", + alignItems: "center", + gap: $theme.sizing.scale300, + color: $theme.colors.contentPrimary, + fontSize: "18px", + fontWeight: 600, + textDecoration: "none", + ":visited": { color: $theme.colors.contentPrimary }, + ":hover": { color: $theme.colors.contentPrimary, textDecoration: "none" }, +})); + +const BrandMark = styled("span", ({ $theme }) => ({ + backgroundColor: $theme.colors.contentPrimary, + color: $theme.colors.backgroundPrimary, + borderRadius: "6px", + padding: "5px 7px", + fontSize: "12px", + lineHeight: 1, +})); + +const BrandSymbol = styled("span", { + display: "inline-flex", + alignItems: "center", + flexShrink: 0, + lineHeight: 0, +}); + +export interface AppBrandProps { + href: string; + /** Accessible name of the home link. */ + homeLabel?: string; + children: ReactNode; + /** Decorative mark; text is rendered as a monogram badge. */ + mark?: ReactNode; +} + +export function AppBrand({ href, homeLabel, children, mark = "" }: AppBrandProps) { + const { Link } = useWebNavigation(); + return + {mark ? typeof mark === "string" + ? + : + : null} + {children} + ; +} diff --git a/web/platform/app-header.test.tsx b/web/platform/app-header.test.tsx new file mode 100644 index 000000000..4e1fcae8e --- /dev/null +++ b/web/platform/app-header.test.tsx @@ -0,0 +1,21 @@ +import { afterEach, expect, it } from "vitest"; +import { cleanup, render, screen, within } from "./test/rtl.js"; +import { AppBrand } from "./app-brand.js"; +import { AppHeader } from "./app-header.js"; + +afterEach(cleanup); + +it("keeps branding and controls in a compact header that can grow when wrapped", () => { + render( + SubmitQueue + + ); + const header = screen.getByRole("banner"); + expect(within(header).getByRole("link", { name: "SubmitQueue" })).toHaveAttribute("href", "/"); + expect(within(header).getByRole("button", { name: "Enable auto-refresh" })).toBeVisible(); + const style = getComputedStyle(header); + expect(style.minHeight).toBe("64px"); + expect(style.height).not.toBe("64px"); + expect(style.boxSizing).toBe("border-box"); + expect(style.flexWrap).toBe("wrap"); +}); diff --git a/web/platform/app-header.tsx b/web/platform/app-header.tsx new file mode 100644 index 000000000..8703f47f2 --- /dev/null +++ b/web/platform/app-header.tsx @@ -0,0 +1,16 @@ +"use client"; + +import { styled } from "baseui"; + +export const AppHeader = styled("header", ({ $theme }) => ({ + minHeight: "64px", + boxSizing: "border-box", + display: "flex", + alignItems: "center", + justifyContent: "space-between", + flexWrap: "wrap", + gap: $theme.sizing.scale600, + paddingTop: $theme.sizing.scale300, + paddingBottom: $theme.sizing.scale300, + borderBottom: `1px solid ${$theme.colors.borderOpaque}`, +})); diff --git a/web/platform/auto-refresh.tsx b/web/platform/auto-refresh.tsx index 829276e93..5bc68ed1a 100644 --- a/web/platform/auto-refresh.tsx +++ b/web/platform/auto-refresh.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { styled, type Theme } from "baseui"; import { Block } from "baseui/block"; -import { Button, KIND, SHAPE, SIZE } from "baseui/button"; +import { Button, KIND, MIN_HIT_AREA, SHAPE, SIZE } from "baseui/button"; import { StatefulTooltip } from "baseui/tooltip"; const RefreshIcon = styled<"svg", { $spinning: boolean }>("svg", ({ $spinning }) => ({ @@ -114,21 +114,17 @@ export function AutoRefresh({ return ( -