Skip to content
Closed
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
78 changes: 59 additions & 19 deletions crates/gateway/config-ui/ui/src/components/tab-bar.test.mjs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
// Pins the tab bar: the right-side actions cluster shows the baked crate
// version (or the dev fallback) as a muted `vX.Y.Z` span, and the seven
// tabs are Cursor's settings nav - a sticky left column of grouped links
// that marks the current route, keeps a label for every icon, and
// collapses to a 40px icon-only column under 710px.
// tabs are the sidebar - a full-height left rail of grouped links that
// marks the current route, keeps a label for every icon, collapses to its
// icons from its toggle, and always shows only the icons under 710px.
import assert from "node:assert/strict";
import test from "node:test";

Expand Down Expand Up @@ -36,7 +36,8 @@ test("the primary nav is a column beside the content, with a divider between eac
root.querySelector(".desk-layout > .desk-content > main#main"),
"the content column holds main",
);
const sequence = [...nav.children].map((child) =>
assert.ok(nav.firstElementChild.matches("button.tab-nav-toggle"), "the toggle leads the sidebar");
const sequence = [...nav.querySelectorAll(":scope > .tab, :scope > [role='separator']")].map((child) =>
child.getAttribute("role") === "separator" ? "|" : child.textContent,
);
assert.deepEqual(
Expand Down Expand Up @@ -64,47 +65,86 @@ test("the nav marks exactly the current route and follows the hash", async () =>
}
});

