Wrap long identity chips inside phone drawers (#2731)

Below the lg breakpoint, the inline drawer's Identity section rendered
alias and IP chips as hand-rolled nowrap spans inside a fixed-layout
table cell. Generated aliases such as `proxmox-lxc-docker:Production
West:pve1:113` or `k8s-production-1:deployment:redis-ffcd-b17c78f012`
are wider than that value cell on a phone, so the cell's hidden overflow
and the drawer's clipping content shell cut them off with no ellipsis.
The part that distinguishes two aliases is at the end, and a phone has
no hover to show the title.

- `MetadataBadge` (the contract owner of read-only chip whitespace
handling) gains an opt-in `wrap` mode: the chip is capped at its
container and breaks inside a token only where no space or hyphen fits.
Default stays nowrap; existing consumers get byte-identical classes.
- The drawer's IP, alias and alias-count chips in
`ResourceDetailSummary.tsx` compose `MetadataBadge`; identity values
wrap in full. Desktop drawers are unchanged (one line per alias).
- The shared template registry requires the consumer and forbids the old
hand-rolled chip classes there; the guardrail and unit tests cover the
wrap classes.
- `04-mobile.spec.ts` opens a Docker container drawer at phone width and
fails if any Identity-section element or text crosses the detail cell,
table wrapper or drawer shell (values that end in their own ellipsis
excepted).

Measured on a private mock estate at 390px: 53 cut chips across 92
Docker/Kubernetes/Proxmox/Machines drawers before, 0 identity chips past
the shell across 140 drawers at 390 and 768px after. Rebased on main
after #2703 (wider phone drawers): the new spec passes on the Pixel 5
and iPhone 12 projects and fails with the two runtime files reverted
(`proxmox-lxc-docker:...:113` still 71px past the cell).

Correlation-summary and redaction-label chips were checked and left
alone: redaction labels are a fixed five-word vocabulary, and the
correlation chips sit outside the nowrap table and break at spaces and
hyphens.
This commit is contained in:
rcourtman
2026-10-08 02:37:34 +01:00
committed by GitHub
9 changed files with 262 additions and 15 deletions
@@ -2355,6 +2355,20 @@ not a replacement status card, CTA band, or page-local nested card.
Proxmox backup source/state chips follow the same boundary: storage/recovery
owns backup-source labels and state semantics, while the visible chip shell
and tone vocabulary route through `MetadataBadge`.
Resource drawer identity chips (the IP address and alias chips and the
alias overflow count in `ResourceDetailSummary.tsx`) compose `MetadataBadge`
too. Badges stay on one line by default; an identity value that can outgrow
a narrow drawer cell opts into the badge's `wrap` mode, which caps the chip
at its container and breaks inside a token only where no space or hyphen
fits. These values differ at their ends (`...:pve1:113`, a container hash)
and a phone has no hover to show a title, so they wrap rather than
truncate. A one-line alias used to overflow its fixed-width value cell at
phone widths, where the cell's hidden overflow and the inline drawer's
clipping content shell cut it off without an ellipsis.
`tests/integration/tests/04-mobile.spec.ts` opens a Docker container drawer
at phone width and checks that no element or text in its Identity section
crosses either edge of those clipping boxes, apart from values that end in
their own ellipsis.
Workload backup freshness follows a separate shared presentation boundary:
`frontend-modern/src/utils/workloadGuestPresentation.ts` owns the canonical
tone and icon mapping consumed by workload rows and drawers. A recorded
@@ -0,0 +1,54 @@
{
"version": 1,
"base_sha": "1a094646d7f21217eda051af6ba0252e208f283e",
"verified_at": "2026-10-07T22:57:56Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx",
"frontend-modern/src/components/shared/MetadataBadge.tsx"
],
"content_sha256": {
"frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx": "cd82ec5bd4dbf1ab54c9b4dd291814f728567700836799c794e2f71046624725",
"frontend-modern/src/components/shared/MetadataBadge.tsx": "0b87b1a7b3b56ee384874a56710cf260a7787388dd43ec67b8ca144412474ad1"
},
"routes": [
"/docker",
"/kubernetes",
"/proxmox",
"/standalone/machines"
],
"viewports": [
{
"width": 390,
"height": 844
},
{
"width": 393,
"height": 851
},
{
"width": 768,
"height": 844
},
{
"width": 1440,
"height": 900
}
],
"states": [
"Private mock stack built from this branch (50 Proxmox nodes, 2 Docker hosts, 1 Kubernetes cluster, 2 generic hosts), Chromium headless",
"Baseline on origin/main at 390px: 92 inline drawers opened across the four routes, 53 descendants past the drawer content shell's right edge, all of them Aliases chips in the Identity section (Docker container drawers 12, Kubernetes drawers 41), for example proxmox-lxc-docker:Production West:pve1:113 at +82px and k8s-production-1:deployment:redis-ffcd-b17c78f012 at +114px; 0 at 768px",
"After the change: 140 inline drawers across the four routes (Proxmox node and guest drawers included) at 390px and 768px, 0 identity chips past the shell; the one remaining element is the Deployments tab inside the Kubernetes cluster drawer's role=tablist scroller (scrolls 102px), reachable by swiping",
"Docker container drawer nginx and Kubernetes drawer checkout-api at 390px: alias and IP chips wrap at spaces and hyphens inside the value cell, full values readable, chip right edge 326px against a 343px shell, +N overflow chip unchanged",
"Same drawers with the two runtime files swapped back to origin/main: chip right edges 352 to 457px, cut off at the cell with no ellipsis",
"Docker container drawer nginx at 1440px: Identity card unchanged, every alias chip on one line",
"Truncating Primary ID and Parent values keep their ellipsis; Docker labels chips unchanged",
"Rebased onto main after the phone drawer width change (#2703, drawer content now fills the table's visible width): Docker container drawer at Pixel 5 and iPhone 12 sizes has no Identity-section element or text past the detail cell, table wrapper or drawer shell; with the two runtime files reverted the proxmox-lxc-docker alias still sits 71px past the cell and app-container-<hash> 20px"
],
"interactions": [
"Opened each sampled row's inline drawer and expanded every nested disclosure inside it before measuring",
"Tapped a Docker container row at Pixel 5 and iPhone 12 sizes (the disclosure chevron is screen-reader only there) and checked that no Identity-section element or text crosses the detail cell, table wrapper or drawer shell",
"Re-ran that check with the runtime files reverted: it failed on every long alias chip",
"Closed each drawer before opening the next"
]
}
@@ -2000,9 +2000,17 @@
{ "path": "src/components/patrol/RunHistoryEntry.tsx" },
{ "path": "src/components/patrol/RunToolCallTrace.tsx" },
{ "path": "src/features/proxmox/proxmoxBackupsTableShared.tsx" },
{ "path": "src/features/patrol/PatrolIntelligenceWorkspace.tsx" }
{ "path": "src/features/patrol/PatrolIntelligenceWorkspace.tsx" },
{ "path": "src/components/Infrastructure/ResourceDetailSummary.tsx" }
],
"forbiddenPatterns": [
{
"path": "src/components/Infrastructure/ResourceDetailSummary.tsx",
"patterns": [
"inline-flex items-center rounded-sm bg-surface-alt px-1.5 py-0.5 text-[10px]",
"inline-flex items-center rounded-sm bg-blue-100 px-1.5 py-0.5 text-[10px]"
]
},
{
"path": "src/components/AI/FindingsPanel.tsx",
"patterns": [
@@ -2,6 +2,7 @@ import { For, Show, type Component, type JSX } from 'solid-js';
import type { Resource } from '@/types/resource';
import { isContainerUpdatePinned } from '@/components/shared/containerUpdateBadgeModel';
import { DetailSectionTable } from '@/components/shared/DetailSectionTable';
import { MetadataBadge } from '@/components/shared/MetadataBadge';
import {
compactDetailRows,
compactDetailSections,
@@ -234,12 +235,9 @@ export const InlineResourceSummaryTables: Component<ResourceSummaryPresentationP
<div class="flex flex-wrap gap-1">
<For each={props.drawer.identityIpValues()}>
{(ip) => (
<span
class="inline-flex items-center rounded-sm bg-blue-100 px-1.5 py-0.5 text-[10px] text-blue-700 dark:bg-blue-900/25 dark:text-blue-200"
title={ip}
>
<MetadataBadge tone="info" size="xs" shape="rounded" wrap title={ip}>
{ip}
</span>
</MetadataBadge>
)}
</For>
</div>,
@@ -259,20 +257,17 @@ export const InlineResourceSummaryTables: Component<ResourceSummaryPresentationP
<div class="flex flex-wrap gap-1">
<For each={props.drawer.aliasPreviewValues()}>
{(value) => (
<span
class="inline-flex items-center rounded-sm bg-surface-alt px-1.5 py-0.5 text-[10px]"
title={value}
>
<MetadataBadge size="xs" shape="rounded" wrap title={value}>
{value}
</span>
</MetadataBadge>
)}
</For>
<Show when={props.drawer.hasAliasOverflow()}>
<span class="inline-flex items-center rounded-sm bg-surface-alt px-1.5 py-0.5 text-[10px] text-muted">
<MetadataBadge tone="muted" size="xs" shape="rounded">
+
{props.drawer.identityAliasValues().length -
props.drawer.aliasPreviewValues().length}
</span>
</MetadataBadge>
</Show>
</div>,
)
@@ -906,6 +906,8 @@ describe('ResourceDetailDrawer runtime and identity cards', () => {
expect(inlineAliases.closest('summary')).toBeNull();
expect(inlineRender.container.querySelectorAll('details')).toHaveLength(1);
expect(inlineRender.getByText('agent-inline-1')).toBeInTheDocument();
// A long alias wraps inside the drawer cell instead of spilling past it.
expect(inlineRender.getByText('agent-inline-1')).toHaveClass('max-w-full', 'wrap-anywhere');
expect(inlineRender.getAllByText('inline-host.local').length).toBeGreaterThan(0);
const overflowResource = baseResource({
@@ -47,4 +47,24 @@ describe('MetadataBadge', () => {
expect(badgeClass).toContain('text-orange-700');
expect(badgeClass).toContain('text-[10px]');
});
it('keeps chips on one line unless a long identity value opts into wrapping', () => {
const defaultClass = getMetadataBadgeClass({ size: 'xs', shape: 'rounded' }).split(' ');
expect(defaultClass).toContain('whitespace-nowrap');
expect(defaultClass).not.toContain('wrap-anywhere');
render(() => (
<MetadataBadge size="xs" shape="rounded" wrap>
proxmox-lxc-docker:Production West:pve1:113
</MetadataBadge>
));
const badge = screen.getByText('proxmox-lxc-docker:Production West:pve1:113');
expect(badge).toHaveClass('max-w-full');
expect(badge).toHaveClass('min-w-0');
expect(badge).toHaveClass('whitespace-normal');
expect(badge).toHaveClass('wrap-anywhere');
expect(badge).toHaveClass('text-left');
expect(badge).not.toHaveClass('whitespace-nowrap');
});
});
@@ -22,11 +22,19 @@ export interface MetadataBadgeProps extends Omit<JSX.HTMLAttributes<HTMLSpanElem
appearance?: MetadataBadgeAppearance;
fit?: boolean;
uppercase?: boolean;
/**
* Lets a long identity value (an alias, an address) wrap inside a narrow
* container instead of spilling past it. Breaks prefer spaces and hyphens
* and split a token only when nothing else fits.
*/
wrap?: boolean;
class?: string;
children?: JSX.Element;
}
const METADATA_BADGE_BASE_CLASS = 'inline-flex items-center gap-1 font-medium whitespace-nowrap';
const METADATA_BADGE_BASE_CLASS = 'inline-flex items-center gap-1 font-medium';
const METADATA_BADGE_NOWRAP_CLASS = 'whitespace-nowrap';
const METADATA_BADGE_WRAP_CLASS = 'min-w-0 max-w-full whitespace-normal wrap-anywhere text-left';
const METADATA_BADGE_SIZE_CLASSES: Record<MetadataBadgeSize, string> = {
xs: 'px-1.5 py-0.5 text-[10px]',
@@ -81,6 +89,7 @@ export function getMetadataBadgeClass(props: {
appearance?: MetadataBadgeAppearance;
fit?: boolean;
uppercase?: boolean;
wrap?: boolean;
class?: string;
}): string {
const tone = props.tone ?? 'neutral';
@@ -90,6 +99,7 @@ export function getMetadataBadgeClass(props: {
return [
METADATA_BADGE_BASE_CLASS,
props.wrap ? METADATA_BADGE_WRAP_CLASS : METADATA_BADGE_NOWRAP_CLASS,
METADATA_BADGE_SIZE_CLASSES[size],
METADATA_BADGE_SHAPE_CLASSES[shape],
METADATA_BADGE_TONE_CLASSES[appearance][tone],
@@ -109,6 +119,7 @@ export const MetadataBadge: Component<MetadataBadgeProps> = (props) => {
'appearance',
'fit',
'uppercase',
'wrap',
'class',
'children',
]);
@@ -123,6 +134,7 @@ export const MetadataBadge: Component<MetadataBadgeProps> = (props) => {
appearance: local.appearance,
fit: local.fit,
uppercase: local.uppercase,
wrap: local.wrap,
class: local.class,
})}
>
@@ -2617,6 +2617,7 @@ describe('shared primitive guardrails', () => {
'src/components/patrol/RunToolCallTrace.tsx',
'src/features/proxmox/proxmoxBackupsTableShared.tsx',
'src/features/patrol/PatrolIntelligenceWorkspace.tsx',
'src/components/Infrastructure/ResourceDetailSummary.tsx',
]);
expect(roleRule?.canonical?.path).toBe('src/components/shared/OrganizationBadges.tsx');
expect(roleRule?.canonical?.export).toBe('OrganizationRoleBadge');
@@ -2684,6 +2685,18 @@ describe('shared primitive guardrails', () => {
expect(metadataBadgeSource).toContain("muted: 'bg-surface-alt text-muted'");
expect(metadataBadgeSource).toContain("warning: 'bg-amber-100 text-amber-800");
expect(metadataBadgeSource).not.toContain(["muted: 'bg", 'slate', '100'].join('-'));
// Drawer identity values differ at their ends and a phone has no hover to
// show a title, so they wrap inside the drawer cell instead of spilling
// past the drawer edge, where the content shell clips them.
expect(metadataBadgeSource).toContain(
"METADATA_BADGE_WRAP_CLASS = 'min-w-0 max-w-full whitespace-normal wrap-anywhere text-left'",
);
expect(resourceDetailSummarySource).toContain(
'<MetadataBadge tone="info" size="xs" shape="rounded" wrap title={ip}>',
);
expect(resourceDetailSummarySource).toContain(
'<MetadataBadge size="xs" shape="rounded" wrap title={value}>',
);
expect(organizationBadgesSource).toContain('MetadataBadge');
expect(organizationBadgesSource).toContain('getOrganizationRoleBadgeTone');
expect(organizationBadgesSource).toContain('getOrganizationShareStatusBadgeTone');
+130 -1
View File
@@ -1,5 +1,9 @@
import { test, expect, devices } from "@playwright/test";
import { ensureAuthenticated, setMockMode } from "./helpers";
import {
ensureAuthenticated,
setMockMode,
waitForDefaultMockRuntimeReady,
} from "./helpers";
const getViewportWidth = async (
page: import("@playwright/test").Page,
@@ -397,6 +401,131 @@ test.describe("Mobile viewport flows", () => {
);
});
test("Drawer identity chips wrap inside the drawer content instead of being clipped", async ({
page,
}) => {
await waitForDefaultMockRuntimeReady(page);
await page.goto("/docker");
// Container drawers carry long generated aliases such as
// app-container-<hash> that are wider than the identity value cell.
const containerRow = page.locator("tr[data-docker-container-row]").first();
if (
["1", "true", "yes", "on"].includes(
String(process.env.PULSE_E2E_REQUIRE_DEFAULT_MOCK_READY || "")
.trim()
.toLowerCase(),
)
) {
// CI runs the default mock estate, which always has Docker containers.
await expect(containerRow).toBeVisible({ timeout: 15_000 });
} else {
const containerRowVisible = await containerRow
.waitFor({ state: "visible", timeout: 15_000 })
.then(
() => true,
() => false,
);
if (!containerRowVisible) {
test.skip(true, "No Docker container rows available to expand");
}
}
// On phones the whole row is the disclosure target.
await containerRow.locator("td").first().click();
await expect(
containerRow.getByRole("button", { name: /^Collapse details for / }),
).toHaveAttribute("aria-expanded", "true");
const identity = page
.locator('[data-testid="resource-identity-section"]')
.first();
await expect(identity).toBeVisible();
await expect(identity.getByText("Aliases")).toBeVisible();
const cutOff = await identity.evaluate((section) => {
// Below lg the inline drawer's content shell clips anything past it.
let shell: HTMLElement | null = section.parentElement;
while (shell && window.getComputedStyle(shell).overflowX !== "clip") {
shell = shell.parentElement;
}
if (!shell) return ["no clipping drawer content shell"];
const offenders: string[] = [];
const walker = document.createTreeWalker(
section,
NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT,
);
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
let rect: DOMRect;
let inlineLevel: boolean;
if (node.nodeType === Node.TEXT_NODE) {
if (!node.textContent?.trim()) continue;
const range = document.createRange();
range.selectNodeContents(node);
rect = range.getBoundingClientRect();
inlineLevel = true;
} else {
rect = (node as Element).getBoundingClientRect();
inlineLevel = window
.getComputedStyle(node as Element)
.display.startsWith("inline");
}
const left = rect.left;
let right = rect.right;
// Inline content that truncates with an ellipsis is meant to end
// inside the block whose line it sits on (a truncating value span or
// label cell). The fixed-layout detail cell also declares an ellipsis,
// but that only marks its own line: a block child such as the chip
// row crosses the cell edge with no ellipsis and is silently cut.
if (inlineLevel) {
let line = node.parentElement;
while (
line &&
line !== shell &&
["inline", "contents"].includes(
window.getComputedStyle(line).display,
)
) {
line = line.parentElement;
}
const lineStyle = line ? window.getComputedStyle(line) : null;
if (
line &&
lineStyle?.textOverflow === "ellipsis" &&
lineStyle.overflowX !== "visible" &&
["block", "inline-block", "table-cell", "list-item", "flow-root"].includes(
lineStyle.display,
)
) {
right = Math.min(right, line.getBoundingClientRect().right);
}
}
// Any clipping box on the way up (the detail cell, the table wrapper,
// the drawer shell) hides whatever still crosses either of its edges.
for (
let box: HTMLElement | null = node.parentElement;
box;
box = box === shell ? null : box.parentElement
) {
const style = window.getComputedStyle(box);
if (style.overflowX === "visible" || style.display === "contents") {
continue;
}
const edges = box.getBoundingClientRect();
const past = Math.max(right - edges.right, edges.left - left);
if (past > 1) {
offenders.push(
`${node.textContent?.trim().slice(0, 60)} (${Math.round(past)}px past ${box.tagName.toLowerCase()})`,
);
break;
}
}
}
return offenders;
});
expect(cutOff).toEqual([]);
});
test("Infrastructure landing loads without horizontal overflow at mobile viewport", async ({
page,
}) => {