Merge branch 'main' into fix-nigthly-test

This commit is contained in:
Johnpaul Chiwetelu
2026-01-29 03:25:47 +01:00
committed by GitHub
14 changed files with 451 additions and 67 deletions

View File

@@ -14,7 +14,12 @@
</template>
<template #header>
<SearchBox v-model="searchQuery" size="lg" class="max-w-[384px]" />
<SearchBox
v-model="searchQuery"
size="lg"
class="max-w-[384px]"
autofocus
/>
</template>
<template #header-right-area>

View File

@@ -1,7 +1,11 @@
<template>
<div
class="comfy-vue-side-bar-container group/sidebar-tab flex h-full flex-col"
:class="props.class"
:class="
cn(
'comfy-vue-side-bar-container group/sidebar-tab flex h-full flex-col',
props.class
)
"
>
<div class="comfy-vue-side-bar-header flex flex-col gap-2">
<Toolbar
@@ -35,6 +39,8 @@
import ScrollPanel from 'primevue/scrollpanel'
import Toolbar from 'primevue/toolbar'
import { cn } from '@/utils/tailwindUtil'
const props = defineProps<{
title: string
class?: string

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { whenever } from '@vueuse/core'
import { breakpointsTailwind, useBreakpoints, whenever } from '@vueuse/core'
import { useTemplateRef } from 'vue'
import Popover from '@/components/ui/Popover.vue'
@@ -10,6 +10,7 @@ defineProps<{
}>()
const feedbackRef = useTemplateRef('feedbackRef')
const isMobile = useBreakpoints(breakpointsTailwind).smaller('md')
whenever(feedbackRef, () => {
const scriptEl = document.createElement('script')
@@ -18,9 +19,20 @@ whenever(feedbackRef, () => {
})
</script>
<template>
<Popover>
<Button
v-if="isMobile"
as="a"
:href="`https://form.typeform.com/to/${dataTfWidget}`"
target="_blank"
variant="inverted"
class="rounded-full size-12"
v-bind="$attrs"
>
<i class="icon-[lucide--circle-question-mark] size-6" />
</Button>
<Popover v-else>
<template #button>
<Button variant="inverted" class="rounded-full size-12">
<Button variant="inverted" class="rounded-full size-12" v-bind="$attrs">
<i class="icon-[lucide--circle-question-mark] size-6" />
</Button>
</template>

View File

@@ -0,0 +1,261 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import type { LGraphNode as LGraphNodeType } from '@/lib/litegraph/src/litegraph'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import type { IColorWidget } from '@/lib/litegraph/src/types/widgets'
import type { ColorWidget as ColorWidgetType } from '@/lib/litegraph/src/widgets/ColorWidget'
type LGraphCanvasType = InstanceType<typeof LGraphCanvas>
function createMockWidgetConfig(
overrides: Partial<IColorWidget> = {}
): IColorWidget {
return {
type: 'color',
name: 'test_color',
value: '#ff0000',
options: {},
y: 0,
...overrides
}
}
function createMockCanvas(): LGraphCanvasType {
return {
setDirty: vi.fn()
} as Partial<LGraphCanvasType> as LGraphCanvasType
}
function createMockEvent(clientX = 100, clientY = 200): CanvasPointerEvent {
return { clientX, clientY } as CanvasPointerEvent
}
describe('ColorWidget', () => {
let node: LGraphNodeType
let widget: ColorWidgetType
let mockCanvas: LGraphCanvasType
let mockEvent: CanvasPointerEvent
let ColorWidget: typeof ColorWidgetType
let LGraphNode: typeof LGraphNodeType
beforeEach(async () => {
vi.clearAllMocks()
vi.useFakeTimers()
// Reset modules to get fresh globalColorInput state
vi.resetModules()
const litegraph = await import('@/lib/litegraph/src/litegraph')
LGraphNode = litegraph.LGraphNode
const colorWidgetModule =
await import('@/lib/litegraph/src/widgets/ColorWidget')
ColorWidget = colorWidgetModule.ColorWidget
node = new LGraphNode('TestNode')
mockCanvas = createMockCanvas()
mockEvent = createMockEvent()
})
afterEach(() => {
vi.useRealTimers()
document
.querySelectorAll('input[type="color"]')
.forEach((el) => el.remove())
})
describe('onClick', () => {
it('should create a color input and append it to document body', () => {
widget = new ColorWidget(createMockWidgetConfig(), node)
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
const input = document.querySelector(
'input[type="color"]'
) as HTMLInputElement
expect(input).toBeTruthy()
expect(input.parentElement).toBe(document.body)
})
it('should set input value from widget value', () => {
widget = new ColorWidget(
createMockWidgetConfig({ value: '#00ff00' }),
node
)
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
const input = document.querySelector(
'input[type="color"]'
) as HTMLInputElement
expect(input.value).toBe('#00ff00')
})
it('should default to #000000 when widget value is empty', () => {
widget = new ColorWidget(createMockWidgetConfig({ value: '' }), node)
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
const input = document.querySelector(
'input[type="color"]'
) as HTMLInputElement
expect(input.value).toBe('#000000')
})
it('should position input at click coordinates', () => {
widget = new ColorWidget(createMockWidgetConfig(), node)
const event = createMockEvent(150, 250)
widget.onClick({ e: event, node, canvas: mockCanvas })
const input = document.querySelector(
'input[type="color"]'
) as HTMLInputElement
expect(input.style.left).toBe('150px')
expect(input.style.top).toBe('250px')
})
it('should click the input on next animation frame', () => {
widget = new ColorWidget(createMockWidgetConfig(), node)
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
const input = document.querySelector(
'input[type="color"]'
) as HTMLInputElement
const clickSpy = vi.spyOn(input, 'click')
expect(clickSpy).not.toHaveBeenCalled()
vi.runAllTimers()
expect(clickSpy).toHaveBeenCalled()
})
it('should reuse the same input element on subsequent clicks', () => {
widget = new ColorWidget(createMockWidgetConfig(), node)
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
const firstInput = document.querySelector('input[type="color"]')
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
const secondInput = document.querySelector('input[type="color"]')
expect(firstInput).toBe(secondInput)
expect(document.querySelectorAll('input[type="color"]').length).toBe(1)
})
it('should update input value when clicking with different widget values', () => {
const widget1 = new ColorWidget(
createMockWidgetConfig({ value: '#ff0000' }),
node
)
const widget2 = new ColorWidget(
createMockWidgetConfig({ value: '#0000ff' }),
node
)
widget1.onClick({ e: mockEvent, node, canvas: mockCanvas })
const input = document.querySelector(
'input[type="color"]'
) as HTMLInputElement
expect(input.value).toBe('#ff0000')
widget2.onClick({ e: mockEvent, node, canvas: mockCanvas })
expect(input.value).toBe('#0000ff')
})
})
describe('onChange', () => {
it('should call setValue when color input changes', () => {
widget = new ColorWidget(
createMockWidgetConfig({ value: '#ff0000' }),
node
)
const setValueSpy = vi.spyOn(widget, 'setValue')
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
const input = document.querySelector(
'input[type="color"]'
) as HTMLInputElement
input.value = '#00ff00'
input.dispatchEvent(new Event('change'))
expect(setValueSpy).toHaveBeenCalledWith('#00ff00', {
e: mockEvent,
node,
canvas: mockCanvas
})
})
it('should call canvas.setDirty after value change', () => {
widget = new ColorWidget(createMockWidgetConfig(), node)
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
const input = document.querySelector(
'input[type="color"]'
) as HTMLInputElement
input.value = '#00ff00'
input.dispatchEvent(new Event('change'))
expect(mockCanvas.setDirty).toHaveBeenCalledWith(true)
})
it('should remove change listener after firing once', () => {
widget = new ColorWidget(createMockWidgetConfig(), node)
const setValueSpy = vi.spyOn(widget, 'setValue')
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
const input = document.querySelector(
'input[type="color"]'
) as HTMLInputElement
input.value = '#00ff00'
input.dispatchEvent(new Event('change'))
input.value = '#0000ff'
input.dispatchEvent(new Event('change'))
// Should only be called once despite two change events
expect(setValueSpy).toHaveBeenCalledTimes(1)
expect(setValueSpy).toHaveBeenCalledWith('#00ff00', expect.any(Object))
})
it('should register new change listener on subsequent onClick', () => {
widget = new ColorWidget(createMockWidgetConfig(), node)
const setValueSpy = vi.spyOn(widget, 'setValue')
// First click and change
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
const input = document.querySelector(
'input[type="color"]'
) as HTMLInputElement
input.value = '#00ff00'
input.dispatchEvent(new Event('change'))
// Second click and change
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
input.value = '#0000ff'
input.dispatchEvent(new Event('change'))
expect(setValueSpy).toHaveBeenCalledTimes(2)
expect(setValueSpy).toHaveBeenNthCalledWith(
1,
'#00ff00',
expect.any(Object)
)
expect(setValueSpy).toHaveBeenNthCalledWith(
2,
'#0000ff',
expect.any(Object)
)
})
})
describe('type', () => {
it('should have type "color"', () => {
widget = new ColorWidget(createMockWidgetConfig(), node)
expect(widget.type).toBe('color')
})
})
})

View File

@@ -1,12 +1,26 @@
import { t } from '@/i18n'
import type { IColorWidget } from '../types/widgets'
import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
import { BaseWidget } from './BaseWidget'
// Have one color input to prevent leaking instances
// Browsers don't seem to fire any events when the color picker is cancelled
let colorInput: HTMLInputElement | null = null
function getColorInput(): HTMLInputElement {
if (!colorInput) {
colorInput = document.createElement('input')
colorInput.type = 'color'
colorInput.style.position = 'absolute'
colorInput.style.opacity = '0'
colorInput.style.pointerEvents = 'none'
colorInput.style.zIndex = '-999'
document.body.appendChild(colorInput)
}
return colorInput
}
/**
* Widget for displaying a color picker
* This is a widget that only has a Vue widgets implementation
* Widget for displaying a color picker using native HTML color input
*/
export class ColorWidget
extends BaseWidget<IColorWidget>
@@ -15,35 +29,59 @@ export class ColorWidget
override type = 'color' as const
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
const { fillStyle, strokeStyle, textAlign } = ctx
this.drawWidgetShape(ctx, options)
const { width } = options
const { y, height } = this
const { height, y } = this
const { margin } = BaseWidget
const { fillStyle, strokeStyle, textAlign, textBaseline, font } = ctx
const swatchWidth = 40
const swatchHeight = height - 6
const swatchRadius = swatchHeight / 2
const rightPadding = 10
ctx.fillStyle = this.background_color
ctx.fillRect(15, y, width - 30, height)
// Swatch fixed on the right
const swatchX = width - margin - rightPadding - swatchWidth
const swatchY = y + 3
ctx.strokeStyle = this.outline_color
ctx.strokeRect(15, y, width - 30, height)
// Draw color swatch as rounded pill
ctx.beginPath()
ctx.roundRect(swatchX, swatchY, swatchWidth, swatchHeight, swatchRadius)
ctx.fillStyle = this.value || '#000000'
ctx.fill()
// Draw label on the left
ctx.fillStyle = this.secondary_text_color
ctx.textAlign = 'left'
ctx.fillText(this.displayName, margin * 2 + 5, y + height * 0.7)
// Draw hex value to the left of swatch
ctx.fillStyle = this.text_color
ctx.font = '11px monospace'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.textAlign = 'right'
ctx.fillText(this.value || '#000000', swatchX - 8, y + height * 0.7)
const text = `Color: ${t('widgets.node2only')}`
ctx.fillText(text, width / 2, y + height / 2)
Object.assign(ctx, {
fillStyle,
strokeStyle,
textAlign,
textBaseline,
font
})
Object.assign(ctx, { textAlign, strokeStyle, fillStyle })
}
onClick(_options: WidgetEventOptions): void {
// This is a widget that only has a Vue widgets implementation
onClick({ e, node, canvas }: WidgetEventOptions): void {
const input = getColorInput()
input.value = this.value || '#000000'
input.style.left = `${e.clientX}px`
input.style.top = `${e.clientY}px`
input.addEventListener(
'change',
() => {
this.setValue(input.value, { e, node, canvas })
canvas.setDirty(true)
},
{ once: true }
)
// Wait for next frame else Chrome doesn't render the color picker at the mouse
// Firefox always opens it in top left of window on Windows
requestAnimationFrame(() => input.click())
}
}

View File

@@ -10,7 +10,7 @@ const createAssetData = (
overrides: Partial<AssetDisplayItem> = {}
): AssetDisplayItem => ({
...baseAsset,
description:
secondaryText:
'High-quality realistic images with perfect detail and natural lighting effects for professional photography',
badges: [
{ label: 'checkpoints', type: 'type' },
@@ -131,20 +131,21 @@ export const EdgeCases: Story = {
// Default case for comparison
createAssetData({
name: 'Complete Data',
description: 'Asset with all data present for comparison'
secondaryText: 'Asset with all data present for comparison'
}),
// No badges
createAssetData({
id: 'no-badges',
name: 'No Badges',
description: 'Testing graceful handling when badges are not provided',
secondaryText:
'Testing graceful handling when badges are not provided',
badges: []
}),
// No stars
createAssetData({
id: 'no-stars',
name: 'No Stars',
description: 'Testing missing stars data gracefully',
secondaryText: 'Testing missing stars data gracefully',
stats: {
downloadCount: '1.8k',
formattedDate: '3/15/25'
@@ -154,7 +155,7 @@ export const EdgeCases: Story = {
createAssetData({
id: 'no-downloads',
name: 'No Downloads',
description: 'Testing missing downloads data gracefully',
secondaryText: 'Testing missing downloads data gracefully',
stats: {
stars: '4.2k',
formattedDate: '3/15/25'
@@ -164,7 +165,7 @@ export const EdgeCases: Story = {
createAssetData({
id: 'no-date',
name: 'No Date',
description: 'Testing missing date data gracefully',
secondaryText: 'Testing missing date data gracefully',
stats: {
stars: '4.2k',
downloadCount: '1.8k'
@@ -174,21 +175,21 @@ export const EdgeCases: Story = {
createAssetData({
id: 'no-stats',
name: 'No Stats',
description: 'Testing when all stats are missing',
secondaryText: 'Testing when all stats are missing',
stats: {}
}),
// Long description
// Long secondaryText
createAssetData({
id: 'long-desc',
name: 'Long Description',
description:
secondaryText:
'This is a very long description that should demonstrate how the component handles text overflow and truncation with ellipsis. The description continues with even more content to ensure we test the 2-line clamp behavior properly and see how it renders when there is significantly more text than can fit in the allocated space.'
}),
// Minimal data
createAssetData({
id: 'minimal',
name: 'Minimal',
description: 'Basic model',
secondaryText: 'Basic model',
tags: ['models'],
badges: [],
stats: {}

View File

@@ -82,14 +82,14 @@
</h3>
<p
:id="descId"
v-tooltip.top="{ value: asset.description, showDelay: tooltipDelay }"
v-tooltip.top="{ value: asset.secondaryText, showDelay: tooltipDelay }"
:class="
cn(
'm-0 text-sm line-clamp-2 [-webkit-box-orient:vertical] [-webkit-line-clamp:2] [display:-webkit-box] text-muted-foreground'
)
"
>
{{ asset.description }}
{{ asset.secondaryText }}
</p>
<div class="flex items-center justify-between gap-2 mt-auto">
<div class="flex gap-3 text-xs text-muted-foreground">

View File

@@ -34,7 +34,7 @@ describe('ModelInfoPanel', () => {
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
last_access_time: '2024-01-01T00:00:00Z',
description: 'A test model description',
secondaryText: 'A test model description',
badges: [],
stats: {},
...overrides

View File

@@ -84,14 +84,14 @@ describe('useAssetBrowser', () => {
expect(result.name).toBe(apiAsset.name)
// Adds display properties
expect(result.description).toBe('Test model')
expect(result.secondaryText).toBe('test-asset.safetensors')
expect(result.badges).toContainEqual({
label: 'checkpoints',
type: 'type'
})
})
it('creates fallback description from tags when metadata missing', () => {
it('creates secondaryText from filename when metadata missing', () => {
const apiAsset = createApiAsset({
tags: ['models', 'loras'],
user_metadata: undefined
@@ -100,7 +100,7 @@ describe('useAssetBrowser', () => {
const { filteredAssets } = useAssetBrowser(ref([apiAsset]))
const result = filteredAssets.value[0]
expect(result.description).toBe('loras model')
expect(result.secondaryText).toBe('test-asset.safetensors')
})
it('removes category prefix from badge labels', () => {

View File

@@ -9,8 +9,8 @@ import type { FilterState } from '@/platform/assets/components/AssetFilterBar.vu
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import {
getAssetBaseModels,
getAssetDescription,
getAssetDisplayName
getAssetDisplayName,
getAssetFilename
} from '@/platform/assets/utils/assetMetadataUtils'
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
import type { NavGroupData, NavItemData } from '@/types/navTypes'
@@ -70,7 +70,7 @@ type AssetBadge = {
// Display properties for transformed assets
export interface AssetDisplayItem extends AssetItem {
description: string
secondaryText: string
badges: AssetBadge[]
stats: {
formattedDate?: string
@@ -116,15 +116,11 @@ export function useAssetBrowser(
// Transform API asset to display asset
function transformAssetForDisplay(asset: AssetItem): AssetDisplayItem {
// Extract description from metadata or create from tags
const typeTag = asset.tags.find((tag) => tag !== 'models')
const description =
getAssetDescription(asset) ||
`${typeTag || t('assetBrowser.unknown')} model`
const secondaryText = getAssetFilename(asset)
// Create badges from tags and metadata
const badges: AssetBadge[] = []
const typeTag = asset.tags.find((tag) => tag !== 'models')
// Type badge from non-root tag
if (typeTag) {
// Remove category prefix from badge label (e.g. "checkpoint/model" → "model")
@@ -152,7 +148,7 @@ export function useAssetBrowser(
return {
...asset,
description,
secondaryText,
badges,
stats
}

View File

@@ -205,7 +205,7 @@ defineExpose({ runButtonClick })
<NodeWidgets
:node-data
:style="{ background: applyLightThemeColor(nodeData.bgcolor) }"
class="py-3 gap-y-3 **:[.col-span-2]:grid-cols-1 not-has-[textarea]:flex-0 rounded-lg max-w-100"
class="py-3 gap-y-3 **:[.col-span-2]:grid-cols-1 *:has-[textarea]:h-50 rounded-lg max-w-100"
/>
</template>
</div>
@@ -237,7 +237,7 @@ defineExpose({ runButtonClick })
:node-data
:class="
cn(
'py-3 gap-y-3 **:[.col-span-2]:grid-cols-1 not-has-[textarea]:flex-0 rounded-lg',
'py-3 gap-y-3 **:[.col-span-2]:grid-cols-1 *:has-[textarea]:h-50 rounded-lg',
nodeData.hasErrors &&
'ring-2 ring-inset ring-node-stroke-error'
)

View File

@@ -0,0 +1,58 @@
<script setup lang="ts">
import {
CollapsibleRoot,
CollapsibleTrigger,
CollapsibleContent
} from 'reka-ui'
import WorkflowsSidebarTab from '@/components/sidebar/tabs/WorkflowsSidebarTab.vue'
import Button from '@/components/ui/button/Button.vue'
import Popover from '@/components/ui/Popover.vue'
import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemplateSelectorDialog'
import { t } from '@/i18n'
import { useCommandStore } from '@/stores/commandStore'
</script>
<template>
<CollapsibleRoot class="flex flex-col">
<CollapsibleTrigger as-child>
<Button variant="secondary" class="size-10 self-end m-4 mb-2">
<i class="icon-[lucide--menu] size-8" />
</Button>
</CollapsibleTrigger>
<CollapsibleContent class="flex gap-2 flex-col">
<div class="w-full border-b-2 border-border-subtle" />
<Popover>
<template #button>
<Button variant="secondary" size="lg" class="w-full">
<i class="icon-[comfy--workflow]" />
{{ t('Workflows') }}
</Button>
</template>
<WorkflowsSidebarTab class="h-300 w-[80vw]" />
</Popover>
<Button
variant="secondary"
size="lg"
class="w-full"
@click="useWorkflowTemplateSelectorDialog().show('menu')"
>
<i class="icon-[comfy--template]" />
{{ t('sideToolbar.templates') }}
</Button>
<Button
variant="secondary"
size="lg"
class="w-full"
@click="
useCommandStore().execute('Comfy.ToggleLinear', {
metadata: { source: 'button' }
})
"
>
<i class="icon-[lucide--log-out]" />
{{ t('linearMode.graphMode') }}
</Button>
<div class="w-full border-b-2 border-border-subtle" />
</CollapsibleContent>
</CollapsibleRoot>
</template>

View File

@@ -156,7 +156,11 @@ watch([selectedIndex, selectedOutput], doEmit)
watch(
() => outputs.media.value,
(newAssets, oldAssets) => {
if (newAssets.length === oldAssets.length || oldAssets.length === 0) return
if (
newAssets.length === oldAssets.length ||
(oldAssets.length === 0 && newAssets.length !== 1)
)
return
if (selectedIndex.value[0] <= 0) {
selectedIndex.value = [0, 0]
return

View File

@@ -9,6 +9,7 @@ import Splitter from 'primevue/splitter'
import SplitterPanel from 'primevue/splitterpanel'
import { ref, useTemplateRef } from 'vue'
import ModeToggle from '@/components/sidebar/ModeToggle.vue'
import TopbarBadges from '@/components/topbar/TopbarBadges.vue'
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
import TypeformPopoverButton from '@/components/ui/TypeformPopoverButton.vue'
@@ -17,6 +18,7 @@ import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { useSettingStore } from '@/platform/settings/settingStore'
import LinearControls from '@/renderer/extensions/linearMode/LinearControls.vue'
import LinearPreview from '@/renderer/extensions/linearMode/LinearPreview.vue'
import MobileMenu from '@/renderer/extensions/linearMode/MobileMenu.vue'
import OutputHistory from '@/renderer/extensions/linearMode/OutputHistory.vue'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import type { ResultItemImpl } from '@/stores/queueStore'
@@ -58,6 +60,7 @@ const linearWorkflowRef = useTemplateRef('linearWorkflowRef')
v-if="mobileDisplay"
class="justify-center border-border-subtle border-t overflow-y-scroll h-[calc(100%-38px)] bg-comfy-menu-bg"
>
<MobileMenu />
<div class="flex flex-col text-muted-foreground">
<LinearPreview
:latent-preview="
@@ -84,12 +87,12 @@ const linearWorkflowRef = useTemplateRef('linearWorkflowRef')
"
/>
<LinearControls ref="linearWorkflowRef" mobile />
<div class="text-base-foreground flex items-center gap-4 justify-end m-4">
<a
href="https://form.typeform.com/to/gmVqFi8l"
v-text="t('linearMode.beta')"
/>
<TypeformPopoverButton data-tf-widget="gmVqFi8l" />
<div class="text-base-foreground flex items-center gap-4">
<div class="border-r border-border-subtle mr-auto">
<ModeToggle class="m-2" />
</div>
<div v-text="t('linearMode.beta')" />
<TypeformPopoverButton data-tf-widget="gmVqFi8l" class="mx-2" />
</div>
</div>
<Splitter