Skip to content
5 changes: 5 additions & 0 deletions .changeset/profile-menu-narrow-rail.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@sapiom/harness": patch
---

Keep the rail's footer panels readable when the rail is dragged narrow: the profile menu and settings popover now carry their own readable width (the studio rail's `--menu-w` / `320px`) and open beside the rail, instead of inheriting the rail's width and wrapping labels on top of each other. The plan card's spend readout now truncates with the plan name instead of running under the Upgrade pill at the same widths.
10 changes: 5 additions & 5 deletions packages/harness/web/e2e/dismiss.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,11 @@ test.describe("settings popover", () => {
await page.getByTestId("telemetry-toggle").click();
await expect(popover).toBeVisible();

// The popover is anchored to the footer account row and grows UP across the
// full-width rail (up-start + matchWidth), so it now covers the rail's
// brand lockup — click the main panel instead, which the rail-width popover
// never reaches.
await page.locator(".terminal-slot").click();
// The panel floats beside the rail over the main pane, so no fixed selector
// is reliably outside it: click a point measured to be clear of it instead.
const box = (await popover.boundingBox())!;
const viewport = page.viewportSize()!;
await page.mouse.click(Math.min(box.x + box.width + 24, viewport.width - 8), 8);
await expect(popover).toBeHidden();
});

Expand Down
55 changes: 53 additions & 2 deletions packages/harness/web/e2e/popover-crop.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,61 @@ test("profile menu opens uncropped off the rail footer", async ({ page }) => {
await expectUncropped(page, page.getByTestId("profile-menu"));
});

