diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 476cd359d..8e3255197 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -4301,8 +4301,10 @@ workload, and infrastructure inline drawers; callers may pass row-specific `data-*` attributes, colspan, and content classes, but they must not recreate the surface-alt detail row shell locally. The content shell must clip horizontal paint below the large breakpoint without becoming a scroll -container, then restore visible overflow for the static desktop layout, so -long operator-state copy cannot escape the shared row border. +container, reset the parent table's `whitespace-nowrap` inheritance, and allow +its descendants to shrink, then restore visible overflow for the static +desktop layout. Long operator-state copy must wrap inside the shared row border +instead of painting beneath adjacent controls or disappearing at the clip edge. Inline detail section content is registry-backed separately from the row shell. `DetailSectionTable`, `InlineDetailPanel`, and `detailSectionModel.ts` own detail row compaction, section-table rendering, value-tone classes, and the @@ -4593,7 +4595,10 @@ When those workspace tabs need an embedded control-bar treatment, they should still stay on the one canonical `frontend-modern/src/components/shared/Subtabs.tsx` primitive and reuse the established shell, list, and button class pattern already proven on owning surfaces like operations rather than introducing new -variant APIs on the primitive. +variant APIs on the primitive. When that rail overflows on phone widths, +`Subtabs` owns visible, accessible edge-scroll controls and keeps them in sync +with native scrolling and rail resize; callers must not add drawer-local arrow +overlays or leave clipped tab labels as the only overflow cue. The search-input enhancement surfaces now follow that same owner split. `frontend-modern/src/components/shared/SearchInputEnhancements.tsx` stays the render shell, `frontend-modern/src/components/shared/useSearchInputEnhancements.ts` diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index ecd36eb44..4e6f7232d 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -1943,7 +1943,11 @@ owners preserve native scrollbar geometry with measured spacers, project wheel and vertical touch movement before native scroll advances, and retain a directional runway around the viewport. A feature may choose a smaller bounded budget, but it must not restore an unbounded `` over estate-sized data or -show a blank viewport while the keyed window catches up. +show a blank viewport while the keyed window catches up. `PlatformWindowedRows` +also reconciles unique string/number resource IDs into stable row proxies, so +sparse live snapshots update fields without remounting an open inline drawer +or discarding its tab and form state. Rows without a unique logical ID retain +reference-keyed rendering rather than paying for guessed index identity. The workload guest-row path now follows the same pattern: the render shell stays in `frontend-modern/src/components/Workloads/GuestRow.tsx`, tooltip-backed cell presentation lives in `frontend-modern/src/components/Workloads/GuestRowCells.tsx`, @@ -2119,7 +2123,10 @@ it may retain total capacity for explanatory copy, but it emits no numeric memory segments, percentage, or fit calculation that could make unknown usage look like zero. Platform tables and resource-detail mappers consume the same canonical unavailable state instead of recomputing `total - free` in render -hot paths. +hot paths. The shell keeps its fitted percentage/capacity label on a +semi-opaque surface scrim with base-content contrast so segment colors cannot +make the small readout illegible; this remains CSS presentation only and adds +no measurement or per-row runtime work. The dashboard metric bar now follows that same pattern: the shell stays in `frontend-modern/src/components/Workloads/MetricBar.tsx`, while width, show-label, sublabel-fit, and threshold-color derivation live in diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 481c6b519..3628d1d7c 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -2222,7 +2222,9 @@ capability-gated. By default the resource follows the tenant Patrol mode. Enabling the optional limit requires selecting exact capabilities and may add a recurring daily time/timezone window; copy must state that the control narrows the tenant mode, while `NeverAutoRemediate` disables automatic action for the -resource. +resource. At phone widths, the collapsed maintenance-window action stacks +below its explanatory copy so the button cannot squeeze policy text into a +narrow side column; desktop retains the compact side-by-side layout. The UI must never infer eligibility from capability names, severity, or the human approval floor. diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 345f8d23f..7aced6c84 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,21 +1,30 @@ { "version": 1, - "base_sha": "f18f15bf7b1fbdf1078bf412754c059694c8b996", - "verified_at": "2026-08-25T15:58:23Z", + "base_sha": "f5eb87437a396048959a851584d3104326e0d671", + "verified_at": "2026-08-25T16:41:20Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/components/UpdateBanner.tsx", - "frontend-modern/src/components/whatsNewModel.ts" + "frontend-modern/src/components/Infrastructure/ResourceOperatorStateSection.tsx", + "frontend-modern/src/components/Workloads/StackedMemoryBar.tsx", + "frontend-modern/src/components/shared/InlineDetailTableRow.tsx", + "frontend-modern/src/components/shared/Subtabs.tsx", + "frontend-modern/src/features/platformPage/PlatformWindowedRows.tsx" ], "content_sha256": { - "frontend-modern/src/components/UpdateBanner.tsx": "455a8546b5b98c4541993ea7f894ad352c87a29392ede0da2a562e05f85b09de", - "frontend-modern/src/components/whatsNewModel.ts": "d81c8ae4fcc59cff06a63b4a7407a97848acd934f6b6e04535059dc89f652471" + "frontend-modern/src/components/Infrastructure/ResourceOperatorStateSection.tsx": "1a01f6e6c8db585c32b37afae33ed7b6671dc76e40e8f49c364ee78659e8a961", + "frontend-modern/src/components/Workloads/StackedMemoryBar.tsx": "3266fa75221643199d41b7a87f8e07181e643171c744ac0e32e2ac9c6773e3bf", + "frontend-modern/src/components/shared/InlineDetailTableRow.tsx": "0e169eb088c0bb0754dda5936b52baecc102f1a50853263517d76931729a1673", + "frontend-modern/src/components/shared/Subtabs.tsx": "5106f14ec387c72bd2d3014200678f342e28b04d5ff372aa95410695fe0dc544", + "frontend-modern/src/features/platformPage/PlatformWindowedRows.tsx": "3f5d02c126379a44803bea3efcc803965d872eb188ec7e0305c973a82fa80924" }, - "routes": ["/release-notes-verification.html"], + "routes": [ + "/truenas/overview", + "/kubernetes/overview" + ], "viewports": [ { - "width": 1440, - "height": 900 + "width": 1280, + "height": 720 }, { "width": 390, @@ -23,13 +32,16 @@ } ], "states": [ - "expanded update banner with a v6.4.0-rc.2 customer-facing What's improved section", - "desktop banner rendered both improvement bullets and excluded the Fixes section from the preview", - "narrow banner retained the full preview at 390px with document scroll width equal to client width" + "TrueNAS system summary with memory-capacity label scrim", + "TrueNAS system drawer Manage tab at desktop width", + "TrueNAS system drawer Manage tab at phone width", + "Kubernetes cluster drawer with five-tab overflow rail before and after scrolling" ], "interactions": [ - "opened the isolated current-tree frontend fixture against the local mock backend", - "expanded the update banner with Show more", - "inspected rendered text, layout geometry, and horizontal overflow at desktop and narrow widths" + "opened the TrueNAS system inline drawer and selected Manage", + "verified Manage remained selected through repeated realtime refresh samples and after the final main rebase", + "inspected wrapped operator-policy help text and the stacked phone maintenance action", + "opened a Kubernetes cluster inline drawer at 390px and used its right-edge tab scroll control", + "verified the left-edge control appeared after scrolling and restored the default viewport" ] } diff --git a/frontend-modern/src/components/Infrastructure/ResourceOperatorStateSection.tsx b/frontend-modern/src/components/Infrastructure/ResourceOperatorStateSection.tsx index ad9bd894b..d5da2af6f 100644 --- a/frontend-modern/src/components/Infrastructure/ResourceOperatorStateSection.tsx +++ b/frontend-modern/src/components/Infrastructure/ResourceOperatorStateSection.tsx @@ -559,7 +559,7 @@ export const ResourceOperatorStateSection: Component -
+

