diff --git a/.changeset/profile-menu-narrow-rail.md b/.changeset/profile-menu-narrow-rail.md
new file mode 100644
index 000000000..9ffc8ae52
--- /dev/null
+++ b/.changeset/profile-menu-narrow-rail.md
@@ -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.
diff --git a/packages/harness/web/e2e/dismiss.spec.ts b/packages/harness/web/e2e/dismiss.spec.ts
index 4d71a391e..c0e3fe672 100644
--- a/packages/harness/web/e2e/dismiss.spec.ts
+++ b/packages/harness/web/e2e/dismiss.spec.ts
@@ -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();
});
diff --git a/packages/harness/web/e2e/popover-crop.spec.ts b/packages/harness/web/e2e/popover-crop.spec.ts
index 7a9f5f850..3cf667627 100644
--- a/packages/harness/web/e2e/popover-crop.spec.ts
+++ b/packages/harness/web/e2e/popover-crop.spec.ts
@@ -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 }) => {
diff --git a/packages/harness/web/e2e/rail-footer-cards.spec.ts b/packages/harness/web/e2e/rail-footer-cards.spec.ts
index b1d848ee8..aa53045ca 100644
--- a/packages/harness/web/e2e/rail-footer-cards.spec.ts
+++ b/packages/harness/web/e2e/rail-footer-cards.spec.ts
@@ -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();
diff --git a/packages/harness/web/src/components/WorkflowsRail.tsx b/packages/harness/web/src/components/WorkflowsRail.tsx
index 9f8180858..073558284 100644
--- a/packages/harness/web/src/components/WorkflowsRail.tsx
+++ b/packages/harness/web/src/components/WorkflowsRail.tsx
@@ -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,
@@ -940,6 +940,7 @@ export function WorkflowsRail({
fixture in demo); a view with nothing to state renders nothing. */}
;
authenticated: boolean;
organizationName: string | null;
telemetryOptIn: boolean;
@@ -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"
>
@@ -1174,12 +1178,15 @@ function ProfileRow({
/>
+ {/* 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. */}