diff --git a/apps/web/src/page/chrome.test.ts b/apps/web/src/page/chrome.test.ts new file mode 100644 index 0000000000..20ab720db8 --- /dev/null +++ b/apps/web/src/page/chrome.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from "vitest" +import { documentTitleOf } from "./chrome" + +describe("documentTitleOf", () => { + test("leads with the page's own name, then its parent", () => { + expect(documentTitleOf([{ label: "Services", href: "/services" }, { label: "checkout-api" }])).toBe( + "checkout-api · Services · Maple", + ) + }) + + test("a name that is still loading leaves the tab on its parent", () => { + expect(documentTitleOf([{ label: "Traces", href: "/traces" }, { label: "Loading…" }])).toBe( + "Traces · Maple", + ) + }) + + test("a deep trail keeps its last two names", () => { + expect( + documentTitleOf([ + { label: "Alerts", href: "/alerts" }, + { label: "Rules", href: "/alerts/rules" }, + { label: "High error rate" }, + ]), + ).toBe("High error rate · Rules · Maple") + }) + + test("no trail is the app's name alone", () => { + expect(documentTitleOf([])).toBe("Maple") + }) +}) diff --git a/apps/web/src/page/chrome.ts b/apps/web/src/page/chrome.ts index 5dcbb1926f..cacc98d7ab 100644 --- a/apps/web/src/page/chrome.ts +++ b/apps/web/src/page/chrome.ts @@ -1,3 +1,4 @@ +import { Array as Arr, String as Str } from "effect" import type { Html, HtmlBuilder } from "foldkit/html" import { AppRoute, isPublicRoute } from "../route" import type { BreadcrumbEntry } from "../shell" @@ -22,6 +23,23 @@ export const breadcrumbsOf = (route: AppRoute): ReadonlyArray = () => [{ label: route._tag }], ) +const APP_NAME = "Maple" + +/** A crumb standing in for a name that has not loaded ("Loading…") names nothing yet. */ +const isPendingLabel = (label: string) => Str.endsWith("…")(label) + +/** + * The tab title: the page's own name first, then its parent, so a row of narrow tabs still tells + * them apart ("checkout-api · Services · Maple"). + */ +export const documentTitleOf = (breadcrumbs: ReadonlyArray): string => { + const names = Arr.filter( + Arr.map(breadcrumbs, (crumb) => crumb.label), + (label) => label !== "" && !isPendingLabel(label), + ) + return Arr.join([...Arr.reverse(Arr.takeRight(names, 2)), APP_NAME], " · ") +} + /** * The page content while the page has no slot or context yet (no org open, a plan-gate * redirect in flight): a page skeleton, so nothing but loading ever paints there. diff --git a/apps/web/src/view.scene.test.ts b/apps/web/src/view.scene.test.ts index 08f029c2be..14c2d43607 100644 --- a/apps/web/src/view.scene.test.ts +++ b/apps/web/src/view.scene.test.ts @@ -3,7 +3,7 @@ import { OrgId } from "@maple/domain" import { Option, Schema } from "effect" import type { HtmlBuilder } from "foldkit/html" import { click, Command, expect as expectView, given, Mount, role, scene, text } from "foldkit/scene" -import { describe, test, vi } from "vitest" +import { describe, expect, test, vi } from "vitest" // The shell's favorites listener targets `window` at import; the scene never touches it. vi.hoisted(() => Object.assign(globalThis, { window: new EventTarget() })) @@ -21,7 +21,7 @@ import * as Dialog from "@maple/views/ui/dialog" import { MountSuggestions, MountWhereEditor } from "./page/traces/list/command" import { Message as TracesMessage } from "./page/traces/list/message" import { settle, update } from "./update" -import { view } from "./view" +import { titleOf, view } from "./view" import * as TooltipHost from "@maple/views/ui/tooltip-host" const orgId = Schema.decodeSync(OrgId)("org_acme") @@ -89,3 +89,13 @@ describe("root view", () => { ) }) }) + +describe("tab title", () => { + test("an open page names the tab, and a route with no page up yet is just Maple", () => { + expect([ + titleOf(tracesPage()), + titleOf(signedIn()), + titleOf({ ...signedIn(), route: AppRoute.NotFound({ path: "/nope" }) }), + ]).toEqual(["Traces · Maple", "Maple", "Page not found · Maple"]) + }) +}) diff --git a/apps/web/src/view.ts b/apps/web/src/view.ts index 82af72947d..76d5ef7790 100644 --- a/apps/web/src/view.ts +++ b/apps/web/src/view.ts @@ -7,7 +7,13 @@ import { Message } from "./message" import { type Model, Session } from "./model" import { onboardingInputsOf, pageContextOf, planGateOf, shellClerkOf } from "./update" import * as OnboardingView from "./page/onboarding/view" -import { breadcrumbsOf, notFoundContent, placeholderContent, rendersShell } from "./page/chrome" +import { + breadcrumbsOf, + documentTitleOf, + notFoundContent, + placeholderContent, + rendersShell, +} from "./page/chrome" import type { PageContext, ShellInfraSurfaces } from "./page/contract" import { isBarePageRoute, @@ -282,8 +288,20 @@ const body = (model: Model, h: HtmlBuilder): Html => ], ) +/** The tab names the open page; a route with no page up yet (or none at all) is just "Maple". */ +export const titleOf = (model: Model): string => + documentTitleOf( + model.page !== null + ? pageBreadcrumbs(model.page) + : AppRoute.matchOrElse>( + model.route, + { NotFound: () => breadcrumbsOf(model.route) }, + () => [], + ), + ) + // NOTE: Foldkit's first render replaces the container, so the view root is `div#app` itself. export const view = (model: Model, h: HtmlBuilder): Document => ({ - title: "Maple", + title: titleOf(model), body: body(model, h), }) diff --git a/docs/design-rules.md b/docs/design-rules.md index 5d9c3e02c3..fbabdc87d0 100644 --- a/docs/design-rules.md +++ b/docs/design-rules.md @@ -97,6 +97,8 @@ bordered box takes its parent's radius minus 1px: `before:rounded-lg-inset` insi step of the hue in light, where the fill itself is near 3:1 as type, and the fill in dark. Fills, borders and strokes keep the tone. Write the utility; never a fill token as `color` in a style object. +- Selected text takes `primary-a4` as its highlight and reads in `foreground`, set once in + tokens.css. No `selection:` utility at a call site. - Washes are translucent and read the same on any surface: `surface-faint`, `surface-sunken`, `surface-strong`. - A see-through surface is a token, never `bg-background/NN` or `bg-card/NN` diff --git a/packages/ui/src/styles/tokens.css b/packages/ui/src/styles/tokens.css index 77c8ccc9a4..e470314dc7 100644 --- a/packages/ui/src/styles/tokens.css +++ b/packages/ui/src/styles/tokens.css @@ -812,6 +812,12 @@ * { @apply border-border outline-ring/50; } + /* Selected text takes the primary's strong wash instead of the system blue, and reads in + `foreground`: a muted or toned run would fall under 4.5:1 on the wash. */ + ::selection { + background-color: var(--color-primary-a4); + color: var(--color-foreground); + } body { @apply font-sans bg-background text-foreground; }