From 8b97eb16a6f17375fbad561411454097605b2e56 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 13 Nov 2026 19:01:29 +0100 Subject: [PATCH 1/2] feat(web): name the open page in the tab title, and tint text selection with the primary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every tab read "Maple", so a row of open tabs could not be told apart. The title now leads with the page's own name and its parent, taken from the breadcrumb trail each page already gives the shell: "checkout-api · Services · Maple". A crumb still loading ("Loading…") is left out, and a route with no page up yet stays "Maple". Selected text took the system blue against a warm palette. It takes the primary's strong wash now and keeps its own colour, set once in tokens.css. --- apps/web/src/page/chrome.test.ts | 30 ++++++++++++++++++++++++++++++ apps/web/src/page/chrome.ts | 18 ++++++++++++++++++ apps/web/src/view.scene.test.ts | 14 ++++++++++++-- apps/web/src/view.ts | 22 ++++++++++++++++++++-- docs/design-rules.md | 2 ++ packages/ui/src/styles/tokens.css | 4 ++++ 6 files changed, 86 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/page/chrome.test.ts 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..d310e56182 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, set once in tokens.css, and keeps its own + colour. 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..b9887f1cf1 100644 --- a/packages/ui/src/styles/tokens.css +++ b/packages/ui/src/styles/tokens.css @@ -812,6 +812,10 @@ * { @apply border-border outline-ring/50; } + /* Selected text takes the primary's strong wash instead of the system blue; the text keeps its colour. */ + ::selection { + background-color: var(--color-primary-a4); + } body { @apply font-sans bg-background text-foreground; } From 2620f48a6b721cf1f35b41fc7ff6f44326e7d784 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 13 Nov 2026 19:18:49 +0100 Subject: [PATCH 2/2] fix(ui): selected text reads in foreground on the primary wash A muted or toned run kept its own colour when selected and fell under 4.5:1 on the wash in the dark theme. Selected text now takes foreground, which is 7:1 on the wash in dark and 12.7:1 in light. --- docs/design-rules.md | 4 ++-- packages/ui/src/styles/tokens.css | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/design-rules.md b/docs/design-rules.md index d310e56182..fbabdc87d0 100644 --- a/docs/design-rules.md +++ b/docs/design-rules.md @@ -97,8 +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, set once in tokens.css, and keeps its own - colour. No `selection:` utility at a call site. +- 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 b9887f1cf1..e470314dc7 100644 --- a/packages/ui/src/styles/tokens.css +++ b/packages/ui/src/styles/tokens.css @@ -812,9 +812,11 @@ * { @apply border-border outline-ring/50; } - /* Selected text takes the primary's strong wash instead of the system blue; the text keeps its colour. */ + /* 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;