From 13b5be7f2281bb006af951c429958fd9a97646a9 Mon Sep 17 00:00:00 2001 From: "prath.shenoy" Date: Tue, 10 Nov 2026 23:17:19 +0000 Subject: [PATCH 1/3] feat(web): Refine shared app headers --- web/platform/app-brand.test.tsx | 39 +++++++++++++++++++++++ web/platform/app-brand.tsx | 54 ++++++++++++++++++++++++++++++++ web/platform/app-header.test.tsx | 21 +++++++++++++ web/platform/app-header.tsx | 16 ++++++++++ web/platform/auto-refresh.tsx | 30 ++++++++++++------ web/platform/index.ts | 2 ++ web/stovepipe/view/app.test.tsx | 13 ++++++++ web/stovepipe/view/app.tsx | 32 +++++++++++++------ web/submitqueue/view/shell.tsx | 45 +++++--------------------- 9 files changed, 195 insertions(+), 57 deletions(-) create mode 100644 web/platform/app-brand.test.tsx create mode 100644 web/platform/app-brand.tsx create mode 100644 web/platform/app-header.test.tsx create mode 100644 web/platform/app-header.tsx 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..aa177905d 100644 --- a/web/platform/auto-refresh.tsx +++ b/web/platform/auto-refresh.tsx @@ -120,16 +120,26 @@ export function AutoRefresh({ onClick={() => setEnabled(current => !current)} overrides={{ BaseButton: { style: ({ $theme, $isSelected, $isFocusVisible }: { $theme: Theme; $isSelected?: boolean; $isFocusVisible?: boolean; - }) => ({ - backgroundColor: $isSelected ? $theme.colors.backgroundAccentLight : "transparent", - color: $isSelected ? $theme.colors.contentAccent : $theme.colors.contentSecondary, - boxShadow: $isFocusVisible ? `inset 0 0 0 2px ${$theme.colors.borderAccent}` : "none", - ":hover": { - backgroundColor: $isSelected ? $theme.colors.backgroundAccentLight : $theme.colors.backgroundSecondary, - color: $isSelected ? $theme.colors.contentAccent : $theme.colors.contentPrimary, - }, - "@media (pointer: coarse)": { width: "44px", height: "44px" }, - }) } }}> + }) => { + const focusRing = `inset 0 0 0 2px ${$theme.colors.borderAccent}`; + const ring = $isFocusVisible ? focusRing + : $isSelected ? "none" : `inset 0 0 0 1px ${$theme.colors.contentTertiary}`; + return { + backgroundColor: $isSelected ? $theme.colors.contentPrimary : "transparent", + color: $isSelected ? $theme.colors.backgroundPrimary : $theme.colors.contentSecondary, + boxShadow: ring, + ":hover": { + backgroundColor: $isSelected ? $theme.colors.contentPrimary : $theme.colors.backgroundSecondary, + color: $isSelected ? $theme.colors.backgroundPrimary : $theme.colors.contentPrimary, + }, + ":focus-visible": { boxShadow: focusRing }, + "@media (hover: hover)": { + ":hover": { boxShadow: ring }, + ":hover:focus-visible": { boxShadow: focusRing }, + }, + "@media (pointer: coarse)": { width: "44px", height: "44px" }, + }; + } } }}>