Files
ComfyUI_frontend/apps/desktop-ui/src/components/install/InstallLocationPicker.vue
Alexander Brown 482ad401d4 fix: eradicate tailwind @apply usage in vue styles (#9146)
## Summary

Remove Tailwind `@apply` from Vue styles across `src/` and
`apps/desktop-ui/src/` to align with Tailwind v4 guidance, replacing
usages with template utilities or native CSS while preserving behavior.

## Changes

- **What**:
- Batch 1: migrated low-risk template/style utility bundles out of
`@apply`.
- Batch 2: converted PrimeVue/`:deep()` override `@apply` blocks to
native CSS declarations.
- Batch 3: converted `src/components/node/NodeHelpContent.vue` markdown
styling from `@apply` to native CSS/token-based declarations.
- Batch 4: converted final desktop pseudo-element `@apply` styles and
removed stale `@reference` directives no longer required.
- Verified `rg -n "^\s*@apply\b" src apps -g "*.vue"` has no real CSS
`@apply` directives remaining (only known template false-positive event
binding in `NodeSearchContent.vue`).

## Review Focus

- Visual parity in components that previously depended on `@apply` in
`:deep()` selectors and markdown content:
  - topbar tabs/popovers, dialogs, breadcrumb, terminal overrides
  - desktop install/dialog/update/maintenance surfaces
  - node help markdown rendering
- Confirm no regressions from removal of now-unneeded `@reference`
directives.

## Screenshots (if applicable)

- No new screenshots included in this PR.
- Screenshot Playwright suite was run with `--grep="@screenshot"` and
reports baseline diffs in this environment (164 passed, 39 failed, 3
skipped) plus a teardown `EPERM` restore error on local path
`C:\Users\DrJKL\ComfyUI\LTXV\user`.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9146-fix-eradicate-tailwind-apply-usage-in-vue-styles-3116d73d3650813d8642e0bada13df32)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-02-24 21:23:52 -08:00

347 lines
10 KiB
Vue

