Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions web/platform/app-brand.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<AppBrand href="/queue" mark="SP">Stovepipe</AppBrand>);
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 <a {...props} data-host-link="true" />;
}
render(<WebNavigationProvider value={{ Link: HostLink }}>
<AppBrand href="/sq" homeLabel="SubmitQueue queues" mark="SQ">SubmitQueue</AppBrand>
</WebNavigationProvider>);
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(<AppBrand href="/"><strong>My queue host</strong></AppBrand>);
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(<AppBrand href="/" mark={<svg viewBox="0 0 32 32" />}>Stovepipe</AppBrand>);
const link = screen.getByRole("link", { name: "Stovepipe" });
expect(link.querySelector("svg")?.parentElement).toHaveAttribute("aria-hidden", "true");
expect(link).toHaveTextContent("Stovepipe");
});
54 changes: 54 additions & 0 deletions web/platform/app-brand.tsx
Original file line number Diff line number Diff line change
@@ -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 <BrandLink $as={Link ?? "a"} href={href} {...(homeLabel ? { "aria-label": homeLabel } : {})}>
{mark ? typeof mark === "string"
? <BrandMark aria-hidden="true">{mark}</BrandMark>
: <BrandSymbol aria-hidden="true">{mark}</BrandSymbol>
: null}
<span>{children}</span>
</BrandLink>;
}
21 changes: 21 additions & 0 deletions web/platform/app-header.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<AppHeader>
<AppBrand href="/" mark="SQ">SubmitQueue</AppBrand>
<button>Enable auto-refresh</button>
</AppHeader>);
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");
});
16 changes: 16 additions & 0 deletions web/platform/app-header.tsx
Original file line number Diff line number Diff line change
@@ -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}`,
}));
16 changes: 6 additions & 10 deletions web/platform/auto-refresh.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => ({
Expand Down Expand Up @@ -114,21 +114,17 @@ export function AutoRefresh({
return (
<Block display="flex" alignItems="center" flexWrap gridGap="scale300" data-paused={paused || terminal}>
<StatefulTooltip content={action} placement="bottomRight" onMouseEnterDelay={150} onMouseLeaveDelay={0}>
<Button type="button" kind={KIND.tertiary} shape={SHAPE.circle} size={SIZE.mini}
<Button type="button" kind={KIND.secondary} shape={SHAPE.circle} size={SIZE.mini} minHitArea={MIN_HIT_AREA.tap}
isSelected={enabled && !terminal} disabled={terminal}
aria-label={action} aria-pressed={enabled && !terminal} aria-busy={refreshing}
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" },
...(!$isSelected && { backgroundColor: "transparent", color: $theme.colors.contentSecondary }),
outline: $isFocusVisible ? `2px solid ${$theme.colors.borderAccent}`
: $isSelected ? "none" : `1px solid ${$theme.colors.contentTertiary}`,
outlineOffset: "-1px",
}) } }}>
<RefreshIcon $spinning={refreshing} width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
Expand Down
2 changes: 2 additions & 0 deletions web/platform/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
"use client";

export * from "./navigation.js";
export * from "./app-brand.js";
export * from "./app-header.js";
export * from "./auto-refresh.js";
export * from "./timestamp.js";
export * from "./table.js";
Expand Down
13 changes: 13 additions & 0 deletions web/stovepipe/view/app.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,19 @@ const model: StovepipePageModel = {
},
},
};
it("keeps the shared brand and refresh control in the app header", () => {
render(<WebNavigationProvider value={{ refresh: async () => undefined }}>
<StovepipeApp model={model} />
</WebNavigationProvider>);
const header = screen.getByRole("banner");
expect(within(header).getByRole("link", { name: "Stovepipe queue" })).toHaveAttribute("href", model.queueHref);
const logo = within(header).getByRole("link", { name: "Stovepipe queue" }).querySelector("svg");
expect(logo?.parentElement).toHaveAttribute("aria-hidden", "true");
expect(within(header).queryByText("SP")).toBeNull();
expect(within(header).getByText("Stovepipe")).toBeVisible();
expect(within(header).getByRole("button", { name: "Enable auto-refresh" })).toBeVisible();
});

it("displays request summary data", () => {
render(<StovepipeApp model={model} />);
const status = screen.getByRole("region", { name: "Request status" });
Expand Down
15 changes: 6 additions & 9 deletions web/stovepipe/view/app.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
"use client";

import { Timestamp, TimeZoneProvider } from "@submitqueue/web-platform";
import { AppBrand, AppHeader, Timestamp, TimeZoneProvider } from "@submitqueue/web-platform";
import { styled } from "baseui";
import { Block } from "baseui/block";
import { Tag } from "baseui/tag";
import { HeadingLarge, HeadingMedium, HeadingSmall, LabelMedium, ParagraphSmall } from "baseui/typography";
import { HeadingLarge, HeadingMedium, HeadingSmall, ParagraphSmall } from "baseui/typography";
import { RefreshControls } from "./refresh.js";
import type { StovepipePageModel } from "../entity/index.js";
import { Link, Section } from "./components.js";
import { RequestDetails, History } from "./pages.js";
import { QueueRequests } from "./queue.js";
import { OpenIssues } from "./issues.js";
import { ProjectResults, RecordedResult } from "./projects.js";
import { StovepipeLogo } from "./stovepipe-logo.js";

const ViewLink = styled<typeof Link, { $active: boolean }>(Link, ({ $theme, $active }) => ({
display: "inline-block",
Expand Down Expand Up @@ -55,15 +55,12 @@ function Page({ model }: { model: StovepipePageModel }) {
<Block className={model.kind === "request" ? "sq-app sq-stovepipe-request" : "sq-app"} backgroundColor="backgroundPrimary" color="contentPrimary" font="ParagraphSmall"
minHeight="100vh" height={model.kind === "request" ? "100dvh" : "auto"} display="grid" gridTemplateColumns="minmax(0, 1fr)" gridTemplateRows={model.kind === "request" ? "auto minmax(min-content, 1fr) auto" : "auto 1fr auto"}
paddingLeft="scale600" paddingRight="scale600">
<Block as="header" display="flex" alignItems="center" justifyContent="space-between" flexWrap gridGap="scale600"
paddingTop="scale300" paddingBottom="scale300">
<Block as={Link} display="inline-flex" alignItems="center" gridGap="scale300" href={model.queueHref}>
<Tag closeable={false} kind="neutral" size="small">SP</Tag><LabelMedium as="span">Stovepipe</LabelMedium>
</Block>
<AppHeader>
<AppBrand href={model.queueHref} homeLabel="Stovepipe queue" mark={<StovepipeLogo />}>Stovepipe</AppBrand>
<Block display="flex" alignItems="center" flexWrap gridGap="scale600">
<RefreshControls {...(model.kind === "issues" && model.showLatestLink ? { href: model.latestHref } : {})} />
</Block>
</Block>
</AppHeader>
<Block as="main" paddingTop="scale400" paddingBottom={model.kind === "request" ? "scale200" : "scale600"}>
<Block as="nav" aria-label="Queues" display="flex" alignItems="center" flexWrap gridGap="scale500" marginBottom="scale400">
{model.queues.map((queue) => (
Expand Down
23 changes: 23 additions & 0 deletions web/stovepipe/view/stovepipe-logo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"use client";

import { styled } from "baseui";

const Logo = styled("svg", ({ $theme }) => ({
width: "28px",
height: "28px",
padding: "2px",
boxSizing: "border-box",
borderRadius: "50%",
border: `1px solid ${$theme.colors.contentPrimary}`,
backgroundColor: $theme.colors.contentPrimary,
color: $theme.colors.backgroundPrimary,
}));

export function StovepipeLogo() {
return <Logo viewBox="-4 -4 40 40" fill="none" focusable="false">
<path d="M23 1v5H12a5 5 0 0 0 0 10h8a5 5 0 0 1 0 10H9v5"
stroke="currentColor" strokeWidth="5" strokeLinejoin="round" />
<ellipse cx="23" cy="1" rx="3.5" ry="1.5" fill="currentColor" />
<ellipse cx="9" cy="31" rx="3.5" ry="1.5" fill="currentColor" />
</Logo>;
}
45 changes: 7 additions & 38 deletions web/submitqueue/view/shell.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
"use client";

import type { ReactNode } from "react";
import { TimeZoneProvider, useWebNavigation } from "@submitqueue/web-platform";
import { AppBrand, AppHeader, TimeZoneProvider } from "@submitqueue/web-platform";
import { styled, withStyle } from "baseui";
import { Block } from "baseui/block";
import { LabelXSmall } from "baseui/typography";
import { AccentLink } from "./web-app.js";

export interface WebShellProps {
children: ReactNode;
Expand All @@ -21,21 +20,14 @@ export interface WebShellProps {
}

/** Header, page, and footer share one centered column so their edges line up at every width. */
const Column = styled("div", {
const columnStyles = {
width: "min(1200px, calc(100% - 48px))",
marginLeft: "auto",
marginRight: "auto",
});
};

const Header = withStyle(Column, ({ $theme }) => ({
minHeight: "80px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
flexWrap: "wrap",
gap: $theme.sizing.scale600,
borderBottom: `1px solid ${$theme.colors.borderOpaque}`,
}));
const Column = styled("div", columnStyles);
const Header = withStyle(AppHeader, columnStyles);

const Footer = withStyle(Column, ({ $theme }) => ({
...$theme.typography.ParagraphXSmall,
Expand All @@ -45,36 +37,13 @@ const Footer = withStyle(Column, ({ $theme }) => ({
borderTop: `1px solid ${$theme.colors.borderOpaque}`,
}));

const BrandLink = withStyle(AccentLink, ({ $theme }) => ({
display: "inline-flex",
alignItems: "center",
gap: $theme.sizing.scale300,
color: $theme.colors.contentPrimary,
fontSize: "18px",
fontWeight: 600,
":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,
}));

export function WebShell({
children, homeHref = "/", homeLabel, brand, brandMark = "", environmentLabel, footer, controls,
}: WebShellProps) {
const { Link } = useWebNavigation();
return <TimeZoneProvider><Block className="sq-app" backgroundColor="backgroundPrimary" color="contentPrimary" font="ParagraphSmall"
minHeight="100vh" display="grid" gridTemplateColumns="minmax(0, 1fr)" gridTemplateRows="auto 1fr auto">
<Header $as="header">
<BrandLink $as={Link ?? "a"} href={homeHref} {...(homeLabel ? { "aria-label": homeLabel } : {})}>
{brandMark ? <BrandMark aria-hidden="true">{brandMark}</BrandMark> : null}<span>{brand}</span>
</BrandLink>
<Header>
<AppBrand href={homeHref} {...(homeLabel ? { homeLabel } : {})} mark={brandMark}>{brand}</AppBrand>
<Block display="flex" alignItems="center" flexWrap gridGap="scale600">
{environmentLabel ? <LabelXSmall as="span" color="contentSecondary">{environmentLabel}</LabelXSmall> : null}
{controls}
Expand Down
Loading