fix(ui): sharpen visual polish, keyboard focus and contrast across logs, tables and charts (#15993)

Co-authored-by: ktx-vaidehi <vaidehi.akhani@kiara.tech>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Ashish Kolhe
2026-10-29 10:39:35 +00:00
committed by GitHub
co-authored by ktx-vaidehi github-actions[bot]
parent a558edd763
commit 6bbdcd7d7e
105 changed files with 1263 additions and 316 deletions
@@ -27,7 +27,7 @@ Authoring reference for O2's core control components (Button, Navbar, RefreshBut
**Key props:**
- `variant` (default `"primary"`) — one of: `primary`, `secondary`, `outline`, `ghost`, `ghost-primary`, `ghost-muted`, `ghost-subtle`, `ghost-destructive`, `ghost-success`, `ghost-warning`, `ghost-neutral`, `outline-destructive`, `sidebar-toggle`, `panel-collapse`, `sidebar-button`, `destructive`, `ai-gradient`, `on-dark-primary`, `on-dark-ghost`, `warning`, `pending`, `preview-slack`, `preview-teams`, `preview-email`, `preview-action`, `webinar-dismiss`, `pricing-chip`, `outline-primary`, and the four tinted-fill variants `tint-teal`, `tint-purple`, `tint-blue`, `tint-orange` (soft categorical fill for tiles that identify an entity by colour, e.g. a cluster code; not for status)
- `size` (default `"md"`) — one of: `xs`, `chip`, `sm`, `sm-action`, `md`, `lg`, `icon`, `icon-xs`, `icon-xs-circle`, `icon-xs-sq`, `icon-chip`, `icon-sm`, `icon-md`, `icon-lg`, `icon-circle`, `icon-circle-sm`, `icon-toolbar`, `icon-panel`, `sidebar-button`, `sm-toolbar`, `chip-12`
- `size` (default `"md"`) — one of: `xs`, `chip`, `sm`, `sm-action`, `md`, `cta`, `lg`, `icon`, `icon-xs`, `icon-xs-circle`, `icon-xs-sq`, `icon-chip`, `icon-sm`, `icon-md`, `icon-lg`, `icon-circle`, `icon-circle-sm`, `icon-toolbar`, `icon-panel`, `sidebar-button`, `sm-toolbar`, `chip-12`
- `iconLeft` (`IconName`) — renders an OIcon before the label; `#icon-left` slot takes precedence if present
- `iconRight` (`IconName`) — renders an OIcon after the label; `#icon-right` slot takes precedence if present
- `loading` (boolean, default `false`) — shows a centered spinner overlay, keeps dimensions, disables interaction
@@ -73,6 +73,7 @@ Authoring reference for O2's core control components (Button, Navbar, RefreshBut
- `orientation` (`"horizontal"` | `"vertical"`, default `"horizontal"`)
- `align` (`"stretch"` | `"center"` | `"start"` | `"end"`, default `"stretch"`) — cross-axis alignment of children
- `radius` (`"sm"` | `"md"` | `"lg"`, default `"md"`) — must match the inner buttons' radius; use `"sm"` when children use `size="chip"` or `size="icon-chip"`
- The group never clips overflow (a clip hides a child's offset focus outline) and lifts the focused child with `z-1`; each child paints its own outer corners, so wrap non-OButton children that need clipping yourself
**Slots:** `default` (one or more OButton children)
**Emits:** none
**Example:**
@@ -22179,6 +22179,7 @@
"removeFieldFromTable": "c308c5301db25ec7",
"resetFields": "7558552898692240",
"resetFilters": "10afa98480f2d06c",
"resultsProgressLabel": "1f6c9561558905ec",
"rowDetail": "2ef826b753739aff",
"rows": "bc51e9e65d79e1c9",
"runQuery": "1837e00c45e8663d",
@@ -22190,6 +22191,7 @@
"savedViewsLabel": "edc47cfbced35fc7",
"savedViewsNotFound": "d271ae2e71620984",
"scanSize": "b41c8c7779b8cc15",
"scannedSoFar": "ff88237fc82a362e",
"scheduleSearchJob": "21daedde3d118f2b",
"searchCancelledNotice": "2949f29dd9e1046e",
"searchDimension": "567db4697413719b",
@@ -31,6 +31,8 @@ const runBtn = '[data-test="logs-search-bar-refresh-btn"]';
const table = '[data-test="logs-search-result-logs-table"]';
const pager = '[data-test="logs-search-result-pagination"]';
const jsonContent = '[data-test="log-detail-json-content"]';
// Syntax-highlight colours are tuned for white rows, not the selected fill; AC8.2 covers the J/K surfaces, not log colouring.
const activeRowLogText = `${table} [data-active-row] .log-cell-html`;
const b64 = (text) =>
Buffer.from(text, "utf8")
@@ -608,6 +610,7 @@ test.describe("Logs J/K navigation (item 4a)", () => {
`${logs.logDetailDialog} .select-noof-records`,
`${logs.logDetailDialog} label`,
`${logs.logDetailDialog} [role="tab"]`,
activeRowLogText,
],
);
expect(drawerScan.passes).toBeGreaterThan(0);
@@ -615,10 +618,11 @@ test.describe("Logs J/K navigation (item 4a)", () => {
await page.keyboard.press("Escape");
await expect(logs.logResultsRow(1)).toBeFocused();
const rowScan = await axeScan(page, [
`${table} [data-active-row]`,
logs.logsRowNavLive,
]);
const rowScan = await axeScan(
page,
[`${table} [data-active-row]`, logs.logsRowNavLive],
[activeRowLogText],
);
expect(rowScan.passes).toBeGreaterThan(0);
expect(rowScan.violations).toEqual([]);
@@ -612,11 +612,11 @@ test.describe("Severity Color Mapping Tests - Issue #9439", () => {
emergency: '#e53935', // severity 0 → level field
alert: '#ea580c', // severity 1
critical: '#f4511e', // severity 2
error: '#ef5350', // severity 3
warning: '#dd7b00', // severity 4
error: '#e53935', // severity 3
warning: '#c46a00', // severity 4
notice: '#16a34a', // severity 5
info: '#1e88e5', // severity 6
debug: '#00a3b7', // severity 7
info: '#1b80d9', // severity 6
debug: '#008a9b', // severity 7
};
// Wait for the results table to actually render its per-row status color bars
@@ -255,6 +255,7 @@ const joinSlack = () => {
<OButton
data-test="community-slack-invite-join-btn"
variant="primary"
size="cta"
class="flex-1"
@click="joinSlack"
>
@@ -266,6 +267,7 @@ const joinSlack = () => {
<OButton
data-test="community-slack-invite-maybe-later-btn"
variant="outline"
size="cta"
@click="dismiss"
>
{{ t("communitySlackInvite.maybeLater") }}
@@ -275,6 +275,7 @@ const handleOpenChange = (open: boolean) => {
<OButton
data-test="connect-data-source-popup-connect-btn"
variant="primary"
size="cta"
class="w-full"
@click="connectDataSource"
>
+1 -1
View File
@@ -17,7 +17,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<template>
<div
icon="info"
class="date-time-container inline-flex items-stretch"
class="date-time-container inline-flex items-stretch [&_button:focus-visible]:z-1"
:class="{
'rounded-default border-button-outline-border min-h-7.5 border': !hideRangeShift,
'w-full [&>*]:flex-1': fieldAppearance,
+1 -1
View File
@@ -29,7 +29,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<span :id="recentKeysHintId" class="sr-only">{{ t("common.timeRangeRecentKeysHint") }}</span>
<div
v-if="recent.length === 0"
class="text-text-muted px-2 pb-1 text-xs"
class="text-text-secondary px-2 pb-1 text-xs"
data-test="date-time-rail-recent-empty"
>
{{ t("common.timeRangeRecentEmpty") }}
+1 -1
View File
@@ -417,7 +417,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
ref="languageListRef"
role="group"
:aria-label="t('menu.language')"
class="rounded-default bg-dropdown-bg border-dropdown-border absolute end-full top-0 z-9999 me-1 min-w-50 border py-1 shadow-lg max-lg:static max-lg:me-0 max-lg:mt-1 max-lg:w-full max-lg:min-w-0 max-lg:shadow-none dark:shadow-lg"
class="rounded-default bg-dropdown-bg shadow-overlay max-lg:border-dropdown-border absolute end-full top-0 z-9999 me-1 min-w-50 border border-transparent py-1 max-lg:static max-lg:me-0 max-lg:mt-1 max-lg:w-full max-lg:min-w-0 max-lg:shadow-none"
data-test="language-dropdown-item"
@keydown="onLanguageListKeydown"
>
+1 -1
View File
@@ -61,7 +61,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</div>
<div
ref="labelRef"
class="nav-menu-item-label line-clamp-2 w-full text-center text-xs leading-tight tracking-[0.01em] break-normal wrap-normal text-ellipsis [hyphens:none] transition-colors duration-250 max-md:text-start max-md:text-sm"
class="nav-menu-item-label line-clamp-2 w-full text-center text-xs leading-tight tracking-[0.01em] break-normal wrap-normal text-ellipsis [hyphens:none] transition-colors duration-250 max-md:text-start max-md:text-sm max-md:leading-snug"
:class="labelClass"
>
{{ title }}
+1 -1
View File
@@ -119,7 +119,7 @@
size="icon-toolbar"
:disabled="props.disableAi"
@click="onAiToggleClick"
class="group text-text-inverse! rounded-default bg-gradient-ai-subtle! hover:bg-gradient-ai! hover:shadow-ai-accent/35! absolute! top-0.75 z-100 h-7.5! min-h-7.5! w-7.5! min-w-7.5! [transition:background_0.3s_ease,box-shadow_0.3s_ease]! hover:shadow-md"
class="group text-text-inverse! rounded-default bg-gradient-ai-subtle! hover:bg-gradient-ai! hover:shadow-ai-accent/35! absolute! top-1 z-100 h-7! min-h-7! w-7! min-w-7! [transition:background_0.3s_ease,box-shadow_0.3s_ease]! hover:shadow-md"
:style="props.hasExpandButton ? { right: '2.375rem' } : { right: '0.25rem' }"
>
<img
+1 -1
View File
@@ -18,7 +18,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<OCollapsible
:model-value="isExpanded"
@update:model-value="handleToggle"
class="field-expansion-item rounded-default w-full overflow-hidden"
class="field-expansion-item rounded-default w-full overflow-clip [overflow-clip-margin:0.25rem]"
trigger-class="px-0! py-0!"
>
<template #trigger>
@@ -122,7 +122,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- Pagination and Reset Controls -->
<div class="flex items-center justify-end gap-1">
<!-- Pagination -->
<div v-if="pagesNumber > 1" class="flex items-center gap-0.5">
<div v-if="pagesNumber > 1" class="flex h-7 items-start gap-0.5 pt-px">
<OTooltip
:content="t('common.totalFields', { count: totalFieldsCount })"
max-width="18.75rem"
@@ -134,7 +134,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<OButton
:data-test="`${dataTestPrefix}-fields-list-pagination-firstpage-button`"
variant="ghost"
size="icon-xs-sq"
size="icon-chip"
:disabled="isFirstPage"
@click="$emit('first-page')"
:aria-label="t('search.firstPage')"
@@ -146,7 +146,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<template v-for="page in visiblePages" :key="page">
<OButton
:variant="currentPage === page ? 'primary' : 'ghost'"
size="icon-xs-sq"
size="icon-chip"
:data-test="`${dataTestPrefix}-fields-list-pagination-page-${page}-button`"
@click="$emit('set-page', page)"
>
@@ -158,7 +158,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<OButton
:data-test="`${dataTestPrefix}-fields-list-pagination-lastpage-button`"
variant="ghost"
size="icon-xs-sq"
size="icon-chip"
:disabled="isLastPage"
@click="$emit('last-page')"
:aria-label="t('search.lastPage')"
@@ -59,7 +59,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</OButton>
</div>
<div
class="filter-mode-toggle border-card-glass-border rounded-default flex overflow-hidden border"
class="filter-mode-toggle border-card-glass-border rounded-default flex overflow-hidden border [&_button:focus-visible]:-outline-offset-2!"
data-test="field-values-panel-filter-mode-toggle"
>
<OButton
@@ -38,7 +38,7 @@
v-if="(groupFieldCount[row.group] ?? 0) > 0"
variant="ghost"
size="icon"
class="flex-shrink-0"
class="size-5! flex-shrink-0"
>
<OIcon
:name="expandGroupRows[row.group] !== false ? 'expand-more' : 'chevron-right'"
@@ -34,7 +34,7 @@
v-if="(groupFieldCount[row.group] ?? 0) > 0"
variant="ghost"
size="icon"
class="flex-shrink-0"
class="size-5! flex-shrink-0"
>
<OIcon
:name="expandGroupRows[row.group] !== false ? 'expand-more' : 'chevron-right'"
@@ -19,7 +19,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<div
v-if="visible"
ref="menuRef"
class="bg-dropdown-bg border-dropdown-border rounded-default fixed z-9999 max-w-[calc(100vw-1rem)] min-w-70 border px-0 py-1 shadow-sm dark:shadow-sm"
class="bg-dropdown-bg rounded-default shadow-overlay fixed z-9999 max-w-[calc(100vw-1rem)] min-w-70 border border-transparent px-0 py-1"
:style="menuStyle"
@click.stop
data-test="alert-context-menu"
@@ -237,7 +237,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</div>
<div
class="rounded-default border-dropdown-border bg-dropdown-bg absolute top-0 left-0 z-9999999 hidden min-w-50 border px-0 py-1 whitespace-nowrap shadow-sm dark:shadow-sm"
class="rounded-default bg-dropdown-bg shadow-overlay absolute top-0 left-0 z-9999999 hidden min-w-50 border border-transparent px-0 py-1 whitespace-nowrap"
data-test="drilldown-menu"
ref="drilldownPopUpRef"
@mouseleave="hidePopupsAndOverlays"
@@ -81,7 +81,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
&nbsp;
</div>
<OButtonGroup
class="axis-field border-border-default border-s-badge-indigo-ol-border bg-surface-panel overflow-hidden border border-s-2 [&>*:not(:first-child)]:!border-s"
class="axis-field border-border-default border-s-badge-indigo-ol-border bg-surface-panel overflow-hidden border border-s-2 [&_button:focus-visible]:-outline-offset-2! [&>*:not(:first-child)]:!border-s"
radius="sm"
:divided="true"
:draggable="isDragArmed('x-' + index)"
@@ -263,7 +263,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
&nbsp;
</div>
<OButtonGroup
class="axis-field border-border-default border-s-badge-orange-ol-border bg-surface-panel overflow-hidden border border-s-2 [&>*:not(:first-child)]:!border-s"
class="axis-field border-border-default border-s-badge-orange-ol-border bg-surface-panel overflow-hidden border border-s-2 [&_button:focus-visible]:-outline-offset-2! [&>*:not(:first-child)]:!border-s"
radius="sm"
:divided="true"
:draggable="isDragArmed('breakdown-' + index)"
@@ -400,7 +400,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
&nbsp;
</div>
<OButtonGroup
class="axis-field border-border-default border-s-badge-success-ol-border bg-surface-panel overflow-hidden border border-s-2 [&>*:not(:first-child)]:!border-s"
class="axis-field border-border-default border-s-badge-success-ol-border bg-surface-panel overflow-hidden border border-s-2 [&_button:focus-visible]:-outline-offset-2! [&>*:not(:first-child)]:!border-s"
radius="sm"
:divided="true"
:draggable="isDragArmed('y-' + index)"
@@ -536,7 +536,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
&nbsp;
</div>
<OButtonGroup
class="axis-field border-border-default border-s-badge-success-ol-border bg-surface-panel overflow-hidden border border-s-2 [&>*:not(:first-child)]:!border-s"
class="axis-field border-border-default border-s-badge-success-ol-border bg-surface-panel overflow-hidden border border-s-2 [&_button:focus-visible]:-outline-offset-2! [&>*:not(:first-child)]:!border-s"
radius="sm"
:divided="true"
:draggable="isDragArmed('z-' + index)"
@@ -0,0 +1,162 @@
// Copyright 2026 OpenObserve Inc.
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { effectScope, nextTick, reactive, ref } from "vue";
import {
MIB_IN_BYTES,
RESULTS_LOADER_DELAY_MS,
formatScannedSize,
scanSizeMibToBytes,
useDelayedFlag,
useResultsLoadingIndicator,
} from "./useResultsLoadingIndicator";
describe("scanSizeMibToBytes", () => {
it("converts MiB to bytes with a 1024 * 1024 factor", () => {
expect(MIB_IN_BYTES).toBe(1_048_576);
expect(scanSizeMibToBytes(1)).toBe(1_048_576);
expect(scanSizeMibToBytes(0.5)).toBe(524_288);
expect(scanSizeMibToBytes(1024)).toBe(1_073_741_824);
});
it("returns 0 for missing, negative or non-numeric sizes", () => {
expect(scanSizeMibToBytes(undefined)).toBe(0);
expect(scanSizeMibToBytes(null)).toBe(0);
expect(scanSizeMibToBytes(-3)).toBe(0);
expect(scanSizeMibToBytes("abc")).toBe(0);
});
});
describe("formatScannedSize", () => {
it("formats a MiB scan size in binary byte units", () => {
expect(formatScannedSize(1)).toBe("1.00 MB");
expect(formatScannedSize(1536)).toBe("1.50 GB");
expect(formatScannedSize(0.5)).toBe("512.00 KB");
});
it("returns null when nothing has been scanned", () => {
expect(formatScannedSize(0)).toBeNull();
expect(formatScannedSize(undefined)).toBeNull();
});
});
describe("useDelayedFlag", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
const run = (source: ReturnType<typeof ref<boolean>>) => {
const scope = effectScope();
const flag = scope.run(() => useDelayedFlag(() => !!source.value))!;
return { flag, scope };
};
it("stays off for a query that settles inside the delay", async () => {
const loading = ref(false);
const { flag, scope } = run(loading);
loading.value = true;
await nextTick();
vi.advanceTimersByTime(RESULTS_LOADER_DELAY_MS - 1);
expect(flag.value).toBe(false);
loading.value = false;
await nextTick();
vi.advanceTimersByTime(RESULTS_LOADER_DELAY_MS);
expect(flag.value).toBe(false);
scope.stop();
});
it("turns on once the source has stayed true for the delay", async () => {
const loading = ref(false);
const { flag, scope } = run(loading);
loading.value = true;
await nextTick();
vi.advanceTimersByTime(RESULTS_LOADER_DELAY_MS);
expect(flag.value).toBe(true);
scope.stop();
});
it("turns off at once when the source turns off", async () => {
const loading = ref(true);
const { flag, scope } = run(loading);
vi.advanceTimersByTime(RESULTS_LOADER_DELAY_MS);
expect(flag.value).toBe(true);
loading.value = false;
await nextTick();
expect(flag.value).toBe(false);
scope.stop();
});
it("drops a pending timer when its scope is disposed", async () => {
const loading = ref(true);
const { flag, scope } = run(loading);
scope.stop();
vi.advanceTimersByTime(RESULTS_LOADER_DELAY_MS);
expect(flag.value).toBe(false);
});
});
describe("useResultsLoadingIndicator", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
const setup = () => {
const searchObj = reactive({
loading: false,
data: { queryResults: { scan_size: 300 } as { scan_size?: number } | null },
});
const scope = effectScope();
const indicator = scope.run(() => useResultsLoadingIndicator(searchObj))!;
return { searchObj, scope, ...indicator };
};
it("shows the size scanned by a fresh run once the delay has passed", async () => {
const { searchObj, scope, showLoader, scannedSize } = setup();
searchObj.loading = true;
await nextTick();
searchObj.data.queryResults = {};
vi.advanceTimersByTime(RESULTS_LOADER_DELAY_MS);
expect(showLoader.value).toBe(true);
expect(scannedSize.value).toBeNull();
searchObj.data.queryResults = { scan_size: 1024 };
expect(scannedSize.value).toBe("1.00 GB");
scope.stop();
});
it("hides the scanned size before the delay and after the run", async () => {
const { searchObj, scope, scannedSize } = setup();
searchObj.loading = true;
await nextTick();
searchObj.data.queryResults = { scan_size: 10 };
expect(scannedSize.value).toBeNull();
vi.advanceTimersByTime(RESULTS_LOADER_DELAY_MS);
expect(scannedSize.value).toBe("10.00 MB");
searchObj.loading = false;
await nextTick();
expect(scannedSize.value).toBeNull();
scope.stop();
});
it("never shows the previous run's size while a page load mutates results in place", async () => {
const { searchObj, scope, showLoader, scannedSize } = setup();
searchObj.loading = true;
await nextTick();
vi.advanceTimersByTime(RESULTS_LOADER_DELAY_MS);
expect(showLoader.value).toBe(true);
expect(scannedSize.value).toBeNull();
searchObj.data.queryResults!.scan_size = 20;
expect(scannedSize.value).toBeNull();
scope.stop();
});
});
@@ -0,0 +1,99 @@
// Copyright 2026 OpenObserve Inc.
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import {
computed,
onScopeDispose,
readonly,
ref,
shallowRef,
watch,
type ComputedRef,
type Ref,
type WatchSource,
} from "vue";
import { getUnitValue } from "@/utils/dashboard/convertDataIntoUnitValue";
export const RESULTS_LOADER_DELAY_MS = 300;
// Search responses carry scan_size in MiB (format_to_mb on the server); partition totals (original_size) are raw bytes.
export const MIB_IN_BYTES = 1024 * 1024;
interface ResultsLoadingState {
loading: boolean;
data: { queryResults?: { scan_size?: number } | null };
}
/** Converts a search response scan_size (MiB) to bytes; anything non-positive or non-numeric is 0. */
export const scanSizeMibToBytes = (mib: unknown): number => {
const value = Number(mib);
return Number.isFinite(value) && value > 0 ? value * MIB_IN_BYTES : 0;
};
/** Formats a MiB scan_size with the shared byte formatter, or null when nothing was scanned. */
export const formatScannedSize = (mib: unknown): string | null => {
const bytes = scanSizeMibToBytes(mib);
if (bytes === 0) return null;
const { value, unit } = getUnitValue(bytes, "bytes", "");
return `${value} ${unit}`;
};
/** Follows `source`, but turns on only after it has stayed true for `delayMs`; it turns off at once. */
export function useDelayedFlag(
source: WatchSource<boolean>,
delayMs = RESULTS_LOADER_DELAY_MS,
): Readonly<Ref<boolean>> {
const shown = ref(false);
let timer: ReturnType<typeof setTimeout> | undefined;
watch(
source,
(on) => {
clearTimeout(timer);
if (!on) {
shown.value = false;
return;
}
timer = setTimeout(() => {
shown.value = true;
}, delayMs);
},
{ immediate: true },
);
onScopeDispose(() => clearTimeout(timer));
return readonly(shown);
}
/** The delayed results loader, plus the size scanned so far by a fresh run (null while paging). */
export function useResultsLoadingIndicator(searchObj: ResultsLoadingState): {
showLoader: Readonly<Ref<boolean>>;
scannedSize: ComputedRef<string | null>;
} {
const showLoader = useDelayedFlag(() => !!searchObj.loading);
const runStartResults = shallowRef<unknown>(undefined);
watch(
() => searchObj.loading,
(on) => {
if (on) runStartResults.value = searchObj.data.queryResults;
},
{ immediate: true },
);
// A fresh run replaces queryResults while paging mutates it in place, so an unchanged object still holds the last run's size.
const scannedSize = computed(() => {
if (!showLoader.value) return null;
const results = searchObj.data.queryResults;
if (!results || results === runStartResults.value) return null;
return formatScannedSize(results.scan_size);
});
return { showLoader, scannedSize };
}
@@ -175,7 +175,7 @@
<!-- One control, not one per role: the common case is continuing the
conversation, and the role that continues it is derivable. The chevron
is there for the times it is not. -->
<div class="inline-flex items-stretch self-start">
<div class="inline-flex items-stretch self-start [&_button:focus-visible]:z-1">
<OButton
variant="outline"
size="xs"
@@ -38,7 +38,7 @@
})
}}
</span>
<div class="inline-flex items-stretch self-center">
<div class="inline-flex items-stretch self-center [&_button:focus-visible]:z-1">
<OButton
variant="outline"
size="icon-xs-sq"
@@ -75,7 +75,7 @@
bench where the repetition implied per-bench values. Schema stays in the
column: a variant can run a provider that supports it beside one that
does not. -->
<div class="inline-flex items-stretch self-center">
<div class="inline-flex items-stretch self-center [&_button:focus-visible]:z-1">
<OButton
variant="outline"
size="xs"
@@ -28,7 +28,7 @@
})
"
:data-test="`scorer-test-input-${variable}`"
class="border-border-default rounded-default bg-surface-base text-text-body placeholder:text-text-muted focus:border-accent box-border max-h-40 w-full [resize:vertical] overflow-y-auto border px-2.25 py-2 font-sans text-xs leading-normal font-normal focus:outline-none"
class="border-border-default rounded-default bg-surface-base text-text-body placeholder:text-input-placeholder focus:border-accent box-border max-h-40 w-full [resize:vertical] overflow-y-auto border px-2.25 py-2 font-sans text-xs leading-normal font-normal focus:outline-none"
@input="updateInput(variable, ($event.target as HTMLTextAreaElement).value)"
/>
</div>
+1 -1
View File
@@ -198,7 +198,7 @@ const variantClasses: Record<NonNullable<BadgeProps["variant"]>, string> = {
const sizeClasses: Record<NonNullable<BadgeProps["size"]>, string> = {
xs: "px-1.5 py-0.5 text-3xs gap-0.5",
sm: "px-2.5 py-1.5 text-2xs gap-1",
md: "px-2.5 py-2 text-xs gap-1.5",
md: "px-2.5 py-1.5 text-xs gap-1.5",
lg: "px-3 py-1.5 text-sm gap-2",
};
+30 -13
View File
@@ -190,9 +190,16 @@ describe("OButton", () => {
// --- Size classes ---
it("applies md size classes by default", () => {
const wrapper = mount(OButton);
expect(wrapper.classes().join(" ")).toContain("h-10");
it("applies the h-8.5 md size classes by default", () => {
const classes = mount(OButton).classes();
expect(classes).toContain("h-8.5");
expect(classes).not.toContain("h-10");
});
it("applies the h-10 cta size classes", () => {
const classes = mount(OButton, { props: { size: "cta" } }).classes();
expect(classes).toContain("h-10");
expect(classes).not.toContain("h-8.5");
});
it("applies sm size classes", () => {
@@ -225,11 +232,11 @@ describe("OButton", () => {
expect(classes).toContain("w-8");
});
it("applies icon-md size classes (h-10 w-10)", () => {
const wrapper = mount(OButton, { props: { size: "icon-md" } });
const classes = wrapper.classes().join(" ");
expect(classes).toContain("h-10");
expect(classes).toContain("w-10");
it("applies icon-md size classes (h-8.5 w-8.5), matching md", () => {
const classes = mount(OButton, { props: { size: "icon-md" } }).classes();
expect(classes).toContain("h-8.5");
expect(classes).toContain("w-8.5");
expect(classes).not.toContain("h-10");
});
it("applies icon-lg size classes (h-12 w-12)", () => {
@@ -326,6 +333,9 @@ describe("OButton", () => {
expect(classes).toContain(
"transition-[color,background-color,border-color,text-decoration-color,fill,stroke,box-shadow]",
);
// Animating outline would fade the focus indicator in from the default grey.
expect(classes).not.toMatch(/transition-\[[^\]]*outline/);
expect(wrapper.classes()).not.toContain("transition-all");
expect(classes).toContain("duration-150");
});
@@ -336,14 +346,21 @@ describe("OButton", () => {
expect(wrapper.classes()).not.toContain("transition-colors");
});
it("applies the unified focus ring at full opacity, never a translucent accent", () => {
it("uses an offset outline in the opaque focus-ring token, not a ring, for focus", () => {
const wrapper = mount(OButton);
const classes = wrapper.classes().join(" ");
expect(classes).toContain("focus-visible:ring-[0.125rem]!");
expect(classes).toContain("focus-visible:ring-focus-ring-accent!");
const classes = wrapper.classes();
// A hugging accent ring merges into the accent-filled primary button; the offset gap keeps focus visible.
expect(classes).toContain("focus-visible:outline-2!");
expect(classes).toContain("focus-visible:outline-solid!");
expect(classes).toContain("focus-visible:outline-offset-2!");
expect(classes).toContain("focus-visible:outline-focus-ring-accent!");
// ring-0! keeps the per-variant rings from painting a second indicator inside the outline.
expect(classes).toContain("focus-visible:ring-0!");
expect(classes).not.toContain("focus-visible:ring-[0.125rem]!");
expect(classes).not.toContain("focus-visible:ring-focus-ring-accent!");
// accent/25 measured 1.38:1 light and 1.50:1 dark against the 3:1 SC 1.4.11 floor;
// an alpha here is a contrast regression, not a styling preference.
expect(classes).not.toContain("focus-visible:ring-accent/25!");
expect(classes).not.toContain("focus-visible:outline-accent/25!");
});
it("retains outline-none as part of base classes", () => {
+1
View File
@@ -75,6 +75,7 @@ export type ButtonSize =
| "sm"
| "sm-action"
| "md"
| "cta"
| "lg"
| "icon"
| "icon-xs"
+6 -9
View File
@@ -400,7 +400,9 @@ const sizeClasses: Record<NonNullable<ButtonProps["size"]>, string> = {
// (needed because the html font-size is 14px, making text-xs = 10.5px instead of 12px)
"chip-12": "h-6 ps-2 pe-1.5 !text-xs gap-1 rounded-default leading-none",
"sm-action": "h-[2.125rem] ps-3 pe-3 min-w-20 text-sm gap-2 rounded-default",
md: "h-10 ps-4 pe-4 text-sm gap-2 rounded-default",
// h-8.5 sorts before h-9 and up, so a caller's own plain height class still overrides md.
md: "h-8.5 ps-4 pe-4 text-sm gap-2 rounded-default",
cta: "h-10 ps-4 pe-4 text-sm gap-2 rounded-default",
lg: "h-12 ps-6 pe-6 text-base gap-3 rounded-default",
// shrink-0: without it min-width:auto lets a tight flex row squash these fixed squares to icon width.
icon: "size-6 shrink-0 p-0 rounded-default gap-x-0",
@@ -412,7 +414,7 @@ const sizeClasses: Record<NonNullable<ButtonProps["size"]>, string> = {
// 24px square — matches chip size for paired close/remove buttons
"icon-chip": "h-6 w-6 shrink-0 p-0 rounded-default gap-x-0",
"icon-sm": "h-8 w-8 shrink-0 p-0 rounded-default gap-x-0",
"icon-md": "h-10 w-10 shrink-0 p-0 rounded-default gap-x-0",
"icon-md": "h-8.5 w-8.5 shrink-0 p-0 rounded-default gap-x-0",
"icon-lg": "h-12 w-12 shrink-0 p-0 rounded-default gap-x-0",
"icon-circle": "size-8 shrink-0 p-0 rounded-full gap-x-0",
"icon-circle-sm": "size-7 shrink-0 p-0 rounded-full gap-x-0",
@@ -445,13 +447,8 @@ const classes = computed<string[]>(() => [
// Medium (500) keeps button labels calm/simple — heavier weights read as shouty.
"font-medium transition-[color,background-color,border-color,text-decoration-color,fill,stroke,box-shadow] duration-150",
"outline-none",
/* Unified focus ring — identical to OInput/OSelect: a 2px OPAQUE primary ring
hugging the control (no ring-offset gap). The trailing `!` overrides
each variant's own ring width/color below, so every button focuses identically.
Reads --color-focus-ring-accent, NOT accent/25: at 25% alpha the ring measured
1.38:1 in light and 1.50:1 in dark, against the 3:1 SC 1.4.11 floor. Solid, it is
4.80:1 / 6.82:1. This override is why the per-variant ring classes never paint. */
"focus-visible:ring-[0.125rem]! focus-visible:ring-focus-ring-accent!",
// A hugging ring vanishes on accent fills, so the outline sits off a gap; ring-0! mutes the per-variant rings.
"focus-visible:outline-2! focus-visible:outline-solid! focus-visible:outline-offset-2! focus-visible:outline-focus-ring-accent! focus-visible:ring-0!",
unavailable.value ? "cursor-not-allowed!" : "disabled:cursor-not-allowed enabled:cursor-pointer",
// Variant + size (active overrides variant to primary appearance)
unavailable.value
@@ -57,6 +57,33 @@ describe("OButtonGroup", () => {
expect(wrapper.classes()).toContain("[&>*:not(:first-child)]:border-t-0");
});
// --- Focus outline ---
it("has no overflow-hidden or overflow-clip class", () => {
const classes = mount(OButtonGroup).classes();
expect(classes).not.toContain("overflow-hidden");
expect(classes).not.toContain("overflow-clip");
});
it("gives a focused descendant z-1", () => {
const wrapper = mount(OButtonGroup);
expect(wrapper.classes()).toContain("[&_:focus-visible]:z-1");
});
it.each([
["horizontal", "rounded-e-none", "rounded-s-none"],
["vertical", "rounded-b-none", "rounded-t-none"],
])(
"scopes %s corner removal to children that are not an only child",
(orientation, first, last) => {
const classes = mount(OButtonGroup, { props: { orientation } }).classes();
expect(classes).toContain(`[&>*:first-child:not(:only-child)]:${first}`);
expect(classes).toContain(`[&>*:last-child:not(:only-child)]:${last}`);
expect(classes).not.toContain(`[&>*:first-child]:${first}`);
expect(classes).not.toContain(`[&>*:last-child]:${last}`);
},
);
// --- Integration: OButton children ---
it("renders OButton children inside the group", () => {
+4 -3
View File
@@ -27,7 +27,8 @@ const alignClasses: Record<NonNullable<ButtonGroupProps["align"]>, string> = {
<template>
<div
:class="[
'inline-flex overflow-hidden',
// Never clip overflow here: it hides a child's offset focus outline; z-1 lifts it over the neighbour.
'inline-flex [&_:focus-visible]:z-1',
radiusClasses[props.radius],
alignClasses[props.align],
props.orientation === 'vertical' ? 'flex-col' : 'flex-row',
@@ -40,8 +41,8 @@ const alignClasses: Record<NonNullable<ButtonGroupProps["align"]>, string> = {
: '',
// First child: keep only the start-side radius (RTL-safe logical properties)
props.orientation === 'horizontal'
? '[&>*:first-child]:rounded-e-none [&>*:last-child]:rounded-s-none'
: '[&>*:first-child]:rounded-b-none [&>*:last-child]:rounded-t-none',
? '[&>*:first-child:not(:only-child)]:rounded-e-none [&>*:last-child:not(:only-child)]:rounded-s-none'
: '[&>*:first-child:not(:only-child)]:rounded-b-none [&>*:last-child:not(:only-child)]:rounded-t-none',
// Middle children: strip all radii
'[&>*:not(:first-child):not(:last-child)]:rounded-none',
// Bordered (outline) children would otherwise stack two 1px edges at every join.
+1 -1
View File
@@ -20,7 +20,7 @@ const classes = computed(() => {
base.push(
"flex flex-row items-center gap-2",
"flex-none",
props.dense ? "px-4 pt-2 pb-1" : "px-4 py-3",
props.dense ? "px-4 pt-2 pb-1" : "px-4 py-2.5",
);
} else if (props.role === "body") {
base.push("flex-1 min-h-0", props.dense ? "px-4 pt-0 pb-3" : "p-4");
+2 -2
View File
@@ -349,7 +349,7 @@ const SIZE_MAP: Record<
block: {
root: "w-full min-h-65 px-6 py-10 max-md:min-h-0 max-md:px-4 max-md:py-5",
stack: "gap-5 max-md:gap-3",
copy: "gap-2 max-md:gap-1",
copy: "gap-2 max-md:gap-1.5",
actions: "gap-2.5 pt-0.5 max-md:gap-2 max-md:pt-0",
extra: "w-full flex flex-col items-center gap-2 pt-1 max-md:pt-0",
title: raw("text-lg! max-md:text-base!"),
@@ -361,7 +361,7 @@ const SIZE_MAP: Record<
compact: {
root: "w-full min-h-0 px-6 py-4 max-md:px-4",
stack: "gap-3",
copy: "gap-2 max-md:gap-1",
copy: "gap-2 max-md:gap-1.5",
actions: "gap-2.5 pt-0.5 max-md:gap-2 max-md:pt-0",
extra: "w-full flex flex-col items-center gap-2 pt-1 max-md:pt-0",
title: raw("text-lg! max-md:text-base!"),
+1 -1
View File
@@ -812,7 +812,7 @@ function onChildMouseenter(event: MouseEvent) {
role="menu"
:aria-label="title"
:aria-describedby="showTrialNote ? trialNoteId : undefined"
class="nav-group-flyout rounded-default border-dropdown-border bg-dropdown-bg min-w-52 border p-1 shadow-md"
class="nav-group-flyout rounded-default bg-dropdown-bg shadow-overlay min-w-52 border border-transparent p-1"
:style="flyoutStyle"
@mouseenter="clearTimers"
@mouseleave="scheduleClose"
+2 -2
View File
@@ -62,7 +62,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<div :class="tabsBelow ? ROW_CLASS : 'contents'">
<!-- flex-1 is basis-0, so the min-w floor is what forces the actions to wrap. -->
<div
class="flex h-full min-w-0 flex-1 items-center gap-3.25 max-lg:h-auto max-lg:self-stretch md:max-lg:min-w-40 @max-4xl/page:h-auto @max-4xl/page:self-stretch"
class="flex h-full min-w-0 flex-1 items-center gap-3.25 max-lg:h-auto max-lg:self-stretch max-md:gap-2 md:max-lg:min-w-40 @max-4xl/page:h-auto @max-4xl/page:self-stretch"
:class="[
hasBack() ? 'max-md:min-w-40' : 'max-md:min-w-24',
hasTabs() && !tabsBelow ? 'max-md:flex-wrap' : 'max-md:flex-nowrap',
@@ -189,7 +189,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<OIcon name="more-vert" size="sm" />
</OButton>
</template>
<div class="flex max-w-56 flex-wrap gap-1 p-1.5">
<div class="flex max-w-56 flex-wrap gap-2 p-1.5">
<slot name="actions-overflow" />
</div>
</ODropdown>
@@ -125,6 +125,22 @@ describe("ORefreshButton", () => {
// --- layout="inline" ---
describe("size", () => {
const button = (props = {}) =>
mount(ORefreshButton, { props, global: globalConfig }).find('[data-test="refresh-button"]');
it("uses the toolbar icon size by default", () => {
expect(button().attributes("size")).toBe("icon-toolbar");
expect(button().classes()).not.toContain("size-5.5!");
});
it("inset renders the icon size with size-5.5", () => {
const inset = button({ size: "inset" });
expect(inset.attributes("size")).toBe("icon");
expect(inset.classes()).toContain("size-5.5!");
});
});
describe("layout=inline", () => {
const inline = (props: Record<string, unknown> = {}) =>
mount(ORefreshButton, {
@@ -13,6 +13,8 @@ export interface RefreshButtonProps {
dataTest?: string;
/** Registry id, so the tooltip shows the page's refresh shortcut. */
shortcutId?: string;
/** `inset` sizes the split-layout button to fill a 1.5rem bordered pill, so the pill need not clip it. */
size?: "toolbar" | "inset";
}
export interface RefreshButtonEmits {
@@ -15,6 +15,7 @@ const props = withDefaults(defineProps<RefreshButtonProps>(), {
layout: "split",
dataTest: "refresh-button",
shortcutId: undefined,
size: "toolbar",
});
const emit = defineEmits<RefreshButtonEmits>();
@@ -130,7 +131,8 @@ function handleClick(e: MouseEvent) {
</span>
<OButton
:variant="variant"
size="icon-toolbar"
:size="size === 'inset' ? 'icon' : 'icon-toolbar'"
:class="size === 'inset' ? 'size-5.5!' : undefined"
icon-left="refresh"
:loading="loading"
:disabled="disabled"
+2 -2
View File
@@ -1355,7 +1355,7 @@ defineExpose({
<div
v-if="slots.toolbar || slots['toolbar-trailing']"
:class="[
'px-page-edge flex items-center gap-2 py-2 max-md:flex-wrap max-md:gap-y-1.5',
'px-page-edge flex items-center gap-2 py-2 max-md:flex-wrap max-md:gap-y-2',
props.toolbarBordered ? 'border-table-row-divider border-b' : '',
]"
data-test="o2-table-toolbar"
@@ -1398,7 +1398,7 @@ defineExpose({
:value="globalFilterLocal"
type="text"
:placeholder="props.globalFilterPlaceholder ?? t('common.searchEllipsis')"
class="text-primary placeholder-text-disabled w-full border-none bg-transparent py-1 ps-7 pe-2 text-sm outline-none"
class="text-primary placeholder:text-input-placeholder w-full border-none bg-transparent py-1 ps-7 pe-2 text-sm outline-none"
data-test="o2-table-global-filter-input"
@input="handleGlobalFilterChange(($event.target as HTMLInputElement).value)"
/>
@@ -95,10 +95,11 @@ const slotAlignClass = computed(() => {
return "flex items-center w-full min-w-0";
});
// Slotted content truncates to one line unless `wrap` is on, mirroring the
// default (non-slot) behaviour.
// Truncates like `truncate`, but clip with a margin lets a focused control's outline in the slot still paint.
const slotContentClass = computed(() =>
props.wrap ? "min-w-0 flex-1 wrap-anywhere whitespace-normal" : "truncate min-w-0 flex-1",
props.wrap
? "min-w-0 flex-1 wrap-anywhere whitespace-normal"
: "min-w-0 flex-1 overflow-clip text-ellipsis whitespace-nowrap [overflow-clip-margin:0.25rem]",
);
const isPinned = computed(() => props.cell.column.getIsPinned?.() ?? false);
@@ -459,8 +460,8 @@ onBeforeUnmount(() => window.removeEventListener("scroll", onScrollDismiss, true
isAction ? 'w-0 whitespace-nowrap' : '',
isPinned
? rowSelected
? 'bg-table-row-selected-bg group-hover/row:bg-table-row-hover-bg transition-colors duration-150'
: 'bg-table-cell-bg group-hover/row:bg-table-row-hover-bg transition-colors duration-150'
? 'bg-table-row-selected-bg transition-colors duration-(--duration-hover-out)'
: 'bg-table-cell-bg group-hover/row:bg-table-row-hover-bg transition-colors duration-(--duration-hover-out) group-hover/row:duration-(--duration-hover-in)'
: '',
wrap
? 'wrap-anywhere whitespace-normal'
@@ -469,7 +470,7 @@ onBeforeUnmount(() => window.removeEventListener("scroll", onScrollDismiss, true
? 'overflow-hidden text-ellipsis whitespace-nowrap'
: 'whitespace-nowrap'
: isAction
? 'overflow-hidden whitespace-nowrap'
? 'overflow-clip whitespace-nowrap [overflow-clip-margin:0.25rem] has-[:focus-visible]:z-2!'
: 'overflow-hidden text-ellipsis whitespace-nowrap',
meta?.cellClass ?? '',
isTreeColumn ? 'relative' : '',
@@ -571,7 +572,7 @@ onBeforeUnmount(() => window.removeEventListener("scroll", onScrollDismiss, true
size="icon-xs-sq"
:data-test="`o2-table-cell-copy-${cell.column.id}`"
:data-copied="copied ? 'true' : undefined"
class="h-4! min-h-0! w-4! shrink-0 opacity-0 transition-opacity group-hover/cell:opacity-100 max-md:opacity-100"
class="h-4! min-h-0! w-4! shrink-0 opacity-0 transition-opacity group-hover/cell:opacity-100 focus-visible:opacity-100 max-md:opacity-100"
:class="align === 'right' ? 'order-first me-1' : 'ms-1'"
:title="copied ? t('common.copiedExclaim') : t('common.copy')"
@click="handleCopy"
@@ -619,7 +620,7 @@ onBeforeUnmount(() => window.removeEventListener("scroll", onScrollDismiss, true
<!-- empty:hidden covers what the vnode walk can't see: slot content that IS a
component but renders nothing, e.g. the AI button on a non-AI build. -->
<div
class="peer bg-surface-overlay border-border-default rounded-default flex items-center gap-1 border border-solid px-1 py-0.5 shadow-lg empty:hidden"
class="peer bg-surface-overlay rounded-default shadow-overlay flex items-center gap-1 border border-solid border-transparent px-1 py-0.5 empty:hidden"
>
<slot
name="cell-hover-actions"
@@ -71,3 +71,72 @@ describe("OTableBodyRow detail row measurement", () => {
expect(measure).toHaveBeenCalledWith(wrapper.find('[data-test="o2-table-row-0"]').element);
});
});
describe("OTableBodyRow selection marking", () => {
let wrapper: VueWrapper | undefined;
afterEach(() => wrapper?.unmount());
const mountSelectable = (props: Record<string, unknown>) => {
const Host = defineComponent({
setup: () => () =>
h("table", [
h("tbody", [h(OTableBodyRow, { row: row as any, table: {} as any, ...props })]),
]),
});
wrapper = mount(Host, { global: { plugins: [i18n] } });
return wrapper.find('[data-test="o2-table-row-0"]').classes();
};
it("omits the hover fill classes on a selected row", () => {
expect(mountSelectable({ isRowSelected: false })).toContain("hover:bg-table-row-hover-bg");
const selected = mountSelectable({ isRowSelected: true, clickable: true });
expect(selected).toContain("bg-table-row-selected-bg");
expect(selected).not.toContain("hover:bg-table-row-hover-bg");
expect(selected).not.toContain("focus-visible:bg-table-row-hover-bg");
});
it("sets the tinted secondary-text variable on selected, active, hovered and focused rows", () => {
const tinted = "[--color-text-secondary:var(--color-table-row-text-secondary-tinted)]";
expect(mountSelectable({ isRowSelected: true, clickable: true })).toContain(tinted);
expect(mountSelectable({ isActive: true })).toContain(tinted);
const idle = mountSelectable({ clickable: true });
expect(idle).not.toContain(tinted);
expect(idle).toEqual(expect.arrayContaining([`hover:${tinted}`, `focus-visible:${tinted}`]));
});
it("outlines a selected spine row instead of stacking a bar on the spine", () => {
const classes = mountSelectable({ isRowSelected: true, statusBarColor: "red" });
expect(classes).toContain("ring-accent");
expect(classes).not.toContain("o2-table-row-selected-bar");
});
it("draws the selection bar on a selected row without a visible spine", () => {
for (const statusBarColor of [undefined, "transparent"]) {
const classes = mountSelectable({ isRowSelected: true, statusBarColor });
expect(classes).toContain("o2-table-row-selected-bar");
expect(classes).not.toContain("ring-accent");
wrapper?.unmount();
}
});
it("gives a clickable row an inset focus-visible outline and no outline transition", () => {
const classes = mountSelectable({ clickable: true });
expect(classes).toEqual(
expect.arrayContaining([
"focus-visible:outline-solid",
"focus-visible:outline-2",
"focus-visible:-outline-offset-2",
"focus-visible:outline-focus-ring-accent",
]),
);
expect(classes).toContain("transition-[background-color]");
expect(classes).not.toContain("transition-colors");
});
it("puts the hover-in and hover-out motion tokens on the background transition", () => {
const classes = mountSelectable({});
expect(classes).toContain("duration-(--duration-hover-out)");
expect(classes).toContain("hover:duration-(--duration-hover-in)");
expect(classes.some((c) => c.startsWith("focus-visible:outline"))).toBe(false);
});
});
@@ -145,6 +145,11 @@ const isStriped = computed(() => {
return props.striped && props.row.index % 2 === 1;
});
// A row with no detected level gets "transparent", which paints no spine.
const hasVisibleSpine = computed(
() => !!props.statusBarColor && props.statusBarColor !== "transparent",
);
const hasExpansionSlot = computed(() => !!slots.expansion);
// ── Tree mode ────────────────────────────────────────────────────
@@ -221,12 +226,20 @@ function onRowMouseleave() {
:tabindex="clickable ? 0 : undefined"
:class="[
'group/row',
'transition-colors duration-150',
'transition-[background-color] duration-(--duration-hover-out) hover:duration-(--duration-hover-in)',
clickable ? 'cursor-pointer' : '',
'hover:bg-table-row-hover-bg',
clickable ? 'focus-visible:bg-table-row-hover-bg focus:outline-none' : '',
isRowSelected || isActive ? 'bg-table-row-selected-bg' : '',
isRowSelected || isActive
? 'bg-table-row-selected-bg [--color-text-secondary:var(--color-table-row-text-secondary-tinted)]'
: 'hover:bg-table-row-hover-bg hover:[--color-text-secondary:var(--color-table-row-text-secondary-tinted)]',
clickable
? 'focus-visible:outline-focus-ring-accent outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-solid'
: '',
clickable && !isRowSelected && !isActive
? 'focus-visible:bg-table-row-hover-bg focus-visible:[--color-text-secondary:var(--color-table-row-text-secondary-tinted)]'
: '',
isActive ? 'ring-focus-ring-accent ring-2 ring-inset' : '',
isRowSelected && !isActive && hasVisibleSpine ? 'ring-accent ring-1 ring-inset' : '',
(isRowSelected || isActive) && !hasVisibleSpine ? 'o2-table-row-selected-bar' : '',
!isRowSelected && !isActive && isStriped ? 'bg-table-row-striped-bg' : '',
statusBarColor ? 'o2-table-row-with-status' : '',
rowClass,
@@ -416,6 +429,22 @@ function onRowMouseleave() {
pointer-events: none;
}
/* keep(complex-state): selection bar on a row without a status spine, drawn like the spine so a pinned or expand cell cannot cover it */
.o2-table-row-selected-bar > td:first-child {
position: relative;
}
.o2-table-row-selected-bar > td:first-child::before {
content: "";
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 0.125rem;
background: var(--color-accent);
z-index: 2;
pointer-events: none;
}
/* keep(generated-content): continuation of the tree connector vertical line
through the warning row, drawn as an ::after off the inline connector-x var. */
.o2-table-tree-warning-cell::after {
@@ -846,7 +846,7 @@ function getStandardStickyTotalStyle(header: any): Record<string, any> {
v-if="(header.column.columnDef.meta as any)?.closable"
type="button"
:data-test="`o2-table-th-remove-${header.id}-btn`"
class="rounded-default text-text-secondary hover:text-text-body hover:bg-table-row-hover-bg inline-flex shrink-0 cursor-pointer items-center justify-center border-0 bg-transparent p-0.5 opacity-0 transition-opacity group-hover:opacity-100 max-md:opacity-100"
class="rounded-default text-text-secondary hover:text-text-body hover:bg-table-row-hover-bg inline-flex shrink-0 cursor-pointer items-center justify-center border-0 bg-transparent p-0.5 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 max-md:opacity-100"
:aria-label="t('components.table.removeColumnAria')"
@click.stop="emit('close-column', header.column.columnDef)"
>
@@ -1260,7 +1260,7 @@ function getStandardStickyTotalStyle(header: any): Record<string, any> {
v-if="(header.column.columnDef.meta as any)?.closable"
type="button"
:data-test="`o2-table-th-remove-${header.id}-btn`"
class="rounded-default text-text-secondary hover:text-text-body hover:bg-table-row-hover-bg inline-flex shrink-0 cursor-pointer items-center justify-center border-0 bg-transparent p-0.5 opacity-0 transition-opacity group-hover:opacity-100 max-md:opacity-100"
class="rounded-default text-text-secondary hover:text-text-body hover:bg-table-row-hover-bg inline-flex shrink-0 cursor-pointer items-center justify-center border-0 bg-transparent p-0.5 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 max-md:opacity-100"
:aria-label="t('components.table.removeColumnAria')"
@click.stop="emit('close-column', header.column.columnDef)"
>
@@ -78,7 +78,7 @@ const selectionLabel = computed(() => {
<div
:data-test="`o2-table-pagination-${position}`"
:class="[
'flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-1',
'flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-1 max-md:gap-y-2',
bordered ? 'border-border-default border-t' : '',
]"
>
+2 -2
View File
@@ -257,8 +257,8 @@ const hasInsideLabel = computed(
'z-10001 w-(--reka-combobox-trigger-width) max-w-(--reka-combobox-trigger-width) min-w-(--reka-combobox-trigger-width)',
'max-h-60 overflow-hidden',
'max-lg:max-h-[var(--reka-popper-available-height,15rem)]',
'rounded-default border shadow-lg',
'bg-select-content-bg border-select-content-border',
'rounded-default shadow-overlay border border-transparent',
'bg-select-content-bg',
'p-1',
// Clip-path reveal: unveiled at full size from its trigger edge (no
// scale/squish). Wipes down by default; top-placed wipes up. Soft
+2 -2
View File
@@ -491,12 +491,12 @@ const wrapperClasses = computed(() => [
:id="errorId"
:data-test="parentDataTest ? `${parentDataTest}-error` : undefined"
:data-test-error-text="effectiveError"
class="text-input-error-text text-xs leading-none"
class="text-input-error-text text-xs leading-snug"
role="alert"
>
{{ effectiveError }}
</span>
<span v-else-if="helpText" class="text-input-hint text-xs leading-none">
<span v-else-if="helpText" class="text-input-hint text-xs leading-snug">
{{ helpText }}
</span>
<span v-else class="flex-1" />
+6 -6
View File
@@ -1482,7 +1482,7 @@ const fieldWidthClass = computed(() => {
:class="[
'z-10001 min-w-(--reka-popover-trigger-width)',
'flex flex-col overflow-hidden',
'rounded-default shadow-lg',
'rounded-default shadow-overlay border border-transparent',
'bg-select-content-bg',
]"
:style="[
@@ -1535,7 +1535,7 @@ const fieldWidthClass = computed(() => {
'cursor-pointer outline-none select-none',
'hover:bg-select-item-hover-bg',
'focus-visible:bg-select-item-hover-bg',
'transition-colors duration-100',
'transition-colors duration-(--duration-hover-out) hover:duration-(--duration-hover-in)',
'border-select-content-border mb-1 border-b pb-1.5',
]"
data-test="o-select-all"
@@ -1654,7 +1654,7 @@ const fieldWidthClass = computed(() => {
:class="[
'flex h-full w-full items-center justify-between gap-2 ps-3 pe-3 text-sm',
'text-select-item-text cursor-pointer select-none',
'transition-colors duration-100',
'transition-colors duration-(--duration-hover-out) hover:duration-(--duration-hover-in)',
vRow.index === highlightedIndex
? 'bg-select-item-hover-bg'
: 'hover:bg-select-item-hover-bg',
@@ -1700,7 +1700,7 @@ const fieldWidthClass = computed(() => {
'pe-3 text-sm',
'text-select-item-text',
'cursor-pointer outline-none select-none',
'transition-colors duration-100',
'transition-colors duration-(--duration-hover-out) hover:duration-(--duration-hover-in)',
// Use flex-col for stacked rich items; flex-row for inline subLabel or simple items
(filteredOptions[vRow.index].subLabel &&
!filteredOptions[vRow.index].subLabelInline) ||
@@ -2070,8 +2070,8 @@ const fieldWidthClass = computed(() => {
'z-10001 min-w-(--reka-select-trigger-width) max-lg:max-w-[calc(100vw-1rem)]',
'overflow-hidden',
'max-lg:max-h-[var(--reka-popper-available-height,75vh)] max-lg:overflow-y-auto',
'rounded-default border shadow-md',
'bg-select-content-bg border-select-content-border',
'rounded-default shadow-overlay border border-transparent',
'bg-select-content-bg',
// Clip-path reveal: unveiled at full size from its trigger edge (no
// scale/squish). Wipes down by default; top-placed wipes up. Soft
// ease-out-expo in (200ms), quick wipe out (140ms).
+1 -1
View File
@@ -53,7 +53,7 @@ onUnmounted(() => {
'py-1.5 ps-3 pe-3 text-sm',
'text-select-item-text rounded-default',
'cursor-pointer outline-none select-none',
'transition-colors duration-100',
'transition-colors duration-(--duration-hover-out) data-highlighted:duration-(--duration-hover-in)',
'data-highlighted:bg-select-item-hover-bg',
'data-[state=checked]:bg-select-item-selected-bg data-[state=checked]:text-select-item-selected-text',
'data-disabled:text-select-item-disabled data-disabled:pointer-events-none data-disabled:cursor-not-allowed',
+1 -1
View File
@@ -63,7 +63,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:placeholder="
modelValue.length > 0 ? '' : (placeholder ?? t('common.typeAndPressEnterOrComma'))
"
class="text-text-body placeholder:text-text-secondary min-w-25 [flex:1_1_6.25rem] border-0 bg-transparent p-1 text-sm outline-none"
class="text-text-body placeholder:text-input-placeholder min-w-25 [flex:1_1_6.25rem] border-0 bg-transparent p-1 text-sm outline-none"
@keydown.enter.prevent="addTag"
@input="handleInput"
@keydown.delete="handleBackspace"
+1 -1
View File
@@ -16,7 +16,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<template>
<div
class="o-field-row group rounded-default hover:bg-field-list-row-hover-bg relative flex h-6 w-full items-center gap-1 ps-1"
class="o-field-row group rounded-default hover:bg-field-list-row-hover-bg relative flex h-6 w-full items-center gap-1 ps-1 max-md:h-7"
:class="[highlight && 'bg-field-list-row-hover-bg!']"
>
<slot />
+30 -33
View File
@@ -195,51 +195,48 @@ describe("OTab", () => {
expect(wrapper.find('[role="tab"]').attributes("disabled")).toBeUndefined();
});
// --- Transition & focus ring (post-migration tweaks) ---
// --- Transition & focus outline ---
it("applies the multi-property transition utility for smooth state changes", () => {
const wrapper = mountTab();
const classes = wrapper.find('[role="tab"]').classes();
// The migration replaced `transition-colors` with an explicit
// multi-property transition that also animates border-color and box-shadow.
it("puts the hover-in and hover-out motion tokens on the colour transition", () => {
const classes = mountTab().find('[role="tab"]').classes();
expect(classes).toContain(
"transition-[color,background-color,border-color,text-decoration-color,fill,stroke,box-shadow]",
"transition-[color,background-color,border-color,text-decoration-color,fill,stroke]",
);
expect(classes).toContain("duration-150");
// Sanity: the old narrow utility should NOT be present anymore.
expect(classes).toContain("duration-(--duration-hover-out)");
expect(classes).toContain("hover:duration-(--duration-hover-in)");
expect(classes).not.toContain("transition-colors");
});
it("renders ring offset utilities so the focus ring is visually separated from the tab edge", () => {
const wrapper = mountTab();
const classes = wrapper.find('[role="tab"]').classes();
expect(classes).toContain("ring-offset-1");
expect(classes).toContain("ring-offset-surface-base");
it("uses an accent outline, not a box-shadow ring, for focus", () => {
const classes = mountTab().find('[role="tab"]').classes();
expect(classes).toEqual(
expect.arrayContaining([
"focus-visible:outline-solid",
"focus-visible:outline-2",
"focus-visible:outline-focus-ring-accent",
]),
);
expect(classes.some((c) => /(^|:)ring-/.test(c))).toBe(false);
});
it("applies a focus-visible ring using the tabs indicator color", () => {
const wrapper = mountTab();
const classes = wrapper.find('[role="tab"]').classes();
expect(classes).toContain("focus-visible:outline-none");
expect(classes).toContain("focus-visible:ring-2");
expect(classes).toContain("focus-visible:ring-tabs-indicator");
it("uses a negative focus outline offset on a horizontal tab", () => {
const classes = mountTab().find('[role="tab"]').classes();
expect(classes).toContain("focus-visible:-outline-offset-2");
expect(classes).not.toContain("focus-visible:outline-offset-2");
});
it("keeps the focus ring utilities on disabled tabs (keyboard navigation still highlights them)", () => {
const wrapper = mountTab({ disable: true });
const classes = wrapper.find('[role="tab"]').classes();
// Focus ring classes belong to baseClasses and must remain regardless of state.
expect(classes).toContain("focus-visible:ring-2");
expect(classes).toContain("focus-visible:ring-tabs-indicator");
expect(classes).toContain("ring-offset-1");
it("uses a positive focus outline offset on a vertical tab", () => {
const classes = mountTab({ isVertical: true }).find('[role="tab"]').classes();
expect(classes).toContain("focus-visible:outline-offset-2");
expect(classes).not.toContain("focus-visible:-outline-offset-2");
});
it("keeps the focus ring utilities on the active tab", () => {
const wrapper = mountTab({ name: "tab1", activeTab: "tab1" });
const classes = wrapper.find('[role="tab"]').classes();
expect(classes).toContain("focus-visible:ring-2");
expect(classes).toContain("focus-visible:ring-tabs-indicator");
expect(classes).toContain("ring-offset-surface-base");
it("keeps the focus outline classes on disabled and active tabs", () => {
for (const opts of [{ disable: true }, { name: "tab1", activeTab: "tab1" }]) {
const classes = mountTab(opts).find('[role="tab"]').classes();
expect(classes).toContain("focus-visible:outline-2");
expect(classes).toContain("focus-visible:outline-focus-ring-accent");
}
});
// --- Grip-gated drag (reorderable) ---
+4 -3
View File
@@ -92,10 +92,11 @@ const baseClasses = computed<string>(() =>
: "px-2",
"font-normal text-sm whitespace-nowrap",
isVertical.value ? "rounded-default" : "rounded-t-default",
"outline-none transition-[color,background-color,border-color,text-decoration-color,fill,stroke,box-shadow] duration-150",
"outline-none transition-[color,background-color,border-color,text-decoration-color,fill,stroke] duration-(--duration-hover-out) hover:duration-(--duration-hover-in)",
"select-none",
"ring-offset-1 ring-offset-surface-base",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-tabs-indicator",
"focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-focus-ring-accent",
// The horizontal strip scrolls on x, which clips y as well, so an outside outline would lose its bottom edge.
isVertical.value ? "focus-visible:outline-offset-2" : "focus-visible:-outline-offset-2",
].join(" "),
);
@@ -92,7 +92,7 @@ onBeforeUnmount(() => {
// 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-dropdown-border rounded-default border shadow-md',
'bg-dropdown-bg rounded-default shadow-overlay border border-transparent',
// Typography
'text-dropdown-item-text text-sm',
// Animation — same clip-path reveal as ODropdown, wiping away from
@@ -56,7 +56,7 @@ function onSelect(event: Event) {
'relative flex items-center gap-2',
'rounded-default w-full px-3 py-1.5',
'cursor-pointer outline-none select-none',
'transition-colors duration-150',
'transition-colors duration-(--duration-hover-out) data-[highlighted]:duration-(--duration-hover-in)',
unavailable
? 'text-dropdown-item-disabled focus-visible:ring-focus-ring-accent cursor-not-allowed focus-visible:ring-2 focus-visible:ring-inset'
: variantClasses[variant],
+1 -1
View File
@@ -304,7 +304,7 @@ onBeforeUnmount(() => {
'min-w-40 p-1 max-lg:max-w-[calc(100vw-1rem)]',
'max-lg:max-h-[var(--reka-popper-available-height,75vh)] max-lg:overflow-y-auto',
// Surface
'bg-dropdown-bg border-dropdown-border rounded-default border shadow-md',
'bg-dropdown-bg rounded-default shadow-overlay border border-transparent',
// Typography
'text-dropdown-item-text text-sm',
// Animation — clip-path reveal: the menu is unveiled at full size from
@@ -57,7 +57,7 @@ const variantClasses: Record<NonNullable<DropdownItemProps["variant"]>, string>
'group/item relative flex items-center gap-2',
'rounded-default w-full px-3 py-1.5',
'cursor-pointer outline-none select-none',
'transition-colors duration-150',
'transition-colors duration-(--duration-hover-out) data-[highlighted]:duration-(--duration-hover-in)',
variantClasses[variant],
'data-[disabled]:text-dropdown-item-disabled data-[disabled]:cursor-not-allowed',
unavailable &&
@@ -75,7 +75,7 @@ const variantClasses: Record<NonNullable<DropdownItemProps["variant"]>, string>
<slot name="icon-left">
<OIcon v-if="props.iconLeft" :name="props.iconLeft" size="sm" />
</slot>
<span v-if="description" class="flex min-w-0 flex-1 flex-col gap-1">
<span v-if="description" class="flex min-w-0 flex-1 flex-col gap-0.5">
<span><slot /></span>
<OText
:id="reasonId"
@@ -83,10 +83,10 @@ function onKeydown(event: KeyboardEvent) {
type="button"
:text-value="textValue"
:aria-describedby="description ? descriptionDomId : undefined"
class="group/sub rounded-default text-dropdown-item-text data-[highlighted]:bg-dropdown-item-hover-bg relative flex w-full cursor-pointer items-center gap-2 px-3 py-1.5 text-start transition-colors duration-150 outline-none select-none"
class="group/sub rounded-default text-dropdown-item-text data-[highlighted]:bg-dropdown-item-hover-bg relative flex w-full cursor-pointer items-center gap-2 px-3 py-1.5 text-start transition-colors duration-(--duration-hover-out) outline-none select-none data-[highlighted]:duration-(--duration-hover-in)"
>
<slot name="icon-left" />
<span v-if="description" class="flex min-w-0 flex-1 flex-col gap-1">
<span v-if="description" class="flex min-w-0 flex-1 flex-col gap-0.5">
<span><slot name="trigger" /></span>
<OText
:id="descriptionDomId"
@@ -106,7 +106,7 @@ function onKeydown(event: KeyboardEvent) {
:collision-padding="8"
:hide-when-detached="true"
:style="{ zIndex: contentZIndex }"
class="bg-dropdown-bg border-dropdown-border rounded-default text-dropdown-item-text max-h-[var(--reka-popper-available-height,75vh)] max-w-[calc(100vw-1rem)] min-w-40 overflow-y-auto border p-1 text-sm shadow-md"
class="bg-dropdown-bg rounded-default text-dropdown-item-text shadow-overlay max-h-[var(--reka-popper-available-height,75vh)] max-w-[calc(100vw-1rem)] min-w-40 overflow-y-auto border border-transparent p-1 text-sm"
@keydown.capture="onKeydown"
@pointer-down-outside="onPointerDownOutside"
>
+1 -1
View File
@@ -243,7 +243,7 @@ onBeforeUnmount(() => {
'outline-none max-lg:max-w-[calc(100vw-1rem)]',
'max-lg:max-h-[var(--reka-popper-available-height,75vh)] max-lg:overflow-y-auto',
// Surface
'bg-dropdown-bg border-dropdown-border rounded-default border shadow-md',
'bg-dropdown-bg rounded-default shadow-overlay border border-transparent',
// Open/close reveal animation (matches ODropdown)
'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)]',
+1 -1
View File
@@ -306,7 +306,7 @@ const contentStyle = computed(() => ({
const contentClasses = computed(() => [
"z-10100 px-2.5 py-1.5",
"bg-surface-overlay rounded-default",
"text-xs text-text-body font-medium leading-relaxed",
"text-xs text-text-body font-medium leading-snug",
// Force long unbreakable tokens (file paths, hashes, URLs) to wrap inside the
// max-width box instead of overflowing onto the content behind the tooltip.
// overflow-wrap is inherited, so nested content (e.g. pre-wrap divs) wraps too.
+15 -9
View File
@@ -60,11 +60,14 @@
--tracking-tight: -0.025em;
--tracking-normal: 0;
--tracking-wide: 0.025em;
/* Headings tighten as they grow; 13px h6 stays at 0 because small text needs its spacing. */
--tracking-heading-display: -0.04em;
--tracking-heading: -0.02em;
/* --- Font weight scale --- */
--font-normal: normal;
--font-medium: 500;
--font-semibold: 600;
--font-semibold: 550; /* Geist is variable, so 550 renders between medium and bold */
--font-bold: 700;
/* --- Primary palette — blue-teal #3F7994 ---
The runtime theme engine is the AUTHORITY for brand colour. `constants/themes.ts`
@@ -265,10 +268,8 @@
--color-chart-table-23: #ffe6e6;
--color-chart-table-24: #e8f4fd;
/* --- Chart grid line (dashed split-line) — light value; dark in dark.css.
1.4.11 exempt (object required to understand content), raised for
perceptibility rather than to hit 3:1. --- */
--color-chart-gridline: rgba(0, 0, 0, 0.2);
/* Solid horizontal split-line, faint on purpose: 1.4.11 exempt, the series carries the data. */
--color-chart-gridline: rgba(0, 10, 23, 0.09);
/* Expected-range band fill; alpha lives in the token so the legend swatch matches. */
--color-chart-band: rgba(115, 115, 115, 0.22);
@@ -607,13 +608,18 @@
--color-log-severity-emergency-indicator: #e53935;
--color-log-severity-alert-indicator: #ea580c;
--color-log-severity-critical-indicator: #f4511e;
--color-log-severity-error-indicator: #ef5350;
--color-log-severity-warning-indicator: #dd7b00;
--color-log-severity-error-indicator: #e53935;
--color-log-severity-warning-indicator: #c46a00;
--color-log-severity-notice-indicator: #16a34a;
--color-log-severity-info-indicator: #1e88e5;
--color-log-severity-debug-indicator: #00a3b7;
--color-log-severity-info-indicator: #1b80d9;
--color-log-severity-debug-indicator: #008a9b;
--color-log-severity-trace-indicator: #8497a0;
--color-log-severity-ok-indicator: #43a047;
/* Logs level-column text: own knobs, so retuning them never moves the shared status text. */
--color-log-severity-error-text: #b8323f;
--color-log-severity-warning-text: #a14b0f;
--color-log-severity-debug-text: #5c606c;
--color-log-severity-info-text: var(--color-text-body);
--color-log-table-header-bg: #f1f3f7;
--color-log-table-row-bg: #ffffff;
--color-log-table-row-border: #e4e6ec;
+2 -2
View File
@@ -389,8 +389,8 @@ describe("the real token sheets", () => {
});
describe.each([
["light", 1076, 1064],
["dark", 1077, 1065],
["light", 1081, 1069],
["dark", 1082, 1070],
] as const)("%s theme", (theme, total, resolved) => {
const scope = THEMES[theme];
const names = colorTokens(scope);
+26 -10
View File
@@ -575,8 +575,8 @@
/* Input (OInput, OTextarea) */
--color-input-bg: var(--color-surface-base);
--color-input-border: var(--color-border-control);
--color-input-border-hover: var(--color-primary-300);
--color-input-border-focus: var(--color-primary-300);
--color-input-border-hover: var(--color-accent);
--color-input-border-focus: var(--color-accent);
--color-input-border-error: var(--color-error-500);
/* Disabled-bg uses grey-200 (#e5e5e5) — `surface-subtle` (grey-100 #f5f5f5)
was almost identical to the enabled white bg in light mode, making
@@ -586,7 +586,7 @@
--color-input-disabled-text: var(--color-text-secondary);
--color-input-focus-ring: var(--color-focus-ring-accent);
--color-input-text: var(--color-text-heading);
--color-input-placeholder: var(--color-text-disabled);
--color-input-placeholder: var(--color-text-placeholder);
/* text-heading (not text-secondary) so labels don't read as disabled/hint text */
--color-input-label: var(--color-text-heading);
--color-input-hint: var(--color-text-secondary);
@@ -601,14 +601,14 @@
--color-select-bg: var(--color-surface-base);
--color-select-border: var(--color-border-control);
--color-select-disabled-border: var(--color-border-default);
--color-select-border-hover: var(--color-primary-300);
--color-select-border-focus: var(--color-primary-300);
--color-select-border-hover: var(--color-accent);
--color-select-border-focus: var(--color-accent);
--color-select-border-error: var(--color-error-500);
/* Same rationale as input-disabled-bg. */
--color-select-disabled-bg: var(--color-grey-200);
--color-select-disabled-text: var(--color-text-secondary);
--color-select-text: var(--color-text-soft);
--color-select-placeholder: var(--color-text-disabled);
--color-select-placeholder: var(--color-text-placeholder);
/* text-heading, not text-secondary (same as input-label) */
--color-select-label: var(--color-text-heading);
--color-select-icon: var(--color-text-secondary);
@@ -692,7 +692,7 @@
--color-file-disabled-bg: var(--color-surface-subtle);
--color-file-disabled-border: var(--color-border-default);
--color-file-text: var(--color-text-heading);
--color-file-placeholder: var(--color-text-disabled);
--color-file-placeholder: var(--color-text-placeholder);
--color-file-label: var(--color-text-secondary);
--color-file-icon: var(--color-text-secondary);
--color-file-error-text: var(--color-error-600);
@@ -712,7 +712,7 @@
--color-datepicker-disabled-bg: var(--color-surface-subtle);
--color-datepicker-disabled-border: var(--color-border-default);
--color-datepicker-text: var(--color-text-heading);
--color-datepicker-placeholder: var(--color-text-disabled);
--color-datepicker-placeholder: var(--color-text-placeholder);
--color-datepicker-label: var(--color-text-label);
--color-datepicker-icon: var(--color-text-secondary);
--color-datepicker-error-text: var(--color-error-600);
@@ -765,8 +765,19 @@
--color-table-header-bg: var(--color-surface-panel);
--color-table-header-text: var(--color-text-secondary);
--color-table-header-border: var(--color-border-default);
--color-table-row-hover-bg: var(--color-primary-50);
--color-table-row-selected-bg: var(--color-primary-100);
/* Hover clears ~1.19:1 on white and selected sits ~1.08:1 above it, so both read without sinking secondary text. */
--color-table-row-hover-bg: color-mix(
in srgb,
var(--color-primary-100) 85%,
var(--color-primary-50)
);
--color-table-row-selected-bg: color-mix(
in srgb,
var(--color-primary-200) 30%,
var(--color-primary-100)
);
/* Rows re-point text-secondary to this on the hover/selected fills, where plain grey-500 falls below 4.5:1. */
--color-table-row-text-secondary-tinted: var(--color-grey-600);
--color-table-row-striped-bg: var(--color-surface-subtle);
/* Row dividers use the SOFT border: lighter than the structural table outline /
sticky-header underline so rows read as a calm list, not a hard grid. The
@@ -1988,6 +1999,7 @@
--color-table-header-border: var(--color-table-header-border);
--color-table-row-hover-bg: var(--color-table-row-hover-bg);
--color-table-row-selected-bg: var(--color-table-row-selected-bg);
--color-table-row-text-secondary-tinted: var(--color-table-row-text-secondary-tinted);
--color-table-row-striped-bg: var(--color-table-row-striped-bg);
--color-table-row-divider: var(--color-table-row-divider);
--color-table-border: var(--color-table-border);
@@ -2171,6 +2183,10 @@
--color-log-severity-debug-indicator: var(--color-log-severity-debug-indicator);
--color-log-severity-trace-indicator: var(--color-log-severity-trace-indicator);
--color-log-severity-ok-indicator: var(--color-log-severity-ok-indicator);
--color-log-severity-error-text: var(--color-log-severity-error-text);
--color-log-severity-warning-text: var(--color-log-severity-warning-text);
--color-log-severity-debug-text: var(--color-log-severity-debug-text);
--color-log-severity-info-text: var(--color-log-severity-info-text);
--color-log-table-row-bg: var(--color-log-table-row-bg);
--color-log-table-row-border: var(--color-log-table-row-border);
--color-purple-700: var(--color-purple-700);
+6 -34
View File
@@ -179,6 +179,12 @@ export const CONTRAST_PAIRS: readonly ContrastPair[] = [
category: "exempt",
why: "SC 1.4.3 exempts text and boundaries that belong to a disabled, inactive control",
},
{
fg: "--color-table-row-text-secondary-tinted",
bg: "--color-table-row-selected-bg",
category: "text",
why: "secondary text inside a selected row sits on the selected fill, the lower-contrast of the two row fills in both themes",
},
{
fg: "--color-table-row-divider",
category: "exempt",
@@ -702,20 +708,6 @@ export const CONTRAST_PAIRS: readonly ContrastPair[] = [
issue: "WCAG-AA colour remediation (follow-up PR)",
themes: ["dark"],
},
{
fg: "--color-input-border-focus",
category: "pending",
why: "fails the threshold against the page surface it is painted on",
issue: "WCAG-AA colour remediation (follow-up PR)",
themes: ["light"],
},
{
fg: "--color-input-border-hover",
category: "pending",
why: "fails the threshold against the page surface it is painted on",
issue: "WCAG-AA colour remediation (follow-up PR)",
themes: ["light"],
},
{
fg: "--color-label-chip-email-text",
bg: "--color-label-chip-email-bg",
@@ -846,20 +838,6 @@ export const CONTRAST_PAIRS: readonly ContrastPair[] = [
why: "a schedule band's own solid fill, painted on it directly, never on the page — amber falls short of AA for this shared white label",
issue: "WCAG-AA colour remediation (follow-up PR)",
},
{
fg: "--color-select-border-focus",
category: "pending",
why: "fails the threshold against the page surface it is painted on",
issue: "WCAG-AA colour remediation (follow-up PR)",
themes: ["light"],
},
{
fg: "--color-select-border-hover",
category: "pending",
why: "fails the threshold against the page surface it is painted on",
issue: "WCAG-AA colour remediation (follow-up PR)",
themes: ["light"],
},
{
fg: "--color-slider-error-text",
category: "pending",
@@ -907,12 +885,6 @@ export const CONTRAST_PAIRS: readonly ContrastPair[] = [
why: "fails the threshold against the page surface it is painted on",
issue: "WCAG-AA colour remediation (follow-up PR)",
},
{
fg: "--color-text-placeholder",
category: "pending",
why: "fails the threshold against the page surface it is painted on",
issue: "WCAG-AA colour remediation (follow-up PR)",
},
{
fg: "--color-thread-tool-link",
category: "pending",
+1 -1
View File
@@ -64,7 +64,7 @@ const LARGE_TEXT_MIN = 3;
const NON_TEXT_MIN = 3;
/** Ratchets. Both only ever go down; see the module docstring. */
const MAX_PENDING = 93;
const MAX_PENDING = 88;
const MAX_EXEMPT = 37;
/** Manifest entries on a bare alias, which are never judged. Only goes down. */
const MAX_UNJUDGED = 7;
+22 -13
View File
@@ -48,7 +48,7 @@
well = recessed / segmented track
overlay = menus, dropdowns, tooltips */
--d-chrome: #0e0f11; /* title bar + left activity-bar strip */
--d-base: #0d1117; /* MAIN: content card + table canvas */
--d-base: #111111; /* MAIN: content card + table canvas */
--d-well: #2d2d2d; /* recessed wells, segmented-control track */
--d-panel: #181818; /* folder sidebar, sub rails, cards, dialogs (DM) */
--d-overlay: #202020; /* menus, dropdowns, tooltips, table-header (top tier,
@@ -66,12 +66,9 @@
label==muted==disabled==placeholder all landed on one value). */
/* Row / item states on the content surface */
--d-hover: var(
--color-primary-900
); /* row & list-item hover — MATCHES
the dashboard-folder hover (theme-tinted, not grey) */
--d-selected: color-mix(in srgb, var(--color-primary-500) 30%, var(--d-panel));
/* selected row — teal-tinted (brand signal) */
/* Both mix into the panel so selected stays >=1.2:1 above hover and severity spines keep 3:1 even on bright custom-theme palettes. */
--d-hover: color-mix(in srgb, var(--color-primary-900) 50%, var(--d-panel));
--d-selected: color-mix(in srgb, var(--color-primary-500) 24%, var(--d-panel));
--d-zebra: #252526; /* striped-row bg — VS Code list alternating row */
/* --- Text — grey palette steps, one per tier (light half of the ramp) --- */
@@ -82,9 +79,8 @@
--color-text-label: var(--color-grey-500); /* form labels, metadata */
--color-text-muted: var(--color-grey-500); /* de-emphasized */
--color-text-disabled: var(--color-grey-600); /* dimmer than labels so disabled reads disabled */
--color-text-placeholder: var(
--color-grey-500
); /* input placeholders — dim but legible, above disabled */
/* Between grey-500 and grey-400: the lowest grey that clears 4.5:1 on the dark canvas without matching secondary text. */
--color-text-placeholder: #7c7c7c;
--color-text-link: var(--color-primary-400); /* clickable links (teal) */
--color-text-link-hover: var(--color-primary-300); /* link hover state (teal) */
--color-text-code: var(--color-grey-300); /* inline code, monospace */
@@ -147,7 +143,7 @@
--color-surface-panel: var(--d-panel); /* folder rails, cards, table-header base */
--color-surface-overlay: var(--d-overlay); /* dropdowns, dialogs, selects, popovers */
--color-surface-subtle: var(--d-well); /* recessed fills, segmented track */
--color-surface-subtle-hover: var(--d-panel); /* hover on a recessed fill (one step up) */
--color-surface-subtle-hover: #383838;
/* Floating chip on a dark panel — a lighter grey so it lifts off the surface. */
--color-surface-chip: var(--color-grey-700);
--color-surface-chip-hover: var(--color-grey-600);
@@ -610,6 +606,11 @@
--shadow-md-c2: rgb(0 0 0 / 0.35);
--shadow-lg-c1: rgb(0 0 0 / 0.55);
--shadow-lg-c2: rgb(0 0 0 / 0.4);
/* eslint-disable local/no-hardcoded-px -- optical shadow geometry, not layout */
--shadow-overlay:
inset 0 1px 0 #ffffff0f, 0 0 0 1px var(--d-border), 0 6px 12px -3px #01040966,
0 6px 18px 0 #01040966;
/* eslint-enable local/no-hardcoded-px */
}
/* Dashboard classic series palette — dark overrides (light values in base.css) */
@@ -664,8 +665,7 @@
--color-chart-table-23: rgba(255, 180, 180, 0.15);
--color-chart-table-24: rgba(180, 225, 245, 0.15);
/* Chart grid line — dark override, raised for perceptibility (see base.css) */
--color-chart-gridline: rgba(255, 255, 255, 0.25);
--color-chart-gridline: rgba(240, 250, 255, 0.18);
/* Chart expected-range band fill — dark override (see base.css) */
--color-chart-band: rgba(255, 255, 255, 0.18);
@@ -806,6 +806,12 @@
--color-table-header-bg: var(--d-overlay); /* header band lifts off rows */
--color-table-row-hover-bg: var(--d-hover); /* neutral hover (not teal-washed) */
--color-table-row-selected-bg: var(--d-selected); /* teal-tinted selection */
/* grey-400 drops to ~4.3:1 on the selected fill of brighter custom themes. */
--color-table-row-text-secondary-tinted: color-mix(
in srgb,
var(--color-grey-300) 50%,
var(--color-grey-400)
);
--color-table-row-striped-bg: var(--d-zebra); /* panel +1 step zebra */
--color-table-row-divider: var(--d-divider); /* solid, visible row separators */
--color-table-highlight-bg: color-mix(in srgb, var(--color-warning-700) 40%, transparent);
@@ -874,6 +880,9 @@
--color-log-severity-debug-indicator: #3daab8;
--color-log-severity-trace-indicator: #7a9baa;
--color-log-severity-ok-indicator: #4dad55;
--color-log-severity-error-text: #eb938d;
--color-log-severity-warning-text: #e3a15a;
--color-log-severity-debug-text: #a8abb2;
--color-log-table-header-bg: var(--color-table-header-bg);
--color-log-table-row-bg: #21242c;
--color-log-table-row-border: rgba(255, 255, 255, 0.14);
+17 -4
View File
@@ -17,11 +17,10 @@
--color-text-secondary: var(--color-grey-500); /* supporting text, descriptions, captions */
--color-text-label: var(--color-grey-400); /* form labels, metadata, badges */
--color-text-muted: var(--color-grey-300); /* disabled, placeholder, de-emphasized */
/* label / disabled / placeholder (+ border-strong, scrollbar-thumb-hover) are
intentionally equal for now: all route through the grey-400 primitive so any one
can diverge later. Distinct intents kept as distinct tokens on purpose, NOT aliases. */
/* label, disabled, border-strong and scrollbar-thumb-hover share grey-400 but stay separate tokens so each can diverge. */
--color-text-disabled: var(--color-grey-400);
--color-text-placeholder: var(--color-grey-400);
/* grey-500, not grey-400: placeholder text must clear 4.5:1 on white. */
--color-text-placeholder: var(--color-grey-500);
--color-text-soft: rgba(0, 0, 0, 0.75); /* black at 75% — readable but not harsh */
--color-text-link: var(--color-primary-600); /* clickable links */
--color-text-link-hover: var(--color-primary-700); /* link hover state */
@@ -188,6 +187,19 @@
--color-focus-ring-danger: var(--color-error-500);
--color-focus-ring-success: var(--color-success-600);
--color-focus-ring-warning: var(--color-warning-700);
/* Motion: hover tints land instantly and fade out, so a moving pointer never trails a lagging highlight. */
--duration-hover-in: 0ms;
--duration-hover-out: 150ms;
--duration-overlay-in: 140ms;
--duration-overlay-out: 100ms;
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
/* Floating menus and popovers: the 1px ring lives in the shadow; dark values in dark.css. */
/* eslint-disable local/no-hardcoded-px -- optical shadow geometry, not layout */
--shadow-overlay:
0 0 0 1px #00000014, 0 1px 1px #00000005, 0 4px 8px -4px #0000000a, 0 16px 24px -8px #0000000f;
/* eslint-enable local/no-hardcoded-px */
}
/* ── Register ALL semantic tokens for Tailwind utilities ── */
@@ -405,6 +417,7 @@
--shadow-scroll-both:
inset 0 8px 6px -6px var(--shadow-scroll-c), inset 0 -8px 6px -6px var(--shadow-scroll-c);
--shadow-glow: 0 0 4px var(--shadow-glow-c);
--shadow-overlay: var(--shadow-overlay);
/* Directional / inset roles — not points on the elevation scale, so they
cannot be expressed as one. */
--shadow-sticky-left-geom: 2px 0 4px -2px;
+2
View File
@@ -1275,6 +1275,8 @@
"tookChip": "{took} مللي ثانية",
"scanSize": "حجم المسح",
"deltaScanSize": "حجم دلتا سكان",
"scannedSoFar": "تم فحص {size}",
"resultsProgressLabel": "تقدم البحث",
"histogramTitleError": "خطأ أثناء إنشاء عنوان المدرج التكراري.",
"selectLogStream": "اختر تدفق السجلات",
"searchInspect": "ابحث عن الفحص",
+2
View File
@@ -1275,6 +1275,8 @@
"tookChip": "{took} ms",
"scanSize": "Scan-Größe",
"deltaScanSize": "Delta-Scan-Größe",
"scannedSoFar": "Gescannt: {size}",
"resultsProgressLabel": "Suchfortschritt",
"histogramTitleError": "Fehler beim Generieren des Histogrammtitels.",
"selectLogStream": "Logs-Stream auswählen",
"searchInspect": "Such-Inspektor",
+2
View File
@@ -1275,6 +1275,8 @@
"tookChip": "{took} ms",
"scanSize": "Scan Size",
"deltaScanSize": "Delta Scan Size",
"scannedSoFar": "Scanned {size}",
"resultsProgressLabel": "Search progress",
"histogramTitleError": "Error while generating histogram title.",
"selectLogStream": "Select logs stream",
"searchInspect": "Search Inspect",
+2
View File
@@ -1275,6 +1275,8 @@
"tookChip": "{took} ms",
"scanSize": "Tamaño de escaneo",
"deltaScanSize": "Tamaño de escaneo delta",
"scannedSoFar": "Escaneado {size}",
"resultsProgressLabel": "Progreso de la búsqueda",
"histogramTitleError": "Error al generar el título del histograma.",
"selectLogStream": "Seleccione el stream de logs",
"searchInspect": "Buscar e inspeccionar",
+2
View File
@@ -1275,6 +1275,8 @@
"tookChip": "{took} ms",
"scanSize": "Taille de scan",
"deltaScanSize": "Taille de scan delta",
"scannedSoFar": "{size} analysés",
"resultsProgressLabel": "Progression de la recherche",
"histogramTitleError": "Erreur lors de la génération du titre de l'histogramme.",
"selectLogStream": "Sélectionnez le flux de journaux",
"searchInspect": "Rechercher Inspect",
+2
View File
@@ -1275,6 +1275,8 @@
"tookChip": "{took} ms",
"scanSize": "Dimensione scansione",
"deltaScanSize": "Dimensione scansione delta",
"scannedSoFar": "Scansionati {size}",
"resultsProgressLabel": "Avanzamento ricerca",
"histogramTitleError": "Errore durante la generazione del titolo dell'istogramma.",
"selectLogStream": "Seleziona lo stream dei log",
"searchInspect": "Cerca Ispeziona",
+2
View File
@@ -1275,6 +1275,8 @@
"tookChip": "{took} ms",
"scanSize": "スキャンサイズ",
"deltaScanSize": "デルタスキャンサイズ",
"scannedSoFar": "{size} をスキャン済み",
"resultsProgressLabel": "検索の進捗",
"histogramTitleError": "ヒストグラムタイトルの生成中にエラーが発生しました。",
"selectLogStream": "ログストリームを選択",
"searchInspect": "検索を検査",
+2
View File
@@ -1275,6 +1275,8 @@
"tookChip": "{took} ms",
"scanSize": "스캔 크기",
"deltaScanSize": "델타 스캔 크기",
"scannedSoFar": "스캔한 크기: {size}",
"resultsProgressLabel": "검색 진행률",
"histogramTitleError": "히스토그램 제목을 생성하는 중 오류가 발생했습니다.",
"selectLogStream": "로그 스트림 선택",
"searchInspect": "검색 검사",
+2
View File
@@ -1275,6 +1275,8 @@
"tookChip": "{took} ms",
"scanSize": "Scangrootte",
"deltaScanSize": "Delta-scangrootte",
"scannedSoFar": "Gescand: {size}",
"resultsProgressLabel": "Zoekvoortgang",
"histogramTitleError": "Fout bij het genereren van de histogramtitel.",
"selectLogStream": "Logstream selecteren",
"searchInspect": "Zoek Inspect",
+2
View File
@@ -1275,6 +1275,8 @@
"tookChip": "{took} ms",
"scanSize": "Rozmiar skanowania",
"deltaScanSize": "Delta rozmiaru skanowania",
"scannedSoFar": "Przeskanowano {size}",
"resultsProgressLabel": "Postęp wyszukiwania",
"histogramTitleError": "Błąd podczas generowania tytułu histogramu.",
"selectLogStream": "Wybierz strumień logów",
"searchInspect": "Inspekcja wyszukiwania",
+2
View File
@@ -1275,6 +1275,8 @@
"tookChip": "{took} ms",
"scanSize": "Tamanho da análise",
"deltaScanSize": "Tamanho da análise delta",
"scannedSoFar": "Analisado {size}",
"resultsProgressLabel": "Progresso da pesquisa",
"histogramTitleError": "Erro ao gerar o título do histograma.",
"selectLogStream": "Selecione o stream de logs",
"searchInspect": "Inspecionar pesquisa",
+2
View File
@@ -1275,6 +1275,8 @@
"tookChip": "{took} мс",
"scanSize": "Размер сканирования",
"deltaScanSize": "Дельта размера сканирования",
"scannedSoFar": "Просканировано {size}",
"resultsProgressLabel": "Прогресс поиска",
"histogramTitleError": "Ошибка при формировании заголовка гистограммы.",
"selectLogStream": "Выбрать поток логов",
"searchInspect": "Инспекция поиска",
+2
View File
@@ -1275,6 +1275,8 @@
"tookChip": "{took} ms",
"scanSize": "Tarama Boyutu",
"deltaScanSize": "Delta Tarama Boyutu",
"scannedSoFar": "Taranan: {size}",
"resultsProgressLabel": "Arama ilerlemesi",
"histogramTitleError": "Histogram başlığı oluşturulurken hata.",
"selectLogStream": "Günlükler akışını seçin",
"searchInspect": "Aramayı İncele",
+2
View File
@@ -1275,6 +1275,8 @@
"tookChip": "{took} ms",
"scanSize": "Kích thước quét",
"deltaScanSize": "Kích thước quét Delta",
"scannedSoFar": "Đã quét {size}",
"resultsProgressLabel": "Tiến độ tìm kiếm",
"histogramTitleError": "Lỗi khi tạo tiêu đề biểu đồ histogram.",
"selectLogStream": "Chọn stream nhật ký",
"searchInspect": "Kiểm tra tìm kiếm",
+2
View File
@@ -1275,6 +1275,8 @@
"tookChip": "{took} 毫秒",
"scanSize": "扫描大小",
"deltaScanSize": "增量扫描大小",
"scannedSoFar": "已扫描 {size}",
"resultsProgressLabel": "搜索进度",
"histogramTitleError": "生成直方图标题时出错。",
"selectLogStream": "选择日志流",
"searchInspect": "搜索检查",
+2
View File
@@ -1275,6 +1275,8 @@
"tookChip": "{took} 毫秒",
"scanSize": "掃描大小",
"deltaScanSize": "掃描大小差異",
"scannedSoFar": "已掃描 {size}",
"resultsProgressLabel": "搜尋進度",
"histogramTitleError": "產生直方圖標題時發生錯誤。",
"selectLogStream": "選擇日誌串流",
"searchInspect": "搜尋檢查",
+10 -4
View File
@@ -29,7 +29,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
(not `flex-1`) keeps this sized to its content so the More button
sits right after the pinned items instead of being pushed to the
far right; it still shrinks + clips when content overflows. -->
<div class="flex min-w-0 flex-initial flex-nowrap items-center gap-1 overflow-hidden">
<div
class="-my-1 flex min-w-0 flex-initial flex-nowrap items-center gap-1 overflow-hidden py-1"
>
<!-- View Mode: Dropdown when very narrow, Toggle Group otherwise -->
<ODropdown v-if="toolbarToggleAsDropdown" side="bottom" align="start">
<template #trigger>
@@ -1003,7 +1005,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
searchObj.meta.logsVisualizeToggle === 'build'
"
>
<div v-if="config.isEnterprise == 'true'" class="flex items-center">
<div
v-if="config.isEnterprise == 'true'"
class="flex items-center [&_button:focus-visible]:z-1"
>
<OButton
v-if="visualizeSearchRequestTraceIds.length > 0"
data-test="logs-search-bar-visualize-cancel-btn"
@@ -1059,7 +1064,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</ODropdownItem>
</ODropdown>
</div>
<div v-else class="flex items-center">
<div v-else class="flex items-center [&_button:focus-visible]:z-1">
<!-- Cancel button when query is running -->
<OButton
v-if="visualizeSearchRequestTraceIds.length > 0 && config.isEnterprise == 'true'"
@@ -1118,7 +1123,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</ODropdown>
</div>
</div>
<div v-else class="flex items-center">
<div v-else class="flex items-center [&_button:focus-visible]:z-1">
<!-- Lift the focused half so its offset outline paints over the joined twin. -->
<!-- Cancel button for patterns tab -->
<OButton
v-if="searchObj.meta.logsVisualizeToggle === 'patterns' && patternsState.loading"
+74 -29
View File
@@ -1334,45 +1334,90 @@ describe("SearchResult Component", () => {
});
});
describe("results progress bar (#14303)", () => {
const progressBars = () => wrapper.findAllComponents({ name: "LoadingProgress" });
const barsLoading = () => progressBars().filter((bar: any) => bar.props("loading") === true);
describe("results progress line (#14303)", () => {
const line = () => wrapper.find('[data-test="logs-results-progress"] [role="progressbar"]');
const setProgress = async (loading: boolean, percentage: number | undefined) => {
wrapper.vm.searchObj.loading = loading;
wrapper.vm.searchObj.loadingProgressPercentage = percentage;
const startQuery = async (percentage: number | undefined) => {
wrapper.vm.searchObj.loading = false;
await nextTick();
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
wrapper.vm.searchObj.loadingHistogram = false;
wrapper.vm.searchObj.loadingHistogramProgressPercentage = 0;
await wrapper.vm.$nextTick();
await flushPromises();
wrapper.vm.searchObj.loadingProgressPercentage = percentage;
wrapper.vm.searchObj.loading = true;
await nextTick();
};
it("binds the streaming results percentage to its own progress bar", async () => {
await setProgress(true, 42);
expect(barsLoading()).toHaveLength(1);
expect(barsLoading()[0].props("loadingProgressPercentage")).toBe(42);
const passDelay = async () => {
vi.advanceTimersByTime(300);
await nextTick();
};
afterEach(() => {
vi.useRealTimers();
});
it("falls back to 0 when no progress has been reported yet", async () => {
await setProgress(true, undefined);
expect(barsLoading()).toHaveLength(1);
expect(barsLoading()[0].props("loadingProgressPercentage")).toBe(0);
it("waits 300ms so a fast query never flashes the line", async () => {
await startQuery(42);
vi.advanceTimersByTime(299);
await nextTick();
expect(line().exists()).toBe(false);
vi.advanceTimersByTime(1);
await nextTick();
expect(line().exists()).toBe(true);
});
it("stays mounted with loading=false so it can run its own fade-out", async () => {
await setProgress(false, 37);
const settled = progressBars().filter(
(bar: any) => bar.props("loadingProgressPercentage") === 37,
it("binds the streaming results percentage to the line", async () => {
await startQuery(42);
await passDelay();
expect(line().attributes("style")).toContain("width: 42%");
expect(line().attributes("aria-valuenow")).toBe("42");
});
it("keeps a 5% floor when no progress has been reported yet", async () => {
await startQuery(undefined);
await passDelay();
expect(line().attributes("style")).toContain("width: 5%");
});
it("removes the line at once when the query settles", async () => {
await startQuery(42);
await passDelay();
wrapper.vm.searchObj.loading = false;
await nextTick();
expect(line().exists()).toBe(false);
expect(wrapper.find('[data-test="logs-results-progress"]').classes()).toContain("opacity-0");
});
it("does not let the histogram progress drive the results line", async () => {
await startQuery(42);
wrapper.vm.searchObj.loadingHistogram = true;
wrapper.vm.searchObj.loadingHistogramProgressPercentage = 90;
await passDelay();
expect(line().attributes("style")).toContain("width: 42%");
});
it("shows the size a fresh run has scanned so far, in place of the scan chip", async () => {
wrapper.vm.searchObj.meta.logsVisualizeToggle = "logs";
wrapper.vm.searchObj.data.histogram.chartParams.titleParts = {
start: 1,
end: 10,
total: "10",
took: 5,
scanLabel: "Scan Size",
scanSize: "300.00 MB",
};
await startQuery(10);
wrapper.vm.searchObj.data.queryResults = { hits: [], scan_size: 1536 };
await passDelay();
expect(wrapper.find('[data-test="logs-result-scan-progress"]').text()).toBe(
"Scanned 1.50 GB",
);
expect(settled).toHaveLength(1);
expect(settled[0].props("loading")).toBe(false);
});
it("does not let the histogram bar stand in for the results bar", async () => {
await setProgress(true, 42);
expect(barsLoading()).toHaveLength(1);
expect(barsLoading()[0].props("loadingProgressPercentage")).toBe(42);
expect(wrapper.vm.searchObj.loadingHistogram).toBe(false);
expect(wrapper.find('[data-test="logs-result-scan-chip"]').exists()).toBe(false);
wrapper.vm.searchObj.loading = false;
await nextTick();
expect(wrapper.find('[data-test="logs-result-scan-progress"]').exists()).toBe(false);
expect(wrapper.find('[data-test="logs-result-scan-chip"]').exists()).toBe(true);
});
});
});
+41 -7
View File
@@ -85,7 +85,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
>{{ recordsChips.time }}</OTag
>
<OTag
v-if="recordsChips.scan"
v-if="recordsChips.scan && !scannedSize"
type="logsResultChip"
value="warn"
class="max-md:hidden"
@@ -94,6 +94,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
>
</template>
<span v-else class="min-w-0 truncate">{{ noOfRecordsTitle }}</span>
<span
v-if="scannedSize"
class="text-text-secondary shrink-0 text-xs"
data-test="logs-result-scan-progress"
>{{ t("search.scannedSoFar", { size: scannedSize }) }}</span
>
</template>
<!-- Patterns mode: structured chips -->
<template v-else>
@@ -172,9 +178,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<template v-else>
<!-- Refresh in bordered wrapper -->
<div
class="border-card-glass-border rounded-default inline-flex h-6 items-center overflow-hidden border px-1"
class="border-card-glass-border rounded-default inline-flex h-6 items-center border px-1"
>
<ORefreshButton
size="inset"
:last-run-at="searchObj.meta.lastRunAt"
:loading="searchObj.loading || searchObj.loadingHistogram"
:disabled="searchObj.loading || searchObj.loadingHistogram"
@@ -281,11 +288,22 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- Outside scrollContainerRef so the results progress bar can't scroll away. -->
<div class="relative">
<LoadingProgress
<div
data-test="logs-results-progress"
:loading="searchObj.loading"
:loadingProgressPercentage="searchObj.loadingProgressPercentage || 0"
/>
class="pointer-events-none absolute inset-x-0 top-0 z-999 h-0.5"
:class="showResultsLoader ? 'opacity-100' : 'opacity-0'"
>
<div
v-if="showResultsLoader"
role="progressbar"
:aria-label="t('search.resultsProgressLabel')"
aria-valuemin="0"
aria-valuemax="100"
:aria-valuenow="resultsProgressWidth"
class="bg-accent h-full transition-[width] duration-300 ease-out"
:style="{ width: `${resultsProgressWidth}%` }"
/>
</div>
</div>
<!-- Combined scroll: histogram + logs/patterns scroll together vertically.
@@ -565,6 +583,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<span
v-if="logsCellHtml(col.id, row)"
class="log-cell-html"
:class="severityFieldTextClass(col.id, value)"
:data-test="
col.id === logsTimestampCol ? 'log-row-timestamp-value' : undefined
"
@@ -572,6 +591,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
/>
<span
v-else
:class="severityFieldTextClass(col.id, value)"
:data-test="
col.id === logsTimestampCol ? 'log-row-timestamp-value' : undefined
"
@@ -960,8 +980,13 @@ import {
import LogsMissingStreamBanner from "@/plugins/logs/LogsMissingStreamBanner.vue";
import O2AIContextAddBtn from "@/components/common/O2AIContextAddBtn.vue";
import { useLogsHighlighter } from "@/composables/useLogsHighlighter";
import { severityIndicatorColor, severityRowClass } from "@/utils/logs/statusParser";
import {
severityFieldTextClass,
severityIndicatorColor,
severityRowClass,
} from "@/utils/logs/statusParser";
import useLogSeverity from "@/composables/useLogs/useLogSeverity";
import { useResultsLoadingIndicator } from "@/composables/useLogs/useResultsLoadingIndicator";
import { isFilterableLogField } from "@/utils/logs/streamNameColumn";
import useBreakpoint from "@/composables/useBreakpoint";
import {
@@ -2463,6 +2488,11 @@ export default defineComponent({
// First paint has nothing to show, so the skeleton is right; once rows exist we switch to `streaming` so partial results stay on screen.
const isResultsSkeleton = computed(() => searchObj.loading && !hasResultRows.value);
const isResultsStreaming = computed(() => searchObj.loading && hasResultRows.value);
const { showLoader: showResultsLoader, scannedSize } = useResultsLoadingIndicator(searchObj);
// A floor keeps the line visible before the first progress event arrives.
const resultsProgressWidth = computed(() =>
Math.round(Math.min(100, Math.max(5, searchObj.loadingProgressPercentage || 0))),
);
const getPartitionPaginations = computed(() => {
return searchObj.data.queryResults?.partitionDetail?.paginations || [];
@@ -2939,6 +2969,7 @@ export default defineComponent({
getLogRowStatusColor,
getLogRowClass,
logRowSeveritySrText,
severityFieldTextClass,
expandedLogIds,
onExpandedLogIdsChange,
openLogDetailsByRow,
@@ -2987,6 +3018,9 @@ export default defineComponent({
hasResultRows,
isResultsSkeleton,
isResultsStreaming,
showResultsLoader,
scannedSize,
resultsProgressWidth,
reorderSelectedFields,
getPaginations,
refreshPagination,
@@ -28,7 +28,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
v-bind="item.filter ? { radius: 'sm', divided: true } : {}"
:class="
item.filter
? 'border-border-default bg-surface-panel overflow-hidden border [&>*:not(:first-child)]:!border-s'
? 'border-border-default bg-surface-panel overflow-hidden border [&_button:focus-visible]:-outline-offset-2! [&>*:not(:first-child)]:!border-s'
: 'flex'
"
:data-test="item.filter ? `profiles-filter-chip-${item.filter.key}` : undefined"
+7 -5
View File
@@ -123,6 +123,8 @@ body {
font-size: var(--text-compact);
font-weight: var(--font-normal);
line-height: var(--leading-base);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/*
@@ -147,35 +149,35 @@ body {
font-size: var(--text-2xl);
font-weight: var(--font-semibold);
line-height: var(--leading-2xl);
letter-spacing: var(--tracking-tight);
letter-spacing: var(--tracking-heading-display);
}
h2 {
font-size: var(--text-xl);
font-weight: var(--font-medium);
line-height: var(--leading-xl);
letter-spacing: var(--tracking-normal);
letter-spacing: var(--tracking-heading);
}
h3 {
font-size: var(--text-lg);
font-weight: var(--font-medium);
line-height: var(--leading-lg);
letter-spacing: var(--tracking-normal);
letter-spacing: var(--tracking-heading);
}
h4 {
font-size: var(--text-base);
font-weight: var(--font-medium);
line-height: var(--leading-md);
letter-spacing: var(--tracking-normal);
letter-spacing: var(--tracking-heading);
}
h5 {
font-size: var(--text-sm);
font-weight: var(--font-medium);
line-height: var(--leading-base);
letter-spacing: var(--tracking-normal);
letter-spacing: var(--tracking-heading);
}
h6 {
+8
View File
@@ -152,6 +152,14 @@ html.theme-switching *::after {
animation-duration: 0.01ms !important;
animation-timing-function: linear !important;
}
/* 0.01ms, not none: transitionend still fires for code that waits on it. */
*,
*::before,
*::after {
transition-duration: 0.01ms !important;
transition-delay: 0s !important;
}
}
/*
+1 -1
View File
@@ -100,7 +100,7 @@ const FALLBACKS: Record<string, string> = {
"--color-chart-table-22": "#FFF2CC",
"--color-chart-table-23": "#FFE6E6",
"--color-chart-table-24": "#E8F4FD",
"--color-chart-gridline": "rgba(0, 0, 0, 0.2)",
"--color-chart-gridline": "rgba(0, 10, 23, 0.09)",
"--color-chart-band": "rgba(115, 115, 115, 0.22)",
// --color-chart-crosshair-bg is deliberately absent: it is dark-only, and the empty
// string light returns is what makes ECharts use its own auto default (base.css:262).
@@ -18,9 +18,12 @@ import { resolve } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
colorToRgba,
createDistinctSeriesColor,
getAreaFillOpacity,
getAreaGradientColor,
getAreaStyleOverride,
getDistinctSeriesColors,
getSeriesHash,
} from "./colorPalette";
const COLOR = "#5b8ef0";
@@ -162,3 +165,121 @@ describe("area fill tokens", () => {
expect(value(darkCss, "--chart-area-stacked-fill-bottom-opacity")).toBe("1");
});
});
describe("getDistinctSeriesColors", () => {
// Vitest stubs CSS imports, so the shipped light/dark series palettes are read from disk.
const palette = (file: string) => {
const css = readFileSync(resolve(process.cwd(), "src/lib/styles/tokens", file), "utf8");
return Array.from(
{ length: 12 },
(_, i) => css.match(new RegExp(`--color-chart-series-${i + 1}:\\s*(#[0-9a-f]{6})`, "i"))![1],
);
};
const phases = ["Failed", "Pending", "Running", "Succeeded", "Unknown"];
it.each(["base.css", "dark.css"])("gives pod phases five different colours in %s", (file) => {
const colors = palette(file);
// The bare name hash sends Pending and Succeeded to one slot and Failed to its near-twin amber.
expect(getSeriesHash("Pending", colors)).toBe(getSeriesHash("Succeeded", colors));
const assigned = getDistinctSeriesColors(phases, colors);
expect(new Set(assigned.values()).size).toBe(5);
const amberTwins = [colors[5], colors[11]];
expect(phases.filter((p) => amberTwins.includes(assigned.get(p)!))).toHaveLength(1);
});
it("keeps a name that does not collide on its hash colour", () => {
const colors = palette("base.css");
expect(getDistinctSeriesColors(phases, colors).get("Running")).toBe(
colors[getSeriesHash("Running", colors)],
);
});
it("does not depend on the order the names arrive in", () => {
const colors = palette("base.css");
const forward = getDistinctSeriesColors(phases, colors);
const backward = getDistinctSeriesColors([...phases].reverse(), colors);
phases.forEach((p) => expect(backward.get(p)).toBe(forward.get(p)));
});
it("keeps every uncontested name on its colour in any subset of the names", () => {
const colors = palette("base.css");
const full = getDistinctSeriesColors(phases, colors);
const uncontested = phases.filter((p) => full.get(p) === colors[getSeriesHash(p, colors)]);
expect(uncontested.length).toBeGreaterThanOrEqual(3);
for (let mask = 1; mask < 1 << phases.length; mask++) {
const subset = phases.filter((_, i) => mask & (1 << i));
const assigned = getDistinctSeriesColors(subset, colors);
subset
.filter((p) => uncontested.includes(p))
.forEach((p) => expect(assigned.get(p)).toBe(full.get(p)));
}
});
it("keeps a name's colour when other names disappear, unless a sorted-earlier name shares its hash slot", () => {
const colors = palette("base.css");
const alone = getDistinctSeriesColors(["Succeeded"], colors).get("Succeeded");
expect(alone).toBe(colors[getSeriesHash("Succeeded", colors)]);
expect(getDistinctSeriesColors(["Running", "Succeeded"], colors).get("Succeeded")).toBe(alone);
expect(getDistinctSeriesColors(["Succeeded", "Unknown"], colors).get("Succeeded")).toBe(alone);
// Pending hashes to the same slot and sorts first, so it holds that colour whenever present.
expect(getDistinctSeriesColors(["Pending", "Succeeded"], colors).get("Succeeded")).not.toBe(
alone,
);
});
it("never auto-assigns a reserved colour or a near shade of it", () => {
const colors = palette("base.css");
const home = colors[getSeriesHash("Running", colors)];
const nearShade = `#${((parseInt(home.slice(1), 16) + 0x020202) & 0xffffff).toString(16).padStart(6, "0")}`;
[home, nearShade].forEach((reserved) => {
const assigned = getDistinctSeriesColors(phases, colors, [reserved]);
expect([...assigned.values()]).not.toContain(home);
expect(new Set(assigned.values()).size).toBe(phases.length);
});
});
it("reuses colours only once every slot is taken", () => {
const names = Array.from({ length: 14 }, (_, i) => `series-${i}`);
const assigned = getDistinctSeriesColors(names, palette("base.css"));
expect(new Set(assigned.values()).size).toBe(12);
});
});
describe("createDistinctSeriesColor", () => {
const root = document.documentElement.style;
const hexes = ["#5b8ef0", "#34d399", "#fb923c", "#f472b6"];
afterEach(() => hexes.forEach((_, i) => root.removeProperty(`--color-chart-series-${i + 1}`)));
it("leaves non-hash colour modes to getSeriesColor", () => {
const color = createDistinctSeriesColor(["a", "b"], { mode: "fixed" }, "light");
expect(color("a")).toBeUndefined();
});
it("skips names that have their own colorBySeries mapping", () => {
hexes.forEach((hex, i) => root.setProperty(`--color-chart-series-${i + 1}`, hex));
const color = createDistinctSeriesColor(
["a", "b"],
{ mode: "palette-classic-by-series", colorBySeries: [{ value: "a", color: "#000000" }] },
"light",
);
expect(color("a")).toBeUndefined();
expect(color("b")).toMatch(/^#[0-9a-f]{6}$/i);
});
it("keeps a colorBySeries colour away from the automatic slices", () => {
hexes.forEach((hex, i) => root.setProperty(`--color-chart-series-${i + 1}`, hex));
const auto = createDistinctSeriesColor(
["a", "b"],
{ mode: "palette-classic-by-series" },
"light",
);
const bColor = auto("b")!;
const color = createDistinctSeriesColor(
["a", "b"],
{ mode: "palette-classic-by-series", colorBySeries: [{ value: "a", color: bColor }] },
"light",
);
expect(color("b")).toBeDefined();
expect(color("b")).not.toBe(bColor);
});
});
+80 -2
View File
@@ -266,6 +266,7 @@ interface ColorConfig {
| "continuous-light-to-dark-blue";
fixedColor?: string[];
seriesBy?: SeriesBy;
colorBySeries?: Array<{ value: string; color?: string }>;
}
function getDomainPartitions(chartMin: number, chartMax: number, numPartitions: number) {
@@ -332,6 +333,83 @@ export const getSeriesColor = (
}
};
// Below this redmean distance two palette colours read as one hue: the amber/yellow pair sits at 20-34.
const NEAR_COLOR_DISTANCE = 60;
const toRgb = (value: string): [number, number, number] | null => {
const hex = /^#([0-9a-f]{6})$/i.exec(value.trim());
if (hex) {
const n = parseInt(hex[1], 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
const rgb = /^rgba?\(\s*(\d+)[\s,]+(\d+)[\s,]+(\d+)/i.exec(value.trim());
return rgb ? [Number(rgb[1]), Number(rgb[2]), Number(rgb[3])] : null;
};
const isNearColor = (a: string, b: string): boolean => {
const x = toRgb(a);
const y = toRgb(b);
if (!x || !y) return a === b;
const rMean = (x[0] + y[0]) / 2;
const [dr, dg, db] = [x[0] - y[0], x[1] - y[1], x[2] - y[2]];
const distance = Math.sqrt(
(2 + rMean / 256) * dr * dr + 4 * dg * dg + (2 + (255 - rMean) / 256) * db * db,
);
return distance < NEAR_COLOR_DISTANCE;
};
/** Name-hashed palette colours; a name leaves its hash slot only when an earlier name or a reserved colour already holds that colour. */
export const getDistinctSeriesColors = (
names: string[],
palette: string[],
reserved: string[] = [],
): Map<string, string> => {
const colors = new Map<string, string>();
const takenColors = [...reserved];
const takenSlots = new Set(
palette.flatMap((color, i) => (reserved.some((r) => isNearColor(color, r)) ? [i] : [])),
);
const isApart = (slot: number) => takenColors.every((c) => !isNearColor(palette[slot], c));
const take = (name: string, slot: number) => {
takenSlots.add(slot);
takenColors.push(palette[slot]);
colors.set(name, palette[slot]);
};
// Sorted, so the same set of names always gets the same colours.
const unique = [...new Set(names)].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
// Uncontested hash slots are claimed before any probing, so a probe never takes another name's own colour.
const displaced = unique.filter((name) => {
const home = getSeriesHash(name, palette);
if (takenSlots.has(home) || !isApart(home)) return true;
take(name, home);
return false;
});
displaced.forEach((name) => {
const home = getSeriesHash(name, palette);
const slots = palette.map((_, i) => (home + i) % palette.length);
const isFree = (slot: number) => !takenSlots.has(slot);
take(name, slots.find((i) => isFree(i) && isApart(i)) ?? slots.find(isFree) ?? home);
});
return colors;
};
/** Per-chart override of getSeriesColor's name hash: returns the distinct colour, or undefined where getSeriesColor decides. */
export const createDistinctSeriesColor = (
names: string[],
colorCfg: ColorConfig | null | undefined,
theme: string,
): ((name: string) => string | undefined) => {
if (colorCfg && colorCfg.mode !== "palette-classic-by-series") return () => undefined;
const mappings = (colorCfg?.colorBySeries ?? []).filter((m) => m?.color);
const mapped = new Set(mappings.map((m) => m.value));
const colors = getDistinctSeriesColors(
names.filter((name) => !mapped.has(name)),
getColorPalette(theme),
mappings.map((m) => String(m.color)),
);
return (name) => colors.get(name);
};
// Dashboard table row-highlight palette. The light/dark swap lives in the
// --color-chart-table-* tokens (base.css / dark.css); this resolves them via
// chartColor(). `_theme` kept for call-site compatibility, ignored.
@@ -375,12 +453,12 @@ export const colorToRgba = (color: string, alpha: number): string => {
};
/**
* Dashed split-line style for chart grids — subtle so the lines recede behind
* Solid split-line style for chart grids — subtle so the lines recede behind
* the data. Theme-aware: faint white on dark, faint black on light. Shared by
* the PromQL and SQL chart builders so the grid look stays consistent.
*/
export const getGridLineStyle = (_theme?: string) => ({
type: "dashed",
type: "solid",
width: 1,
// light/dark swap lives in the --color-chart-gridline token (base/dark css).
color: chartColor("--color-chart-gridline"),
@@ -52,6 +52,7 @@ vi.mock("./calculateGridForSubPlot", () => ({
}));
vi.mock("./colorPalette", () => ({
createDistinctSeriesColor: vi.fn(() => () => undefined),
ColorModeWithoutMinMax: { palette: "palette" },
getMetricMinMaxValue: vi.fn(() => [0, 100]),
getSeriesColor: vi.fn(() => "#FF0000"),
@@ -2710,7 +2711,7 @@ describe("Convert PromQL Data Utils", () => {
mockAnnotations,
);
expect(result.options.xAxis.splitLine.show).toBe(true);
expect(result.options.xAxis.splitLine.show).toBe(false);
expect(result.options.yAxis.splitLine.show).toBe(true);
});
@@ -2744,7 +2745,7 @@ describe("Convert PromQL Data Utils", () => {
mockAnnotations,
);
expect(result.options.xAxis.splitLine.show).toBe(true);
expect(result.options.xAxis.splitLine.show).toBe(false);
expect(result.options.yAxis.splitLine.show).toBe(true);
});
@@ -2884,8 +2885,8 @@ describe("Convert PromQL Data Utils", () => {
// Based on the ternary logic: panelSchema?.config?.show_gridlines !== undefined ? panelSchema.config.show_gridlines : true
// When show_gridlines is null, it's not undefined, so it uses the null value
// The splitLine.show gets set to null, not the default true
expect(result.options.xAxis.splitLine.show).toBe(null);
// The y splitLine.show gets set to null, not the default true; the x axis never draws gridlines
expect(result.options.xAxis.splitLine.show).toBe(false);
expect(result.options.yAxis.splitLine.show).toBe(null);
});
});
@@ -3557,7 +3558,7 @@ describe("Convert PromQL Data Utils", () => {
mockAnnotations,
);
expect(result.options.xAxis.splitLine.show).toBe(true);
expect(result.options.xAxis.splitLine.show).toBe(false);
expect(result.options.legend.show).toBe(false);
expect(result.extras.isTimeSeries).toBe(true);
});
@@ -3846,10 +3847,42 @@ describe("Convert PromQL Data Utils", () => {
// Should show both axis borders and gridlines
expect(result.options.xAxis.axisLine.show).toBe(true);
expect(result.options.yAxis.axisLine.show).toBe(true);
expect(result.options.xAxis.splitLine.show).toBe(true);
expect(result.options.xAxis.splitLine.show).toBe(false);
expect(result.options.yAxis.splitLine.show).toBe(true);
});
it("draws the x baseline by default and hides it when axis_border_show is false", async () => {
const searchQueryData = [
{
resultType: "matrix",
result: [
{
metric: { __name__: "baseline_metric" },
values: [[1640435200, "10"]],
},
],
},
];
const build = (config: Record<string, unknown>) =>
convertPromQLData(
{ id: "panel1", type: "line", config, queries: [{ config: { promql_legend: "" } }] },
searchQueryData,
mockStore,
mockChartPanelRef,
mockHoveredSeriesState,
mockAnnotations,
);
const byDefault = await build({});
expect(byDefault.options.xAxis.axisLine.show).toBe(true);
expect(byDefault.options.yAxis.axisLine.show).toBe(false);
expect(byDefault.options.xAxis.axisLabel.fontSize).toBe(12);
expect(byDefault.options.xAxis.axisLabel.color).toBe(byDefault.options.yAxis.axisLabel.color);
const hidden = await build({ axis_border_show: false });
expect(hidden.options.xAxis.axisLine.show).toBe(false);
});
it("should handle empty result but valid structure", async () => {
const panelSchema = {
id: "panel1",
+11 -5
View File
@@ -33,8 +33,10 @@ import { ANNOTATION_MARK_AREA_Z } from "@/utils/dashboard/constants";
import { escapeHtml } from "@/utils/html";
import { addCommasToNumber } from "@/utils/formatters";
import {
chartAxisLine,
chartColor,
chartNumber,
chartTextColor,
dataZoomBrushStyle,
CHART_SELECTION_FILL,
} from "@/utils/chartTheme";
@@ -388,7 +390,6 @@ export const convertPromQLData = async (
// Set gridlines visibility based on config.show_gridlines (default: true)
const showGridlines =
panelSchema?.config?.show_gridlines !== undefined ? panelSchema.config.show_gridlines : true;
// Subtle dashed grid lines so they recede behind the data
const gridLineStyle = getGridLineStyle(store.state.theme);
await importMoment();
@@ -852,13 +853,16 @@ export const convertPromQLData = async (
axisLine: {
show: searchQueryData?.every((it: any) => it && it.result && it.result.length == 0)
? true
: (panelSchema.config?.axis_border_show ?? false),
: (panelSchema.config?.axis_border_show ?? true),
lineStyle: { color: chartAxisLine() },
},
splitLine: {
show: showGridlines,
show: false,
lineStyle: gridLineStyle,
},
axisLabel: {
color: chartTextColor(),
fontSize: 12,
// hide axis label if overlaps
hideOverlap: true,
// For time-based x-axis (type: "time"), rotation and truncation are not applicable
@@ -873,6 +877,8 @@ export const convertPromQLData = async (
min: getFinalAxisValue(panelSchema.config.y_axis_min, min, true),
max: getFinalAxisValue(panelSchema.config.y_axis_max, max, false),
axisLabel: {
color: chartTextColor(),
fontSize: 12,
// hide colliding tick labels instead of drawing them on top of each
// other (short panels fit fewer labels than ECharts offers)
hideOverlap: true,
@@ -962,8 +968,8 @@ export const convertPromQLData = async (
if (options.xAxis) {
(Array.isArray(options.xAxis) ? options.xAxis : [options.xAxis]).forEach(
(axis: { splitLine: { show: boolean; lineStyle: unknown } }) => {
// if (!axis.splitLine) axis.splitLine = {};
axis.splitLine.show = showGridlines;
// Gridlines are horizontal only: the time axis draws none.
axis.splitLine.show = false;
axis.splitLine.lineStyle = gridLineStyle;
},
);
@@ -91,6 +91,7 @@ vi.mock("../query/sqlUtils", () => ({
}));
vi.mock("./colorPalette", () => ({
createDistinctSeriesColor: vi.fn(() => () => undefined),
ColorModeWithoutMinMax: vi.fn(),
getSeriesColor: vi.fn(() => "#ff0000"),
getSQLMinMaxValue: vi.fn(() => [0, 100]),
@@ -9648,6 +9649,51 @@ describe("convertSQLData", () => {
});
describe("Show Gridlines Configuration", () => {
it.each([
[true, true],
[false, false],
[undefined, true],
])(
"h-bar with show_gridlines=%s draws value-axis gridlines=%s and none on the category axis",
async (showGridlines, expected) => {
const schema = {
...mockPanelSchema,
type: "h-bar",
config: { ...mockPanelSchema.config, show_gridlines: showGridlines },
queries: [
{
fields: { x: [{ alias: "category" }], y: [{ alias: "value" }], z: [], breakdown: [] },
},
],
};
const searchData = [
[
{ category: "A", value: 100 },
{ category: "B", value: 200 },
],
];
const result = await convertSQLData(
schema,
searchData,
mockStore,
mockChartPanelRef,
mockHoveredSeriesState,
mockResultMetaData,
mockMetadata,
mockChartPanelStyle,
mockAnnotations,
);
const valueAxes = [result.options.xAxis].flat();
const categoryAxes = [result.options.yAxis].flat();
expect(valueAxes[0].type).toBe("value");
expect(categoryAxes[0].type).toBe("category");
valueAxes.forEach((axis: any) => expect(axis.splitLine.show).toBe(expected));
categoryAxes.forEach((axis: any) => expect(axis.splitLine.show).toBe(false));
},
);
it("should use default gridlines (true) when config is undefined", async () => {
const schema = {
...mockPanelSchema,
@@ -51,6 +51,7 @@ vi.mock("./shared/gridBuilder", () => ({
}));
vi.mock("../colorPalette", () => ({
createDistinctSeriesColor: vi.fn(() => () => undefined),
getSeriesColor: vi.fn((colorConfig, name) => {
// Simple mock that returns colors based on name
const colors: Record<string, string> = {
@@ -1004,6 +1005,33 @@ describe("PieConverter", () => {
expect(result.series[0].data).toHaveLength(1);
});
it("colours each slice from the chart-wide distinct map before the name hash", async () => {
const { createDistinctSeriesColor } = await import("../colorPalette");
vi.mocked(createDistinctSeriesColor).mockImplementationOnce((names, colorCfg, theme) => {
expect(names).toEqual(["Pending", "Succeeded"]);
expect(colorCfg).toBeUndefined();
expect(theme).toBe("light");
return (name: string) => (name === "Succeeded" ? "#5b8ef0" : undefined);
});
const processedData: ProcessedPromQLData[] = [
{
series: [
{ name: "Pending", values: [[1, "3"]] },
{ name: "Succeeded", values: [[1, "3"]] },
],
timestamps: [1],
queryIndex: 0,
},
];
const result = converter.convert(processedData, { type: "pie" }, mockStore, mockExtras);
const colorOf = (name: string) =>
result.series[0].data.find((d: any) => d.name === name).itemStyle?.color;
expect(colorOf("Succeeded")).toBe("#5b8ef0");
expect(colorOf("Pending")).toBe("#CCCCCC");
});
it("should handle when getSeriesColor returns null", async () => {
const { getSeriesColor } = await import("../colorPalette");
vi.mocked(getSeriesColor).mockReturnValueOnce(null);
@@ -17,7 +17,7 @@ import { PromQLChartConverter, ProcessedPromQLData } from "./shared/types";
import { applyAggregation } from "./shared/dataProcessor";
import { buildTooltip } from "./shared/axisBuilder";
import { buildPieChartConfig } from "./shared/gridBuilder";
import { getSeriesColor } from "../colorPalette";
import { createDistinctSeriesColor, getSeriesColor } from "../colorPalette";
import { getUnitValue, formatUnitValue } from "../convertDataIntoUnitValue";
import { createSeriesOverrideLookup } from "../seriesOverrides";
import { MAX_SEATS_PRECISION, pieLegendPercentDecimals } from "../legendConfiguration";
@@ -78,20 +78,28 @@ export class PieConverter implements PromQLChartConverter {
});
});
const distinctColor = createDistinctSeriesColor(
seriesDataCollection.map((s) => s.name),
config.color,
store.state.theme,
);
// Now apply colors and build data with unit formatting
seriesDataCollection.forEach((seriesData) => {
// Extract numeric values for color calculation
const numericValues = seriesData.rawValues.map(([, val]) => parseFloat(val));
const color = getSeriesColor(
config.color || null,
seriesData.name,
numericValues,
chartMin,
chartMax,
store.state.theme,
config.color?.colorBySeries,
);
const color =
distinctColor(seriesData.name) ??
getSeriesColor(
config.color || null,
seriesData.name,
numericValues,
chartMin,
chartMax,
store.state.theme,
config.color?.colorBySeries,
);
data.push({
name: seriesData.name,
@@ -37,6 +37,7 @@ vi.mock("@/utils/dashboard/chartColorUtils", () => ({
}));
vi.mock("@/utils/dashboard/colorPalette", () => ({
createDistinctSeriesColor: vi.fn(() => () => undefined),
getSeriesColor: vi.fn(() => "#FF5733"),
getSQLMinMaxValue: vi.fn(() => ({ min: 0, max: 100 })),
}));
@@ -23,6 +23,7 @@ vi.mock("@/utils/dashboard/convertDataIntoUnitValue", () => ({
}));
vi.mock("@/utils/dashboard/colorPalette", () => ({
createDistinctSeriesColor: vi.fn(() => () => undefined),
getSeriesColor: vi.fn(() => "#FF5733"),
}));
@@ -13,7 +13,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { getSeriesColor } from "../../colorPalette";
import { createDistinctSeriesColor, getSeriesColor } from "../../colorPalette";
import { createSeriesOverrideLookup } from "../../seriesOverrides";
import { chartColor } from "@/utils/chartTheme";
import {
@@ -47,6 +47,11 @@ export function applyPieDonutChart(ctx: SQLContext): void {
getPieChartRadius,
} = ctx;
const seriesOverrides = createSeriesOverrideLookup(panelSchema?.config);
const distinctColor = createDistinctSeriesColor(
(options?.xAxis?.[0]?.data ?? []).map(String),
panelSchema?.config?.color,
store.state.theme,
);
if (panelSchema.type === "pie") {
// Add more padding for pie chart
@@ -93,6 +98,7 @@ export function applyPieDonutChart(ctx: SQLContext): void {
? null
: (params: any) => {
return (
distinctColor(String(params.name)) ??
getSeriesColor(
panelSchema?.config?.color,
params.name,
@@ -101,7 +107,8 @@ export function applyPieDonutChart(ctx: SQLContext): void {
chartMax,
store.state.theme,
panelSchema?.config?.color?.colorBySeries,
) ?? null
) ??
null
);
},
},
@@ -233,6 +240,7 @@ export function applyPieDonutChart(ctx: SQLContext): void {
? null
: (params: any) => {
return (
distinctColor(String(params.name)) ??
getSeriesColor(
panelSchema?.config?.color,
params.name,
@@ -241,7 +249,8 @@ export function applyPieDonutChart(ctx: SQLContext): void {
chartMax,
store.state.theme,
panelSchema?.config?.color?.colorBySeries,
) ?? null
) ??
null
);
},
},
@@ -30,7 +30,7 @@ import {
calculateChartDimensions,
calculatePieChartRadius,
} from "../../legendConfiguration";
import { chartColor, dataZoomBrushStyle } from "@/utils/chartTheme";
import { chartAxisLine, chartColor, chartTextColor, dataZoomBrushStyle } from "@/utils/chartTheme";
import { getPropsByChartTypeForSeries } from "../../sqlChartSeriesProps";
import { processData } from "../../sqlProcessData";
import { fillMissingValues } from "../../sqlMissingValueFiller";
@@ -74,7 +74,6 @@ export function buildSQLContext(
// Set gridlines visibility based on config.show_gridlines (default: true)
const showGridlines =
panelSchema?.config?.show_gridlines !== undefined ? panelSchema.config.show_gridlines : true;
// Subtle dashed grid lines so they recede behind the data
const gridLineStyle = getGridLineStyle(store.state.theme);
const extras: any = {};
@@ -663,6 +662,8 @@ export function buildSQLContext(
fontSize: 14,
},
axisLabel: {
color: chartTextColor(),
fontSize: 12,
interval:
panelSchema.type == "h-stacked"
? "auto"
@@ -679,13 +680,14 @@ export function buildSQLContext(
rotate: labelRotation,
},
splitLine: {
show: showGridlines,
show: false,
lineStyle: gridLineStyle,
},
axisLine: {
show: searchQueryData?.every((it: any) => it.length == 0)
? true
: (panelSchema.config?.axis_border_show ?? false),
: (panelSchema.config?.axis_border_show ?? true),
lineStyle: { color: chartAxisLine() },
},
axisTick: {
show: xAxisKeys.length + breakDownKeys.length == 1 ? false : true,
@@ -720,6 +722,8 @@ export function buildSQLContext(
ellipsis: "..",
},
axisLabel: {
color: chartTextColor(),
fontSize: 12,
hideOverlap: true,
formatter: function (value: any) {
try {
@@ -775,7 +779,8 @@ export function buildSQLContext(
if (options.xAxis) {
(Array.isArray(options.xAxis) ? options.xAxis : [options.xAxis]).forEach(
(axis: { splitLine: { show: boolean; lineStyle: unknown } }) => {
axis.splitLine.show = showGridlines;
// Gridlines run along the value axis only; the category axis draws none.
axis.splitLine.show = false;
axis.splitLine.lineStyle = gridLineStyle;
},
);
+20 -2
View File
@@ -32,6 +32,7 @@ import {
resolveLogSeverity,
resolveSeverityFieldValue,
setSeverityInferenceEnabled,
severityFieldTextClass,
severityIndicatorColor,
severityRowClass,
severityStringValues,
@@ -1048,6 +1049,23 @@ describe("indicator colours and row classes (A7)", () => {
expect(severityRowClass(sev({ status: 503 }))).toBe("o2-log-level-error o2-log-level-src-http");
});
it("colours only a severity field's own cell by its level", () => {
expect(severityFieldTextClass("level", "ERROR")).toBe(
"font-mono font-medium text-log-severity-error-text",
);
expect(severityFieldTextClass("level", "warn")).toBe(
"font-mono font-medium text-log-severity-warning-text",
);
expect(severityFieldTextClass("severity", "debug")).toBe(
"font-mono font-medium text-log-severity-debug-text",
);
expect(severityFieldTextClass("level", "info")).toBe(
"font-mono font-medium text-log-severity-info-text",
);
expect(severityFieldTextClass("message", "ERROR")).toBe("");
expect(severityFieldTextClass("status", 503)).toBe("");
});
it("defines every indicator token in light and dark", () => {
const here = dirname(fileURLToPath(import.meta.url));
const tokens = join(here, "../../lib/styles/tokens");
@@ -1070,8 +1088,8 @@ describe("indicator colours and row classes (A7)", () => {
expect(base).toContain(token);
expect(dark).toContain(token);
}
expect(base).toContain("--color-log-severity-warning-indicator: #dd7b00;");
expect(base).toContain("--color-log-severity-debug-indicator: #00a3b7;");
expect(base).toContain("--color-log-severity-warning-indicator: #c46a00;");
expect(base).toContain("--color-log-severity-debug-indicator: #008a9b;");
expect(base).toContain("--color-log-severity-trace-indicator: #8497a0;");
});
});

Some files were not shown because too many files have changed in this diff Show More