Files
ComfyUI_frontend/src/platform/updates/components/WhatsNewPopup.vue
Christian Byrne ef4e4a69d5 fix: enable enforce-consistent-class-order tailwind lint rule (#9428)
## Summary

Enable `better-tailwindcss/enforce-consistent-class-order` lint rule and
auto-fix all 1027 violations across 263 files. Stacked on #9427.

## Changes

- **What**: Sort Tailwind classes into consistent order via `eslint
--fix`
- Enable `enforce-consistent-class-order` as `'error'` in eslint config
- Purely cosmetic reordering — no behavioral or visual changes

## Review Focus

Mechanical auto-fix PR — all changes are class reordering only. This is
the largest diff but lowest risk since it changes no class names, only
their order.

**Stack:** #9417#9427 → **this PR**

Fixes #9300 (partial — 3 of 3 rules)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9428-fix-enable-enforce-consistent-class-order-tailwind-lint-rule-31a6d73d3650811c9065f5178ba3e724)
by [Unito](https://www.unito.io)
2026-03-05 17:24:34 -08:00

433 lines
9.9 KiB
Vue

<template>
<div v-if="shouldShow" class="whats-new-popup-container left-4">
<div class="whats-new-popup" @click.stop>
<!-- Close Button -->
<Button
class="close-button absolute top-2 right-2 z-10 size-8 rounded-lg p-2 opacity-50"
:aria-label="$t('g.close')"
size="icon-sm"
variant="muted-textonly"
@click="closePopup"
>
<i class="icon-[lucide--x]" />
</Button>
<!-- Modal Body -->
<div class="modal-body flex flex-1 flex-col gap-4 px-0 pt-0 pb-2">
<!-- Release Content -->
<div
class="content-text max-h-96 overflow-y-auto"
v-html="formattedContent"
></div>
</div>
<!-- Modal Footer -->
<div
class="modal-footer flex items-center justify-between gap-4 px-4 pb-4"
>
<a
class="learn-more-link flex items-center gap-2 py-1 text-sm font-normal"
:href="changelogUrl"
target="_blank"
rel="noopener noreferrer"
@click="closePopup"
>
<i class="icon-[lucide--external-link]"></i>
{{ $t('whatsNewPopup.learnMore') }}
</a>
<div class="footer-actions flex items-center gap-4">
<Button
class="h-8"
size="sm"
variant="muted-textonly"
@click="closePopup"
>
{{ $t('whatsNewPopup.later') }}
</Button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { default as DOMPurify } from 'dompurify'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useExternalLink } from '@/composables/useExternalLink'
import { formatVersionAnchor } from '@/utils/formatUtil'
import { renderMarkdownToHtml } from '@/utils/markdownRendererUtil'
import type { ReleaseNote } from '../common/releaseService'
import { useReleaseStore } from '../common/releaseStore'
const { buildDocsUrl } = useExternalLink()
const releaseStore = useReleaseStore()
const { t } = useI18n()
// Define emits
const emit = defineEmits<{
'whats-new-dismissed': []
}>()
// Local state for dismissed status
const isDismissed = ref(false)
// Get latest release from store
const latestRelease = computed<ReleaseNote | null>(() => {
return releaseStore.recentRelease
})
// Show popup when on latest version and not dismissed
const shouldShow = computed(
() => releaseStore.shouldShowPopup && !isDismissed.value
)
// Generate changelog URL with version anchor (language-aware)
const changelogUrl = computed(() => {
const changelogBaseUrl = buildDocsUrl('/changelog', { includeLocale: true })
if (latestRelease.value?.version) {
const versionAnchor = formatVersionAnchor(latestRelease.value.version)
return `${changelogBaseUrl}#${versionAnchor}`
}
return changelogBaseUrl
})
const formattedContent = computed(() => {
if (!latestRelease.value?.content) {
return DOMPurify.sanitize(`<p>${t('whatsNewPopup.noReleaseNotes')}</p>`)
}
try {
const markdown = latestRelease.value.content
// Check if content is meaningful (not just whitespace)
const trimmedContent = markdown.trim()
if (!trimmedContent || trimmedContent.replace(/\s+/g, '') === '') {
return DOMPurify.sanitize(`<p>${t('whatsNewPopup.noReleaseNotes')}</p>`)
}
// Extract image and remaining content separately
const imageMatch = markdown.match(/!\[.*?\]\(.*?\)/)
const image = imageMatch ? imageMatch[0] : ''
// Remove image from content but keep original title
const contentWithoutImage = markdown.replace(/!\[.*?\]\(.*?\)/, '').trim()
// Reorder: image first, then original content
const reorderedContent = [image, contentWithoutImage]
.filter(Boolean)
.join('\n\n')
// renderMarkdownToHtml already sanitizes with DOMPurify, so this is safe
return renderMarkdownToHtml(reorderedContent)
} catch (error) {
console.error('Error parsing markdown:', error)
// Fallback to plain text with line breaks - sanitize the HTML we create
const fallbackContent = latestRelease.value.content.replace(/\n/g, '<br>')
return fallbackContent.trim()
? DOMPurify.sanitize(fallbackContent)
: DOMPurify.sanitize(`<p>${t('whatsNewPopup.noReleaseNotes')}</p>`)
}
})
const show = () => {
isDismissed.value = false
}
const hide = () => {
isDismissed.value = true
emit('whats-new-dismissed')
}
const closePopup = async () => {
// Mark "what's new" seen when popup is closed
if (latestRelease.value) {
await releaseStore.handleWhatsNewSeen(latestRelease.value.version)
}
hide()
}
// Initialize on mount
onMounted(async () => {
// Fetch releases if not already loaded
if (!releaseStore.releases.length) {
await releaseStore.fetchReleases()
}
})
// Expose methods for parent component and tests
defineExpose({
show,
hide,
closePopup
})
</script>
<style scoped>
/* Popup container - positioning handled by parent */
.whats-new-popup-container {
--whats-new-popup-bottom: 1rem;
position: absolute;
bottom: var(--whats-new-popup-bottom);
z-index: 1000;
pointer-events: auto;
}
.whats-new-popup {
background: var(--interface-menu-surface);
border-radius: 8px;
max-width: 400px;
width: 400px;
border: 1px solid var(--interface-menu-stroke);
box-shadow: 1px 1px 8px 0 rgb(0 0 0 / 0.2);
position: relative;
display: flex;
flex-direction: column;
}
/* Modal Body */
.modal-body {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 0;
flex: 1;
}
.modal-header {
display: flex;
flex-direction: column;
gap: 8px;
}
.content-text {
color: var(--text-primary);
font-size: 14px;
line-height: 1.5;
word-wrap: break-word;
padding: 0 1rem;
}
/* Style the markdown content */
/* Title */
.content-text :deep(*) {
box-sizing: border-box;
}
.content-text :deep(h1) {
color: var(--text-secondary);
font-family: Inter, sans-serif;
font-size: 14px;
font-weight: 400;
margin-top: 1rem;
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* What's new title - targets h2 or strong text after h1 */
.content-text :deep(h2),
.content-text :deep(h1 + p strong) {
color: var(--text-primary);
font-family: Inter, sans-serif;
font-size: 14px;
font-weight: 600;
margin: 0 0 8px;
line-height: 1.429;
}
/* Regular paragraphs - short description */
.content-text :deep(p) {
color: var(--text-secondary);
font-family: Inter, sans-serif;
margin: 1rem 0;
}
/* List */
.content-text :deep(ul),
.content-text :deep(ol) {
margin-bottom: 0;
padding-left: 0;
list-style: none;
}
.content-text :deep(ul:first-child),
.content-text :deep(ol:first-child) {
margin-top: 0;
}
.content-text :deep(ul:last-child),
.content-text :deep(ol:last-child) {
margin-bottom: 0;
}
.content-text :deep(li) {
margin-bottom: 6px;
position: relative;
padding-left: 18px;
color: var(--text-secondary);
font-family: Inter, sans-serif;
font-size: 14px;
font-weight: 400;
line-height: 1.2102;
}
.content-text :deep(li:last-child) {
margin-bottom: 0;
}
.content-text :deep(li::before) {
content: '';
position: absolute;
left: 4px;
top: 7px;
width: 6px;
height: 6px;
border: 2px solid var(--text-secondary);
border-radius: 50%;
background: transparent;
}
.content-text :deep(li strong) {
color: var(--text-secondary);
font-family: Inter, sans-serif;
font-size: 14px;
font-weight: 400;
line-height: 1.2102;
margin-right: 4px;
}
.content-text :deep(li p) {
margin: 2px 0 0;
display: inline;
}
/* Code styling */
.content-text :deep(code) {
background-color: var(--input-surface);
border: 1px solid var(--interface-menu-stroke);
border-radius: 4px;
padding: 2px 6px;
color: var(--text-primary);
white-space: nowrap;
}
.content-text :deep(img) {
width: 100%;
height: 200px;
margin: 0 0 16px;
object-fit: cover;
display: block;
border-radius: 8px;
}
.content-text :deep(img:first-child) {
margin: -1rem -1rem 16px;
width: calc(100% + 2rem);
border-top-left-radius: 8px;
border-top-right-radius: 8px;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
/* Add border to content when image is present */
.content-text:has(img:first-child) {
border-left: 1px solid var(--interface-menu-stroke);
border-right: 1px solid var(--interface-menu-stroke);
border-top: 1px solid var(--interface-menu-stroke);
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
margin: -1px;
margin-bottom: 0;
}
.content-text :deep(img + h1) {
margin-top: 0;
}
/* Secondary headings */
.content-text :deep(h3) {
color: var(--text-primary);
font-family: Inter, sans-serif;
font-size: 16px;
font-weight: 600;
margin: 16px 0 8px;
line-height: 1.4;
}
/* Modal Footer */
.modal-footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
padding: 16px;
border-top: none;
}
.footer-actions {
display: flex;
align-items: center;
gap: 16px;
}
.learn-more-link {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-secondary);
font-size: 14px;
font-weight: 400;
line-height: 1.2102;
text-decoration: none;
padding: 4px 0;
}
.learn-more-link:hover {
color: var(--text-primary);
}
.learn-more-link i {
width: 16px;
height: 16px;
}
.action-secondary {
height: 32px;
padding: 4px 0;
background: transparent;
border: none;
color: var(--text-secondary);
font-size: 14px;
font-weight: 400;
line-height: 1.2102;
cursor: pointer;
border-radius: 4px;
}
.action-secondary:hover {
color: var(--text-primary);
}
.action-primary {
height: 40px;
padding: 8px 16px;
background: var(--interface-menu-component-surface-hovered);
border-radius: 8px;
border: none;
color: var(--text-primary);
font-size: 14px;
font-weight: 400;
line-height: 1.2102;
cursor: pointer;
}
.action-primary:hover {
background: var(--button-hover-surface);
}
</style>