test("every nav cell has a 12px icon and a label that survives the icon-only column", async () => {
test("every nav cell has a 14px icon and a label that survives the icon-only rail", async () => {
const { root } = await openDesk();
const tabs = [...root.querySelectorAll(".tab-nav .tab")];
assert.equal(tabs.length, 7);
for (const tab of tabs) {
const icon = tab.querySelector("svg");
assert.ok(icon, `${tab.textContent} has an icon`);
assert.equal(icon.getAttribute("width"), "12");
assert.equal(icon.getAttribute("height"), "12");
assert.equal(icon.getAttribute("width"), "14");
assert.equal(icon.getAttribute("height"), "14");
assert.equal(icon.getAttribute("aria-hidden"), "true");
const label = tab.querySelector(".tab-label");
assert.ok(label && label.textContent !== "", "the label stays in the DOM for assistive tech");
assert.equal(tab.getAttribute("title"), label.textContent, "the icon-only column keeps a tooltip");
}
});

test("the nav column is sticky and clamp-wide, 48px from the content, and icon-only under 710px", async () => {
test("the toggle collapses the sidebar to its icons and expands it again", async () => {
const { root } = await openDesk();
const nav = root.querySelector("nav.tab-nav");
const toggle = nav.querySelector(".tab-nav-toggle");
assert.equal(toggle.getAttribute("aria-controls"), nav.id, "the toggle names the sidebar it controls");
assert.equal(toggle.getAttribute("aria-expanded"), "true", "the sidebar starts expanded");
assert.equal(toggle.getAttribute("aria-label"), "Collapse sidebar");
assert.equal(nav.classList.contains("is-collapsed"), false);

toggle.click();
assert.equal(nav.classList.contains("is-collapsed"), true, "a press collapses the sidebar");
assert.equal(toggle.getAttribute("aria-expanded"), "false");
assert.equal(toggle.getAttribute("aria-label"), "Expand sidebar");
assert.equal(nav.querySelectorAll(".tab .tab-label").length, 7, "the labels stay as link names");

toggle.click();
assert.equal(nav.classList.contains("is-collapsed"), false, "a second press expands it");
assert.equal(toggle.getAttribute("aria-expanded"), "true");
});

test("the sidebar is a full-height chrome rail under the header strip", async () => {
const header = await bundledDeclarations(".tab-bar");
assert.equal(header.get("position"), "sticky");
assert.equal(header.get("height"), "var(--desk-header-height)");

const layout = await bundledDeclarations(".desk-layout");
assert.equal(layout.get("display"), "flex");
assert.equal(squash(layout.get("gap")), "48px", "a 48px gap to the content");
assert.equal(squash(layout.get("min-height")), "calc(100svh-var(--desk-header-height))");

const nav = await bundledDeclarations(".tab-nav");
assert.equal(nav.get("position"), "sticky");
assert.equal(squash(nav.get("width")), "clamp(100px,25%,200px)");
assert.equal(nav.get("inset-block-start"), "var(--desk-header-height)", "pinned under the header");
assert.equal(squash(nav.get("height")), "calc(100svh-var(--desk-header-height))", "full height");
assert.equal(nav.get("width"), "var(--sidebar-width)");
assert.equal(nav.get("background"), "var(--chrome)");
assert.equal(squash(nav.get("border-right")), "1pxsolidvar(--line)");

const cell = await bundledDeclarations(".tab");
assert.equal(squash(cell.get("padding")), "4px6px", "cells are padded 4px 6px");
assert.equal(cell.get("border-radius"), "var(--radius)", "a 6px radius");
assert.equal(cell.get("font-size"), "var(--font-size-sm)", "12px text");
assert.equal(cell.get("line-height"), "var(--line-height-sm)", "16px lines");
assert.equal(cell.get("color"), "var(--cursor-text-secondary)");
assert.equal(cell.get("border-radius"), "var(--radius)", "a 9px radius");
assert.equal(cell.get("font-size"), "var(--font-size-base)");
assert.equal(cell.get("color"), "var(--dim)");
const hover = await bundledDeclarations(".tab:hover");
const selected = await bundledDeclarations(".tab[aria-current=page]");
assert.equal(hover.get("background"), "var(--cursor-bg-quaternary)", "6% hover fill");
assert.equal(selected.get("background"), "var(--cursor-bg-quaternary)", "6% selected fill");
assert.equal(hover.get("background"), "var(--panel-2)", "a hover raises the cell one step");
assert.equal(hover.get("color"), "var(--text)");
assert.equal(selected.get("background"), "var(--panel-3)", "the current page two steps");
assert.equal(selected.get("color"), "var(--text)");

const divider = await bundledDeclarations(".tab-divider");
assert.equal(squash(divider.get("border-top")), "1pxsolidvar(--cursor-stroke-tertiary)");
assert.equal(squash(divider.get("border-top")), "1pxsolidvar(--line-soft)");

const collapsed = await bundledDeclarations(".tab-nav.is-collapsed");
assert.equal(collapsed.get("width"), "var(--sidebar-collapsed-width)");
const toggleRule = await bundledDeclarations(".tab-nav-toggle");
assert.equal(toggleRule.get("align-self"), "flex-end", "the toggle hugs its chevron at the rail's end");
const collapsedToggle = await bundledDeclarations(".tab-nav.is-collapsed .tab-nav-toggle");
assert.equal(collapsedToggle.get("align-self"), "center", "and centers over the icon column");
const collapsedLabel = await bundledDeclarations(".tab-nav.is-collapsed .tab-label");
assert.equal(squash(collapsedLabel.get("clip-path")), "inset(50%)", "the label hides but stays readable");

const narrow = await bundledDeclarations(".tab-nav", { media: NARROW });
assert.equal(narrow.get("width"), "40px", "a 40px column");
assert.equal(narrow.get("width"), "var(--sidebar-collapsed-width)", "icons only in a narrow window");
const narrowToggle = await bundledDeclarations(".tab-nav-toggle", { media: NARROW });
assert.equal(narrowToggle.get("display"), "none", "the narrow rail has nothing to toggle");
const label = await bundledDeclarations(".tab-label", { media: NARROW });
assert.equal(squash(label.get("clip-path")), "inset(50%)", "the label hides but stays readable");
});
48 changes: 37 additions & 11 deletions crates/gateway/config-ui/ui/src/components/tab-bar.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
// The tab bar: a slim header strip with the medallion (standalone only),
// the profile switcher, and the right cluster holding the connection dot
// The tab bar: a header strip with the medallion (standalone only), the
// profile switcher, and the right cluster holding the connection dot
// [Adapted: llama-swap] plus the container the Apply/Revert pair mounts
// into; and, as its own element, Cursor's settings nav - seven icon+label
// links in three groups, set in a sticky left column that drops its labels
// and becomes a 40px icon column in a narrow window. The caller mounts the
// strip above the desk and the nav beside the content.
// into; and, as its own element, the sidebar - seven icon+label links in
// three groups under a collapse toggle, set in a full-height left rail that
// collapses to its icons on request and always in a narrow window. The
// caller mounts the strip above the desk and the sidebar beside the
// content.

import {
ChevronLeft,
ChevronRight,
Cloud,
Cpu,
Folder,
Expand All @@ -30,8 +33,8 @@ const APP_VERSION = typeof __APP_VERSION__ === "string" ? __APP_VERSION__ : "dev
/** One tab: destination view, label, lucide icon, and hash target. */
type Tab = readonly [view: PageId, label: string, icon: IconNode, href: string];

/** The nav's icon size: Cursor's 12px settings-nav glyphs. */
const ICON_SIZE = 12;
/** The sidebar's icon size. */
const ICON_SIZE = 14;

/**
* The tabs in nav order, grouped: the gateway's own settings, then the
Expand Down Expand Up @@ -68,7 +71,7 @@ export interface TabBarOptions {
export interface TabBar {
/** The `<header class="tab-bar">` strip: medallion, profile switcher, actions. */
element: HTMLElement;
/** The `<nav class="tab-nav">` column of grouped links, mounted beside the content. */
/** The `<nav class="tab-nav">` sidebar of grouped links, mounted beside the content. */
nav: HTMLElement;
/** Moves `aria-current` (and the selected fill) to `view`. */
setActivePage(view: PageId | null): void;
Expand Down Expand Up @@ -97,7 +100,30 @@ export function createTabBar(options: TabBarOptions): TabBar {

const nav = document.createElement("nav");
nav.setAttribute("aria-label", "Primary");
nav.id = "primary-nav";
nav.className = "tab-nav";
// The toggle collapses the sidebar to its icons; the state lasts for the
// session only.
const toggle = document.createElement("button");
toggle.type = "button";
toggle.className = "tab-nav-toggle";
toggle.setAttribute("aria-controls", nav.id);
const setCollapsed = (collapsed: boolean) => {
nav.classList.toggle("is-collapsed", collapsed);
toggle.setAttribute("aria-expanded", String(!collapsed));
toggle.setAttribute("aria-label", collapsed ? "Expand sidebar" : "Collapse sidebar");
toggle.title = toggle.getAttribute("aria-label") ?? "";
toggle.replaceChildren(
lucideElement(collapsed ? ChevronRight : ChevronLeft, {
"aria-hidden": "true",
width: ICON_SIZE,
height: ICON_SIZE,
}),
);
};
setCollapsed(false);
toggle.addEventListener("click", () => setCollapsed(!nav.classList.contains("is-collapsed")));
nav.append(toggle);
const tabByPage = new Map<PageId, HTMLAnchorElement>();
TAB_GROUPS.forEach((group, index) => {
if (index > 0) {
Expand All @@ -110,8 +136,8 @@ export function createTabBar(options: TabBarOptions): TabBar {
const tab = document.createElement("a");
tab.className = "tab";
tab.href = href;
// The label is the link's name; the column hides it, so a tooltip
// keeps the icon-only column readable.
// The label is the link's name; a collapsed sidebar hides it, so a
// tooltip keeps the icon-only rail readable.
tab.title = label;
const svg = lucideElement(icon, {
"aria-hidden": "true",
Expand Down
31 changes: 27 additions & 4 deletions crates/gateway/config-ui/ui/src/pages/models-page.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,17 @@ test("Local and Remote tabs render only their catalog subsets", async () => {
assert.equal(root.querySelector(".orphan-section"), null, "Remote never shows local files");
});

test("the Local split carries the green family and the Remote split the blue", async () => {
const { dom, root } = await open();
assert.ok(root.querySelector(".models-split.fam-local"), "Local marks its split");
assert.equal(root.querySelector(".models-split.fam-remote"), null);

navigate(dom, "#/remote");
await settle();
assert.ok(root.querySelector(".models-split.fam-remote"), "Remote marks its split");
assert.equal(root.querySelector(".models-split.fam-local"), null);
});

test("STT entries show the Mic badge and implicit non-editable kind", async () => {
const { dom, root } = await open();
const row = [...root.querySelectorAll(".model-row")].find((entry) =>
Expand Down Expand Up @@ -362,16 +373,28 @@ test("a non-numeric entry in a model's numeric field raises an alert under the i
assert.equal(fixed.querySelector("input").value, "512", "the edit is kept");
});

test("model rows sit 12px apart with no hover fill, and the search box takes Cursor's padding", async () => {
test("model rows sit 12px apart, mark the selection with a family-colored rule, and the search box is a pill", async () => {
const list = await bundledDeclarations(".model-list");
assert.equal(list.get("display"), "flex");
assert.equal(list.get("flex-direction"), "column");
assert.equal(list.get("gap"), "var(--space-3)", "a 12px gap");
const hover = await bundledDeclarations(".model-row:hover");
assert.equal(hover.get("background"), undefined, "no hover fill");
assert.equal(hover.get("background"), "var(--panel-2)", "a hover raises the row");
const selected = await bundledDeclarations(".model-row[aria-current=true]");
assert.equal(selected.get("border-inline-start-color"), "var(--accent)");
const local = await bundledDeclarations(".fam-local .model-row[aria-current=true]");
assert.equal(local.get("border-inline-start-color"), "var(--good)", "Local rows take green");
const remote = await bundledDeclarations(".fam-remote .model-row[aria-current=true]");
assert.equal(remote.get("border-inline-start-color"), "var(--blue)", "Remote rows take blue");
const search = await bundledDeclarations(".models-toolbar .input");
assert.equal(squash(search.get("padding")), "5px12px", "padding 5px 12px");
assert.equal(search.get("border-radius"), "var(--radius-sm)", "a 4px radius");
assert.equal(squash(search.get("padding")), "5px14px", "padding 5px 14px");
assert.equal(search.get("border-radius"), "var(--radius-full)", "a pill");
const sideBySide = await bundledDeclarations(".split-list", { media: "(min-width: 768px)" });
assert.equal(
sideBySide.get("padding-inline-end"),
"var(--space-4)",
"the list's toolbar stays 16px off the divider, as the detail pane does",
);
const spin = await bundledDeclarations(".models-refresh.is-loading svg");
assert.match(spin.get("animation") ?? "", /spin/, "the icon rotates while loading");
});
4 changes: 3 additions & 1 deletion crates/gateway/config-ui/ui/src/pages/models-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,9 @@ export function createModelsPage(deps: ModelsPageDeps): ModelsPage {
title.textContent = deps.scope === "local" ? "Local" : "Remote";

const split = document.createElement("div");
split.className = "split models-split";
// The family class picks the scope's hue for selections and filters:
// green for Local, blue for Remote.
split.className = `split models-split fam-${deps.scope}`;
listBox = document.createElement("div");
listBox.className = "split-list";
detailBox = document.createElement("div");
Expand Down
25 changes: 14 additions & 11 deletions crates/gateway/config-ui/ui/src/pages/settings-page.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,8 @@ test("the About panel renders the medallion, version, and license link", async (
const license = root.querySelector(".about-license");
assert.equal(license.href, "https://www.boost.org/LICENSE_1_0.txt");
assert.equal(license.rel, "noopener");
const licenseRule = await bundledDeclarations(".about-license");
assert.equal(licenseRule.get("color"), "var(--blue-text)", "the license link reads as a link, not an action");
});

test("a settings section puts its title above the card and lays each row as label, control, help", async () => {
Expand Down Expand Up @@ -210,18 +212,19 @@ test("a settings section puts its title above the card and lays each row as labe
assert.deepEqual(order, ["label", "control", "help"], "the grid reads label, control, then help");
});

test("the card is a 4% tinted 12px block with no border or padding, and its rows are grid cells", async () => {
test("the card is a raised 12px block with a line border and no padding, and its rows are grid cells", async () => {
const card = await bundledDeclarations(".settings-card");
assert.equal(card.get("border"), "0", "no border on the wrapper");
assert.equal(card.get("padding"), "0", "no padding on the wrapper");
assert.equal(card.get("background"), "none");

const heading = await bundledDeclarations(".settings-card .section-heading");
assert.equal(heading.get("font-size"), "var(--font-size-sm)", "a 12px title");
assert.equal(heading.get("color"), "var(--cursor-text-secondary)");
assert.equal(heading.get("font-size"), "var(--font-size-lg)", "a 15px title");
assert.equal(heading.get("color"), "var(--text)");

const body = await bundledDeclarations(".settings-card > .section-body");
assert.equal(body.get("background"), "var(--cursor-bg-quinary)", "a 4% tint");
assert.equal(body.get("background"), "var(--panel-2)", "the raised surface");
assert.equal(squash(body.get("border")), "1pxsolidvar(--line)", "a line border");
assert.equal(body.get("border-radius"), "var(--radius-xl)", "a 12px radius");
assert.equal(body.get("padding"), "0");

Expand All @@ -230,27 +233,27 @@ test("the card is a 4% tinted 12px block with no border or padding, and its rows
const control = await bundledDeclarations(".field-row>:not(.field-head):not(.field-help)");
assert.equal(control.get("grid-column"), "2", "the control sits on the right");
const label = await bundledDeclarations(".field-head label");
assert.equal(label.get("font-size"), "var(--font-size-base)", "a 13px label");
assert.equal(label.get("font-size"), "var(--font-size-base)", "a 13.5px label");
assert.equal(label.get("font-weight"), "400");
const help = await bundledDeclarations(".field-help");
assert.equal(help.get("font-size"), "var(--font-size-base)", "13px help text");
assert.equal(help.get("color"), "var(--cursor-text-secondary)");
assert.equal(help.get("font-size"), "var(--font-size-base)", "13.5px help text");
assert.equal(help.get("color"), "var(--dim)");
});

test("banners are 12px cards, the pending one in the accent tint, and callouts carry a 4px left bar", async () => {
const banner = await bundledDeclarations(".banner");
assert.equal(banner.get("border-radius"), "var(--radius-xl)");
assert.equal(squash(banner.get("padding")), "12px14px");
const pending = await bundledDeclarations(".banner-pending");
assert.equal(pending.get("background"), "var(--cursor-accent-subtle)", "accent at 8%");
assert.equal(pending.get("background"), "var(--accent-subtle)", "accent at 8%");
assert.equal(
squash(pending.get("border-color")),
"color-mix(insrgb,var(--cursor-accent)20%,transparent)",
"color-mix(insrgb,var(--accent)20%,transparent)",
"accent at 20%",
);
for (const [selector, color] of [
[".banner-restart", "var(--warning)"],
[".banner-warning", "var(--warning)"],
[".banner-restart", "var(--warn)"],
[".banner-warning", "var(--warn)"],
[".banner-danger", "var(--danger)"],
]) {
const callout = await bundledDeclarations(selector);
Expand Down
15 changes: 8 additions & 7 deletions crates/gateway/config-ui/ui/src/styles/base.css
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/* Foundation stylesheet: cascade layer order, reset, typography, and
focus defaults. The design tokens come from shared-ui/tokens.css (the
Cursor Dark sheet both Runvil UIs share), imported by main.ts
Gateway UI style guide's palette), imported by main.ts
ahead of this file so the layer order below is declared before any
layered component rules. Later selectors stay low-specificity because
precedence is settled by the layer order declared here. */
Expand Down Expand Up @@ -52,20 +52,21 @@

html {
height: 100%;
font-size: var(--font-size-root);
}

body {
min-height: 100vh;
min-height: 100svh;
background: var(--bg-primary);
color: var(--text-primary);
background: var(--panel);
color: var(--text);
font-family: var(--font);
font-size: var(--font-size-base);
}

h1 {
font-size: 1.25rem;
font-weight: 600;
font-size: var(--font-size-title);
font-weight: var(--font-weight-title);
}

code,
Expand All @@ -76,11 +77,11 @@
}

a {
color: var(--cursor-text-link);
color: var(--blue-text);
}

:focus-visible {
outline: 2px solid var(--cursor-accent);
outline: 2px solid var(--accent);
outline-offset: 2px;
}

Expand Down
Loading
Loading