From d643df35df4fff7c92cfe929be02209c2c91c8cb Mon Sep 17 00:00:00 2001 From: WildAi <2853742+wildminder@users.noreply.github.com> Date: Thu, 23 Apr 2026 02:08:31 +0300 Subject: [PATCH] feat(frontend): BEM modernization + fix Node 2.0 vertical stretching --- js/extension.js | 982 +++++++++++++++++++++++++++++------------------- 1 file changed, 590 insertions(+), 392 deletions(-) diff --git a/js/extension.js b/js/extension.js index 0b9df51..7bb2145 100644 --- a/js/extension.js +++ b/js/extension.js @@ -117,6 +117,320 @@ const logger = { console.table(data); } }; +const DESIGN_TOKEN_STYLES = ` +/* ============================================================================ + * VoxCPM Design Tokens + * ============================================================================ + * Semantic tokens that reference ComfyUI's native CSS variables. + * Fallback values ensure compatibility with all ComfyUI themes. + * ============================================================================ */ + +:root { + /* ── Spacing ── */ + --voxcpm-space-2xs: 2px; + --voxcpm-space-xs: 4px; + --voxcpm-space-s: 6px; + --voxcpm-space-m: 8px; + --voxcpm-space-l: 12px; + --voxcpm-space-xl: 16px; + --voxcpm-space-2xl: 24px; + + /* ── Typography ── */ + --voxcpm-font-size-2xs: 10px; + --voxcpm-font-size-xs: 12px; + --voxcpm-font-size-s: 13px; + --voxcpm-font-size-m: 14px; + --voxcpm-font-size-l: 18px; + --voxcpm-font-size-xl: 24px; + --voxcpm-font-family: var(--comfy-font-family, Arial, sans-serif); + --voxcpm-font-family-mono: monospace; + + /* ── Surfaces ── */ + --voxcpm-bg-input: var(--comfy-input-bg, rgba(255, 255, 255, 0.05)); + --voxcpm-bg-input-hover: var(--comfy-input-bg-hover, rgba(255, 255, 255, 0.08)); + --voxcpm-bg-input-active: var(--comfy-input-bg-active, rgba(255, 255, 255, 0.1)); + --voxcpm-bg-surface: var(--bg-color, #1e1e1e); + --voxcpm-bg-elevated: var(--input-bg, #2a2a2a); + + /* ── Borders ── */ + --voxcpm-border-color: var(--border-color, rgba(255, 255, 255, 0.15)); + --voxcpm-border-color-hover: var(--comfy-input-border-hover, rgba(255, 255, 255, 0.3)); + --voxcpm-border-radius-xs: 4px; + --voxcpm-border-radius-s: 6px; + --voxcpm-border-radius-m: 8px; + + /* ── Text ── */ + --voxcpm-text-primary: var(--fg-color, #ddd); + --voxcpm-text-secondary: var(--fg-color, rgba(255, 255, 255, 0.7)); + --voxcpm-text-muted: var(--fg-color, rgba(255, 255, 255, 0.4)); + --voxcpm-text-on-primary: #fff; + + /* ── Accent ── */ + --voxcpm-accent: var(--primary-color, #4a9eff); + --voxcpm-accent-hover: var(--primary-color-hover, #3a8eef); + + /* ── Feedback ── */ + --voxcpm-color-success: var(--success-color, #4caf50); + --voxcpm-color-error: var(--error-color, #f44336); + + /* ── Motion ── */ + --voxcpm-duration-fast: 150ms; + --voxcpm-duration-normal: 200ms; + --voxcpm-easing: ease; + + /* ── Widget ── */ + --voxcpm-widget-height: 44px; + --voxcpm-input-height: 26px; + --voxcpm-browse-width: 30px; + + /* ── Focus ── */ + --voxcpm-focus-ring-color: var(--voxcpm-accent); + --voxcpm-focus-ring-width: 2px; + --voxcpm-focus-ring-offset: 1px; +} +`.trim(); +const STYLE_ELEMENT_ID$1 = "voxcpm-design-tokens"; +let tokensInjected = false; +function injectDesignTokens() { + if (tokensInjected) { + return; + } + if (document.getElementById(STYLE_ELEMENT_ID$1)) { + tokensInjected = true; + return; + } + const style = document.createElement("style"); + style.id = STYLE_ELEMENT_ID$1; + style.textContent = DESIGN_TOKEN_STYLES; + document.head.appendChild(style); + tokensInjected = true; +} +const MODEL_SELECTOR_CLASSES = { + /** Block — the widget container (applied to WidgetDOM.vue parent div) */ + BLOCK: "voxcpm-model-selector", + /** Element — horizontal row containing display + browse button */ + ROW: "voxcpm-model-selector__row", + /** Element — clickable model display area (opens dropdown) */ + DISPLAY: "voxcpm-model-selector__display", + /** Element — folder icon in the display area */ + ICON: "voxcpm-model-selector__icon", + /** Element — model name text in the display area */ + TEXT: "voxcpm-model-selector__text", + /** Element — dropdown arrow in the display area */ + ARROW: "voxcpm-model-selector__arrow", + /** Element — browse button for custom path selection */ + BROWSE: "voxcpm-model-selector__browse", + /** Element — custom path indicator shown below the row */ + PATH: "voxcpm-model-selector__path", + /** Modifier — path indicator visible state */ + PATH_VISIBLE: "voxcpm-model-selector__path--visible" +}; +const MODEL_SELECTOR_STYLES = ` +/* ============================================================================ + * VoxCPM Model Selector Widget (BEM) + * Block: .voxcpm-model-selector + * ============================================================================ */ + +/* ── Block ── */ +.${MODEL_SELECTOR_CLASSES.BLOCK} { + display: flex; + flex-direction: column; + gap: 0; + width: 100%; + padding: var(--voxcpm-space-2xs) 0; + box-sizing: border-box; + /* Prevent vertical stretching in Node 2.0 mode. + * + * WidgetDOM.vue wraps DOM widgets with class="flex flex-col *:flex-1", + * which applies flex:1 to ALL direct children via the universal selector. + * This causes DOM widgets to stretch vertically like text areas. + * + * Two-part fix: + * 1. flex:none !important — overrides the *:flex-1 rule to prevent + * flex growth/shrink. The !important is required because the parent's + * *:flex-1 selector has equal specificity (0,1,0) but we need to win. + * 2. align-self:flex-start !important — prevents the flex container from + * stretching this item along the cross axis (vertical in flex-col). + * Without this, even with flex:none, the parent's align-items:stretch + * (the default) would still make the widget fill the full column height. + * + * Additionally, --comfy-widget-min-height and --comfy-widget-max-height + * CSS custom properties are set equal as a safety net. These are read + * by DOMWidgetImpl.computeLayoutSize() as a fallback, but since we + * intentionally do NOT set computeLayoutSize() on the widget (to avoid + * triggering 'auto' grid row sizing in Node 2.0), these properties + * serve as documentation of the intended height rather than an active + * layout mechanism. */ + flex: none !important; + align-self: flex-start !important; + --comfy-widget-min-height: var(--voxcpm-widget-height); + --comfy-widget-max-height: var(--voxcpm-widget-height); + --comfy-widget-height: var(--voxcpm-widget-height); +} + +/* ── Row: display + browse (horizontal layout) ── */ +.${MODEL_SELECTOR_CLASSES.ROW} { + display: flex; + align-items: center; + gap: var(--voxcpm-space-xs); + width: 100%; + box-sizing: border-box; +} + +/* ── Display: clickable area to open dropdown ── */ +.${MODEL_SELECTOR_CLASSES.DISPLAY} { + display: flex; + align-items: center; + gap: var(--voxcpm-space-s); + flex: 1; + padding: var(--voxcpm-space-xs) var(--voxcpm-space-m); + background: var(--voxcpm-bg-input); + border: 1px solid var(--voxcpm-border-color); + border-radius: var(--voxcpm-border-radius-xs); + cursor: pointer; + min-height: var(--voxcpm-input-height); + color: var(--voxcpm-text-primary); + font-family: var(--voxcpm-font-family); + font-size: var(--voxcpm-font-size-xs); + transition: border-color var(--voxcpm-duration-fast) var(--voxcpm-easing), + background var(--voxcpm-duration-fast) var(--voxcpm-easing); + user-select: none; + overflow: hidden; +} + +.${MODEL_SELECTOR_CLASSES.DISPLAY}:hover { + border-color: var(--voxcpm-border-color-hover); + background: var(--voxcpm-bg-input-hover); +} + +.${MODEL_SELECTOR_CLASSES.DISPLAY}:active { + background: var(--voxcpm-bg-input-active); +} + +/* ── Icon: folder icon indicating model source ── */ +.${MODEL_SELECTOR_CLASSES.ICON} { + font-size: var(--voxcpm-font-size-s); + opacity: 0.8; + flex-shrink: 0; + line-height: 1; +} + +/* ── Text: model name ── */ +.${MODEL_SELECTOR_CLASSES.TEXT} { + flex: 1; + color: var(--voxcpm-text-primary); + font-size: var(--voxcpm-font-size-xs); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.3; +} + +/* ── Arrow: dropdown indicator ── */ +.${MODEL_SELECTOR_CLASSES.ARROW} { + font-size: 9px; + opacity: 0.5; + margin-left: auto; + flex-shrink: 0; + line-height: 1; + transition: opacity var(--voxcpm-duration-fast) var(--voxcpm-easing); +} + +.${MODEL_SELECTOR_CLASSES.DISPLAY}:hover .${MODEL_SELECTOR_CLASSES.ARROW} { + opacity: 0.8; +} + +/* ── Browse: custom path button ── */ +.${MODEL_SELECTOR_CLASSES.BROWSE} { + /* Reset ComfyUI's global button styles */ + all: initial; + display: flex; + align-items: center; + justify-content: center; + padding: var(--voxcpm-space-xs) var(--voxcpm-space-m); + min-width: var(--voxcpm-browse-width); + height: var(--voxcpm-input-height); + font-size: var(--voxcpm-font-size-s); + border-radius: var(--voxcpm-border-radius-xs); + cursor: pointer; + flex-shrink: 0; + border: 1px solid var(--voxcpm-border-color); + background: var(--voxcpm-bg-input); + color: var(--voxcpm-text-primary); + font-family: var(--voxcpm-font-family); + transition: border-color var(--voxcpm-duration-fast) var(--voxcpm-easing), + background var(--voxcpm-duration-fast) var(--voxcpm-easing); + line-height: 1; + box-sizing: border-box; +} + +.${MODEL_SELECTOR_CLASSES.BROWSE}:hover { + border-color: var(--voxcpm-border-color-hover); + background: var(--voxcpm-bg-input-hover); +} + +.${MODEL_SELECTOR_CLASSES.BROWSE}:active { + background: var(--voxcpm-bg-input-active); +} + +/* ── Path: custom path indicator (below row, hidden by default) ── */ +.${MODEL_SELECTOR_CLASSES.PATH} { + font-size: var(--voxcpm-font-size-2xs); + color: var(--voxcpm-text-muted); + padding: var(--voxcpm-space-2xs) 0 0 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + width: 100%; + box-sizing: border-box; + display: none; +} + +.${MODEL_SELECTOR_CLASSES.PATH_VISIBLE} { + display: block; +} + +/* ── Focus: keyboard accessibility ── */ +.${MODEL_SELECTOR_CLASSES.DISPLAY}:focus-visible, +.${MODEL_SELECTOR_CLASSES.BROWSE}:focus-visible { + outline: var(--voxcpm-focus-ring-width) solid var(--voxcpm-focus-ring-color); + outline-offset: var(--voxcpm-focus-ring-offset); +} + +/* ── Reduced motion ── */ +@media (prefers-reduced-motion: reduce) { + .${MODEL_SELECTOR_CLASSES.BLOCK} *, + .${MODEL_SELECTOR_CLASSES.BLOCK} *::before, + .${MODEL_SELECTOR_CLASSES.BLOCK} *::after { + transition-duration: 0ms !important; + } +} +`.trim(); +const STYLE_ELEMENT_ID = "voxcpm-model-selector-styles"; +let stylesInjected = false; +function injectModelSelectorStyles() { + if (stylesInjected) { + return; + } + if (document.getElementById(STYLE_ELEMENT_ID)) { + stylesInjected = true; + return; + } + const style = document.createElement("style"); + style.id = STYLE_ELEMENT_ID; + style.textContent = MODEL_SELECTOR_STYLES; + document.head.appendChild(style); + stylesInjected = true; +} +let allStylesInjected = false; +function injectAllStyles() { + if (allStylesInjected) { + return; + } + injectDesignTokens(); + injectModelSelectorStyles(); + allStylesInjected = true; +} function getSessionItem(key) { try { return sessionStorage.getItem(key); @@ -341,54 +655,54 @@ function showWelcomeDialog() { return new Promise((resolve) => { closeActiveDialog(); const overlay = document.createElement("div"); - overlay.className = "voxcpm-dialog-overlay"; + overlay.className = "voxcpm-dialog__overlay"; overlay.innerHTML = ` -
-
- 🎙️ -

VoxCPM Model Setup

-
-
-

- Welcome to VoxCPM! How would you like to configure your model path? -

-
- - -
-
- -
+
+
+ 🎙️ +

VoxCPM Model Setup

+
+
+

+ Welcome to VoxCPM! How would you like to configure your model path? +

+
+ + +
+
+ +
`; let selectedOption = null; - const optionCards = overlay.querySelectorAll(".voxcpm-option-card"); + const optionCards = overlay.querySelectorAll(".voxcpm-dialog__option"); optionCards.forEach((card) => { card.addEventListener("click", () => { - optionCards.forEach((c) => c.classList.remove("voxcpm-option-selected")); - card.classList.add("voxcpm-option-selected"); + optionCards.forEach((c) => c.classList.remove("voxcpm-dialog__option--selected")); + card.classList.add("voxcpm-dialog__option--selected"); selectedOption = card.getAttribute("data-option"); const continueBtn2 = overlay.querySelector("#voxcpm-continue"); if (continueBtn2) { @@ -417,38 +731,38 @@ function showPathSelectionDialog(initialPath) { return new Promise((resolve) => { closeActiveDialog(); const overlay = document.createElement("div"); - overlay.className = "voxcpm-dialog-overlay"; + overlay.className = "voxcpm-dialog__overlay"; overlay.innerHTML = ` -
-
-

Select Model Directory

-
-
-
- - -
- -
-
- -
+
+
+

Select Model Directory

+
+
+
+ + +
+ +
+
+ +
`; let isValid = false; let currentPath = initialPath || ""; @@ -477,22 +791,22 @@ function showPathSelectionDialog(initialPath) { modelsPreview.style.display = "block"; modelCount.textContent = result.models.length.toString(); modelsList.innerHTML = result.models.map((m) => ` -
  • - 📁 - ${m.name} -
  • - `).join(""); - validationMessage.innerHTML = `Found ${result.models.length} VoxCPM-compatible models`; +
  • + 📁 + ${m.name} +
  • + `).join(""); + validationMessage.innerHTML = `Found ${result.models.length} VoxCPM-compatible models`; confirmBtn.disabled = false; } else { modelsPreview.style.display = "none"; - validationMessage.innerHTML = `${result.error || "Invalid path or no models found"}`; + validationMessage.innerHTML = `${result.error || "Invalid path or no models found"}`; confirmBtn.disabled = true; } } catch (error) { logger.warn("Path validation failed:", error); modelsPreview.style.display = "none"; - validationMessage.innerHTML = `Failed to validate path`; + validationMessage.innerHTML = `Failed to validate path`; confirmBtn.disabled = true; isValid = false; } @@ -536,10 +850,13 @@ function injectDialogStyles() { return; } const css = ` -/* VoxCPM Dialog Styles */ +/* ============================================================================ + * VoxCPM Dialog Styles (BEM) + * Block: .voxcpm-dialog + * ============================================================================ */ -/* Overlay */ -.voxcpm-dialog-overlay { +/* ── Overlay ── */ +.voxcpm-dialog__overlay { position: fixed; top: 0; left: 0; @@ -552,418 +869,282 @@ function injectDialogStyles() { z-index: 10000; } -/* Dialog Container */ +/* ── Block: Dialog Container ── */ .voxcpm-dialog { - background: var(--bg-color, #1e1e1e); - border-radius: 8px; + background: var(--voxcpm-bg-surface); + border-radius: var(--voxcpm-border-radius-m); max-width: 500px; width: 90%; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); } -.voxcpm-dialog-large { +.voxcpm-dialog--large { max-width: 600px; } -/* Dialog Header */ -.voxcpm-dialog-header { +/* ── Header ── */ +.voxcpm-dialog__header { display: flex; align-items: center; - gap: 12px; - padding: 20px 24px; - border-bottom: 1px solid var(--border-color, #333); + gap: var(--voxcpm-space-l); + padding: var(--voxcpm-space-2xl); + border-bottom: 1px solid var(--voxcpm-border-color); } -.voxcpm-dialog-icon { - font-size: 24px; +.voxcpm-dialog__icon { + font-size: var(--voxcpm-font-size-xl); } -.voxcpm-dialog-title { +.voxcpm-dialog__title { margin: 0; - font-size: 18px; - color: var(--fg-color, #fff); + font-size: var(--voxcpm-font-size-l); + color: var(--voxcpm-text-primary); } -/* Dialog Body */ -.voxcpm-dialog-body { - padding: 24px; +/* ── Body ── */ +.voxcpm-dialog__body { + padding: var(--voxcpm-space-2xl); } -.voxcpm-dialog-description { +.voxcpm-dialog__description { margin: 0 0 20px; - color: var(--fg-color, #ccc); + color: var(--voxcpm-text-secondary); line-height: 1.5; } -/* Options Container */ -.voxcpm-options { +/* ── Options Container ── */ +.voxcpm-dialog__options { display: flex; flex-direction: column; - gap: 12px; + gap: var(--voxcpm-space-l); } -/* Option Card */ -.voxcpm-option-card { +/* ── Option Card ── */ +.voxcpm-dialog__option { display: flex; - gap: 16px; - padding: 16px; - background: var(--input-bg, #2a2a2a); - border: 2px solid var(--border-color, #333); - border-radius: 8px; + gap: var(--voxcpm-space-xl); + padding: var(--voxcpm-space-xl); + background: var(--voxcpm-bg-elevated); + border: 2px solid var(--voxcpm-border-color); + border-radius: var(--voxcpm-border-radius-m); cursor: pointer; text-align: left; - transition: all 0.2s; + transition: border-color var(--voxcpm-duration-normal) var(--voxcpm-easing), + background var(--voxcpm-duration-normal) var(--voxcpm-easing); } -.voxcpm-option-card:hover { - border-color: var(--primary-color, #4a9eff); +.voxcpm-dialog__option:hover { + border-color: var(--voxcpm-accent); } -.voxcpm-option-selected { - border-color: var(--primary-color, #4a9eff); - background: var(--input-bg-hover, #333); +.voxcpm-dialog__option--selected { + border-color: var(--voxcpm-accent); + background: var(--voxcpm-bg-input-hover); } -.voxcpm-option-icon { +.voxcpm-dialog__option-icon { font-size: 32px; flex-shrink: 0; } -.voxcpm-option-content h3 { - margin: 0 0 4px; - color: var(--fg-color, #fff); - font-size: 14px; +.voxcpm-dialog__option-content h3 { + margin: 0 0 var(--voxcpm-space-xs); + color: var(--voxcpm-text-primary); + font-size: var(--voxcpm-font-size-m); } -.voxcpm-option-path { - margin: 0 0 8px; - font-family: monospace; - font-size: 12px; - color: var(--muted-color, #888); +.voxcpm-dialog__option-path { + margin: 0 0 var(--voxcpm-space-m); + font-family: var(--voxcpm-font-family-mono); + font-size: var(--voxcpm-font-size-xs); + color: var(--voxcpm-text-muted); } -.voxcpm-option-description { +.voxcpm-dialog__option-description { margin: 0; - font-size: 12px; - color: var(--muted-color, #aaa); + font-size: var(--voxcpm-font-size-xs); + color: var(--voxcpm-text-muted); } -/* Dialog Footer */ -.voxcpm-dialog-footer { +/* ── Footer ── */ +.voxcpm-dialog__footer { display: flex; justify-content: flex-end; - gap: 12px; - padding: 16px 24px; - border-top: 1px solid var(--border-color, #333); + gap: var(--voxcpm-space-l); + padding: var(--voxcpm-space-xl) var(--voxcpm-space-2xl); + border-top: 1px solid var(--voxcpm-border-color); } -/* Buttons */ -.voxcpm-btn { - padding: 8px 16px; - border-radius: 4px; - font-size: 14px; +/* ── Buttons ── */ +.voxcpm-dialog__btn { + padding: var(--voxcpm-space-m) var(--voxcpm-space-xl); + border-radius: var(--voxcpm-border-radius-xs); + font-size: var(--voxcpm-font-size-m); cursor: pointer; - transition: all 0.2s; + transition: background var(--voxcpm-duration-normal) var(--voxcpm-easing), + border-color var(--voxcpm-duration-normal) var(--voxcpm-easing); } -.voxcpm-btn-secondary { +.voxcpm-dialog__btn--secondary { background: transparent; - border: 1px solid var(--border-color, #333); - color: var(--fg-color, #ccc); + border: 1px solid var(--voxcpm-border-color); + color: var(--voxcpm-text-secondary); } -.voxcpm-btn-secondary:hover { - background: var(--input-bg, #2a2a2a); +.voxcpm-dialog__btn--secondary:hover { + background: var(--voxcpm-bg-elevated); } -.voxcpm-btn-primary { - background: var(--primary-color, #4a9eff); +.voxcpm-dialog__btn--primary { + background: var(--voxcpm-accent); border: none; - color: #fff; + color: var(--voxcpm-text-on-primary); } -.voxcpm-btn-primary:hover:not(:disabled) { - background: var(--primary-color-hover, #3a8eef); +.voxcpm-dialog__btn--primary:hover:not(:disabled) { + background: var(--voxcpm-accent-hover); } -.voxcpm-btn-primary:disabled { +.voxcpm-dialog__btn--primary:disabled { opacity: 0.5; cursor: not-allowed; } -/* Path Input Group */ -.voxcpm-path-input-group { +/* ── Input Group ── */ +.voxcpm-dialog__input-group { display: flex; - gap: 8px; - margin-bottom: 16px; + gap: var(--voxcpm-space-m); + margin-bottom: var(--voxcpm-space-xl); } -.voxcpm-path-input { +.voxcpm-dialog__input { flex: 1; - padding: 8px 12px; - background: var(--input-bg, #2a2a2a); - border: 1px solid var(--border-color, #333); - border-radius: 4px; - color: var(--fg-color, #fff); - font-size: 14px; + padding: var(--voxcpm-space-m) var(--voxcpm-space-l); + background: var(--voxcpm-bg-elevated); + border: 1px solid var(--voxcpm-border-color); + border-radius: var(--voxcpm-border-radius-xs); + color: var(--voxcpm-text-primary); + font-size: var(--voxcpm-font-size-m); + font-family: var(--voxcpm-font-family); } -.voxcpm-path-input:focus { +.voxcpm-dialog__input:focus { outline: none; - border-color: var(--primary-color, #4a9eff); + border-color: var(--voxcpm-accent); } -/* Models Preview */ -.voxcpm-models-preview { - background: var(--input-bg, #2a2a2a); - border-radius: 4px; - padding: 12px; - margin-bottom: 16px; +/* ── Models Preview ── */ +.voxcpm-dialog__preview { + background: var(--voxcpm-bg-elevated); + border-radius: var(--voxcpm-border-radius-xs); + padding: var(--voxcpm-space-l); + margin-bottom: var(--voxcpm-space-xl); } -.voxcpm-models-preview h4 { - margin: 0 0 8px; - font-size: 12px; - color: var(--muted-color, #888); +.voxcpm-dialog__preview h4 { + margin: 0 0 var(--voxcpm-space-m); + font-size: var(--voxcpm-font-size-xs); + color: var(--voxcpm-text-muted); } -.voxcpm-models-list { +.voxcpm-dialog__models-list { list-style: none; margin: 0; padding: 0; } -.voxcpm-models-list li { +.voxcpm-dialog__models-list li { display: flex; align-items: center; - gap: 8px; - padding: 4px 0; + gap: var(--voxcpm-space-m); + padding: var(--voxcpm-space-xs) 0; } -.voxcpm-model-icon { - font-size: 14px; +.voxcpm-dialog__model-icon { + font-size: var(--voxcpm-font-size-m); } -.voxcpm-model-name { - font-size: 13px; - color: var(--fg-color, #ccc); +.voxcpm-dialog__model-name { + font-size: var(--voxcpm-font-size-s); + color: var(--voxcpm-text-secondary); } -/* Validation Message */ -.voxcpm-validation-message { - font-size: 12px; +/* ── Validation ── */ +.voxcpm-dialog__validation { + font-size: var(--voxcpm-font-size-xs); } -.voxcpm-validation-success { - color: var(--success-color, #4caf50); +.voxcpm-dialog__validation--success { + color: var(--voxcpm-color-success); } -.voxcpm-validation-error { - color: var(--error-color, #f44336); +.voxcpm-dialog__validation--error { + color: var(--voxcpm-color-error); } -/* Path Indicator Widget */ +/* ── Focus: keyboard accessibility ── */ +.voxcpm-dialog__option:focus-visible, +.voxcpm-dialog__btn:focus-visible, +.voxcpm-dialog__input:focus-visible { + outline: var(--voxcpm-focus-ring-width) solid var(--voxcpm-focus-ring-color); + outline-offset: var(--voxcpm-focus-ring-offset); +} + +/* ── Reduced motion ── */ +@media (prefers-reduced-motion: reduce) { + .voxcpm-dialog *, + .voxcpm-dialog *::before, + .voxcpm-dialog *::after { + transition-duration: 0ms !important; + } +} + +/* ============================================================================ + * VoxCPM Path Indicator Widget (BEM) + * Block: .voxcpm-path-indicator + * ============================================================================ */ + .voxcpm-path-indicator { display: flex; align-items: center; - gap: 8px; - padding: 4px 8px; - background: var(--input-bg, #2a2a2a); - border-radius: 4px; - font-size: 12px; + gap: var(--voxcpm-space-m); + padding: var(--voxcpm-space-xs) var(--voxcpm-space-m); + background: var(--voxcpm-bg-elevated); + border-radius: var(--voxcpm-border-radius-xs); + font-size: var(--voxcpm-font-size-xs); } -.voxcpm-path-text { - color: var(--muted-color, #888); - font-family: monospace; +.voxcpm-path-indicator__text { + color: var(--voxcpm-text-muted); + font-family: var(--voxcpm-font-family-mono); } -.voxcpm-path-change-btn { +.voxcpm-path-indicator__change-btn { background: transparent; border: none; cursor: pointer; - font-size: 14px; - padding: 2px; + font-size: var(--voxcpm-font-size-m); + padding: var(--voxcpm-space-2xs); + color: var(--voxcpm-text-primary); + transition: opacity var(--voxcpm-duration-fast) var(--voxcpm-easing); } -.voxcpm-path-change-btn:hover { +.voxcpm-path-indicator__change-btn:hover { opacity: 0.8; } + +.voxcpm-path-indicator__change-btn:focus-visible { + outline: var(--voxcpm-focus-ring-width) solid var(--voxcpm-focus-ring-color); + outline-offset: var(--voxcpm-focus-ring-offset); +} `; const style = document.createElement("style"); style.id = styleId; style.textContent = css; document.head.appendChild(style); } -const MODEL_SELECTOR_STYLES = ` -/* ============================================================================ - * VoxCPM Model Selector Widget - * ============================================================================ */ - -.voxcpm-model-selector { - display: flex; - flex-direction: column; - gap: 0; - width: 100%; - padding: 2px 0; - box-sizing: border-box; - /* Prevent vertical stretching in Node 2.0 mode. - * - * WidgetDOM.vue wraps DOM widgets with class="flex flex-col *:flex-1", - * which applies flex:1 to ALL direct children via the universal selector. - * This causes DOM widgets to stretch vertically like text areas. - * - * We override with flex:none !important to keep our single-line widget - * at a fixed height. The !important is required because the parent's - * *:flex-1 selector has equal specificity (0,1,0) but we need to win - * due to the universal selector's cascade behavior. - * - * Additionally, ComfyUI's DOMWidgetImpl.computeLayoutSize() reads - * --comfy-widget-min-height and --comfy-widget-max-height CSS custom - * properties. Setting them equal forces a fixed layout height. - * These serve as a fallback for DOM widgets that don't override - * computeLayoutSize() in JavaScript. */ - flex: none !important; - --comfy-widget-min-height: 44px; - --comfy-widget-max-height: 44px; - --comfy-widget-height: 44px; -} - -/* Model display area - clickable to open dropdown */ -.voxcpm-model-display { - display: flex; - align-items: center; - gap: 6px; - flex: 1; - padding: 5px 8px; - background: var(--comfy-input-bg, rgba(255, 255, 255, 0.05)); - border: 1px solid var(--border-color, rgba(255, 255, 255, 0.15)); - border-radius: 4px; - cursor: pointer; - min-height: 26px; - color: var(--fg-color, #ddd); - font-family: var(--comfy-font-family, Arial, sans-serif); - font-size: 12px; - transition: border-color 0.15s ease, background 0.15s ease; - user-select: none; - overflow: hidden; -} - -.voxcpm-model-display:hover { - border-color: var(--comfy-input-border-hover, rgba(255, 255, 255, 0.3)); - background: var(--comfy-input-bg-hover, rgba(255, 255, 255, 0.08)); -} - -.voxcpm-model-display:active { - background: var(--comfy-input-bg-active, rgba(255, 255, 255, 0.1)); -} - -/* Folder icon indicating model source */ -.voxcpm-model-icon { - font-size: 13px; - opacity: 0.8; - flex-shrink: 0; - line-height: 1; -} - -/* Model name text */ -.voxcpm-model-text { - flex: 1; - color: var(--fg-color, #ddd); - font-size: 12px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - line-height: 1.3; -} - -/* Dropdown arrow */ -.voxcpm-model-arrow { - font-size: 9px; - opacity: 0.5; - margin-left: auto; - flex-shrink: 0; - line-height: 1; - transition: opacity 0.15s ease; -} - -.voxcpm-model-display:hover .voxcpm-model-arrow { - opacity: 0.8; -} - -/* Browse button */ -.voxcpm-browse-btn { - display: flex; - align-items: center; - justify-content: center; - padding: 5px 8px !important; - min-width: 30px; - height: 26px; - font-size: 13px; - border-radius: 4px !important; - cursor: pointer; - flex-shrink: 0; - border: 1px solid var(--border-color, rgba(255, 255, 255, 0.15)) !important; - background: var(--comfy-input-bg, rgba(255, 255, 255, 0.05)) !important; - color: var(--fg-color, #ddd) !important; - transition: border-color 0.15s ease, background 0.15s ease; - line-height: 1; -} - -.voxcpm-browse-btn:hover { - border-color: var(--comfy-input-border-hover, rgba(255, 255, 255, 0.3)) !important; - background: var(--comfy-input-bg-hover, rgba(255, 255, 255, 0.08)) !important; -} - -.voxcpm-browse-btn:active { - background: var(--comfy-input-bg-active, rgba(255, 255, 255, 0.1)) !important; -} - -/* Custom path indicator (shown below selector when custom path is active) */ -.voxcpm-model-custom-path { - font-size: 10px; - color: var(--fg-color, rgba(255, 255, 255, 0.4)); - padding: 1px 0 0 0; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - width: 100%; - box-sizing: border-box; - display: none; -} - -.voxcpm-model-custom-path--visible { - display: block; -} - -/* Selector row: model display + browse button (horizontal layout) */ -.voxcpm-model-selector-row { - display: flex; - align-items: center; - gap: 4px; - width: 100%; - box-sizing: border-box; -} -`.trim(); -const STYLE_ELEMENT_ID = "voxcpm-model-selector-styles"; -let stylesInjected = false; -function injectModelSelectorStyles() { - if (stylesInjected) { - return; - } - if (document.getElementById(STYLE_ELEMENT_ID)) { - stylesInjected = true; - return; - } - const style = document.createElement("style"); - style.id = STYLE_ELEMENT_ID; - style.textContent = MODEL_SELECTOR_STYLES; - document.head.appendChild(style); - stylesInjected = true; -} const selectorRefsMap = /* @__PURE__ */ new Map(); function createModelSelectorWidget(node) { - injectModelSelectorStyles(); const widgets = node.widgets; if (!widgets) { logger.warn("No widgets found on node:", node.id); @@ -977,32 +1158,37 @@ function createModelSelectorWidget(node) { hideDefaultComboWidget(modelWidget); const fragment = document.createDocumentFragment(); const modelDisplay = document.createElement("div"); - modelDisplay.className = "voxcpm-model-display"; + modelDisplay.className = MODEL_SELECTOR_CLASSES.DISPLAY; modelDisplay.title = "Click to select a model"; + modelDisplay.tabIndex = 0; + modelDisplay.setAttribute("role", "combobox"); + modelDisplay.setAttribute("aria-expanded", "false"); + modelDisplay.setAttribute("aria-haspopup", "listbox"); const folderIcon = document.createElement("span"); - folderIcon.className = "voxcpm-model-icon"; + folderIcon.className = MODEL_SELECTOR_CLASSES.ICON; folderIcon.textContent = UI.MODEL_SELECTOR.DEFAULT_ICON; const modelText = document.createElement("span"); - modelText.className = "voxcpm-model-text"; + modelText.className = MODEL_SELECTOR_CLASSES.TEXT; modelText.textContent = modelWidget.value || UI.MODEL_SELECTOR.PLACEHOLDER; const arrow = document.createElement("span"); - arrow.className = "voxcpm-model-arrow"; + arrow.className = MODEL_SELECTOR_CLASSES.ARROW; arrow.textContent = UI.MODEL_SELECTOR.ARROW; const browseBtn = document.createElement("button"); - browseBtn.className = "voxcpm-browse-btn"; + browseBtn.className = MODEL_SELECTOR_CLASSES.BROWSE; browseBtn.textContent = UI.MODEL_SELECTOR.CUSTOM_ICON; browseBtn.title = "Browse for custom model directory"; + browseBtn.setAttribute("role", "button"); const pathIndicator = document.createElement("div"); - pathIndicator.className = "voxcpm-model-custom-path"; + pathIndicator.className = MODEL_SELECTOR_CLASSES.PATH; const savedCustomPath = node.properties?.["custom_model_path"]; if (savedCustomPath) { folderIcon.textContent = UI.MODEL_SELECTOR.CUSTOM_ICON; pathIndicator.textContent = savedCustomPath; - pathIndicator.classList.add("voxcpm-model-custom-path--visible"); + pathIndicator.classList.add(MODEL_SELECTOR_CLASSES.PATH_VISIBLE); } modelDisplay.append(folderIcon, modelText, arrow); const row = document.createElement("div"); - row.className = "voxcpm-model-selector-row"; + row.className = MODEL_SELECTOR_CLASSES.ROW; row.append(modelDisplay, browseBtn); fragment.append(row, pathIndicator); const widgetChildren = [row, pathIndicator]; @@ -1023,6 +1209,21 @@ function createModelSelectorWidget(node) { node ); }); + modelDisplay.addEventListener("keydown", (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + e.stopPropagation(); + modelDisplay.setAttribute("aria-expanded", "true"); + openModelDropdown( + e, + modelWidget, + modelText, + folderIcon, + pathIndicator, + node + ); + } + }); browseBtn.addEventListener("click", async (e) => { e.preventDefault(); e.stopPropagation(); @@ -1047,20 +1248,16 @@ function createModelSelectorWidget(node) { const parentObserver = new MutationObserver(() => { const rowParent = row.parentElement; if (rowParent) { - rowParent.classList.add("voxcpm-model-selector"); + rowParent.classList.add(MODEL_SELECTOR_CLASSES.BLOCK); rowParent.setAttribute("node-id", String(node.id)); parentObserver.disconnect(); - logger.debug("Added voxcpm-model-selector class + node-id to parent div for node:", node.id); + logger.debug("Added", MODEL_SELECTOR_CLASSES.BLOCK, "class + node-id to parent div for node:", node.id); } }); parentObserver.observe(document.body, { childList: true, subtree: true }); domWidget.tooltip = "Select the VoxCPM model to use"; domWidget.computeSize = () => [0, UI.MODEL_SELECTOR.MIN_HEIGHT]; - domWidget.computeLayoutSize = () => ({ - minHeight: UI.MODEL_SELECTOR.MIN_HEIGHT, - maxHeight: UI.MODEL_SELECTOR.MIN_HEIGHT, - minWidth: 0 - }); + domWidget.computeLayoutSize = void 0; const allWidgets = node.widgets; if (allWidgets && allWidgets.length > 1) { const widgetIndex = allWidgets.indexOf(domWidget); @@ -1160,7 +1357,7 @@ async function handleBrowseClick(node, modelWidget, modelText, folderIcon, pathI updateModelOptions(modelWidget, data.models, node); folderIcon.textContent = UI.MODEL_SELECTOR.CUSTOM_ICON; pathIndicator.textContent = path; - pathIndicator.classList.add("voxcpm-model-custom-path--visible"); + pathIndicator.classList.add(MODEL_SELECTOR_CLASSES.PATH_VISIBLE); if (data.models.length > 0) { modelText.textContent = modelWidget.value; } else { @@ -1221,10 +1418,10 @@ function syncModelSelectorDisplay(node, value) { if (customPath) { refs.folderIcon.textContent = UI.MODEL_SELECTOR.CUSTOM_ICON; refs.pathIndicator.textContent = customPath; - refs.pathIndicator.classList.add("voxcpm-model-custom-path--visible"); + refs.pathIndicator.classList.add(MODEL_SELECTOR_CLASSES.PATH_VISIBLE); } else { refs.folderIcon.textContent = UI.MODEL_SELECTOR.DEFAULT_ICON; - refs.pathIndicator.classList.remove("voxcpm-model-custom-path--visible"); + refs.pathIndicator.classList.remove(MODEL_SELECTOR_CLASSES.PATH_VISIBLE); } logger.debug("Synced model selector display for node:", node.id, "value:", displayValue); } @@ -1365,6 +1562,7 @@ const VoxCPMExtension = { * Called after canvas is created, before nodes are loaded. */ async init() { + injectAllStyles(); logger.log("Frontend extension initialized"); }, /**