<template>
<div class="flex flex-col gap-8 w-full max-w-3xl mx-auto select-none">
<!-- Installation Path Section -->
<div class="grow flex flex-col gap-6 text-neutral-300">
<h2 class="font-inter font-bold text-3xl text-neutral-100 text-center">
{{ $t('install.locationPicker.title') }}
</h2>
<p class="text-center text-neutral-400 px-12">
{{ $t('install.locationPicker.subtitle') }}
</p>
<!-- Path Input -->
<div class="flex gap-2 px-12">
<InputText
v-model="installPath"
:placeholder="$t('install.locationPicker.pathPlaceholder')"
class="flex-1 bg-neutral-800/50 border-neutral-700 text-neutral-200 placeholder:text-neutral-500"
:class="{ 'p-invalid': pathError }"
@update:model-value="validatePath"
@focus="onFocus"
/>
<Button
icon="pi pi-folder-open"
severity="secondary"
class="bg-neutral-700 hover:bg-neutral-600 border-0"
@click="browsePath"
/>
</div>
<!-- Error Messages -->
<div v-if="pathError || pathExists || nonDefaultDrive" class="px-12">
<Message
v-if="pathError"
severity="error"
class="whitespace-pre-line w-full"
>
{{ pathError }}
</Message>
<Message v-if="pathExists" severity="warn" class="w-full">
{{ $t('install.pathExists') }}
</Message>
<Message v-if="nonDefaultDrive" severity="warn" class="w-full">
{{ $t('install.nonDefaultDrive') }}
</Message>
</div>
<!-- Collapsible Sections using PrimeVue Accordion -->
<Accordion
v-model:value="activeAccordionIndex"
:multiple="true"
class="location-picker-accordion"
:pt="{
root: 'bg-transparent border-0',
panel: {
root: 'border-0 mb-0'
},
header: {
root: 'border-0',
content:
'text-neutral-400 hover:text-neutral-300 px-4 py-2 flex items-center gap-3',
toggleicon: 'text-xs order-first mr-0'
},
content: {
root: 'bg-transparent border-0',
content: 'text-neutral-500 text-sm pl-11 pb-3 pt-0'
}
}"
>
<AccordionPanel value="0">
<AccordionHeader>
{{ $t('install.locationPicker.migrateFromExisting') }}
</AccordionHeader>
<AccordionContent>
<MigrationPicker
v-model:source-path="migrationSourcePath"
v-model:migration-item-ids="migrationItemIds"
/>
</AccordionContent>
</AccordionPanel>
<AccordionPanel value="1">
<AccordionHeader>
{{ $t('install.locationPicker.chooseDownloadServers') }}
</AccordionHeader>
<AccordionContent>
<template
v-for="([item, modelValue], index) in mirrors"
:key="item.settingId + item.mirror"
>
<Divider v-if="index > 0" class="my-8" />
<MirrorItem
v-model="modelValue.value"
:item="item"
@state-change="validationStates[index] = $event"
/>
</template>
</AccordionContent>
</AccordionPanel>
</Accordion>
</div>
</div>
</template>
<script setup lang="ts">
import { TorchMirrorUrl } from '@comfyorg/comfyui-electron-types'
import type { TorchDeviceType } from '@comfyorg/comfyui-electron-types'
import { isInChina } from '@comfyorg/shared-frontend-utils/networkUtil'
import Accordion from 'primevue/accordion'
import AccordionContent from 'primevue/accordioncontent'
import AccordionHeader from 'primevue/accordionheader'
import AccordionPanel from 'primevue/accordionpanel'
import Button from 'primevue/button'
import Divider from 'primevue/divider'
import InputText from 'primevue/inputtext'
import Message from 'primevue/message'
import { computed, onMounted, ref } from 'vue'
import type { ModelRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { PYPI_MIRROR, PYTHON_MIRROR } from '@/constants/uvMirrors'
import type { UVMirror } from '@/constants/uvMirrors'
import { electronAPI } from '@/utils/envUtil'
import { ValidationState } from '@/utils/validationUtil'
import MigrationPicker from './MigrationPicker.vue'
import MirrorItem from './mirror/MirrorItem.vue'
const { t } = useI18n()
const installPath = defineModel<string>('installPath', { required: true })
const pathError = defineModel<string>('pathError', { required: true })
const migrationSourcePath = defineModel<string>('migrationSourcePath')
const migrationItemIds = defineModel<string[]>('migrationItemIds')
const pythonMirror = defineModel<string>('pythonMirror', {
default: ''
})
const pypiMirror = defineModel<string>('pypiMirror', {
default: ''
})
const torchMirror = defineModel<string>('torchMirror', {
default: ''
})
const { device } = defineProps<{ device: TorchDeviceType | null }>()
const pathExists = ref(false)
const nonDefaultDrive = ref(false)
const inputTouched = ref(false)
// Accordion state - array of active panel values
const activeAccordionIndex = ref<string[] | undefined>(undefined)
const electron = electronAPI()
// Mirror configuration logic
function getTorchMirrorItem(device: TorchDeviceType): UVMirror {
const settingId = 'Comfy-Desktop.UV.TorchInstallMirror'
switch (device) {
case 'mps':
return {
settingId,
mirror: TorchMirrorUrl.NightlyCpu,
fallbackMirror: TorchMirrorUrl.NightlyCpu
}
case 'nvidia':
return {
settingId,
mirror: TorchMirrorUrl.Cuda,
fallbackMirror: TorchMirrorUrl.Cuda
}
case 'amd':
case 'cpu':
default:
return {
settingId,
mirror: PYPI_MIRROR.mirror,
fallbackMirror: PYPI_MIRROR.fallbackMirror
}
}
}
const userIsInChina = ref(false)
const useFallbackMirror = (mirror: UVMirror) => ({
...mirror,
mirror: mirror.fallbackMirror
})
const mirrors = computed<[UVMirror, ModelRef<string>][]>(() =>
(
[
[PYTHON_MIRROR, pythonMirror],
[PYPI_MIRROR, pypiMirror],
[getTorchMirrorItem(device ?? 'cpu'), torchMirror]
] as [UVMirror, ModelRef<string>][]
).map(([item, modelValue]) => [
userIsInChina.value ? useFallbackMirror(item) : item,
modelValue
])
)
const validationStates = ref<ValidationState[]>(
mirrors.value.map(() => ValidationState.IDLE)
)
// Get default install path on component mount
onMounted(async () => {
const paths = await electron.getSystemPaths()
installPath.value = paths.defaultInstallPath
await validatePath(paths.defaultInstallPath)
userIsInChina.value = await isInChina()
})
const validatePath = async (path: string | undefined) => {
try {
pathError.value = ''
pathExists.value = false
nonDefaultDrive.value = false
const validation = await electron.validateInstallPath(path ?? '')
// Create a pre-formatted list of errors
if (!validation.isValid) {
const errors: string[] = []
if (validation.cannotWrite) errors.push(t('install.cannotWrite'))
if (validation.freeSpace < validation.requiredSpace) {
const requiredGB = validation.requiredSpace / 1024 / 1024 / 1024
errors.push(`${t('install.insufficientFreeSpace')}: ${requiredGB} GB`)
}
if (validation.parentMissing) errors.push(t('install.parentMissing'))
if (validation.isOneDrive) errors.push(t('install.isOneDrive'))
if (validation.isInsideAppInstallDir)
errors.push(t('install.insideAppInstallDir'))
if (validation.isInsideUpdaterCache)
errors.push(t('install.insideUpdaterCache'))
if (validation.error)
errors.push(`${t('install.unhandledError')}: ${validation.error}`)
pathError.value = errors.join('\n')
}
if (validation.isNonDefaultDrive) nonDefaultDrive.value = true
if (validation.exists) pathExists.value = true
} catch (error) {
pathError.value = t('install.pathValidationFailed')
}
}
const browsePath = async () => {
try {
const result = await electron.showDirectoryPicker()
if (result) {
installPath.value = result
await validatePath(result)
}
} catch (error) {
pathError.value = t('install.failedToSelectDirectory')
}
}
const onFocus = async () => {
if (!inputTouched.value) {
inputTouched.value = true
return
}
// Refresh validation on re-focus
await validatePath(installPath.value)
}
</script>
<style scoped>
:deep(.location-picker-accordion) {
padding-inline: calc(var(--spacing) * 12);
.p-accordionpanel {
border: 0;
background-color: transparent;
}
.p-accordionheader {
margin-top: calc(var(--spacing) * 2);
border: 0;
border-radius: var(--radius-xl);
background-color: color-mix(
in srgb,
var(--color-neutral-800) 50%,
transparent
);
transition:
background-color 0.2s ease,
border-radius 0.5s ease;
&:hover {
background-color: color-mix(
in srgb,
var(--color-neutral-700) 50%,
transparent
);
}
}
/* When panel is expanded, adjust header border radius */
.p-accordionpanel-active {
.p-accordionheader {
border-top-left-radius: var(--radius-xl);
border-top-right-radius: var(--radius-xl);
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
}
.p-accordionheader-toggle-icon {
&::before {
content: '\e902';
}
}
}
.p-accordioncontent {
border: 0;
border-top-left-radius: 0;
border-top-right-radius: 0;
border-bottom-right-radius: var(--radius-xl);
border-bottom-left-radius: var(--radius-xl);
background-color: color-mix(
in srgb,
var(--color-neutral-800) 50%,
transparent
);
}
.p-accordioncontent-content {
background-color: transparent;
padding-top: calc(var(--spacing) * 3);
padding-right: calc(var(--spacing) * 5);
padding-bottom: calc(var(--spacing) * 5);
padding-left: calc(var(--spacing) * 5);
}
/* Override default chevron icons to use up/down */
.p-accordionheader-toggle-icon {
&::before {
content: '\e933';
}
}
}
</style>