@@ -574,13 +574,13 @@ export const ResourceOperatorStateSection: Component Schedule window } > -

+
- ); - }} - +
+
+ + {(tab) => { + const selected = () => local.value === tab.value; + return ( + + ); + }} + +
+ + + + + +
); diff --git a/frontend-modern/src/components/shared/__tests__/InlineDetailTableRow.test.tsx b/frontend-modern/src/components/shared/__tests__/InlineDetailTableRow.test.tsx index b443952c3..81fbefc66 100644 --- a/frontend-modern/src/components/shared/__tests__/InlineDetailTableRow.test.tsx +++ b/frontend-modern/src/components/shared/__tests__/InlineDetailTableRow.test.tsx @@ -37,6 +37,8 @@ describe('InlineDetailTableRow', () => { expect(detail.parentElement).toHaveClass('sm:px-4'); expect(detail.parentElement).toHaveClass('sticky'); expect(detail.parentElement).toHaveClass('left-0'); + expect(detail.parentElement).toHaveClass('min-w-0'); + expect(detail.parentElement).toHaveClass('whitespace-normal'); expect(detail.parentElement).toHaveClass('max-w-[calc(100vw-3.5rem)]'); expect(detail.parentElement).toHaveClass('lg:static'); expect(detail.parentElement).toHaveClass('lg:max-w-none'); diff --git a/frontend-modern/src/components/shared/__tests__/Subtabs.test.tsx b/frontend-modern/src/components/shared/__tests__/Subtabs.test.tsx index 924c34454..85537f66d 100644 --- a/frontend-modern/src/components/shared/__tests__/Subtabs.test.tsx +++ b/frontend-modern/src/components/shared/__tests__/Subtabs.test.tsx @@ -76,4 +76,45 @@ describe('Subtabs', () => { } } }); + + it('shows phone scroll affordances when the tab rail is clipped', async () => { + render(() => ( + + )); + + const tablist = screen.getByRole('tablist', { name: 'Resource detail sections' }); + const scrollBy = vi.fn(); + Object.defineProperties(tablist, { + clientWidth: { configurable: true, value: 180 }, + scrollWidth: { configurable: true, value: 420 }, + scrollLeft: { configurable: true, writable: true, value: 0 }, + scrollBy: { configurable: true, value: scrollBy }, + }); + window.dispatchEvent(new Event('resize')); + + const scrollRight = await screen.findByRole('button', { + name: 'Resource detail sections: scroll right', + }); + expect(scrollRight).toHaveClass('sm:hidden'); + expect(screen.queryByRole('button', { name: /scroll left/i })).not.toBeInTheDocument(); + + await fireEvent.click(scrollRight); + expect(scrollBy).toHaveBeenCalledWith({ left: 126, behavior: 'smooth' }); + + tablist.scrollLeft = 120; + tablist.dispatchEvent(new Event('scroll')); + expect( + await screen.findByRole('button', { name: 'Resource detail sections: scroll left' }), + ).toBeInTheDocument(); + }); }); diff --git a/frontend-modern/src/features/platformPage/PlatformWindowedRows.tsx b/frontend-modern/src/features/platformPage/PlatformWindowedRows.tsx index 53f43722c..7ebaf3aec 100644 --- a/frontend-modern/src/features/platformPage/PlatformWindowedRows.tsx +++ b/frontend-modern/src/features/platformPage/PlatformWindowedRows.tsx @@ -1,16 +1,54 @@ -import { For, Show, type Accessor, type JSX } from 'solid-js'; +import { + createEffect, + createMemo, + createSignal, + For, + Show, + type Accessor, + type JSX, +} from 'solid-js'; +import { createStore, reconcile } from 'solid-js/store'; import { usePlatformWindowedItems } from './usePlatformWindowedItems'; export interface PlatformWindowedRowsProps { items: Accessor; children: (item: Row, index: Accessor) => JSX.Element; + /** + * Stable logical identity for rows rebuilt from live resource snapshots. + * When omitted, rows with unique string/number `id` fields are stabilized + * automatically; non-resource rows retain reference-keyed rendering. + */ + keyExtractor?: (item: Row) => string | number; colSpan?: number; estimatedRowHeight?: number; enableThreshold?: number; windowSize?: number; } +type StablePlatformRow = { + __platformWindowKey: string | number; + value: Row; +}; + +const defaultRowKey = (item: Row): string | number | undefined => { + if (typeof item !== 'object' || item === null || !('id' in item)) return undefined; + const id = (item as { id?: unknown }).id; + return typeof id === 'string' || typeof id === 'number' ? id : undefined; +}; + +const buildStableRows = ( + items: readonly Row[], + keyExtractor?: (item: Row) => string | number, +): StablePlatformRow[] | undefined => { + const keys = items.map((item) => keyExtractor?.(item) ?? defaultRowKey(item)); + if (keys.some((key) => key === undefined) || new Set(keys).size !== keys.length) return undefined; + return items.map((value, index) => ({ + __platformWindowKey: keys[index]!, + value, + })); +}; + /** * Canonical bounded renderer for ordinary platform table rows. * @@ -19,8 +57,27 @@ export interface PlatformWindowedRowsProps { * prewarms the runway while touch input remains compositor-native. */ export function PlatformWindowedRows(props: PlatformWindowedRowsProps) { + const initialStableRows = buildStableRows(props.items(), props.keyExtractor); + const [stableRows, setStableRows] = createStore[]>( + initialStableRows ?? [], + ); + const [usesStableRows, setUsesStableRows] = createSignal(initialStableRows !== undefined); + + createEffect(() => { + const next = buildStableRows(props.items(), props.keyExtractor); + if (!next) { + setUsesStableRows(false); + return; + } + setStableRows(reconcile(next, { key: '__platformWindowKey' })); + setUsesStableRows(true); + }); + + const renderItems = createMemo(() => + usesStableRows() ? stableRows.map((stableRow) => stableRow.value) : props.items(), + ); const windowing = usePlatformWindowedItems({ - items: props.items, + items: renderItems, estimatedItemHeight: props.estimatedRowHeight, enableThreshold: props.enableThreshold, windowSize: props.windowSize, @@ -31,7 +88,7 @@ export function PlatformWindowedRows(props: PlatformWindowedRowsProps) ); return ( - + { expect(container.querySelectorAll('[data-platform-window-spacer]')).toHaveLength(2); }); + it('preserves keyed row component state when live snapshots replace row objects', async () => { + let mounts = 0; + let disposals = 0; + const [items, setItems] = createSignal([{ id: 'node-a', label: 'First snapshot' }]); + + const StatefulRow = (props: { item: { id: string; label: string } }) => { + mounts += 1; + onCleanup(() => { + disposals += 1; + }); + return ( + + {props.item.label} + + + + + ); + }; + + render(() => ( + + + + {(item) => } + + +
+ )); + + const input = screen.getByRole('textbox', { + name: 'Row-local state', + }) as HTMLInputElement; + input.value = 'still editing'; + setItems([{ id: 'node-a', label: 'Refreshed snapshot' }]); + + expect(await screen.findByText('Refreshed snapshot')).toBeInTheDocument(); + expect(screen.getByRole('textbox', { name: 'Row-local state' })).toBe(input); + expect(input).toHaveValue('still editing'); + expect(mounts).toBe(1); + expect(disposals).toBe(0); + }); + it('keeps estate-sized card lists within their configured mounted-item budget', () => { const items = () => Array.from({ length: 1_000 }, (_, index) => index); const { container } = render(() => (