{UNBREAKABLE}
@@ -64,5 +80,80 @@ describe('Scrollable', () => {
);
expect(viewport.scrollWidth).toBeGreaterThan(viewport.clientWidth);
+
+ // The orientation guard must not over-hide: a bar whose own axis
+ // overflows stays visible.
+ const scrollbar = document.querySelector
(
+ '[data-scope="scroll-area"][data-part="scrollbar"][data-orientation="horizontal"]'
+ )!;
+
+ await waitFor(() => getComputedStyle(scrollbar).display !== 'none');
+ expect(getComputedStyle(scrollbar).display).not.toBe('none');
+ });
+
+ it('hides the vertical scrollbar when only sideways spill exists', async () => {
+ // Chakra's stock guard hides a scrollbar only when NEITHER axis
+ // overflows, so nowrap rows spilling sideways pinned a min-size thumb on
+ // the vertical bar (dynamic prompts, JSON previews). The theme's
+ // orientation-scoped guard is what this pins.
+ const viewport = await renderScrollable(
+
+ {UNBREAKABLE}
+ {UNBREAKABLE}
+ {UNBREAKABLE}
+
,
+ 'vertical'
+ );
+
+ expect(viewport.scrollHeight).toBeLessThanOrEqual(viewport.clientHeight);
+
+ const scrollbar = document.querySelector(
+ '[data-scope="scroll-area"][data-part="scrollbar"][data-orientation="vertical"]'
+ )!;
+
+ await waitFor(() => getComputedStyle(scrollbar).display === 'none');
+
+ expect(scrollbar).not.toBeNull();
+ // The spill is real and zag records it; only the orientation guard keeps
+ // the vertical bar out of it.
+ expect(scrollbar.hasAttribute('data-overflow-x')).toBe(true);
+ expect(viewport.scrollWidth).toBeGreaterThan(viewport.clientWidth);
+ expect(getComputedStyle(scrollbar).display).toBe('none');
+ });
+
+ it('hides the scrollbar for non-overflowing content inside a popover', async () => {
+ host = document.createElement('div');
+ document.body.append(host);
+ root = createRoot(host);
+
+ await act(async () => {
+ root?.render(
+
+
+
+
+
+
+
+ one short row
+
+
+
+
+
+
+
+ );
+ await new Promise((resolve) => {
+ globalThis.setTimeout(resolve, 50);
+ });
+ });
+
+ const scrollbar = document.querySelector('[data-scope="scroll-area"][data-part="scrollbar"]')!;
+
+ expect(scrollbar).not.toBeNull();
+ await waitFor(() => !scrollbar.hasAttribute('data-overflow-y'));
+ expect(scrollbar.hasAttribute('data-overflow-y')).toBe(false);
+ expect(getComputedStyle(scrollbar).display).toBe('none');
});
});
diff --git a/invokeai/frontend/webv2/src/platform/ui/Scrollable.tsx b/invokeai/frontend/webv2/src/platform/ui/Scrollable.tsx
index 250ee89a62a..fefd5071252 100644
--- a/invokeai/frontend/webv2/src/platform/ui/Scrollable.tsx
+++ b/invokeai/frontend/webv2/src/platform/ui/Scrollable.tsx
@@ -4,6 +4,8 @@ import { ScrollArea } from '@chakra-ui/react';
import { usePreservedScrollOffset } from '@platform/react/usePreservedScrollOffset';
import { useRef } from 'react';
+import { useScrollAreaPhantomHeal } from './useScrollAreaPhantomHeal';
+
type ScrollAreaRootProps = ComponentProps;
type ScrollAreaContentProps = ComponentProps;
type ScrollAreaViewportProps = ComponentProps;
@@ -55,6 +57,8 @@ export const Scrollable = ({
// container that stops being rendered loses its offset outright.
usePreservedScrollOffset(resolvedViewportRef);
+ useScrollAreaPhantomHeal(resolvedViewportRef);
+
return (
{
id: T;
- label: string;
+ /** Usually a string; gallery tabs carry a dimmed count span. */
+ label: ReactNode;
}
/** Roving focus for a horizontal tablist: arrows cycle, Home/End jump. */
@@ -48,6 +53,7 @@ export const SegmentTabs = ({
activeId,
ariaLabel,
idBase,
+ isCompact = false,
onSelect,
showActivePanel = true,
tabs,
@@ -56,12 +62,21 @@ export const SegmentTabs = ({
activeId: T;
ariaLabel: string;
idBase: string;
+ /** Embedded strips (popovers, dialog headers) drop the panel-strip height and outer padding. */
+ isCompact?: boolean;
onSelect: (id: T) => void;
showActivePanel?: boolean;
tabs: readonly SegmentTab[];
trailing?: ReactNode;
}) => (
-
+
({
isSelected: boolean;
/** Selected AND its panel is visible; a collapsed block keeps selection without the shown look. */
isShown: boolean;
- label: string;
+ label: ReactNode;
onSelect: (id: T) => void;
}) => {
const select = useCallback(() => onSelect(id), [id, onSelect]);
@@ -126,19 +141,19 @@ const SegmentTabButton = ({
{
+ ariaLabel?: string;
+ disabled?: boolean;
+ /** Default true: the control fills its container, split into equal segments. */
+ isFullWidth?: boolean;
+ onChange: (value: string) => void;
+ options: readonly SegmentedControlOption[];
+ value: string | null;
+}
+
+/** The house segmented control: an `xs` group of equal centered segments with `2xs` labels. */
+export const SegmentedControl = ({
+ ariaLabel,
+ disabled,
+ isFullWidth = true,
+ onChange,
+ options,
+ value,
+ ...rest
+}: SegmentedControlProps) => {
+ const handleValueChange = useCallback(
+ ({ value: next }: SegmentGroup.ValueChangeDetails) => {
+ if (next !== null) {
+ onChange(next);
+ }
+ },
+ [onChange]
+ );
+
+ return (
+
+
+ {options.map((option) => (
+
+
+ {option.label}
+
+ ))}
+
+ );
+};
diff --git a/invokeai/frontend/webv2/src/platform/ui/Tabs.browser.test.tsx b/invokeai/frontend/webv2/src/platform/ui/Tabs.browser.test.tsx
index e013fd812a8..0efc99939a6 100644
--- a/invokeai/frontend/webv2/src/platform/ui/Tabs.browser.test.tsx
+++ b/invokeai/frontend/webv2/src/platform/ui/Tabs.browser.test.tsx
@@ -1,12 +1,10 @@
-import { Box, ChakraProvider, Stack, Tabs } from '@chakra-ui/react';
+import { ChakraProvider, Tabs } from '@chakra-ui/react';
import { system } from '@theme/system';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, describe, expect, it } from 'vitest';
import { userEvent } from 'vitest/browser';
-const variants = ['line', 'subtle', 'enclosed', 'outline', 'plain'] as const;
-
let host: HTMLDivElement | null = null;
let root: Root | null = null;
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -18,184 +16,47 @@ afterEach(async () => {
root = null;
});
-/**
- * This test drives real pointer input across five variants, and each move costs
- * whatever the machine can spare. Measured on a loaded 16-core box, the moves
- * alone took 7–13.5s against the default 15s, and every failure was a timeout
- * sitting at 12.5–13.6s — never an assertion. The settle waits were flat at
- * ~1.1s throughout, so synchronization was never the problem; the budget was.
- * Dropping the redundant unhovers below removes a third of the moves, and this
- * ceiling covers what is left.
- */
-const HOVER_SWEEP_TIMEOUT_MS = 60_000;
-
-describe('tab hover styles', () => {
- it(
- 'gives every variant restrained hover feedback without changing selected or disabled tabs',
- async () => {
- host = document.createElement('div');
- document.body.append(host);
- root = createRoot(host);
-
- await act(async () => {
- root?.render(
-
-
-
-
-
-
-
- {variants.map((variant) => (
-
-
-
- Idle
-
-
- Selected
-
-
- Disabled
-
-
-
- ))}
-
-
- );
- await new Promise((resolve) => {
- globalThis.setTimeout(resolve, 0);
- });
- });
-
- const mutedBackground = getProbeStyle(host, 'muted background probe').backgroundColor;
- const faintMutedBackground = getProbeStyle(host, 'faint muted background probe').backgroundColor;
- const lightMutedBackground = getProbeStyle(host, 'light muted background probe').backgroundColor;
- const emphasizedBackground = getProbeStyle(host, 'emphasized background probe').backgroundColor;
- const emphasizedBorder = getProbeStyle(host, 'emphasized border probe').borderColor;
- const expectedHoverBackgrounds = {
- enclosed: emphasizedBackground,
- line: faintMutedBackground,
- outline: mutedBackground,
- plain: lightMutedBackground,
- subtle: mutedBackground,
- };
-
- for (const variant of variants) {
- const idle = host.querySelector(`[aria-label="${variant} idle"]`)!;
- const selected = host.querySelector(`[aria-label="${variant} selected"]`)!;
- const disabled = host.querySelector(`[aria-label="${variant} disabled"]`)!;
- const idleBefore = getInteractionStyles(idle);
- const selectedBefore = getInteractionStyles(selected);
- const disabledBefore = getInteractionStyles(disabled);
-
- await act(async () => {
- await userEvent.hover(idle);
- await waitForSettledStyles(idle);
- });
- const idleHovered = getInteractionStyles(idle);
- expect(idleHovered.transitionDuration).toBe('0.1s');
- expect(idleHovered.transitionProperty).toBe('background, border-color, color');
- expect(idleHovered.backgroundColor).toBe(expectedHoverBackgrounds[variant]);
- if (variant === 'line' || variant === 'plain') {
- expect(idleHovered.color).not.toBe(idleBefore.color);
- } else {
- expect(idleHovered.color).toBe(idleBefore.color);
- }
- if (variant === 'outline') {
- expect(idleHovered.borderColor).toBe(emphasizedBorder);
- }
-
- await act(async () => {
- await userEvent.hover(selected);
- await waitForSettledStyles(selected);
- });
- expect(getInteractionStyles(selected)).toEqual(selectedBefore);
-
- await act(async () => {
- await userEvent.hover(disabled);
- await waitForSettledStyles(disabled);
- });
- expect(getInteractionStyles(disabled)).toEqual(disabledBefore);
- }
-
- const lineIdle = host.querySelector('[aria-label="line idle"]')!;
- const lineSelected = host.querySelector('[aria-label="line selected"]')!;
- await act(async () => {
- await userEvent.tab();
- await userEvent.keyboard('{ArrowLeft}');
+describe('tab focus feedback', () => {
+ it('keeps the keyboard focus outline through hover without disturbing the selected tab', async () => {
+ host = document.createElement('div');
+ document.body.append(host);
+ root = createRoot(host);
+
+ await act(async () => {
+ root?.render(
+
+
+
+
+ Idle
+
+
+ Selected
+
+
+
+
+ );
+ await new Promise((resolve) => {
+ globalThis.setTimeout(resolve, 0);
});
- expect(document.activeElement).toBe(lineIdle);
- const focusOutline = getComputedStyle(lineIdle).outline;
- expect(focusOutline).not.toBe('none');
-
- await act(async () => {
- await userEvent.hover(lineIdle);
- await waitForSettledStyles(lineIdle);
- });
- expect(getComputedStyle(lineIdle).outline).toBe(focusOutline);
- expect(lineSelected.dataset.selected).toBe('');
- },
- HOVER_SWEEP_TIMEOUT_MS
- );
-});
-
-const getInteractionStyles = (element: HTMLElement) => {
- const styles = getComputedStyle(element);
-
- return {
- backgroundColor: styles.backgroundColor,
- borderColor: styles.borderColor,
- color: styles.color,
- transitionDuration: styles.transitionDuration,
- transitionProperty: styles.transitionProperty,
- };
-};
-
-/** Consecutive unchanged samples that count as "the transition has finished". */
-const STABLE_SAMPLES = 3;
-const SAMPLE_INTERVAL_MS = 16;
-
-/**
- * Waits until an element's interaction styles stop changing, rather than
- * sleeping a fixed interval after each hover.
- *
- * These transitions run for 0.1s, but the test hovers fifteen triggers and
- * used to wait 200ms every time — three seconds of sleeping against a 15s
- * test timeout, which is what made this fail on a loaded CI runner. Sampling
- * until the values hold still takes as long as the machine actually needs,
- * and usually far less.
- */
-const waitForSettledStyles = (element: HTMLElement, timeoutMs = 5000): Promise =>
- new Promise((resolve, reject) => {
- const deadline = Date.now() + timeoutMs;
- let previous = JSON.stringify(getInteractionStyles(element));
- let stableSamples = 0;
-
- const sample = () => {
- const current = JSON.stringify(getInteractionStyles(element));
-
- stableSamples = current === previous ? stableSamples + 1 : 0;
- previous = current;
-
- if (stableSamples >= STABLE_SAMPLES) {
- resolve();
-
- return;
- }
-
- if (Date.now() > deadline) {
- reject(new Error(`Timed out after ${timeoutMs}ms waiting for styles to settle; last value ${current}`));
-
- return;
- }
-
- globalThis.setTimeout(sample, SAMPLE_INTERVAL_MS);
- };
-
- globalThis.setTimeout(sample, SAMPLE_INTERVAL_MS);
+ });
+
+ const idle = host.querySelector('[aria-label="line idle"]')!;
+ const selected = host.querySelector('[aria-label="line selected"]')!;
+
+ await act(async () => {
+ await userEvent.tab();
+ await userEvent.keyboard('{ArrowLeft}');
+ });
+ expect(document.activeElement).toBe(idle);
+ const focusOutline = getComputedStyle(idle).outline;
+ expect(focusOutline).not.toBe('none');
+
+ await act(async () => {
+ await userEvent.hover(idle);
+ });
+ expect(getComputedStyle(idle).outline).toBe(focusOutline);
+ expect(selected.dataset.selected).toBe('');
});
-
-const getProbeStyle = (container: HTMLElement, label: string) =>
- getComputedStyle(container.querySelector(`[aria-label="${label}"]`)!);
+});
diff --git a/invokeai/frontend/webv2/src/platform/ui/ToggleDot.tsx b/invokeai/frontend/webv2/src/platform/ui/ToggleDot.tsx
index f014609bd6e..aeb376a5bd8 100644
--- a/invokeai/frontend/webv2/src/platform/ui/ToggleDot.tsx
+++ b/invokeai/frontend/webv2/src/platform/ui/ToggleDot.tsx
@@ -38,7 +38,7 @@ export const ToggleDot = ({
bg={checked ? 'accent.solid' : 'transparent'}
borderColor={checked ? 'accent.solid' : 'border.emphasized'}
borderWidth="1px"
- cursor={disabled ? 'not-allowed' : 'pointer'}
+ cursor={disabled ? 'not-allowed' : undefined}
flexShrink="0"
h="3"
rounded="full"
diff --git a/invokeai/frontend/webv2/src/platform/ui/hints/FeatureHint.tsx b/invokeai/frontend/webv2/src/platform/ui/hints/FeatureHint.tsx
index 82af2d5319f..16b9ec75373 100644
--- a/invokeai/frontend/webv2/src/platform/ui/hints/FeatureHint.tsx
+++ b/invokeai/frontend/webv2/src/platform/ui/hints/FeatureHint.tsx
@@ -40,7 +40,7 @@ const HintCard = ({ hint, onDisable }: { hint: FeatureHintId; onDisable: (() =>
{onDisable && (
-
-
+
-
+
{t('widgets.queue.itemTitle', { id: item.id })}
@@ -137,7 +137,7 @@ export const QueueItemActions = ({ item }: { item: QueueItemReadModel }) => {
-
+
diff --git a/invokeai/frontend/webv2/src/workbench/queue-integration/localQueueItemSource.test.ts b/invokeai/frontend/webv2/src/workbench/queue-integration/localQueueItemSource.test.ts
index 076f3b2614f..8478d4faecc 100644
--- a/invokeai/frontend/webv2/src/workbench/queue-integration/localQueueItemSource.test.ts
+++ b/invokeai/frontend/webv2/src/workbench/queue-integration/localQueueItemSource.test.ts
@@ -33,6 +33,8 @@ const wanModel: ModelConfig = {
const submitVideo = () => {
let state = createInitialWorkbenchState();
+ // Video is no longer placed by the non-video defaults; add it first.
+ state = workbenchReducer(state, { region: 'left', type: 'toggleRegionWidget', widgetId: 'video' });
state = workbenchReducer(state, {
type: 'patchWidgetValues',
values: { model: wanModel, positivePrompt: 'a fox running' },
diff --git a/invokeai/frontend/webv2/src/workbench/settings/AboutSettings.tsx b/invokeai/frontend/webv2/src/workbench/settings/AboutSettings.tsx
new file mode 100644
index 00000000000..1798df29474
--- /dev/null
+++ b/invokeai/frontend/webv2/src/workbench/settings/AboutSettings.tsx
@@ -0,0 +1,70 @@
+import { HStack, Icon, Link, Spinner, Stack, Text } from '@chakra-ui/react';
+import { useCapabilities } from '@features/identity';
+import { useMountEffect } from '@platform/react/useMountEffect';
+import { DiscordIcon, GithubIcon } from '@platform/ui/BrandIcon';
+import { JsonPreview } from '@platform/ui/JsonPreview';
+import { useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { refreshAboutInfo, useAboutInfo } from './aboutInfoStore';
+
+const GITHUB_URL = 'https://github.com/invoke-ai/InvokeAI';
+const DISCORD_URL = 'https://discord.gg/ZmtBAhwWhy';
+
+/**
+ * The legacy About modal's content as a settings section: the server's
+ * version, the community links, and the full system-information blob
+ * (installed dependency versions plus, for admins, the redacted runtime
+ * config) as copyable JSON.
+ */
+export const AboutSettings = () => {
+ const { t } = useTranslation();
+ const { canManageAppConfig } = useCapabilities();
+ const info = useAboutInfo();
+
+ useMountEffect(() => {
+ void refreshAboutInfo(canManageAppConfig);
+ });
+
+ const systemInfo = useMemo(
+ () => ({
+ version: info.version,
+ dependencies: info.dependencies,
+ ...(info.runtimeConfig ? { config: info.runtimeConfig } : {}),
+ }),
+ [info.dependencies, info.runtimeConfig, info.version]
+ );
+
+ return (
+
+
+
+ {info.version ? `Invoke v${info.version}` : 'Invoke'}
+
+
+
+
+ {t('settings.about.github')}
+
+
+
+ {t('settings.about.discord')}
+
+
+
+
+ {info.loadState === 'loading' || info.loadState === 'idle' ? (
+
+
+ {t('settings.about.loading')}
+
+ ) : info.loadState === 'error' ? (
+
+ {info.error}
+
+ ) : (
+
+ )}
+
+ );
+};
diff --git a/invokeai/frontend/webv2/src/workbench/settings/SettingsDialog.tsx b/invokeai/frontend/webv2/src/workbench/settings/SettingsDialog.tsx
index 855b5b5762c..c0c504b6f80 100644
--- a/invokeai/frontend/webv2/src/workbench/settings/SettingsDialog.tsx
+++ b/invokeai/frontend/webv2/src/workbench/settings/SettingsDialog.tsx
@@ -39,6 +39,7 @@ import {
Code2Icon,
DatabaseIcon,
FolderIcon,
+ InfoIcon,
KeyboardIcon,
ListOrderedIcon,
MapIcon,
@@ -53,6 +54,7 @@ import {
import { useCallback, useEffect, useId, useMemo, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
+import { AboutSettings } from './AboutSettings';
import { GenerationDevicesSettings } from './GenerationDevicesSettings';
import { HotkeysSettingsSection } from './HotkeysSettingsSection';
import { ImageMapVocabularySettings } from './ImageMapVocabularySettings';
@@ -111,7 +113,6 @@ export const SettingsDialog = ({ isOpen, onClose }: { isOpen: boolean; onClose:
closeOnInteractOutside={false}
lazyMount
open={isOpen}
- placement="center"
scrollBehavior="inside"
size="xl"
unmountOnExit
@@ -161,7 +162,7 @@ const SettingsDialogContent = ({ onClose }: { onClose: () => void }) => {
-
+
@@ -229,6 +230,12 @@ const SettingsTabs = () => {
label: t('settings.tabs.workspace'),
value: 'workspace',
},
+ {
+ children: ,
+ icon: InfoIcon,
+ label: t('settings.tabs.about'),
+ value: 'about',
+ },
],
[hasWorkbench, t]
);
@@ -456,8 +463,8 @@ const BehaviorSection = () => {
/>
@@ -483,12 +490,6 @@ const ProjectSection = () => {
},
[updateProjectSettings]
);
- const updateShowProgressDetails = useCallback(
- (checked: boolean) => {
- updateProjectSettings({ showProgressDetails: checked });
- },
- [updateProjectSettings]
- );
const updateAntialiasProgressImages = useCallback(
(checked: boolean) => {
updateProjectSettings({ antialiasProgressImages: checked });
@@ -513,13 +514,6 @@ const ProjectSection = () => {
label="Use CPU noise"
onChange={updateUseCpuNoise}
/>
-
{
);
};
+const AboutSection = () => {
+ const { t } = useTranslation();
+
+ return (
+
+
+
+
+
+ );
+};
+
const ImageMapSection = () => {
const { t } = useTranslation();
@@ -773,13 +779,11 @@ const WorkspaceSection = () => {
const SettingToggle = ({
checked,
- comingSoon,
description,
label,
onChange,
}: {
checked: boolean;
- comingSoon?: boolean;
description?: string;
label: string;
onChange: (checked: boolean) => void;
@@ -794,7 +798,6 @@ const SettingToggle = ({
void;
@@ -850,7 +851,6 @@ const SettingSelect = ({
return (