diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 649ab7b690..f3c90c8383 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -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 diff --git a/frontend-modern/browser-verification/b1b04e2d4be6b172.json b/frontend-modern/browser-verification/b1b04e2d4be6b172.json new file mode 100644 index 0000000000..d6cf5138b1 --- /dev/null +++ b/frontend-modern/browser-verification/b1b04e2d4be6b172.json @@ -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- 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" + ] +} diff --git a/frontend-modern/scripts/shared-template-registry.json b/frontend-modern/scripts/shared-template-registry.json index 44cb11003f..aab917a0f0 100644 --- a/frontend-modern/scripts/shared-template-registry.json +++ b/frontend-modern/scripts/shared-template-registry.json @@ -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": [ diff --git a/frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx b/frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx index 43975c192f..2194f2126a 100644 --- a/frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx +++ b/frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx @@ -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 {(ip) => ( - + {ip} - + )} , @@ -259,20 +257,17 @@ export const InlineResourceSummaryTables: Component {(value) => ( - + {value} - + )} - + + {props.drawer.identityAliasValues().length - props.drawer.aliasPreviewValues().length} - + , ) diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.identity-runtime.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.identity-runtime.test.tsx index 7109df0030..c831341960 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.identity-runtime.test.tsx +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.identity-runtime.test.tsx @@ -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({ diff --git a/frontend-modern/src/components/shared/MetadataBadge.test.tsx b/frontend-modern/src/components/shared/MetadataBadge.test.tsx index 0d0e80b428..2d3b7a974c 100644 --- a/frontend-modern/src/components/shared/MetadataBadge.test.tsx +++ b/frontend-modern/src/components/shared/MetadataBadge.test.tsx @@ -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(() => ( + + proxmox-lxc-docker:Production West:pve1:113 + + )); + + 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'); + }); }); diff --git a/frontend-modern/src/components/shared/MetadataBadge.tsx b/frontend-modern/src/components/shared/MetadataBadge.tsx index 00ca796e85..396c415509 100644 --- a/frontend-modern/src/components/shared/MetadataBadge.tsx +++ b/frontend-modern/src/components/shared/MetadataBadge.tsx @@ -22,11 +22,19 @@ export interface MetadataBadgeProps extends Omit = { 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 = (props) => { 'appearance', 'fit', 'uppercase', + 'wrap', 'class', 'children', ]); @@ -123,6 +134,7 @@ export const MetadataBadge: Component = (props) => { appearance: local.appearance, fit: local.fit, uppercase: local.uppercase, + wrap: local.wrap, class: local.class, })} > diff --git a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts index af75efec6f..a03aa77e49 100644 --- a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts +++ b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts @@ -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( + '', + ); + expect(resourceDetailSummarySource).toContain( + '', + ); expect(organizationBadgesSource).toContain('MetadataBadge'); expect(organizationBadgesSource).toContain('getOrganizationRoleBadgeTone'); expect(organizationBadgesSource).toContain('getOrganizationShareStatusBadgeTone'); diff --git a/tests/integration/tests/04-mobile.spec.ts b/tests/integration/tests/04-mobile.spec.ts index 431ff6294b..6e1dccce53 100644 --- a/tests/integration/tests/04-mobile.spec.ts +++ b/tests/integration/tests/04-mobile.spec.ts @@ -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- 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, }) => {