From c66c55abfd8be030dc7b03ec9e21c8cb309c79fd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 21 Jul 2026 00:57:24 -0700 Subject: [PATCH] fix(ui): keep sessions hub tabs stationary (#112039) --- ui/src/components/hub-tabs.ts | 90 ++++++++++++ ui/src/components/plugins-hub-tabs.ts | 130 ++++-------------- .../sessions-hub-header.browser.test.ts | 88 ++++++++++++ ui/src/components/sessions-hub-header.ts | 21 +++ ui/src/components/sessions-hub-tabs.test.ts | 82 +++++++++++ ui/src/components/sessions-hub-tabs.ts | 65 +++------ ui/src/pages/sessions/sessions-page.ts | 28 ++-- ui/src/pages/worktrees/worktrees-page.ts | 24 ++-- ui/src/styles/hub-tabs.css | 95 +++++++++++++ ui/src/styles/plugins.css | 54 -------- 10 files changed, 440 insertions(+), 237 deletions(-) create mode 100644 ui/src/components/hub-tabs.ts create mode 100644 ui/src/components/sessions-hub-header.browser.test.ts create mode 100644 ui/src/components/sessions-hub-header.ts create mode 100644 ui/src/components/sessions-hub-tabs.test.ts create mode 100644 ui/src/styles/hub-tabs.css diff --git a/ui/src/components/hub-tabs.ts b/ui/src/components/hub-tabs.ts new file mode 100644 index 000000000000..481ce6f02996 --- /dev/null +++ b/ui/src/components/hub-tabs.ts @@ -0,0 +1,90 @@ +import { html, nothing, type TemplateResult } from "lit"; +import { ref } from "lit/directives/ref.js"; +import "../styles/hub-tabs.css"; +import "./web-awesome-tabs.ts"; + +export type HubTabOption = { + value: T; + label: unknown; + badge?: unknown; +}; + +type HubTabsProps = { + id: string; + active: T; + tabs: ReadonlyArray>; + ariaLabel: string; + panelId: string; + className?: string; + onSelect: (tab: T) => void; +}; + +// Keyboard activation unmounts a route-owned strip, so the destination strip +// reclaims focus on first render. The timeout prevents an aborted navigation +// from stealing focus later. +const PENDING_FOCUS_WINDOW_MS = 2000; +let pendingFocus: { hubId: string; tab: string; at: number } | null = null; +let pointerActivation: { hubId: string; tab: string } | null = null; + +function selectHubTab(tab: T, props: HubTabsProps) { + const activatedByPointer = pointerActivation?.hubId === props.id && pointerActivation.tab === tab; + pointerActivation = null; + if (!activatedByPointer && tab !== props.active) { + pendingFocus = { hubId: props.id, tab, at: Date.now() }; + } + props.onSelect(tab); +} + +function reclaimFocus(hubId: string, tab: string, element: Element | undefined) { + if (!element || pendingFocus?.hubId !== hubId || pendingFocus.tab !== tab) { + return; + } + const pending = pendingFocus; + pendingFocus = null; + if (Date.now() - pending.at > PENDING_FOCUS_WINDOW_MS) { + return; + } + // The ref fires while the strip is still inside Lit's template fragment. + // A task lets both Lit and Web Awesome finish connecting before focus moves. + window.setTimeout(() => { + if (element.isConnected) { + (element as HTMLElement).focus(); + } + }, 0); +} + +export function renderHubTabs(props: HubTabsProps): TemplateResult { + const className = `hub-tabs ${props.id}-hub-tabs${props.className ? ` ${props.className}` : ""}`; + return html` + ) => selectHubTab(event.detail.name, props)} + > + ${props.tabs.map((tab) => { + const selected = props.active === tab.value; + return html` + { + pointerActivation = event.detail > 0 ? { hubId: props.id, tab: tab.value } : null; + }} + @keydown=${() => { + pointerActivation = null; + }} + ${selected ? ref((element) => reclaimFocus(props.id, tab.value, element)) : nothing} + > + ${tab.label}${tab.badge ?? nothing} + + `; + })} + + `; +} diff --git a/ui/src/components/plugins-hub-tabs.ts b/ui/src/components/plugins-hub-tabs.ts index 1c1ab451f7f0..ee1079c3a2fb 100644 --- a/ui/src/components/plugins-hub-tabs.ts +++ b/ui/src/components/plugins-hub-tabs.ts @@ -1,22 +1,9 @@ -// Shared tab strip for the Plugins hub: the plugins, skills, and skill-workshop -// routes render it under one "Plugins" header so the three surfaces read as tabs -// of a single page even though each tab keeps its own route and loader. -import { html, nothing } from "lit"; -import { ref } from "lit/directives/ref.js"; +import { html } from "lit"; import { t } from "../i18n/index.ts"; -import "./web-awesome-tabs.ts"; +import { renderHubTabs, type HubTabOption } from "./hub-tabs.ts"; export type PluginsHubTab = "installed" | "discover" | "skills" | "workshop"; -const HUB_TABS: readonly PluginsHubTab[] = ["installed", "discover", "skills", "workshop"]; - -// Keyboard activation of a cross-route tab unmounts the strip that had focus, -// so the destination strip reclaims focus for its active tab on first render. -// Time-bounded so an aborted navigation cannot steal focus much later. -const PENDING_FOCUS_WINDOW_MS = 2000; -let pendingFocus: { tab: PluginsHubTab; at: number } | null = null; -let pointerActivation = false; - type PluginsHubTabsProps = { active: PluginsHubTab; /** Installed-plugin count badge; omit on pages without catalog data. */ @@ -24,96 +11,31 @@ type PluginsHubTabsProps = { onSelect: (tab: PluginsHubTab) => void; }; -function hubTabLabel(tab: PluginsHubTab): string { - switch (tab) { - case "installed": - return t("pluginsPage.installedTab"); - case "discover": - return t("pluginsPage.discoverTab"); - case "skills": - return t("tabs.skills"); - case "workshop": - return t("pluginsPage.workshopTab"); - default: - return tab satisfies never; - } +function hubTabs(installedCount: number | null): ReadonlyArray> { + return [ + { + value: "installed", + label: t("pluginsPage.installedTab"), + badge: + installedCount === null + ? undefined + : html`${installedCount}`, + }, + { value: "discover", label: t("pluginsPage.discoverTab") }, + { value: "skills", label: t("tabs.skills") }, + { value: "workshop", label: t("pluginsPage.workshopTab") }, + ]; } -function selectHubTab(tab: PluginsHubTab, props: PluginsHubTabsProps) { - // Keyboard activation unmounts the focused strip; only then should the - // destination strip pull focus after the route swap. Skip - // same-tab activation: it does not navigate, and a lingering entry would - // let a later re-render steal focus from whatever the user moved on to. - if (!pointerActivation && tab !== props.active) { - pendingFocus = { tab, at: Date.now() }; - } - pointerActivation = false; - props.onSelect(tab); -} - -function reclaimFocus(tab: PluginsHubTab, element: Element | undefined) { - if (!element || pendingFocus?.tab !== tab) { - return; - } - const pending = pendingFocus; - pendingFocus = null; - if (Date.now() - pending.at > PENDING_FOCUS_WINDOW_MS) { - return; - } - // The ref fires while the strip is still inside lit's template fragment. - // A task lets both Lit and Web Awesome finish connecting before focus moves. - window.setTimeout(() => { - if (element.isConnected) { - (element as HTMLElement).focus(); - } - }, 0); -} - -/** - * Every hub page marks its main content container with - * id="plugins-hub-panel" so aria-controls stays valid on each route. - * Styled as page-level navigation (.hub-tabs in ui/src/styles/plugins.css), - * deliberately distinct from segmented filter pills, keeping tablist semantics. - */ +/** Every route marks its main content with id="plugins-hub-panel". */ export function renderPluginsHubTabs(props: PluginsHubTabsProps) { - return html` - ) => - selectHubTab(event.detail.name, props)} - > - ${HUB_TABS.map((tab) => { - const selected = props.active === tab; - const count = tab === "installed" ? (props.installedCount ?? null) : null; - return html` - { - // Trusted pointer clicks carry a click count. Keyboard and AT - // synthesized clicks use detail=0 and need focus recovery. - pointerActivation = event.detail > 0; - }} - @keydown=${() => { - // Any keyboard interaction supersedes a prior pointer click. - // This also clears clicks on the already-active tab, which do - // not emit wa-tab-show and would otherwise leave stale state. - pointerActivation = false; - }} - ${selected ? ref((element) => reclaimFocus(tab, element)) : nothing} - > - ${hubTabLabel(tab)} - ${count === null ? nothing : html`${count}`} - - `; - })} - - `; + return renderHubTabs({ + id: "plugins", + active: props.active, + tabs: hubTabs(props.installedCount ?? null), + ariaLabel: t("pluginsPage.hubTablistLabel"), + panelId: "plugins-hub-panel", + className: "plugins-tabs", + onSelect: props.onSelect, + }); } diff --git a/ui/src/components/sessions-hub-header.browser.test.ts b/ui/src/components/sessions-hub-header.browser.test.ts new file mode 100644 index 000000000000..6fbe31584d9a --- /dev/null +++ b/ui/src/components/sessions-hub-header.browser.test.ts @@ -0,0 +1,88 @@ +import { html, render } from "lit"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { i18n } from "../i18n/index.ts"; +import "../styles.css"; +import { renderSessionsHubHeader } from "./sessions-hub-header.ts"; + +const hasBrowserLayout = !navigator.userAgent.toLowerCase().includes("jsdom"); + +async function useViewport(width: number, height = 800) { + const { page } = await import("vitest/browser"); + await page.viewport(width, height); +} + +async function mount(active: "sessions" | "worktrees", withActions: boolean) { + const container = document.createElement("div"); + container.style.width = "calc(100vw - 32px)"; + container.style.maxWidth = "1120px"; + document.body.append(container); + render( + renderSessionsHubHeader({ + active, + title: "Threads", + actions: withActions ? html`
Agent selector
` : undefined, + onSelect: () => undefined, + }), + container, + ); + const group = container.querySelector }>( + "wa-tab-group", + ); + await group?.updateComplete; + return container; +} + +function overlaps(left: DOMRect, right: DOMRect): boolean { + return !( + left.right <= right.left || + left.left >= right.right || + left.bottom <= right.top || + left.top >= right.bottom + ); +} + +describe.skipIf(!hasBrowserLayout)("Sessions hub header browser layout", () => { + beforeEach(async () => { + await i18n.setLocale("en"); + }); + + afterEach(() => { + document.body.replaceChildren(); + }); + + it.each([1280, 820])( + "keeps the tab strip fixed without overlap at a %dpx viewport", + async (width) => { + await useViewport(width); + const sessions = await mount("sessions", true); + const sessionsTitle = sessions.querySelector(".sessions-hub-header__title"); + const sessionsTabs = sessions.querySelector(".sessions-hub-tabs"); + const sessionsActions = sessions.querySelector(".sessions-hub-header__actions"); + const sessionsLeft = sessionsTabs?.getBoundingClientRect().left; + expect(sessionsLeft).toBeTypeOf("number"); + expect(sessionsTabs?.getBoundingClientRect().width).toBeGreaterThan(0); + expect(sessionsActions?.childElementCount).toBe(1); + expect( + overlaps(sessionsTitle!.getBoundingClientRect(), sessionsTabs!.getBoundingClientRect()), + ).toBe(false); + expect( + overlaps(sessionsActions!.getBoundingClientRect(), sessionsTabs!.getBoundingClientRect()), + ).toBe(false); + sessions.remove(); + + const worktrees = await mount("worktrees", false); + const worktreesTabs = worktrees.querySelector(".sessions-hub-tabs"); + const worktreesLeft = worktreesTabs?.getBoundingClientRect().left; + expect(worktreesLeft).toBeTypeOf("number"); + expect(worktrees.querySelector(".sessions-hub-header__actions")?.childElementCount).toBe(0); + expect(Math.abs((sessionsLeft ?? 0) - (worktreesLeft ?? 0))).toBeLessThanOrEqual(1); + }, + ); + + it("keeps the page header hidden on mobile", async () => { + await useViewport(414, 800); + const sessions = await mount("sessions", true); + const header = sessions.querySelector(".sessions-hub-header"); + expect(getComputedStyle(header!).display).toBe("none"); + }); +}); diff --git a/ui/src/components/sessions-hub-header.ts b/ui/src/components/sessions-hub-header.ts new file mode 100644 index 000000000000..ab325199f08b --- /dev/null +++ b/ui/src/components/sessions-hub-header.ts @@ -0,0 +1,21 @@ +import { html, nothing, type TemplateResult } from "lit"; +import { renderSessionsHubTabs, type SessionsHubTab } from "./sessions-hub-tabs.ts"; + +type SessionsHubHeaderProps = { + active: SessionsHubTab; + title: unknown; + actions?: unknown; + onSelect: (tab: SessionsHubTab) => void; +}; + +export function renderSessionsHubHeader(props: SessionsHubHeaderProps): TemplateResult { + return html` +
+
+
${props.title}
+
+ ${renderSessionsHubTabs({ active: props.active, onSelect: props.onSelect })} +
${props.actions ?? nothing}
+
+ `; +} diff --git a/ui/src/components/sessions-hub-tabs.test.ts b/ui/src/components/sessions-hub-tabs.test.ts new file mode 100644 index 000000000000..eeaba18f4c2f --- /dev/null +++ b/ui/src/components/sessions-hub-tabs.test.ts @@ -0,0 +1,82 @@ +/* @vitest-environment jsdom */ + +import { render } from "lit"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { i18n } from "../i18n/index.ts"; +import { renderSessionsHubTabs } from "./sessions-hub-tabs.ts"; + +type SessionsHubTabsProps = Parameters[0]; + +async function mount(props: SessionsHubTabsProps): Promise { + const container = document.createElement("div"); + document.body.append(container); + render(renderSessionsHubTabs(props), container); + const group = container.querySelector }>( + "wa-tab-group", + ); + await group?.updateComplete; + return container; +} + +describe("renderSessionsHubTabs", () => { + beforeEach(async () => { + await i18n.setLocale("en"); + }); + + afterEach(() => { + document.body.innerHTML = ""; + }); + + it("renders the route hub with manual activation and a shared panel target", async () => { + const container = await mount({ active: "worktrees", onSelect: () => undefined }); + const group = container.querySelector("wa-tab-group"); + const tabs = [...container.querySelectorAll("wa-tab")]; + + expect(group?.getAttribute("activation")).toBe("manual"); + expect(tabs.map((tab) => tab.id)).toEqual(["sessions-tab-sessions", "sessions-tab-worktrees"]); + expect(tabs.map((tab) => tab.getAttribute("aria-controls"))).toEqual([ + "sessions-hub-panel", + "sessions-hub-panel", + ]); + expect(tabs.map((tab) => tab.getAttribute("aria-selected"))).toEqual(["false", "true"]); + }); + + it("delegates cross-route selection", async () => { + const onSelect = vi.fn(); + const container = await mount({ active: "sessions", onSelect }); + + container.querySelector("wa-tab-group")?.dispatchEvent( + new CustomEvent("wa-tab-show", { + bubbles: true, + composed: true, + detail: { name: "worktrees" }, + }), + ); + + expect(onSelect).toHaveBeenLastCalledWith("worktrees"); + }); + + it("hands focus to the destination strip after keyboard navigation", async () => { + const source = await mount({ active: "sessions", onSelect: () => undefined }); + source + .querySelector("#sessions-tab-worktrees") + ?.dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", bubbles: true, composed: true }), + ); + source.querySelector("wa-tab-group")?.dispatchEvent( + new CustomEvent("wa-tab-show", { + bubbles: true, + composed: true, + detail: { name: "worktrees" }, + }), + ); + source.remove(); + + const destination = await mount({ active: "worktrees", onSelect: () => undefined }); + await vi.waitFor(() => { + expect(document.activeElement).toBe( + destination.querySelector("#sessions-tab-worktrees"), + ); + }); + }); +}); diff --git a/ui/src/components/sessions-hub-tabs.ts b/ui/src/components/sessions-hub-tabs.ts index 40d44f2e7491..44e3de9ba946 100644 --- a/ui/src/components/sessions-hub-tabs.ts +++ b/ui/src/components/sessions-hub-tabs.ts @@ -1,61 +1,28 @@ -// Shared tab strip for the Sessions hub: the sessions and worktrees routes -// render it under one header so the two surfaces read as tabs of a single -// page even though each tab keeps its own route and loader (same pattern as -// plugins-hub-tabs.ts). -import { html } from "lit"; import { t } from "../i18n/index.ts"; -import "./web-awesome-tabs.ts"; +import { renderHubTabs, type HubTabOption } from "./hub-tabs.ts"; -type SessionsHubTab = "sessions" | "worktrees"; - -const HUB_TABS: readonly SessionsHubTab[] = ["sessions", "worktrees"]; +export type SessionsHubTab = "sessions" | "worktrees"; type SessionsHubTabsProps = { active: SessionsHubTab; onSelect: (tab: SessionsHubTab) => void; }; -function hubTabLabel(tab: SessionsHubTab): string { - switch (tab) { - case "sessions": - return t("tabs.sessions"); - case "worktrees": - return t("tabs.worktrees"); - default: - return tab satisfies never; - } +function hubTabs(): ReadonlyArray> { + return [ + { value: "sessions", label: t("tabs.sessions") }, + { value: "worktrees", label: t("tabs.worktrees") }, + ]; } -/** - * Every hub page marks its main content container with - * id="sessions-hub-panel" so aria-controls stays valid on each route. - * Styled as page-level navigation (.hub-tabs in ui/src/styles/plugins.css). - */ +/** Every route marks its main content with id="sessions-hub-panel". */ export function renderSessionsHubTabs(props: SessionsHubTabsProps) { - return html` - ) => - props.onSelect(event.detail.name)} - > - ${HUB_TABS.map((tab) => { - const selected = props.active === tab; - return html` - - ${hubTabLabel(tab)} - - `; - })} - - `; + return renderHubTabs({ + id: "sessions", + active: props.active, + tabs: hubTabs(), + ariaLabel: t("sessionsPage.hubTablistLabel"), + panelId: "sessions-hub-panel", + onSelect: props.onSelect, + }); } diff --git a/ui/src/pages/sessions/sessions-page.ts b/ui/src/pages/sessions/sessions-page.ts index 1bc44a2ed113..df4f1cdcca55 100644 --- a/ui/src/pages/sessions/sessions-page.ts +++ b/ui/src/pages/sessions/sessions-page.ts @@ -15,7 +15,7 @@ import { fetchSessionMenuWork } from "../../components/session-menu-work.ts"; import type { SessionMenuAction, SessionMenuWork } from "../../components/session-menu.ts"; import "../../components/session-menu.ts"; import { isStoppableCloudWorkerPlacement } from "../../components/session-row-badges.ts"; -import { renderSessionsHubTabs } from "../../components/sessions-hub-tabs.ts"; +import { renderSessionsHubHeader } from "../../components/sessions-hub-header.ts"; import { renderSettingsWorkspace } from "../../components/settings-workspace.ts"; import { t } from "../../i18n/index.ts"; import { openEditor } from "../../lib/editor-links.ts"; @@ -1267,23 +1267,19 @@ class SessionsPage extends OpenClawLightDomElement { return html``; } return html` -
-
-
${titleForRoute("sessions")}
-
- ${renderSessionsHubTabs({ - active: "sessions", - onSelect: (tab) => { - if (tab !== "sessions") { - context.navigate(tab); - } - }, - })} - ${renderAgentScopeControl({ + ${renderSessionsHubHeader({ + active: "sessions", + title: titleForRoute("sessions"), + actions: renderAgentScopeControl({ agents: context.agents.state.agentsList?.agents ?? [], selection: context.agentSelection, - })} -
+ }), + onSelect: (tab) => { + if (tab !== "sessions") { + context.navigate(tab); + } + }, + })} ${renderSettingsWorkspace( renderSessions({ loading: this.loading, diff --git a/ui/src/pages/worktrees/worktrees-page.ts b/ui/src/pages/worktrees/worktrees-page.ts index fb7a69435f21..078cc3eae1fc 100644 --- a/ui/src/pages/worktrees/worktrees-page.ts +++ b/ui/src/pages/worktrees/worktrees-page.ts @@ -6,7 +6,7 @@ import type { GatewayBrowserClient } from "../../api/gateway.ts"; import { titleForRoute } from "../../app-navigation.ts"; import { pathForRoute } from "../../app-route-paths.ts"; import { applicationContext, type ApplicationContext } from "../../app/context.ts"; -import { renderSessionsHubTabs } from "../../components/sessions-hub-tabs.ts"; +import { renderSessionsHubHeader } from "../../components/sessions-hub-header.ts"; import { renderSettingsEmpty, renderSettingsPage, @@ -472,19 +472,15 @@ class WorktreesPage extends OpenClawLightDomElement { { wide: true }, ); return html` -
-
-
${titleForRoute("sessions")}
-
- ${renderSessionsHubTabs({ - active: "worktrees", - onSelect: (tab) => { - if (tab !== "worktrees") { - this.context?.navigate(tab); - } - }, - })} -
+ ${renderSessionsHubHeader({ + active: "worktrees", + title: titleForRoute("sessions"), + onSelect: (tab) => { + if (tab !== "worktrees") { + this.context?.navigate(tab); + } + }, + })} ${renderSettingsWorkspace(body, { id: "sessions-hub-panel" })} `; } diff --git a/ui/src/styles/hub-tabs.css b/ui/src/styles/hub-tabs.css new file mode 100644 index 000000000000..da42aa204533 --- /dev/null +++ b/ui/src/styles/hub-tabs.css @@ -0,0 +1,95 @@ +/* Page hubs use quiet text tabs on a hairline track so route navigation stays + distinct from segmented filters inside each page. */ +wa-tab-group.hub-tabs { + --track-color: color-mix(in srgb, var(--border) 60%, transparent); + --indicator-color: var(--accent); +} + +wa-tab-group.hub-tabs::part(body) { + display: none; +} + +wa-tab-group.hub-tabs::part(tabs) { + gap: var(--space-2); +} + +wa-tab.hub-tab { + font-size: var(--control-ui-text-sm); + font-weight: 550; + color: var(--muted); + transition: color var(--duration-normal) var(--ease-out); +} + +/* Neutralize wa-tab's stock 1em padding; keep a slim nav-tab hit area. */ +wa-tab.hub-tab::part(base) { + padding: var(--space-1) var(--space-2) var(--space-2); + gap: 6px; + font: inherit; + color: inherit; +} + +wa-tab.hub-tab:hover { + color: var(--text); +} + +wa-tab.hub-tab[active] { + color: var(--text-strong); +} + +wa-tab.hub-tab:focus-visible { + outline: none; + border-radius: var(--radius-sm); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 22%, transparent); +} + +/* Web Awesome also draws its stock focus ring on the inner base part; + without this reset both rings render at once. */ +wa-tab.hub-tab:focus-visible::part(base) { + outline: none; +} + +/* Equal outer columns keep the Sessions hub tabs centered when the Threads + route adds its trailing agent selector and Worktrees leaves that slot empty. */ +.content-header.sessions-hub-header { + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); +} + +.sessions-hub-header__title, +.sessions-hub-header__actions { + min-width: 0; +} + +.sessions-hub-header__actions { + justify-self: end; +} + +@media (min-width: 769px) and (max-width: 1024px) { + .content-header.sessions-hub-header { + grid-template-columns: minmax(0, 1fr) auto; + grid-template-areas: + "title actions" + "tabs tabs"; + row-gap: var(--space-2); + max-height: none; + } + + .sessions-hub-header__title { + grid-area: title; + } + + .sessions-hub-tabs { + grid-area: tabs; + justify-self: center; + } + + .sessions-hub-header__actions { + grid-area: actions; + } +} + +@media (max-width: 768px), (max-width: 932px) and (max-height: 500px) and (orientation: landscape) { + .content-header.sessions-hub-header { + display: none; + } +} diff --git a/ui/src/styles/plugins.css b/ui/src/styles/plugins.css index fd9598666ce9..060276308acb 100644 --- a/ui/src/styles/plugins.css +++ b/ui/src/styles/plugins.css @@ -15,60 +15,6 @@ padding: var(--space-3) var(--space-4) 0; } -/* ---------- hub tabs ---------- */ - -/* Hub tab strips (plugins + sessions hubs) are page-level navigation, not - filters: quiet text tabs on a hairline track with an accent indicator, so - they read differently from the segmented filter pills inside the pages. - Track/indicator come from wa-tab-group's shadow machinery. */ -wa-tab-group.hub-tabs { - --track-color: color-mix(in srgb, var(--border) 60%, transparent); - --indicator-color: var(--accent); -} - -wa-tab-group.hub-tabs::part(body) { - display: none; -} - -wa-tab-group.hub-tabs::part(tabs) { - gap: var(--space-2); -} - -wa-tab.hub-tab { - font-size: var(--control-ui-text-sm); - font-weight: 550; - color: var(--muted); - transition: color var(--duration-normal) var(--ease-out); -} - -/* Neutralize wa-tab's stock 1em padding; keep a slim nav-tab hit area. */ -wa-tab.hub-tab::part(base) { - padding: var(--space-1) var(--space-2) var(--space-2); - gap: 6px; - font: inherit; - color: inherit; -} - -wa-tab.hub-tab:hover { - color: var(--text); -} - -wa-tab.hub-tab[active] { - color: var(--text-strong); -} - -wa-tab.hub-tab:focus-visible { - outline: none; - border-radius: var(--radius-sm); - box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 22%, transparent); -} - -/* Web Awesome also draws its stock focus ring on the inner base part; - without this reset both rings render at once. */ -wa-tab.hub-tab:focus-visible::part(base) { - outline: none; -} - /* ---------- toolbar ---------- */ .plugins-toolbar {