fix: logs sidebar table will be default view and draggable tabs (#13368)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bhargav
2026-07-23 06:53:13 +00:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 35e2a36fd2
commit ef2efdf5fc
24 changed files with 1500 additions and 609 deletions
@@ -243,6 +243,7 @@ class UnflattenedPage {
await this.page
.locator('[data-test="logs-search-result-detail-dialog"][data-state="open"]')
.waitFor({ state: 'visible', timeout: 10000 });
await this.openJsonDetailTab();
// JSON content renders asynchronously after the drawer opens. Wait for the
// first detail key to actually render instead of a fixed 3s sleep — the keys
// appearing IS the signal callers need before probing for _o2_id, and the
@@ -254,6 +255,33 @@ class UnflattenedPage {
.catch(() => {});
}
/**
* Select the JSON tab in the log detail drawer.
*
* The drawer now opens on the Table tab — see 4194865a63 "logs sidebar table
* will be default view and draggable tabs", which flipped
* SearchResult.vue's `detailTableInitialTab` and DetailTable's `initialTab`
* default from "json" to "table". Everything this page object reads
* (`_o2_id`, the per-key `log-expand-detail-key-*` rows, the unflattened
* sub-tab) lives in the JSON panel, and `OTabPanels` is used with
* `keep-alive`, so the inactive JSON panel stays mounted but `v-show`-hidden.
* It is therefore present in the DOM yet never *visible*, which is what made
* every `_o2_id` probe time out.
*
* The tab bar is user-reorderable (order persisted in localStorage), so
* select by data-test rather than position.
*/
async openJsonDetailTab() {
const jsonTab = this.page
.locator('[data-test="logs-search-result-detail-dialog"] [data-test="log-detail-json-tab"]')
.first();
await jsonTab.waitFor({ state: 'visible', timeout: 10000 });
if ((await jsonTab.getAttribute('data-state')) !== 'active') {
await jsonTab.click();
}
await this.logDetailJsonContent.waitFor({ state: 'visible', timeout: 10000 });
}
/**
* Close the log detail drawer if it is open. Uses Escape as the fallback
* when the close button is intercepted by an overlay.
@@ -276,9 +304,18 @@ class UnflattenedPage {
* drawer OPEN on the matching row so the caller can continue interacting
* with the `_o2_id` element. Returns -1 if no row in the range contains
* `_o2_id`.
*
* Pass `deadlineAt` (an absolute `Date.now()` value) to cap how long the
* sweep may run. Each row costs a drawer open plus a probe, so a 15-row
* sweep on a slow runner could otherwise outlive the whole test timeout and
* get the test killed mid-action instead of returning -1.
*/
async findRowWithO2Id(maxRows = 5) {
async findRowWithO2Id(maxRows = 5, { deadlineAt = null } = {}) {
for (let rowIndex = 0; rowIndex < maxRows; rowIndex++) {
if (deadlineAt && Date.now() >= deadlineAt) {
console.warn(`findRowWithO2Id: time budget reached after scanning ${rowIndex} row(s)`);
break;
}
const expandBtn = this.page.locator(
`[data-test="log-table-column-${rowIndex}-_timestamp"] [data-test="table-row-expand-menu"]`
);
@@ -1,5 +1,4 @@
const { test, expect, navigateToBase } = require('../utils/enhanced-baseFixtures.js');
const logData = require("../../fixtures/log.json");
const PageManager = require('../../pages/page-manager.js');
const testLogger = require('../utils/test-logger.js');
const { getOrgIdentifier, isCloudEnvironment } = require('../utils/cloud-auth.js');
@@ -10,21 +9,53 @@ const { ingestTestData: ingestion } = require('../utils/data-ingestion.js');
test.describe("Unflattened testcases", () => {
let pageManager;
// let logData;
function removeUTFCharacters(text) {
// Remove UTF characters using regular expression
return text.replace(/[^\x00-\x7F]/g, " ");
}
// ── Time budget ───────────────────────────────────────────────────────────
// beforeEach + body + afterEach all share ONE Playwright timeout. The waits
// below used to be sized in isolation and optimistically — a 90s + 120s
// readiness gate, then up to 3 scan attempts each fronted by a fresh
// navigation and a 60s + 120s query wait — so their sum could exceed the
// whole budget several times over. When it did, Playwright killed the test in
// the middle of whatever it was doing and reported
// "page.goto: Target page, context or browser has been closed" — the symptom
// of the kill, not the cause. Every long wait is now clamped to the time
// actually left, so a slow backend produces a diagnostic failure while the
// browser is still alive, and the honest path has room to finish.
let testStartedAt = 0;
// Held back so each test's "toggle Store Original Data back OFF" cleanup can
// always run; leaving the stream with the flag ON leaks into the next test in
// this serial file.
const CLEANUP_RESERVE_MS = 45000;
// A scan attempt costs a row sweep plus, on a miss, a fresh navigation and a
// full re-query. Only start another round when at least this much is left.
const SCAN_ATTEMPT_COST_MS = 90000;
function msLeft(reserve = CLEANUP_RESERVE_MS) {
const budget = test.info().timeout;
if (!budget) return Number.MAX_SAFE_INTEGER; // timeout disabled
return budget - (Date.now() - testStartedAt) - reserve;
}
function clampToBudget(preferred, { min = 10000, reserve = CLEANUP_RESERVE_MS } = {}) {
return Math.max(min, Math.min(preferred, msLeft(reserve)));
}
async function applyQueryButton(page) {
// runQueryAndWaitForResults already handles an in-flight auto-search internally:
// it waits for the button to exit the "Cancel" state before clicking, and if the
// state never clears it cancels and re-clicks Run itself. The old double-click
// wrapper (3s sleep + run + 1s sleep + run) duplicated that logic and added ~5s
// of fixed sleep to every call.
await pageManager.logsPage.runQueryAndWaitForResults();
await pageManager.logsPage.runQueryAndWaitForResults(clampToBudget(60000, { min: 20000 }));
// 2-minute timeout: cloud re-indexing after Store Original Data changes takes longer than the default 30s
await pageManager.logsPage.waitForSearchResults(120000);
// Up to 2 minutes: cloud re-indexing after Store Original Data changes takes
// longer than the default 30s — but never longer than the budget left.
await pageManager.logsPage.waitForSearchResults(clampToBudget(120000, { min: 20000 }));
}
// Re-run the log search from a FRESH page load so the row scan below sees a
@@ -38,9 +69,9 @@ test.describe("Unflattened testcases", () => {
// otherwise fall back to the quick-mode interesting-fields-only query).
async function runFreshLogSearch(page, { sqlSelectAll = false } = {}) {
await page.goto(
`${process.env.ZO_BASE_URL}/web/logs?org_identifier=${getOrgIdentifier()}&stream_type=logs&stream=e2e_automate`
`${process.env.ZO_BASE_URL}/web/logs?org_identifier=${getOrgIdentifier()}&stream_type=logs&stream=e2e_automate`,
{ waitUntil: 'domcontentloaded', timeout: clampToBudget(45000, { min: 15000 }) }
);
await page.waitForLoadState('domcontentloaded');
if (sqlSelectAll) {
await pageManager.logsPage.typeQuery('SELECT * FROM "e2e_automate" ORDER BY _timestamp DESC');
await pageManager.logsPage.waitForQueryEditorValue('ORDER BY _timestamp DESC');
@@ -48,7 +79,77 @@ test.describe("Unflattened testcases", () => {
await applyQueryButton(page);
}
// Backend readiness gate for _o2_id. A single budget-bounded wait: the old
// 90s-then-120s pair could consume 210s of a 300s test on its own, leaving
// nothing for the UI work it was supposed to be preparing.
async function waitForO2IdReady() {
const ready = await pageManager.unflattenedPage.waitForO2IdQueryable({
timeout: clampToBudget(90000, { min: 20000 }),
});
if (ready) {
testLogger.info('_o2_id confirmed queryable via search API');
} else {
testLogger.warn('_o2_id readiness gate timed out — the UI scan below is unlikely to find it');
}
return ready;
}
// Scan the rendered log rows for _o2_id, leaving the detail drawer open on the
// matching row. A miss means UI/streaming lag, which only a fresh navigation +
// re-query recovers (re-clicking Run in place does not recover a stale/partial
// streaming result set), so each retry costs a full search — hence the budget
// check before starting another one.
async function scanForO2IdOrFail(page, { sqlSelectAll = false, backendReady = true, reserve = CLEANUP_RESERVE_MS } = {}) {
for (let attempt = 1; attempt <= 3; attempt++) {
const matchedRow = await pageManager.unflattenedPage.findRowWithO2Id(15, {
deadlineAt: Date.now() + clampToBudget(45000, { min: 15000, reserve }),
});
if (matchedRow !== -1) {
testLogger.info(`Found _o2_id in row ${matchedRow} (attempt ${attempt})`);
await pageManager.unflattenedPage.o2IdText.click();
return;
}
testLogger.warn(`_o2_id not found in the scanned rows on attempt ${attempt}`);
await pageManager.unflattenedPage.closeLogDetailDrawerIfOpen();
// A fresh search only fixes UI-side staleness. If the backend never
// surfaced _o2_id, retrying the UI cannot help — it just burns the budget
// until Playwright kills the test mid-action.
if (!backendReady) break;
if (attempt === 3) break;
if (msLeft(reserve) < SCAN_ATTEMPT_COST_MS) {
testLogger.warn('Not enough test budget left for another scan attempt');
break;
}
testLogger.info('Re-running the query from a fresh page before the next scan');
await runFreshLogSearch(page, { sqlSelectAll });
}
// Diagnostic: dump what the first row actually does contain. Skipped when
// the budget is nearly gone — being killed here would replace the useful
// error below with an opaque "browser has been closed".
if (msLeft(0) > 30000) {
try {
await pageManager.unflattenedPage.openLogRowDetail(0);
const allKeys = await pageManager.unflattenedPage.allLogDetailKeys.allTextContents();
testLogger.error('Available fields in log detail', { fields: allKeys });
} catch (e) {
testLogger.error('Could not retrieve available fields');
}
}
throw new Error(
'Failed to find _o2_id field in log details' +
(backendReady
? ''
: ' — the search API never returned a record containing _o2_id, so "Store Original Data" had not taken effect on the backend') +
` (${Math.round(Math.max(0, msLeft(0)) / 1000)}s of test budget left)`
);
}
test.beforeEach(async ({ page }) => {
testStartedAt = Date.now();
pageManager = new PageManager(page);
if (isCloudEnvironment()) {
await navigateToBase(page);
@@ -102,11 +203,14 @@ test.describe("Unflattened testcases", () => {
await pageManager.unflattenedPage.closeButton.click();
await page.waitForTimeout(500);
await page.goto(
`${logData.logsUrl}?org_identifier=${getOrgIdentifier()}`
);
await pageManager.logsPage.selectStream("e2e_automate");
await applyQueryButton(page);
// Leave the browser on the logs page with e2e_automate selected. Two costs
// were removed here because nothing in either test consumes their result:
// - the trailing search (up to 180s of query waiting) — both tests navigate
// straight back to Streams from here;
// - the explicit goto, since selectStream navigates to the logs page itself.
// apiWaitMs=0 also skips selectStream's own stream-availability poll (up to
// 120s on cloud) — waitForStreamAvailable above already established that.
await pageManager.logsPage.selectStream("e2e_automate", 5, 0);
});
test.afterEach(async ({ page }) => {
@@ -153,54 +257,22 @@ test.describe("Unflattened testcases", () => {
// Deterministic readiness gate: poll the search API until _o2_id is queryable
// instead of sleeping a fixed interval and hoping indexing has caught up.
// This is what makes the single UI scan below succeed on the first attempt.
// This is what makes the UI scan below succeed on the first attempt.
testLogger.info('Waiting for _o2_id to become queryable via search API');
// Respect the gate's result. On heavily contended CI runners indexing can
// lag past the gate window; the gate returning false (ignored previously)
// is exactly when the subsequent UI scan was doomed. Give the backend one
// more long wait before falling through to the UI scan, so the UI step
// isn't asked to find a field the backend hasn't surfaced yet.
const o2idReady = await pageManager.unflattenedPage.waitForO2IdQueryable();
if (o2idReady) {
testLogger.info('_o2_id confirmed queryable via search API');
} else {
testLogger.warn('_o2_id readiness gate timed out; waiting once more before UI scan');
const o2idReadyRetry = await pageManager.unflattenedPage.waitForO2IdQueryable({ timeout: 120000 });
testLogger.info('_o2_id readiness gate second wait result', { ready: o2idReadyRetry });
}
const o2idReady = await waitForO2IdReady();
// Navigate directly with stream in URL — selectStream would deselect it because
// the Pinia store already has e2e_automate selected from beforeEach
testLogger.info('Navigating to logs with e2e_automate stream');
await page.goto(`${process.env.ZO_BASE_URL}/web/logs?org_identifier=${getOrgIdentifier()}&stream_type=logs&stream=e2e_automate`);
await page.waitForLoadState('domcontentloaded');
await page.waitForTimeout(1000);
await page.goto(
`${process.env.ZO_BASE_URL}/web/logs?org_identifier=${getOrgIdentifier()}&stream_type=logs&stream=e2e_automate`,
{ waitUntil: 'domcontentloaded', timeout: clampToBudget(45000, { min: 15000 }) }
);
await applyQueryButton(page);
testLogger.info('Search query applied, logs should now contain _o2_id field');
testLogger.info('Searching log rows for _o2_id field (iterates first 15 rows per attempt)');
let o2idFound = false;
// The backend readiness gate above already guarantees _o2_id is queryable, so
// a miss here is UI/streaming lag — recover with a FRESH log search (reload +
// re-run) rather than re-ingesting. Re-clicking Run in place does not recover a
// stale/partial streaming result set; a fresh navigation does (see runFreshLogSearch).
for (let attempt = 1; attempt <= 3; attempt++) {
const matchedRow = await pageManager.unflattenedPage.findRowWithO2Id(15);
if (matchedRow !== -1) {
testLogger.info(`Found _o2_id in row ${matchedRow} (attempt ${attempt})`);
await pageManager.unflattenedPage.o2IdText.click();
o2idFound = true;
break;
}
testLogger.warn(`_o2_id not found in first 15 rows on attempt ${attempt}, re-running query from a fresh page`);
await pageManager.unflattenedPage.closeLogDetailDrawerIfOpen();
if (attempt < 3) {
await runFreshLogSearch(page);
}
}
if (!o2idFound) {
throw new Error('Failed to find _o2_id field in log details after 3 attempts');
}
testLogger.info('Searching log rows for _o2_id field');
await scanForO2IdOrFail(page, { backendReady: o2idReady });
testLogger.info('Switching to unflattened tab');
await pageManager.unflattenedPage.unflattenedTab.waitFor();
@@ -286,26 +358,15 @@ test.describe("Unflattened testcases", () => {
// instead of a fixed 15s sleep + UI re-ingestion retry loop. This was the
// dominant source of the timeout flake on contended CI runners.
testLogger.info('Waiting for _o2_id to become queryable via search API');
// Respect the gate's result. On heavily contended CI runners indexing can
// lag past the gate window; the gate returning false (ignored previously)
// is exactly when the subsequent UI scan was doomed. Give the backend one
// more long wait before falling through to the UI scan, so the UI step
// isn't asked to find a field the backend hasn't surfaced yet.
const o2idReady = await pageManager.unflattenedPage.waitForO2IdQueryable();
if (o2idReady) {
testLogger.info('_o2_id confirmed queryable via search API');
} else {
testLogger.warn('_o2_id readiness gate timed out; waiting once more before UI scan');
const o2idReadyRetry = await pageManager.unflattenedPage.waitForO2IdQueryable({ timeout: 120000 });
testLogger.info('_o2_id readiness gate second wait result', { ready: o2idReadyRetry });
}
const o2idReady = await waitForO2IdReady();
// Navigate directly with stream in URL — selectStream would deselect it because
// the Pinia store already has e2e_automate selected from beforeEach
testLogger.info('Navigating to logs with e2e_automate stream');
await page.goto(`${process.env.ZO_BASE_URL}/web/logs?org_identifier=${getOrgIdentifier()}&stream_type=logs&stream=e2e_automate`);
await page.waitForLoadState('domcontentloaded');
await page.waitForTimeout(1000);
await page.goto(
`${process.env.ZO_BASE_URL}/web/logs?org_identifier=${getOrgIdentifier()}&stream_type=logs&stream=e2e_automate`,
{ waitUntil: 'domcontentloaded', timeout: clampToBudget(45000, { min: 15000 }) }
);
testLogger.info('Ensuring Quick Mode is on');
await pageManager.logsPage.ensureQuickModeState(true);
@@ -348,38 +409,12 @@ test.describe("Unflattened testcases", () => {
testLogger.info('Executing SELECT * query to fetch fresh data with _o2_id');
await applyQueryButton(page);
testLogger.info('Searching log rows for _o2_id field (iterates first 15 rows per attempt)');
testLogger.info('Searching log rows for _o2_id field');
// With ORDER BY _timestamp DESC the newest rows come first (the fixture carries
// no _timestamp, so the Store-Original-Data re-ingest is genuinely newest). The
// backend readiness gate above guarantees _o2_id is queryable, so a miss here is
// UI/streaming lag — recover with a FRESH SELECT * search (reload + re-type +
// re-run) rather than re-ingesting. Re-clicking Run in place does not recover a
// stale/partial streaming result set; a fresh navigation does.
let o2idFound = false;
for (let attempt = 1; attempt <= 3; attempt++) {
const matchedRow = await pageManager.unflattenedPage.findRowWithO2Id(15);
if (matchedRow !== -1) {
testLogger.info(`Found _o2_id in row ${matchedRow} (attempt ${attempt})`);
await pageManager.unflattenedPage.o2IdText.click();
o2idFound = true;
break;
}
testLogger.warn(`_o2_id not found in first 15 rows on attempt ${attempt}, re-running query from a fresh page`);
if (attempt === 3) {
try {
const allKeys = await pageManager.unflattenedPage.allLogDetailKeys.allTextContents();
testLogger.error('Available fields in log detail', { fields: allKeys });
} catch (e) {
testLogger.error('Could not retrieve available fields');
}
break;
}
await pageManager.unflattenedPage.closeLogDetailDrawerIfOpen();
await runFreshLogSearch(page, { sqlSelectAll: true });
}
if (!o2idFound) {
throw new Error('Failed to find _o2_id field in log details after 3 attempts');
}
// no _timestamp, so the Store-Original-Data re-ingest is genuinely newest).
// Larger reserve than the default: this test's tail does a third ingestion and
// a full explorer walk after the cleanup toggle, so it needs more budget kept back.
await scanForO2IdOrFail(page, { sqlSelectAll: true, backendReady: o2idReady, reserve: 90000 });
await page.waitForTimeout(500);
testLogger.info('Switching to unflattened tab');
@@ -447,7 +482,9 @@ test.describe("Unflattened testcases", () => {
await page.waitForTimeout(1500);
testLogger.info('Waiting for log detail panel to load');
await pageManager.unflattenedPage.logDetailJsonContent.waitFor({ state: "visible", timeout: 10000 });
// The drawer opens on the Table tab, so select JSON before waiting on the
// JSON panel and its per-key rows (see openJsonDetailTab).
await pageManager.unflattenedPage.openJsonDetailTab();
await page.waitForTimeout(1000);
testLogger.info('Looking for timestamp dropdown in log details');
+1 -4
View File
@@ -2,7 +2,7 @@
"totals": {
"rawProjectRamp": 139,
"rawVarInComponent": 585,
"arbZ": 35,
"arbZ": 34,
"darkMechanism": 20,
"unscopedStyle": 7,
"stylePxUnit": 90
@@ -382,9 +382,6 @@
"lib/navigation/Tabs/OTab.vue": {
"rawProjectRamp": 1
},
"lib/overlay/Dropdown/ODropdown.vue": {
"arbZ": 1
},
"lib/overlay/Tooltip/OTooltip.vue": {
"arbZ": 1
},
@@ -0,0 +1,83 @@
import { describe, it, expect, vi } from "vitest";
import { h, nextTick } from "vue";
import { mount } from "@vue/test-utils";
import OContextMenu from "./OContextMenu.vue";
import OContextMenuItem from "./OContextMenuItem.vue";
// Reka UI portals content into <body>. Disable portal for unit tests.
vi.mock("reka-ui", async (importOriginal) => {
const actual = await importOriginal<typeof import("reka-ui")>();
return {
...actual,
ContextMenuPortal: actual.ContextMenuContent, // render inline
};
});
function mountMenu(props: Record<string, unknown> = {}) {
return mount(OContextMenu, {
props,
slots: {
trigger: "<div data-testid='region'>Right click me</div>",
default: {
render: () => h(OContextMenuItem, {}, { default: () => "Copy" }),
},
},
attachTo: document.body,
});
}
// ContextMenuTrigger awaits nextTick before opening, so callers need two ticks.
async function rightClick(wrapper: ReturnType<typeof mountMenu>) {
await wrapper.find('[data-testid="region"]').trigger("contextmenu");
await nextTick();
await nextTick();
}
describe("OContextMenu", () => {
it("renders the trigger slot as-child without a wrapper element", () => {
const wrapper = mountMenu();
const region = wrapper.find('[data-testid="region"]');
expect(region.exists()).toBe(true);
// as-child means the slot root IS the trigger — it carries the state attr
expect(region.attributes("data-state")).toBe("closed");
});
it("stays closed until a contextmenu event fires", () => {
const wrapper = mountMenu();
expect(wrapper.text()).not.toContain("Copy");
});
it("opens on right-click and renders menu content", async () => {
const wrapper = mountMenu();
await rightClick(wrapper);
expect(wrapper.emitted("update:open")?.[0]).toEqual([true]);
});
it("does not open when disabled", async () => {
const wrapper = mountMenu({ disabled: true });
await rightClick(wrapper);
expect(wrapper.emitted("update:open")).toBeUndefined();
});
it("emits update:open(false) when Escape is pressed", async () => {
const wrapper = mountMenu();
await rightClick(wrapper);
window.dispatchEvent(
new KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
);
await nextTick();
await nextTick();
const events = wrapper.emitted("update:open") ?? [];
expect(events.at(-1)).toEqual([false]);
});
it("closes when an ancestor scrolls, since the menu is pointer-anchored", async () => {
const wrapper = mountMenu();
await rightClick(wrapper);
window.dispatchEvent(new Event("scroll"));
await nextTick();
await nextTick();
const events = wrapper.emitted("update:open") ?? [];
expect(events.at(-1)).toEqual([false]);
});
});
@@ -0,0 +1,46 @@
/**
* OContextMenu.types.ts — public types for OContextMenu.
*
* OContextMenu is the right-click (and long-press on touch) counterpart to
* ODropdown. Positioning is anchored to the pointer, not to the trigger
* element, so the trigger can be an arbitrarily large region (a whole table,
* a canvas, a list) and the menu still opens exactly where the user clicked.
*/
import type { ContextMenuRootProps } from "reka-ui";
// Re-export family types so callers can import everything from one place if needed
export type {
ContextMenuItemVariant,
ContextMenuItemProps,
ContextMenuItemEmits,
ContextMenuItemSlots,
} from "./OContextMenuItem.types";
export type { ContextMenuLabelSlots } from "./OContextMenuLabel.types";
export type { ContextMenuSeparatorSlots } from "./OContextMenuSeparator.types";
export interface ContextMenuProps {
/**
* Disables the menu without unmounting it — right-click falls through to the
* browser's native context menu. Use for read-only or permission-gated views.
*/
disabled?: boolean;
/** Whether the menu blocks interaction with the rest of the page (default: false) */
modal?: ContextMenuRootProps["modal"];
/** Extra class(es) for the content (menu) element */
contentClass?: string;
}
export interface ContextMenuEmits {
(e: "update:open", value: boolean): void;
}
export interface ContextMenuSlots {
/**
* The region that responds to right-click — rendered as-child, so it keeps
* its own tag and attributes (no wrapper element is introduced).
*/
trigger?: () => unknown;
/** OContextMenuItem / OContextMenuLabel / OContextMenuSeparator children */
default?: () => unknown;
}
@@ -0,0 +1,119 @@
<script setup lang="ts">
import type {
ContextMenuProps,
ContextMenuEmits,
ContextMenuSlots,
} from "./OContextMenu.types";
import {
ContextMenuRoot,
ContextMenuTrigger,
ContextMenuPortal,
ContextMenuContent,
} from "reka-ui";
import { onBeforeUnmount, ref, watch } from "vue";
import {
setActiveOverlay,
clearActiveOverlay,
} from "../Dropdown/ODropdown.context";
const props = withDefaults(defineProps<ContextMenuProps>(), {
disabled: false,
modal: false,
});
const emit = defineEmits<ContextMenuEmits>();
defineSlots<ContextMenuSlots>();
// ContextMenuRoot owns `open` internally (it must, since the pointer position
// is captured by the trigger at right-click time and there is no way to supply
// it from outside). We mirror the state so we can participate in the shared
// single-active-overlay coordination and close on scroll.
const isOpen = ref(false);
function handleOpenChange(open: boolean) {
isOpen.value = open;
emit("update:open", open);
}
// ContextMenuRoot owns `open` with no imperative close and no `open` prop, so
// the only way to dismiss it from outside is the path reka-ui already listens
// on: an Escape keystroke on `window` (DismissableLayer binds it there).
function closeSelf() {
if (!isOpen.value || typeof window === "undefined") return;
window.dispatchEvent(
new KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
);
}
// Opening a context menu must dismiss any dropdown/select left open, and vice
// versa — otherwise two unrelated overlays float at once.
watch(isOpen, (open) => {
if (open) setActiveOverlay(closeSelf);
else clearActiveOverlay(closeSelf);
});
onBeforeUnmount(() => clearActiveOverlay(closeSelf));
// The menu is anchored to a fixed pointer coordinate, so any scroll leaves it
// stranded away from the content it describes. Close instead. Scrolls that
// originate inside the menu itself (a long, scrollable menu) are ignored.
function handleViewportScroll(event: Event) {
if (!isOpen.value) return;
const target = event.target;
if (target instanceof Element && target.closest?.('[role="menu"]')) return;
closeSelf();
}
watch(isOpen, (open) => {
if (typeof window === "undefined") return;
if (open) {
window.addEventListener("scroll", handleViewportScroll, {
capture: true,
passive: true,
});
} else {
window.removeEventListener("scroll", handleViewportScroll, true);
}
});
onBeforeUnmount(() => {
if (typeof window !== "undefined") {
window.removeEventListener("scroll", handleViewportScroll, true);
}
});
</script>
<template>
<ContextMenuRoot :modal="modal" @update:open="handleOpenChange">
<ContextMenuTrigger :disabled="disabled" as-child>
<slot name="trigger" />
</ContextMenuTrigger>
<ContextMenuPortal>
<ContextMenuContent
:hide-when-detached="true"
:collision-padding="8"
:class="[
// Layout + stacking — z-menu is the shared overlay layer (tokens/base.css)
'min-w-40 p-1 z-menu',
// Surface — a context menu is visually the same object as a dropdown
// menu, so it deliberately shares the dropdown token set rather than
// duplicating one that would have to be kept in sync.
'bg-dropdown-bg border border-dropdown-border rounded-default shadow-md',
// Typography
'text-sm text-dropdown-item-text',
// Animation — same clip-path reveal as ODropdown, wiping away from
// the pointer. Soft ease-out-expo in, quick wipe out.
'data-[state=open]:animate-[o2-reveal-down-in_140ms_cubic-bezier(0.16,1,0.3,1)]',
'data-[state=closed]:animate-[o2-reveal-down-out_100ms_cubic-bezier(0.4,0,1,1)]',
'data-[side=top]:data-[state=open]:animate-[o2-reveal-up-in_140ms_cubic-bezier(0.16,1,0.3,1)]',
'data-[side=top]:data-[state=closed]:animate-[o2-reveal-up-out_100ms_cubic-bezier(0.4,0,1,1)]',
props.contentClass,
]"
>
<slot />
</ContextMenuContent>
</ContextMenuPortal>
</ContextMenuRoot>
</template>
@@ -0,0 +1,101 @@
import { describe, it, expect, vi } from "vitest";
import { h, nextTick } from "vue";
import { mount } from "@vue/test-utils";
import OContextMenu from "./OContextMenu.vue";
import OContextMenuItem from "./OContextMenuItem.vue";
// Reka UI portals content into <body>. Disable portal for unit tests.
vi.mock("reka-ui", async (importOriginal) => {
const actual = await importOriginal<typeof import("reka-ui")>();
return {
...actual,
ContextMenuPortal: actual.ContextMenuContent, // render inline
};
});
// OContextMenuItem requires a MenuRoot context — mount it inside an open menu.
async function mountItemInMenu(
itemProps: Record<string, unknown> = {},
itemSlots: Record<string, () => ReturnType<typeof h>> = {},
) {
const wrapper = mount(OContextMenu, {
slots: {
trigger: "<div data-testid='region'>Right click me</div>",
default: { render: () => h(OContextMenuItem, itemProps, itemSlots) },
},
attachTo: document.body,
});
await wrapper.find('[data-testid="region"]').trigger("contextmenu");
await nextTick();
await nextTick();
return wrapper;
}
describe("OContextMenuItem", () => {
it("renders slot content", async () => {
const wrapper = await mountItemInMenu(
{},
{ default: () => h("span", "Copy value") },
);
expect(wrapper.text()).toContain("Copy value");
});
it("renders icon-left slot", async () => {
const wrapper = await mountItemInMenu(
{},
{ "icon-left": () => h("span", { "data-testid": "icon-left" }, "←") },
);
expect(wrapper.find('[data-testid="icon-left"]').exists()).toBe(true);
});
it("renders icon-right slot", async () => {
const wrapper = await mountItemInMenu(
{},
{ "icon-right": () => h("span", { "data-testid": "icon-right" }, "→") },
);
expect(wrapper.find('[data-testid="icon-right"]').exists()).toBe(true);
});
it("sets data-disabled when disabled=true", async () => {
const wrapper = await mountItemInMenu({ disabled: true });
expect(wrapper.find("[data-disabled]").exists()).toBe(true);
});
it("applies default variant classes", async () => {
const wrapper = await mountItemInMenu(
{},
{ default: () => h("span", "Action") },
);
expect(wrapper.find(".text-dropdown-item-text").exists()).toBe(true);
});
it("applies destructive variant classes", async () => {
const wrapper = await mountItemInMenu(
{ variant: "destructive" },
{ default: () => h("span", "Delete") },
);
expect(wrapper.find(".text-dropdown-item-destructive-text").exists()).toBe(
true,
);
});
it("emits select when clicked", async () => {
const wrapper = await mountItemInMenu(
{},
{ default: () => h("span", "Copy") },
);
const item = wrapper.findComponent(OContextMenuItem);
await item.trigger("click");
expect(item.emitted("select")).toBeTruthy();
});
it("does not emit select when disabled", async () => {
const wrapper = await mountItemInMenu(
{ disabled: true },
{ default: () => h("span", "Copy") },
);
const item = wrapper.findComponent(OContextMenuItem);
await item.trigger("click");
expect(item.emitted("select")).toBeFalsy();
});
});
@@ -0,0 +1,44 @@
/**
* OContextMenuItem.types.ts — public types for OContextMenuItem.
*/
import type { IconName } from "../../core/Icon/OIcon.icons";
/** Visual intent of the item */
export type ContextMenuItemVariant = "default" | "destructive";
export interface ContextMenuItemProps {
/** Visual intent — use destructive for Delete/Remove actions */
variant?: ContextMenuItemVariant;
/** Prevents the user from interacting with this item */
disabled?: boolean;
/** Text used for typeahead matching (overrides text content) */
textValue?: string;
/** Icon placed before the label — renders an OIcon internally */
iconLeft?: IconName;
/**
* Renders a keyboard-shortcut hint (OShortcut keycaps) at the trailing edge
* of the item. Accepts a pre-symbolised string (`"⌘I"`) or an array of
* keycaps (`["⌘", "I"]`). Omit to show no hint.
*/
shortcut?: string | string[];
/**
* Registry shortcut id — resolves the hint keys from `shortcutRegistry.ts`
* (the single source of truth) so a key change there updates this item too.
* Prefer this over `shortcut`; `shortcut` wins if both are set.
*/
shortcutId?: string;
}
export interface ContextMenuItemEmits {
(e: "select", event: Event): void;
}
export interface ContextMenuItemSlots {
/** Icon placed before the label */
"icon-left"?: () => unknown;
/** Label text / content */
default?: () => unknown;
/** Icon placed after the label */
"icon-right"?: () => unknown;
}
@@ -0,0 +1,57 @@
<script setup lang="ts">
import type {
ContextMenuItemProps,
ContextMenuItemEmits,
ContextMenuItemSlots,
} from "./OContextMenuItem.types";
import { ContextMenuItem } from "reka-ui";
import OIcon from "../../core/Icon/OIcon.vue";
import OShortcut from "../../core/Shortcut/OShortcut.vue";
const props = withDefaults(defineProps<ContextMenuItemProps>(), {
disabled: false,
variant: "default",
});
const emit = defineEmits<ContextMenuItemEmits>();
defineSlots<ContextMenuItemSlots>();
const variantClasses: Record<
NonNullable<ContextMenuItemProps["variant"]>,
string
> = {
default:
"text-dropdown-item-text data-[highlighted]:bg-dropdown-item-hover-bg",
destructive:
"text-dropdown-item-destructive-text data-[highlighted]:bg-dropdown-item-destructive-hover-bg",
};
</script>
<template>
<ContextMenuItem
:disabled="disabled"
:text-value="textValue"
:class="[
'relative flex items-center gap-2',
'w-full px-3 py-1.5 rounded-default',
'cursor-pointer select-none outline-none',
'transition-colors duration-150',
variantClasses[variant],
'data-[disabled]:text-dropdown-item-disabled data-[disabled]:cursor-not-allowed',
]"
@select="(e: Event) => emit('select', e)"
>
<slot name="icon-left">
<OIcon v-if="props.iconLeft" :name="props.iconLeft" size="sm" />
</slot>
<slot />
<slot name="icon-right" />
<OShortcut
v-if="props.shortcut || props.shortcutId"
:keys="props.shortcut"
:id="props.shortcutId"
class="ms-auto ps-4"
/>
</ContextMenuItem>
</template>
@@ -0,0 +1,22 @@
import { describe, it, expect } from "vitest";
import { mount } from "@vue/test-utils";
import OContextMenuLabel from "./OContextMenuLabel.vue";
describe("OContextMenuLabel", () => {
it("renders slot content", () => {
const wrapper = mount(OContextMenuLabel, {
slots: { default: "kubernetes_namespace_name" },
});
expect(wrapper.text()).toContain("kubernetes_namespace_name");
});
it("applies label token class", () => {
const wrapper = mount(OContextMenuLabel);
expect(wrapper.classes().join(" ")).toContain("text-dropdown-label");
});
it("truncates long labels rather than widening the menu", () => {
const wrapper = mount(OContextMenuLabel);
expect(wrapper.classes().join(" ")).toContain("truncate");
});
});
@@ -0,0 +1,8 @@
/**
* OContextMenuLabel.types.ts — public types for OContextMenuLabel.
*/
export interface ContextMenuLabelSlots {
/** Label content — a non-interactive heading for the group beneath it */
default?: () => unknown;
}
@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { ContextMenuLabelSlots } from "./OContextMenuLabel.types";
import { ContextMenuLabel } from "reka-ui";
defineSlots<ContextMenuLabelSlots>();
</script>
<template>
<ContextMenuLabel
class="px-3 py-1.5 text-xs font-medium text-dropdown-label select-none truncate"
>
<slot />
</ContextMenuLabel>
</template>
@@ -0,0 +1,15 @@
import { describe, it, expect } from "vitest";
import { mount } from "@vue/test-utils";
import OContextMenuSeparator from "./OContextMenuSeparator.vue";
describe("OContextMenuSeparator", () => {
it("renders a separator element", () => {
const wrapper = mount(OContextMenuSeparator);
expect(wrapper.element).toBeTruthy();
});
it("applies separator bg class", () => {
const wrapper = mount(OContextMenuSeparator);
expect(wrapper.classes().join(" ")).toContain("bg-dropdown-separator");
});
});
@@ -0,0 +1,8 @@
/**
* OContextMenuSeparator.types.ts — public types for OContextMenuSeparator.
*/
export interface ContextMenuSeparatorSlots {
/** Separators render no content; the slot exists for API symmetry only. */
default?: () => unknown;
}
@@ -0,0 +1,7 @@
<script setup lang="ts">
import { ContextMenuSeparator } from "reka-ui";
</script>
<template>
<ContextMenuSeparator class="my-1 h-px bg-dropdown-separator" />
</template>
+2 -2
View File
@@ -282,8 +282,8 @@ onBeforeUnmount(() => {
@pointer-down-outside="handlePointerDownOutside"
@focus-outside="handleFocusOutside"
:class="[
// Layout + stacking (must be above the app header/drawer: 2000/3000)
'min-w-40 p-1 z-[6000]',
// Layout + stacking — z-menu is the shared overlay layer (tokens/base.css)
'min-w-40 p-1 z-menu',
// Surface
'bg-dropdown-bg border border-dropdown-border rounded-default shadow-md',
// Typography
+12
View File
@@ -296,6 +296,18 @@
own knob so it holds independent of --radius-default. */
--radius-checkbox: 0.25rem; /* 4px — checkbox tick box only */
/* --- Stacking scale ---
Portaled floating surfaces need a shared knob, not a number typed at each
call site. The app stack, for context: header 2000, drawer/dialog scrim
5999, drawer/dialog content 6000 (+1000 per nesting level, computed in
ODialog/ODrawer).
--z-menu sits at the base overlay layer: a menu opened from the page must
clear the header, and one opened from a dialog is portaled after that
dialog's content, so equal z + later DOM order puts it on top.
Consumed as the `z-menu` utility (registered in styles/tailwind.css) —
never as an arbitrary z-index at the call site. */
--z-menu: 6000; /* dropdown / context menu surfaces */
/* --- Shadow scale — hairline-border philosophy ---
Only md/lg render real elevation (overlays: dialogs, menus). Cards use a
1px border, not a drop shadow — a flat surface needs NO shadow class. */
+3 -1
View File
@@ -8981,7 +8981,9 @@
"cellActions": {
"copy": "Copy",
"includeTerm": "Include Term",
"excludeTerm": "Exclude Term"
"excludeTerm": "Exclude Term",
"sendValueToAi": "Send Value to AI",
"sendRowToAi": "Send Row to AI"
},
"indexList": {
"noMatchingFields": "No matching fields found",
+18 -11
View File
@@ -237,7 +237,7 @@ describe("DetailTable Component", () => {
});
it("should initialize reactive data correctly", () => {
expect(wrapper.vm.tab).toBe("json");
expect(wrapper.vm.tab).toBe("table");
expect(wrapper.vm.selectedRelativeValue).toBe(10);
expect(wrapper.vm.shouldWrapValues).toBe(true);
expect(wrapper.vm.recordSizeOptions).toEqual([
@@ -307,10 +307,10 @@ describe("DetailTable Component", () => {
});
// Test 11-15: Tab Functionality
it("should start with JSON tab active", () => {
expect(wrapper.vm.tab).toBe("json");
const jsonTab = wrapper.find('[data-test="log-detail-json-tab"]');
expect(jsonTab.exists()).toBe(true);
it("should start with Table tab active", () => {
expect(wrapper.vm.tab).toBe("table");
const tableTab = wrapper.find('[data-test="log-detail-table-tab"]');
expect(tableTab.exists()).toBe(true);
// OTab uses Tailwind classes for active state
});
@@ -442,15 +442,22 @@ describe("DetailTable Component", () => {
expect(wrapper.vm.shouldWrapValues).toBe(!initialValue);
});
// Asserts the handler's observable effect rather than spying on the instance
// method: the template captures the handler into the vnode at render time, so
// a spy installed on wrapper.vm afterwards is only picked up if something
// forces a re-render — which made the old spy-based version silently depend
// on `tab` actually changing here.
it("should call toggleWrapLogDetails when wrap toggle changed", async () => {
const spy = vi.spyOn(wrapper.vm, "toggleWrapLogDetails");
wrapper.vm.tab = "table";
await nextTick();
const setItemSpy = vi.spyOn(Storage.prototype, "setItem");
const initialValue = wrapper.vm.shouldWrapValues;
const wrapToggle = wrapper.find('[data-test="log-detail-wrap-values-toggle-btn"]');
await wrapToggle.trigger("click");
expect(spy).toHaveBeenCalled();
expect(setItemSpy).toHaveBeenCalledWith(
"wrap-log-details",
String(!initialValue),
);
});
it("should update localStorage when toggleWrapLogDetails called", () => {
+131 -32
View File
@@ -22,35 +22,20 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- Single Tab Row -->
<div class="flex justify-between items-center shrink-0">
<div class="flex items-center gap-2 -mb-0.75">
<OTabs v-model="tab" align="left">
<OTabs
v-model="tab"
align="left"
reorderable
@reorder="onTabReorder"
>
<!-- Correlation tabs are only present when service streams are enabled
and an enterprise license is active; see availableTabs. -->
<OTab
data-test="log-detail-json-tab"
name="json"
:label="t('common.json')"
/>
<OTab
data-test="log-detail-table-tab"
name="table"
:label="t('common.table')"
/>
<!-- Correlation Tabs (only visible when service streams enabled and enterprise license) -->
<OTab
data-test="correlated-logs-tab"
v-if="serviceStreamsEnabled && config.isEnterprise === 'true'"
name="correlated-logs"
:label="t('correlation.correlatedLogs')"
/>
<OTab
data-test="correlated-metrics-tab"
v-if="serviceStreamsEnabled && config.isEnterprise === 'true'"
name="correlated-metrics"
:label="t('correlation.correlatedMetrics')"
/>
<OTab
data-test="correlated-traces-tab"
v-if="serviceStreamsEnabled && config.isEnterprise === 'true'"
name="correlated-traces"
:label="t('correlation.correlatedTraces')"
v-for="tabItem in tabOrder"
:key="tabItem.name"
:data-test="tabItem.dataTest"
:name="tabItem.name"
:label="tabItem.label"
/>
</OTabs>
</div>
@@ -318,7 +303,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:fts-fields="correlationProps.ftsFields"
:time-range="correlationProps.timeRange"
:hide-dimension-filters="true"
@close="tab = 'json'"
@close="tab = 'table'"
/>
<!-- Loading/Empty state when no data -->
<div v-else class="flex items-center justify-center h-full py-20">
@@ -351,7 +336,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:fts-fields="correlationProps.ftsFields"
:time-range="correlationProps.timeRange"
:hide-dimension-filters="true"
@close="tab = 'json'"
@close="tab = 'table'"
/>
<!-- Loading/Empty state when no data -->
<div v-else class="flex items-center justify-center h-full py-20">
@@ -513,7 +498,7 @@ export default defineComponent({
},
initialTab: {
type: String,
default: "json",
default: "table",
},
},
methods: {
@@ -546,7 +531,7 @@ export default defineComponent({
const store = useStore();
const { isDark } = useTheme();
const tableDropdownOpenMap = reactive<Record<string, boolean>>({});
const tab = ref(props.initialTab || "json");
const tab = ref(props.initialTab || "table");
const selectedRelativeValue = ref<number>(10);
const recordSizeOptions = ref<Array<{ label: string; value: number }>>([
{ label: "10", value: 10 },
@@ -659,6 +644,118 @@ export default defineComponent({
return store.state.zoConfig.service_streams_enabled !== false;
});
// Tab bar is drag-to-reorder (same pattern as the home page): the list of
// tabs that *may* be shown is derived from config, while the user's chosen
// order lives in localStorage.
const LS_TAB_ORDER_KEY = "o2_log_detail_tab_order";
const availableTabs = computed(() => {
const tabs = [
{
name: "table",
label: t("common.table"),
dataTest: "log-detail-table-tab",
},
{
name: "json",
label: t("common.json"),
dataTest: "log-detail-json-tab",
},
];
if (serviceStreamsEnabled.value && config.isEnterprise === "true") {
tabs.push(
{
name: "correlated-logs",
label: t("correlation.correlatedLogs"),
dataTest: "correlated-logs-tab",
},
{
name: "correlated-metrics",
label: t("correlation.correlatedMetrics"),
dataTest: "correlated-metrics-tab",
},
{
name: "correlated-traces",
label: t("correlation.correlatedTraces"),
dataTest: "correlated-traces-tab",
},
);
}
return tabs;
});
type DetailTab = { name: string; label: string; dataTest: string };
const loadTabOrder = (): DetailTab[] => {
try {
const saved = window.localStorage.getItem(LS_TAB_ORDER_KEY);
if (saved) {
const names: string[] = JSON.parse(saved);
const ordered = names
.map((name) => availableTabs.value.find((t) => t.name === name))
.filter(Boolean) as DetailTab[];
// Append any tab that is available but missing from the saved order.
availableTabs.value.forEach((t) => {
if (!ordered.find((o) => o.name === t.name)) ordered.push(t);
});
return ordered;
}
} catch {
/* ignore: fall back to the default tab order */
}
return [...availableTabs.value];
};
const tabOrder = ref<DetailTab[]>(loadTabOrder());
// Enterprise/service-stream config arrives asynchronously, so re-sync the
// order whenever the set of available tabs changes — keeping the user's
// order and appending anything new.
watch(
() => availableTabs.value.map((t) => t.name).join(","),
() => {
const merged: DetailTab[] = [];
tabOrder.value.forEach((t) => {
const match = availableTabs.value.find((a) => a.name === t.name);
if (match) merged.push(match);
});
availableTabs.value.forEach((a) => {
if (!merged.find((m) => m.name === a.name)) merged.push(a);
});
tabOrder.value = merged;
},
);
// OTabs reports the intended move (dragged name → target name + which side
// of the target); we apply it to our own list and persist it.
const onTabReorder = ({
from,
to,
before = true,
}: {
from: string | number;
to: string | number;
before?: boolean;
}) => {
if (from === to) return;
const order = [...tabOrder.value];
const fromIdx = order.findIndex((t) => t.name === from);
if (fromIdx === -1) return;
const [moved] = order.splice(fromIdx, 1);
// Recompute the target index after removal, then insert on the chosen side.
let toIdx = order.findIndex((t) => t.name === to);
if (toIdx === -1) return;
if (!before) toIdx += 1;
order.splice(toIdx, 0, moved);
tabOrder.value = order;
window.localStorage.setItem(
LS_TAB_ORDER_KEY,
JSON.stringify(order.map((t) => t.name)),
);
};
onBeforeMount(() => {
if (window.localStorage.getItem("wrap-log-details") === null) {
window.localStorage.setItem("wrap-log-details", "true");
@@ -897,6 +994,8 @@ export default defineComponent({
tableColumns,
tableRows,
serviceStreamsEnabled,
tabOrder,
onTabReorder,
config,
getContentSize,
getDisplayValue,
+2 -2
View File
@@ -1145,7 +1145,7 @@ export default defineComponent({
const correlationDashboardProps = ref<any>(null);
const correlationLoading = ref(false);
const correlationError = ref<string | null>(null);
const detailTableInitialTab = ref<string>("json");
const detailTableInitialTab = ref<string>("table");
const { findRelatedTelemetry, semanticGroups } = useServiceCorrelation();
// Flag to prevent duplicate correlation API calls
@@ -1441,7 +1441,7 @@ export default defineComponent({
const openLogDetails = (props: any, index: number) => {
searchObj.meta.showDetailTab = true;
searchObj.meta.resultGrid.navigation.currentRowIndex = index;
detailTableInitialTab.value = "json"; // Reset to default tab
detailTableInitialTab.value = "table"; // Reset to default tab
// Prepare correlation context (but don't open panel automatically)
const logData = searchObj.data.queryResults?.hits?.[index];
+2 -4
View File
@@ -99,8 +99,6 @@ vi.mock("@/utils/logs/statusParser", () => ({
// ── Stubs ──────────────────────────────────────────────────────────────────────
const globalStubs = {
JsonPreview: { template: "<div />" },
CellActions: { template: "<div />" },
O2AIContextAddBtn: { template: "<div />" },
FlexRender: { template: "<span />" },
};
@@ -147,9 +145,9 @@ describe("TenstackTable", () => {
expect(wrapper.vm.isFunctionErrorOpen).toBe(false);
});
it("initializes activeCellActionId as empty string", () => {
it("initializes contextCell as null", () => {
const wrapper = mountComponent();
expect(wrapper.vm.activeCellActionId).toBe("");
expect(wrapper.vm.contextCell).toBeNull();
});
it("initializes expandedRowIndices as empty Set", () => {
File diff suppressed because it is too large Load Diff
+8
View File
@@ -22,6 +22,14 @@
@source "../**/*.{vue,js,ts}";
/* Stacking utility for portaled floating surfaces. Tailwind v4 has no z-index
theme namespace (`z-*` only takes bare numbers or arbitrary values), so the
token is exposed as a real utility here rather than left as an arbitrary
z-index at each call site. Value lives in tokens/base.css — change it there. */
@utility z-menu {
z-index: var(--z-menu);
}
/*
* Light↔dark mode switch — soft curtain sweep (View Transitions API, started
* by switchThemeMode in utils/theme.ts). The outgoing theme holds fully opaque