test("settings popover opens uncropped off the rail footer", async ({ page }) => {
/* The profile menu carries its own readable width; the narrowest rail is where
inheriting the trigger's width used to wrap labels over each other. */
test("profile menu rows stay single-line at the rail's minimum width", async ({ page }) => {
await page.evaluate(() => {
localStorage.setItem("sapiom-harness-pane-widths", JSON.stringify({ rail: 180 }));
});
await page.reload();
await expect(page.locator(".rail-workflows")).toBeVisible();

const trigger = page.getByTestId("brand-identity");
await trigger.click();
const menu = page.getByTestId("profile-menu");
await expect(menu).toBeVisible();

const rows = await menu.locator(".profile-menu-item").evaluateAll((els) =>
els.map((el) => {
const rect = el.getBoundingClientRect();
return { top: rect.top, bottom: rect.bottom, height: rect.height, overflow: el.scrollHeight - el.clientHeight };
}),
);
expect(rows.length).toBeGreaterThan(2);
for (const row of rows) {
// --tree-row-h is 30px; a wrapped label would double this.
expect(row.height, "row is one line tall").toBeLessThanOrEqual(31);
// A wrapped label spilling past a fixed-height row is what reads as
// "overlapping menu items" on screen while the boxes still look fine.
expect(row.overflow, "label fits inside its row").toBeLessThanOrEqual(1);
}
for (let i = 1; i < rows.length; i += 1) {
expect(rows[i].top, "row starts below the one before it").toBeGreaterThanOrEqual(rows[i - 1].bottom - 0.5);
}
// The panel keeps its own width rather than squeezing into the narrow rail.
const triggerBox = await trigger.boundingBox();
const menuBox = await menu.boundingBox();
expect(menuBox!.width).toBeGreaterThan(triggerBox!.width);
expect(menuBox!.width, "menu holds its readable floor").toBeGreaterThanOrEqual(240);
// And it opens beside the rail rather than over it: on the rail's own
// footprint a panel this shape reads as nothing having happened.
const railBox = await page.locator(".rail-workflows").boundingBox();
expect(menuBox!.x, "menu clears the rail's right edge").toBeGreaterThanOrEqual(railBox!.x + railBox!.width);
await expectUncropped(page, menu);
});

test("settings popover opens beside the rail, uncropped", async ({ page }) => {
await page.getByTestId("brand-identity").click();
await page.getByTestId("settings-trigger").click();
await expectUncropped(page, page.getByTestId("settings-popover"));
const popover = page.getByTestId("settings-popover");
await expectUncropped(page, popover);

const popoverBox = await popover.boundingBox();
expect(popoverBox!.width, "popover holds its readable floor").toBeGreaterThanOrEqual(280);
const railBox = await page.locator(".rail-workflows").boundingBox();
expect(popoverBox!.x, "popover clears the rail's right edge").toBeGreaterThanOrEqual(
railBox!.x + railBox!.width,
);
});

test("session bar menu opens uncropped at the header's right cluster", async ({ page }) => {
Expand Down
21 changes: 21 additions & 0 deletions packages/harness/web/e2e/rail-footer-cards.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,27 @@ test.describe("plan card", () => {
await expect(menu).not.toBeVisible();
});

test("keeps the readout clear of the Upgrade pill at the rail's minimum width", async ({
page,
}) => {
await page.evaluate(() => {
localStorage.setItem("sapiom-harness-pane-widths", JSON.stringify({ rail: 180 }));
});
await page.reload();
await expect(page.getByTestId("plan-card")).toBeVisible();

// The copy column shrinks with the rail, so the money line has to clip:
// painted at full length it runs straight under the CTA beside it.
const readout = page.getByTestId("plan-balance");
const clipped = await readout.evaluate((el) => el.scrollWidth > el.clientWidth);
const readoutBox = (await readout.boundingBox())!;
const upgradeBox = (await page.getByTestId("plan-upgrade").boundingBox())!;
expect(readoutBox.x + readoutBox.width, "readout ends before the pill").toBeLessThanOrEqual(
upgradeBox.x + 0.5,
);
expect(clipped, "a value with nowhere to go is ellipsised, not overflowing").toBe(true);
});

test("sits above the account row inside one footer block", async ({ page }) => {
const footer = page.locator(".rail-footer");
await expect(footer.getByTestId("plan-card")).toBeVisible();
Expand Down
17 changes: 12 additions & 5 deletions packages/harness/web/src/components/WorkflowsRail.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { JSX } from "react";
import type { JSX, RefObject } from "react";
import type {
AppState,
EditorKind,
Expand Down Expand Up @@ -940,6 +940,7 @@ export function WorkflowsRail({
fixture in demo); a view with nothing to state renders nothing. */}
<PlanCard plan={accountPlan} />
<ProfileRow
railRef={railRef}
onToast={onToast}
authenticated={authenticated}
organizationName={organizationName}
Expand Down Expand Up @@ -994,6 +995,7 @@ type ProfileAuthProgress =
| { status: "error"; message: string };

function ProfileRow({
railRef,
authenticated,
organizationName,
telemetryOptIn,
Expand All @@ -1014,6 +1016,8 @@ function ProfileRow({
onSelectOverview,
onToast,
}: {
/** The rail itself: the edge the profile menu opens beside. */
railRef: RefObject<HTMLElement | null>;
authenticated: boolean;
organizationName: string | null;
telemetryOptIn: boolean;
Expand Down Expand Up @@ -1151,8 +1155,8 @@ function ProfileRow({
open={settingsOpen}
anchorRef={triggerRef}
onDismiss={closeSettings}
placement="up-start"
matchWidth
placement="right-end"
besideRef={railRef}
className="settings-popover"
testid="settings-popover"
>
Expand All @@ -1174,12 +1178,15 @@ function ProfileRow({
/>
</AnchoredPopover>

{/* Like the settings panel above: BESIDE the rail, bottom edges aligned.
A panel that floats over the rail on the rail's own footprint reads as
nothing having happened; clearing the rail gives it its own ground. */}
<AnchoredPopover
open={menuOpen}
anchorRef={triggerRef}
onDismiss={closeMenu}
placement="up-start"
matchWidth
placement="right-end"
besideRef={railRef}
className="profile-menu"
role="menu"
testid="profile-menu"
Expand Down
40 changes: 36 additions & 4 deletions packages/harness/web/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@
its glyphs land on the pane inset while the hover shape breathes around
them. Mirrors the studio rail reference. */
--row-bleed: var(--sp2);
/* ONE readable width for floating menus, capped to the viewport. Menus are
read as their own surface, so they take this width rather than the width
of whatever row opened them. Mirrors the studio rail reference. */
--menu-w: min(var(--col-w), calc(100vw - var(--sp4)));
/* A menu's own horizontal inset, the --pane-pad-x of a floating surface: its
rows bleed by --row-bleed inside it, so the well is inset by the
difference and the glyphs still land on one line. */
--menu-pad-x: var(--sp3);
--font-sans: var(--font);
--font-mono: var(--mono);

Expand Down Expand Up @@ -1298,6 +1306,10 @@ input {
font-size: var(--type-meta);
color: var(--text-dim);
white-space: nowrap;
/* Clips like the name above it: the copy column shrinks with the rail, and a
value with nowhere left to go must end rather than run under the CTA. */
overflow: hidden;
text-overflow: ellipsis;
}

/* The Upgrade CTA rides the shared .pill surface but at a clickable height —
Expand Down Expand Up @@ -1474,6 +1486,17 @@ button.rail-footer-card:hover {
min-width: 12rem;
}

/* Footer / profile menus: fixed readable width — never match the rail trigger,
whose width the user can drag down to the rail minimum, narrower than "Open
Sapiom dashboard". A menu is its own surface, so it is sized by what it has
to say, not by the row that opened it; the popover's measured pass shifts the
wider panel back into view. Mirrors the studio rail reference. */
.profile-menu {
width: var(--menu-w);
min-width: 240px;
padding: var(--sp2) calc(var(--menu-pad-x) - var(--row-bleed));
}

@media (prefers-reduced-motion: reduce) {
.canvas-detail-menu,
.canvas-run-menu,
Expand All @@ -1493,7 +1516,13 @@ button.rail-footer-card:hover {
align-items: center;
gap: var(--sp2);
width: 100%;
height: var(--tree-row-h);
/* A floor, not a fixed height, and one line: at the fixed menu width a label
clips rather than wrapping on top of the row below it. */
height: auto;
min-height: var(--tree-row-h);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding: 0 var(--sp2);
border: 0;
background: transparent;
Expand Down Expand Up @@ -3089,11 +3118,14 @@ button.rail-footer-card:hover {
subtle shadow, and — in light mode — a solid accent fill on hover (the
product's `hover:bg-accent`); dark mode keeps the more restrained neutral
hover the outline variant uses there. */
/* Portaled off the rail's profile row (AnchoredPopover, matchWidth): the
surface comes from the ONE popover recipe; only the roomier content
padding is its own. */
/* Portaled off the rail's profile row and opened beside the rail: the surface
comes from the ONE popover recipe; only the roomier content padding and its
own readable width — the rail's width is the user's to drag, not this
panel's — are its own. Mirrors the studio rail reference. */
.settings-popover {
padding: var(--sp4);
width: min(320px, calc(100vw - var(--sp4)));
min-width: 280px;
}

.settings-identity {
Expand Down
Loading