Workflows page: one bar, quieter cards

The page's three rows (kind pills, search, tag select, count and close; view, share and
save; the drag tip strip) become one bar: search, a Filter menu (kind and tag), the count,
grid or list, share and import, save the canvas. Cards keep the picture, the name, a line
of steps, sampler, LoRAs and size, and the model; the models' state is a dot (words only
when some are missing), 功能模块 marks a partial workflow, and Open and ⋯ (edit, export,
delete) show on hover. ui_popup_menu.js is the small menu both use.

The page's styles move to styles/28-workflows.css; the card and bar rules spread over four
stylesheets and the ~240 lines of CSS injected from ui_recipe_cards.js are removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DemonGatanjieu
2026-10-10 21:50:53 +08:00
co-authored by Claude Opus 5.5
parent a0aee2ec38
commit 617a7a3bc4
16 changed files with 753 additions and 1345 deletions
+10 -8
View File
@@ -458,11 +458,8 @@ covered by the `styles.css` manifest.
with stage interaction in `ui_image_stage.js` and metadata tabs in
`ui_image_inspector.js`.
- `ui_recipes.js` / `ui_recipe_detail.js` and `ui_notebooks.js`
own their respective workspace surfaces and persistence flows. `ui_recipes.js` owns
the Workflow Recipe studio catalog workspace with search/filter tags, grid/list layout toggle,
dedicated top-right modal close anchor (permanently decoupled from the tool button row to prevent wrapping displacement),
streamlined action header (preserving active workflow saving while pruning unfinished package
import entrypoints), and card browsing. `ui_notebooks.js` owns the Combos workspace (its list or one combo's editor,
own their respective workspace surfaces and persistence flows. `ui_recipes.js` hands the
Workflows page (its catalog) the recipe services (save, edit, fetch) and opens a recipe by name. `ui_notebooks.js` owns the Combos workspace (its list or one combo's editor,
with Back to the list) and combo persistence; `ui_notebook_editor.js` owns unfolded card editing (modularized into single-responsibility
sub-functions adhering to the 50-line rule: sticky top action toolbar with floating More popover dropdown and timed two-step delete safety guard,
unfolded companion models card with unconstrained multi-column tile flow eliminating nested gallery scrollbars,
@@ -497,9 +494,14 @@ covered by the `styles.css` manifest.
`(node_id, widget_index, category, saved_value)`. `recipe_parser.js` summarizes
models, LoRAs, samplers and prompts (linked prompt nodes before embedded loader
prompts) and keeps the summary in step with widget edits using the same node rules.
`ui_recipe_catalog.js` owns recipe
filters, navigation, dismissible topbar drag guidance strip with localStorage persistence, the 3-step empty-state onboarding blueprint (`renderRecipeEmptyGuide`), and background catalog-wide model readiness resolution (`resolveCatalogRecipeReadiness`), `ui_recipe_cards.js` owns cards and card actions
(including `grab` drag affordance, cover `可拖拽` badge, harmonized multi-state model readiness pill with `getRecipeReadiness` synchronizing available, missing, and pending matches with detail overview, and direct canvas drag-and-drop), `ui_recipe_dialogs.js` owns save/edit dialogs, and `ui_recipe_media.js` owns shared cover helpers. Detail sessions synchronize detected model availability back to `owner.recipeRecords` via `syncRecipeReferencesToCatalog`.
`ui_recipe_catalog.js` is the Workflows page: one bar (search, the filter menu with kind and tag,
the count, grid or list, share and import, save the canvas), the list, the empty page's three-step
guide (`renderRecipeEmptyGuide`) and the catalog-wide model readiness check
(`resolveCatalogRecipeReadiness`). `ui_recipe_cards.js` is one card: the cover with the models'
state (`getRecipeReadiness`: a dot, or words when some are missing) and 功能模块 for a partial
workflow, the name, a line of steps, sampler, LoRAs and size, then its model; Open and ⋯ (edit, export, delete)
over the cover on hover; a press opens the details, a drag opens it on the canvas. Its styles are
`styles/28-workflows.css`. `ui_popup_menu.js` is the small menu under a button they share, `ui_recipe_dialogs.js` owns save/edit dialogs, and `ui_recipe_media.js` owns shared cover helpers. Detail sessions synchronize detected model availability back to `owner.recipeRecords` via `syncRecipeReferencesToCatalog`.
- `ui_prompt_composer.js` owns the standalone Prompt Studio drawer (dock side, width, Esc);
`ui_prompt_workbench.js` fills it: the top bar (tags or text view, the meanings' language) and
saving a box as a card. `ui_prompt_target.js` decides what is edited, the prompt boxes of
+4
View File
@@ -27,6 +27,10 @@
- **Key parameters (关键参数)**: press ☆ before a value's name (tick **Show equal values too** to star one that matches) and it stays at the top of every comparison, for every workflow, kept with your ComfyUI settings.
- **Parameter sets in view (参数方案更好找)**: a workflow card with saved sets shows **▤ N** (how many), which opens them directly. Each set there shows its key values (those unlike the workflow's own in colour), how many values differ, and ⇄ to compare it with the workflow.
### 🗂️ A tidier Workflows page (工作流页面重做)
- One bar on top: search, **Filter** (kind and tag in one menu), grid or list, share and import, and **Save the canvas**. The drag tip strip and the red ✕ are gone (Esc still closes the page; the empty page explains dragging).
- Cards keep the picture, the name, a line of steps, sampler, LoRAs and size, and the model. The models' state is a dot on the cover, with words only when some are missing; 功能模块 marks a partial workflow. **Open** and **⋯** (edit, export, delete) show when the pointer is on a card.
### ⚙️ Settings in tabs (设置分组)
- The settings page is split into tabs: **Look, Models, Workflows, Prompts, Backup, Help**. "Change where downloads go" opens Models directly.
- **Where downloads (and now imports) go is a choice**: by base model (the new default), in Downloads then by base model, all in a Downloads folder, straight in the type's folder, or your own folder typed by hand; an example path shows the result. Without a saved setting, downloads now go by base model instead of into a Downloads folder.
+2 -1
View File
@@ -98,7 +98,8 @@ parameter editing, raw nodes, and preset saving; and `ui_recipe_models.js`
lists the models with previews and whether each is here under its saved name. Inline persistence is centralized
in `ui_recipe_metadata.js`, with pure ordering/value helpers in
`ui_recipe_parameter_utils.js`. The catalog shell lives in `ui_recipe_catalog.js`,
cards and card actions in `ui_recipe_cards.js`, save/edit dialogs in
cards and card actions in `ui_recipe_cards.js` (styles in `styles/28-workflows.css`; the ⋯
and filter menus are `ui_popup_menu.js`), save/edit dialogs in
`ui_recipe_dialogs.js`, and shared cover helpers in `ui_recipe_media.js`.
`ui_recipe_versions.js` owns version comparison and restore, while
`ui_recipe_gallery.js` owns result rendering and opens `ui_gallery_detail.js`
+10 -8
View File
@@ -606,6 +606,11 @@ export const i18n = {
paramSetSame: "与默认参数相同",
paramSetCompare: "和默认参数对比",
paramSetCount: "参数方案 {count}",
workflowsSteps: "{count} 步",
workflowsFilter: "筛选",
workflowsFilterOn: "筛选 · {count}",
workflowsFilterKind: "类型",
workflowsFilterTag: "标签",
paramSetCountHint: "打开这个工作流的参数方案",
compareModeOn: "启用",
compareModeMuted: "已静音",
@@ -1610,7 +1615,6 @@ export const i18n = {
recipeDialogNo: '否',
recipeNoTags: '暂无标签',
recipeNoMatches: '没有匹配的配方。请清除搜索或标签筛选。',
recipeFilterByTag: '按此标签筛选',
recipeImport: '导入配方包',
recipeExport: '导出配方包',
recipePackageExportTitle: '导出配方包:{name}',
@@ -1641,10 +1645,8 @@ export const i18n = {
recipeDragHint: '按住拖拽至画布打开工作流',
recipeDragTargetCanvas: '释放以在画布打开工作流',
recipeCardDragHint: '按住卡片可拖拽至画布打开工作流',
recipeDraggableBadge: '可拖拽',
recipeCardPrimaryActionTooltipComplete: '在新画布中打开工作流(也可直接按住卡片拖至画布)',
recipeCardPrimaryActionTooltipPartial: '追加模块至当前画布(也可直接按住卡片拖至画布)',
recipeDragGlobalHint: '提示:按住卡片直接拖拽到 ComfyUI 画布空白处释放,即可立即载入工作流',
recipeEmptyGuideTitle: '工作流配方工坊暂无配方',
recipeEmptyGuideSubtitle: "把调好的工作流连同模型和参数存下来,下次直接用。",
recipeEmptyStep1Title: '1. 保存当前工作流',
@@ -1853,7 +1855,6 @@ export const i18n = {
recipeRawNodesShowMore: '展开其余 {count} 个节点…',
recipeRawNodesLoadedCount: '已显示 {shown} / {total} 个节点',
recipeCardSpecsCheckpoint: '大模型',
recipeCardSpecsLoras: 'LoRA',
recipeCardSpecsSteps: '步数',
recipeCardSpecsSampler: '采样',
recipeCardSpecsResolution: '尺寸',
@@ -2908,6 +2909,11 @@ export const i18n = {
paramSetSame: "Same as the default",
paramSetCompare: "Compare with the default",
paramSetCount: "{count} parameter sets",
workflowsSteps: "{count} steps",
workflowsFilter: "Filter",
workflowsFilterOn: "Filter · {count}",
workflowsFilterKind: "Kind",
workflowsFilterTag: "Tag",
paramSetCountHint: "Open this workflow's parameter sets",
compareModeOn: "On",
compareModeMuted: "Muted",
@@ -3911,7 +3917,6 @@ export const i18n = {
recipeClearFilters: 'Clear filters',
recipeNoTags: 'No tags yet',
recipeNoMatches: 'No recipes match. Clear the search or tag filters.',
recipeFilterByTag: 'Filter by this tag',
recipeImport: 'Import package',
recipeExport: 'Export recipe package',
recipePackageExportTitle: 'Export recipe package: {name}',
@@ -3943,10 +3948,8 @@ export const i18n = {
recipeDragHint: 'Drag onto canvas to load workflow',
recipeDragTargetCanvas: 'Drop to load workflow onto canvas',
recipeCardDragHint: 'Hold & drag card onto canvas to load workflow',
recipeDraggableBadge: 'Draggable',
recipeCardPrimaryActionTooltipComplete: 'Open workflow in new canvas (or drag & drop card directly)',
recipeCardPrimaryActionTooltipPartial: 'Append module to current canvas (or drag & drop card directly)',
recipeDragGlobalHint: 'Tip: Drag and drop cards directly onto ComfyUI canvas to load workflows',
recipeEmptyGuideTitle: 'Workflow Recipe Studio is Empty',
recipeEmptyGuideSubtitle: "Keep a tuned workflow with its models and settings, and use it again next time.",
recipeEmptyStep1Title: '1. Save Current Workflow',
@@ -4155,7 +4158,6 @@ export const i18n = {
recipeRawNodesShowMore: 'Show {count} more nodes…',
recipeRawNodesLoadedCount: 'Loaded {shown} / {total} nodes',
recipeCardSpecsCheckpoint: 'Base',
recipeCardSpecsLoras: 'LoRA',
recipeCardSpecsSteps: 'Steps',
recipeCardSpecsSampler: 'Sampler',
recipeCardSpecsResolution: 'Size',
+4 -4
View File
@@ -40,8 +40,8 @@ export function closeWorkspace() {
this.recipeReturnState = null;
delete this.recipeDetailPayload;
if (this.recipeListContainer) this.recipeListContainer.style.display = '';
const actionbar = this.recipeView?.querySelector('.anomalous-recipe-actionbar');
if (actionbar) actionbar.style.display = '';
const bar = this.recipeView?.querySelector('.anomalous-wf-bar');
if (bar) bar.style.display = '';
if (this.detailPanel) {
this.stopMediaInContainer?.(this.detailPanel);
this.detailPanel.replaceChildren();
@@ -83,8 +83,8 @@ export function hideAllPanels() {
this.recipeReturnState = null;
delete this.recipeDetailPayload;
if (this.recipeListContainer) this.recipeListContainer.style.display = '';
const actionbar = this.recipeView?.querySelector('.anomalous-recipe-actionbar');
if (actionbar) actionbar.style.display = '';
const bar = this.recipeView?.querySelector('.anomalous-wf-bar');
if (bar) bar.style.display = '';
if (this.detailPanel) {
this.stopMediaInContainer?.(this.detailPanel);
this.detailPanel.replaceChildren();
+3 -7
View File
@@ -63,11 +63,8 @@ export function showParameterSetCount(owner, card, recipe, signatures) {
const own = recipe?.data?.parameter_signature?.value;
const sets = Array.isArray(map[recipe?.filename]) ? map[recipe.filename] : [];
const count = sets.filter(signature => !own || signature !== own).length;
// First in the specs line: the card's height is fixed, so a line of its own would push into it
// (a card without specs has that line free).
const strip = card.querySelector('.anomalous-recipe-specs-strip');
const header = card.querySelector('.anomalous-recipe-card-header');
if (!count || !(strip || header)) return;
const line = card.querySelector('.anomalous-wf-extras');
if (!count || !line) return;
const chip = el('button', 'anomalous-recipe-set-chip', `▤ ${count}`);
chip.type = 'button';
chip.title = `${t('paramSetCount', { count })} · ${t('paramSetCountHint')}`;
@@ -77,8 +74,7 @@ export function showParameterSetCount(owner, card, recipe, signatures) {
owner.recipeReturnState = { activeTab: 'parameters' };
card.click();
};
if (strip) strip.prepend(chip);
else header.after(chip);
line.append(chip);
});
}
+79
View File
@@ -0,0 +1,79 @@
/**
* A small menu that opens under a button (the workflow card's ⋯, the Workflows page's
* filter): rows that run an action, rows ticked as chosen, and headings. One is open at a
* time; a press outside it, Esc, scrolling or a chosen row closes it.
*/
let openMenu = null;
function el(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
/** Closes the open menu, if any. */
export function closePopupMenu() {
openMenu?.();
}
/**
* Opens `items` under `anchor`: { heading } or { label, run, checked?, danger? }. A row with
* `checked` (true or false) shows a tick when true. Pressing the anchor again closes it.
*/
export function openPopupMenu(anchor, items) {
const wasOpenHere = openMenu?.anchor === anchor;
closePopupMenu();
if (wasOpenHere) return;
const menu = el('div', 'anomalous-popup-menu');
menu.setAttribute('role', 'menu');
for (const item of items) {
if (item.heading) {
menu.append(el('div', 'anomalous-popup-menu-heading', item.heading));
continue;
}
const row = el('button', `anomalous-popup-menu-item${item.danger ? ' is-danger' : ''}`);
row.type = 'button';
row.setAttribute('role', item.checked === undefined ? 'menuitem' : 'menuitemradio');
if (item.checked !== undefined) {
row.setAttribute('aria-checked', String(Boolean(item.checked)));
row.append(el('span', 'anomalous-popup-menu-tick', item.checked ? '✓' : ''));
}
row.append(el('span', 'anomalous-popup-menu-label', item.label));
row.onclick = (event) => {
event.stopPropagation();
close();
item.run?.();
};
menu.append(row);
}
document.body.append(menu);
const box = anchor.getBoundingClientRect();
const { width, height } = menu.getBoundingClientRect();
const left = Math.min(Math.max(8, box.right - width), window.innerWidth - width - 8);
const below = box.bottom + 4;
menu.style.left = `${left}px`;
menu.style.top = `${below + height > window.innerHeight - 8 ? Math.max(8, box.top - height - 4) : below}px`;
const onPointer = (event) => {
if (!menu.contains(event.target) && !anchor.contains(event.target)) close();
};
const onKey = (event) => {
if (event.key !== 'Escape') return;
event.stopPropagation();
close();
};
const close = () => {
menu.remove();
window.removeEventListener('pointerdown', onPointer, true);
window.removeEventListener('keydown', onKey, true);
window.removeEventListener('scroll', close, true);
if (openMenu === close) openMenu = null;
};
close.anchor = anchor;
window.addEventListener('pointerdown', onPointer, true);
window.addEventListener('keydown', onKey, true);
window.addEventListener('scroll', close, true);
openMenu = close;
}
+102 -445
View File
@@ -1,4 +1,11 @@
/** Workflow Recipe cards and card-level actions. */
/**
* A Workflow Recipe's card on the Workflows page: its cover (a dot when its models are all
* here, words when some are missing; 功能模块 marks a partial workflow), its name, a line of
* its steps, sampler, LoRAs and size, and its model. Open (or append, for a partial one) and ⋯ (edit, export,
* delete) show over the cover while the pointer is on the card; a press elsewhere opens its
* details, and the card drags onto the canvas. The page lays cards as a grid or a list
* (styles/28-workflows.css).
*/
import { app } from "../../../scripts/app.js";
import { translate } from "./locales.js";
@@ -8,6 +15,7 @@ import { applyRecipeToCanvas, showRecipeDetail } from "./ui_recipe_detail.js";
import { exportRecipePackage } from "./ui_recipe_package.js";
import { appendText } from "./ui_recipe_detail_dom.js";
import { appendRecipeCover, outputImageUrl, previewIsVideo, recipeAssetUrl, safeThumbnail } from "./ui_recipe_media.js";
import { openPopupMenu } from "./ui_popup_menu.js";
const t = (key, params) => translate(key, params);
@@ -26,11 +34,6 @@ async function runRecipeCardAction(actionButton, action, errorKey) {
}
}
function compactText(value, limit = 110) {
const text = String(value || '').trim().replace(/\s+/g, ' ');
return text.length > limit ? `${text.slice(0, limit - 1)}…` : text;
}
function modelDisplayName(value) {
const path = String(value || '').replace(/\\/g, '/');
const filename = path.split('/').pop() || '';
@@ -40,42 +43,25 @@ function modelDisplayName(value) {
function formatRecipeResolution(res) {
if (!res) return '';
if (typeof res === 'string' || typeof res === 'number') return String(res);
if (Array.isArray(res) && res.length >= 2) return `${res[0]}x${res[1]}`;
if (Array.isArray(res) && res.length >= 2) return `${res[0]}×${res[1]}`;
if (typeof res === 'object') {
const w = res.width ?? res.w ?? res.x;
const h = res.height ?? res.h ?? res.y;
if (w && h) return `${w}x${h}`;
if (w && h) return `${w}×${h}`;
}
return '';
}
function createRecipeQuickSpecs(params) {
if (!params || typeof params !== 'object') return null;
const strip = document.createElement('div');
strip.className = 'anomalous-recipe-specs-strip';
/** The line of how a recipe draws: steps, sampler, LoRAs, size. */
function recipeMetaLine(params) {
if (!params || typeof params !== 'object') return '';
const loras = Array.isArray(params.loras) ? params.loras : [];
if (loras.length > 0) {
const loraTag = appendText(strip, 'span', '', 'anomalous-recipe-spec-tag is-lora');
loraTag.innerHTML = `<svg style="width:12px;height:12px;margin-right:4px;vertical-align:-1px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20.59 13.41l-7.17 7.17a2 2 0 01-2.83 0L2 12V2h10l8.59 8.59a2 2 0 010 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>${loras.length} ${t('recipeCardSpecsLoras')}`;
loraTag.title = loras.map((l) => (typeof l === 'object' && l?.name ? l.name : String(l))).join(', ');
}
if (params.steps) {
const samplerName = params.sampler_name ? ` · ${params.sampler_name}` : '';
const stepTag = appendText(strip, 'span', '', 'anomalous-recipe-spec-tag is-step');
stepTag.innerHTML = `<svg style="width:12px;height:12px;margin-right:4px;vertical-align:-1px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>${params.steps} ${t('recipeCardSpecsSteps')}${samplerName}`;
stepTag.title = `${params.steps} ${t('recipeCardSpecsSteps')}${samplerName}`;
}
const formattedRes = formatRecipeResolution(params.resolution);
if (formattedRes) {
const resTag = appendText(strip, 'span', '', 'anomalous-recipe-spec-tag');
resTag.innerHTML = `<svg style="width:12px;height:12px;margin-right:4px;vertical-align:-1px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="9" y1="3" x2="9" y2="21"/></svg>${formattedRes}`;
resTag.title = `${t('recipeCardSpecsResolution')}: ${formattedRes}`;
}
return strip.childElementCount ? strip : null;
return [
params.steps ? t('workflowsSteps', { count: params.steps }) : '',
params.sampler_name || '',
loras.length ? `${loras.length} LoRA` : '',
formatRecipeResolution(params.resolution),
].filter(Boolean).join(' · ');
}
export function getRecipeReadiness(recipeData) {
@@ -106,426 +92,98 @@ export function getRecipeReadiness(recipeData) {
return { status: 'ready', label: t('recipeStatusReady') };
}
export function ensureRecipeGuideStyles() {
if (typeof document === 'undefined') return;
if (document.querySelector?.('#anomalous-recipe-guide-styles')) return;
if (typeof document.createElement !== 'function') return;
const style = document.createElement('style');
style.id = 'anomalous-recipe-guide-styles';
style.textContent = `
.anomalous-recipe-card {
cursor: grab !important;
}
.anomalous-recipe-card:active {
cursor: grabbing !important;
}
.anomalous-recipe-card button,
.anomalous-recipe-card a,
.anomalous-recipe-card select,
.anomalous-recipe-card input {
cursor: pointer;
}
.anomalous-recipe-drag-badge {
position: absolute;
top: 6px;
left: 6px;
padding: 2px 6px;
border-radius: 4px;
font-size: 0.65rem;
font-weight: 500;
background: rgba(10, 12, 16, 0.75);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
border: 1px solid var(--amb-border);
color: var(--amb-text-soft);
display: inline-flex;
align-items: center;
gap: 3px;
pointer-events: none;
transition: opacity 0.15s ease, background 0.15s ease, border-color 0.15s ease;
opacity: 0.85;
z-index: 2;
}
.anomalous-recipe-card:hover .anomalous-recipe-drag-badge {
opacity: 1;
background: rgba(15, 23, 42, 0.9);
border-color: color-mix(in srgb, var(--amb-link) 40%, transparent);
color: var(--amb-text-main);
}
/* 修复封面底部模型就绪状态徽标对比度与穿透:深色高对比毛玻璃底板 */
.anomalous-recipe-readiness-pill {
display: inline-flex !important;
align-items: center !important;
gap: 5px !important;
background: rgba(10, 12, 18, 0.88) !important;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 1px solid var(--amb-border-strong) !important;
padding: 2.5px 8px !important;
border-radius: 6px !important;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.45) !important;
color: var(--amb-text-main) !important;
}
.anomalous-recipe-readiness-pill.is-ready {
background: rgba(10, 12, 18, 0.9) !important;
border-color: rgba(16, 185, 129, 0.5) !important;
}
.anomalous-recipe-readiness-pill.is-ready .anomalous-recipe-readiness-text {
color: var(--amb-ok-soft) !important;
font-weight: 600 !important;
}
.anomalous-recipe-readiness-pill.is-warning {
background: rgba(10, 12, 18, 0.9) !important;
border-color: rgba(245, 158, 11, 0.6) !important;
}
.anomalous-recipe-readiness-pill.is-warning .anomalous-recipe-readiness-text {
color: var(--amb-warn-soft) !important;
font-weight: 600 !important;
}
.anomalous-recipe-readiness-pill.is-missing {
background: rgba(10, 12, 18, 0.9) !important;
border-color: rgba(239, 68, 68, 0.6) !important;
}
.anomalous-recipe-readiness-pill.is-missing .anomalous-recipe-readiness-text {
color: var(--amb-danger-text) !important;
font-weight: 600 !important;
}
.anomalous-recipe-readiness-dot {
width: 7px !important;
height: 7px !important;
flex-shrink: 0 !important;
}
.anomalous-recipe-drag-hint-strip {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 6px 14px;
margin: 8px 16px 4px 16px;
border-radius: 8px;
font-size: 12px;
color: var(--amb-text-muted);
background: var(--amb-lt-chip, rgba(30, 41, 59, 0.5));
border: 1px solid var(--amb-border);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
flex-shrink: 0;
line-height: 1.4;
}
.anomalous-recipe-drag-hint-content {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.anomalous-recipe-drag-hint-icon {
font-size: 14px;
line-height: 1;
flex-shrink: 0;
}
.anomalous-recipe-drag-hint-text {
color: var(--amb-text-soft);
font-size: 12px;
}
.anomalous-recipe-drag-hint-close {
background: transparent;
border: none;
color: var(--amb-text-dim);
font-size: 16px;
line-height: 1;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
transition: all 0.15s ease;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.anomalous-recipe-drag-hint-close:hover {
color: var(--amb-text-main);
background: rgba(var(--amb-ink-rgb), 0.08);
}
.anomalous-recipe-empty-guide {
grid-column: 1 / -1;
max-width: 580px;
margin: 32px auto;
padding: 28px 24px;
background: rgba(var(--amb-ink-rgb), 0.025);
border: 1px solid var(--amb-border);
border-radius: 16px;
text-align: center;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.anomalous-recipe-empty-icon {
width: 52px;
height: 52px;
border-radius: 50%;
background: color-mix(in srgb, var(--amb-link) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--amb-link) 25%, transparent);
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
margin-bottom: 2px;
}
.anomalous-recipe-empty-title {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--amb-text-main);
letter-spacing: 0.2px;
}
.anomalous-recipe-empty-subtitle {
margin: 0;
font-size: 12.5px;
line-height: 1.55;
color: var(--amb-text-muted);
max-width: 440px;
}
.anomalous-recipe-empty-steps {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 12px;
width: 100%;
margin-top: 8px;
text-align: left;
}
.anomalous-recipe-empty-step {
background: rgba(var(--amb-ink-rgb), 0.03);
border: 1px solid var(--amb-border);
border-radius: 10px;
padding: 14px 14px;
display: flex;
flex-direction: column;
gap: 6px;
transition: background 0.15s ease, border-color 0.15s ease;
}
.anomalous-recipe-empty-step:hover {
background: rgba(var(--amb-ink-rgb), 0.05);
border-color: var(--amb-border-strong);
}
.anomalous-recipe-step-icon {
font-size: 20px;
line-height: 1;
margin-bottom: 2px;
}
.anomalous-recipe-empty-step strong {
font-size: 13px;
color: var(--amb-text-main);
font-weight: 600;
}
.anomalous-recipe-empty-step p {
margin: 0;
font-size: 11.5px;
line-height: 1.5;
color: var(--amb-text-muted);
}
.anomalous-recipe-empty-action-btn {
margin-top: 6px;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 18px;
font-size: 13px;
font-weight: 500;
color: var(--amb-btn-primary-text);
background: var(--amb-btn-primary-bg);
border: none;
border-radius: 8px;
cursor: pointer;
transition: background 0.15s ease;
}
.anomalous-recipe-empty-action-btn:hover {
background: #4338ca;
}
`;
(document.head || document.body)?.appendChild(style);
/** The models' state on the cover: a dot when all is well, words when something is missing. */
function readinessMark(data) {
const readiness = getRecipeReadiness(data);
const label = readiness.label.replace(/^[🟢🟡🔴⏳]\s*/u, '');
const mark = document.createElement('span');
mark.className = `anomalous-wf-status is-${readiness.status}`;
mark.title = `${label} — ${t('recipeReadinessHint')}`;
if (readiness.status === 'missing') mark.textContent = label;
else mark.setAttribute('aria-label', label);
return mark;
}
export function createRecipeCard(owner, recipe, services) {
ensureRecipeGuideStyles();
const data = recipe?.data || {};
const card = document.createElement('article');
card.className = 'anomalous-recipe-card';
card.style.cursor = 'grab';
card.title = `${data.name || t('recipeUntitled')} — ${t('recipeCardDragHint') || '按住可拖拽至画布打开工作流'}`;
async function deleteRecipe(owner, recipe) {
if (!await anomalousConfirm(t('recipeDeleteConfirm'))) return;
const response = await fetch('/anomalous/delete_recipe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: recipe.filename }),
});
if (!response.ok) throw new Error('recipe deletion failed');
await owner.refreshRecipes();
}
// 1. Bento Media Wrap: Cover on TOP (136px)
const mediaWrap = document.createElement('div');
mediaWrap.className = 'anomalous-recipe-card-media-wrap';
const sourceImageUrl = outputImageUrl(data.source_image);
const savedCover = recipeAssetUrl(recipe.filename, data.presentation?.cover_asset_id);
const thumbnail = savedCover || (previewIsVideo(sourceImageUrl)
? sourceImageUrl
: safeThumbnail(data.thumbnail) || sourceImageUrl);
appendRecipeCover(mediaWrap, thumbnail, data.name || t('recipeThumbnail'), recipe?.filename || data.name);
// Frosted Glass Chips at bottom-left of cover (Readiness Pill + Base Model)
const bottomChips = document.createElement('div');
bottomChips.className = 'anomalous-recipe-cover-bottom-chips';
const readiness = getRecipeReadiness(data);
const readinessPill = document.createElement('span');
readinessPill.className = `anomalous-recipe-readiness-pill is-${readiness.status}`;
readinessPill.title = `${readiness.label} — ${t('recipeReadinessHint') || (window.anomalous_browser_lang === 'zh' ? '检测工作流所需模型在本地环境中的就绪状态,点击卡片进入详情可进行智能比对与替换' : 'Detects model dependencies; open details for smart matching and replacement')}`;
const dot = document.createElement('span');
dot.className = `anomalous-recipe-readiness-dot is-${readiness.status}`;
const rText = document.createElement('span');
rText.className = 'anomalous-recipe-readiness-text';
rText.textContent = readiness.label.replace(/^[🟢🟡🔴⏳]\s*/, '');
readinessPill.append(dot, rText);
bottomChips.appendChild(readinessPill);
const baseModelStr = data.params?.baseModel || data.params?.baseModels;
if (baseModelStr) {
const pill = document.createElement('span');
pill.className = 'anomalous-recipe-cover-pill';
pill.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline-block;vertical-align:-1px;margin-right:4px;"><path d="m21 16-9 5-9-5V8l9-5 9 5v8z"/><path d="m3.27 6.96 8.73 4.84 8.73-4.84"/><path d="M12 22.08V11.8"/></svg>';
appendText(pill, 'span', modelDisplayName(baseModelStr));
pill.title = String(baseModelStr);
bottomChips.appendChild(pill);
}
mediaWrap.appendChild(bottomChips);
// Scope Pill (Top-right of cover)
const isPartial = data.workflow_scope === 'partial';
const scopePill = document.createElement('span');
scopePill.className = `anomalous-recipe-scope-pill ${isPartial ? 'is-partial' : 'is-complete'}`;
const scopeSvg = isPartial
? `<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline-block;vertical-align:-1px;margin-right:3px;"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><circle cx="18" cy="6" r="3"/><path d="M18 9v6"/><path d="M9 6h6"/></svg>`
: `<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline-block;vertical-align:-1px;margin-right:3px;"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>`;
scopePill.innerHTML = `${scopeSvg}<span>${t(isPartial ? 'recipeScopePill_partial' : 'recipeScopePill_complete')}</span>`;
mediaWrap.appendChild(scopePill);
// Drag Affordance Badge (Top-left of cover)
const dragBadge = document.createElement('span');
dragBadge.className = 'anomalous-recipe-drag-badge';
dragBadge.innerHTML = `<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor" style="display:inline-block;vertical-align:-1px;margin-right:3px;"><circle cx="9" cy="5" r="2"/><circle cx="15" cy="5" r="2"/><circle cx="9" cy="12" r="2"/><circle cx="15" cy="12" r="2"/><circle cx="9" cy="19" r="2"/><circle cx="15" cy="19" r="2"/></svg><span>${t('recipeDraggableBadge') || (window.anomalous_browser_lang === 'zh' ? '可拖拽' : 'Draggable')}</span>`;
dragBadge.title = t('recipeCardDragHint') || '按住可拖拽至画布打开工作流';
mediaWrap.appendChild(dragBadge);
card.appendChild(mediaWrap);
// 2. Card Body
const body = document.createElement('div');
body.className = 'anomalous-recipe-card-body';
// Header: Clean Title (Readiness relocated to cover pill)
const header = document.createElement('div');
header.className = 'anomalous-recipe-card-header';
const title = appendText(header, 'h3', data.name || t('recipeUntitled'), 'anomalous-recipe-card-title');
title.title = data.name || t('recipeUntitled');
body.appendChild(header);
// Quick Specs Strip
const specs = createRecipeQuickSpecs(data.params);
if (specs) body.appendChild(specs);
// Compact Tags (first 3)
if (Array.isArray(data.tags) && data.tags.length) {
const tags = document.createElement('div');
tags.className = 'anomalous-recipe-tags';
for (const tag of data.tags.slice(0, 3)) {
const tagButton = appendText(tags, 'button', compactText(tag, 18), 'anomalous-recipe-badge anomalous-recipe-badge-tag');
tagButton.type = 'button';
tagButton.title = t('recipeFilterByTag');
tagButton.onclick = (event) => {
event.stopPropagation();
if (!owner.recipeSelectedTags) owner.recipeSelectedTags = new Set();
owner.recipeSelectedTags = new Set([tag]);
if (owner.recipeTagSelect) owner.recipeTagSelect.value = tag;
owner.renderRecipeList(owner.recipeRecords || []);
};
}
body.appendChild(tags);
}
// Footer: Primary Action Button + Mini Actions
const footer = document.createElement('div');
footer.className = 'anomalous-recipe-card-footer';
const appendBtn = document.createElement('button');
appendBtn.type = 'button';
appendBtn.className = 'anomalous-recipe-btn-primary-action anomalous-tooltip-target';
const actionSvg = `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" style="display:inline-block;vertical-align:-1px;margin-right:4px;"><polygon points="5 3 19 12 5 21 5 3"/></svg>`;
appendBtn.innerHTML = `${actionSvg}<span>${t(isPartial ? 'recipeAppendCanvas' : 'recipeOpenCanvas')}</span>`;
appendBtn.removeAttribute('title');
const primaryTooltip = isPartial
? (t('recipeCardPrimaryActionTooltipPartial') || `${t('recipeAppendCanvas')}(也可按住卡片直接拖至画布)`)
: (t('recipeCardPrimaryActionTooltipComplete') || `${t('recipeOpenCanvas')}(也可按住卡片直接拖至画布)`);
appendBtn.setAttribute('data-tooltip', primaryTooltip);
appendBtn.setAttribute('data-tooltip-pos', 'top');
appendBtn.onclick = (e) => {
e.stopPropagation();
runRecipeCardAction(appendBtn, async () => {
/** Open / append and ⋯, shown over the cover while the pointer is on the card. */
function cardActions(owner, recipe, services, isPartial) {
const actions = document.createElement('div');
actions.className = 'anomalous-wf-actions';
const open = appendText(actions, 'button', `▶ ${t(isPartial ? 'recipeAppendCanvas' : 'recipeOpenCanvas')}`, 'anomalous-wf-open');
open.type = 'button';
open.title = t(isPartial ? 'recipeCardPrimaryActionTooltipPartial' : 'recipeCardPrimaryActionTooltipComplete');
open.onclick = (event) => {
event.stopPropagation();
runRecipeCardAction(open, async () => {
const graph = app.graph;
const fullRecipe = await services.fetchRecipeData(recipe.filename);
if (app.graph !== graph) throw new Error('materialTargetChanged');
await applyRecipeToCanvas(owner, fullRecipe);
}, isPartial ? 'recipeAppendError' : 'recipeOpenError');
};
footer.appendChild(appendBtn);
const miniActions = document.createElement('div');
miniActions.className = 'anomalous-recipe-card-mini-actions';
const editBtn = document.createElement('button');
editBtn.type = 'button';
editBtn.className = 'anomalous-recipe-card-mini-btn anomalous-tooltip-target';
editBtn.innerHTML = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:block;"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg>`;
editBtn.removeAttribute('title');
editBtn.setAttribute('data-tooltip', t('recipeEdit'));
editBtn.setAttribute('data-tooltip-pos', 'top');
editBtn.onclick = (e) => {
e.stopPropagation();
runRecipeCardAction(editBtn, () => services.editRecipe(owner, recipe?.data || {}, recipe.filename), 'recipeUpdateError');
const more = appendText(actions, 'button', '⋯', 'anomalous-wf-more');
more.type = 'button';
more.title = t('notebookMore');
more.setAttribute('aria-label', t('notebookMore'));
more.onclick = (event) => {
event.stopPropagation();
openPopupMenu(more, [
{ label: t('recipeEdit'), run: () => runRecipeCardAction(more, () => services.editRecipe(owner, recipe?.data || {}, recipe.filename), 'recipeUpdateError') },
{ label: t('recipeExport'), run: () => exportRecipePackage(recipe) },
{ label: t('recipeDelete'), danger: true, run: () => runRecipeCardAction(more, () => deleteRecipe(owner, recipe), 'recipeDeleteError') },
]);
};
return actions;
}
const exportBtn = document.createElement('button');
exportBtn.type = 'button';
exportBtn.className = 'anomalous-recipe-card-mini-btn anomalous-tooltip-target';
exportBtn.innerHTML = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:block;"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>`;
exportBtn.removeAttribute('title');
exportBtn.setAttribute('data-tooltip', t('recipeExport'));
exportBtn.setAttribute('aria-label', t('recipeExport'));
exportBtn.setAttribute('data-tooltip-pos', 'top');
exportBtn.onclick = (e) => {
e.stopPropagation();
exportRecipePackage(recipe);
};
/** The model's line; the parameter set count joins it on the right (ui_param_set_keys.js). */
function modelLine(params) {
const line = document.createElement('div');
line.className = 'anomalous-wf-extras';
const model = modelDisplayName(params?.baseModel || params?.baseModels);
const name = appendText(line, 'span', model, 'anomalous-wf-model');
name.title = model;
return line;
}
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'anomalous-recipe-card-mini-btn is-delete anomalous-tooltip-target';
removeBtn.innerHTML = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:block;"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg>`;
removeBtn.removeAttribute('title');
removeBtn.setAttribute('data-tooltip', t('recipeDelete'));
removeBtn.setAttribute('data-tooltip-pos', 'top');
removeBtn.onclick = (e) => {
e.stopPropagation();
runRecipeCardAction(removeBtn, async () => {
if (!await anomalousConfirm(t('recipeDeleteConfirm'))) return;
const response = await fetch('/anomalous/delete_recipe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: recipe.filename }),
});
if (!response.ok) throw new Error('recipe deletion failed');
await owner.refreshRecipes();
}, 'recipeDeleteError');
};
export function createRecipeCard(owner, recipe, services) {
const data = recipe?.data || {};
const isPartial = data.workflow_scope === 'partial';
const card = document.createElement('article');
card.className = 'anomalous-wf-card';
card.title = `${data.name || t('recipeUntitled')} — ${t('recipeCardDragHint')}`;
miniActions.append(editBtn, exportBtn, removeBtn);
footer.appendChild(miniActions);
body.appendChild(footer);
card.appendChild(body);
const cover = document.createElement('div');
cover.className = 'anomalous-wf-cover';
const sourceImageUrl = outputImageUrl(data.source_image);
const savedCover = recipeAssetUrl(recipe.filename, data.presentation?.cover_asset_id);
const thumbnail = savedCover || (previewIsVideo(sourceImageUrl)
? sourceImageUrl
: safeThumbnail(data.thumbnail) || sourceImageUrl);
appendRecipeCover(cover, thumbnail, data.name || t('recipeThumbnail'), recipe?.filename || data.name);
cover.append(readinessMark(data));
if (isPartial) appendText(cover, 'span', t('recipeScopePill_partial'), 'anomalous-wf-kind');
card.append(cover, cardActions(owner, recipe, services, isPartial));
const info = document.createElement('div');
info.className = 'anomalous-wf-info';
const title = appendText(info, 'h3', data.name || t('recipeUntitled'), 'anomalous-wf-title');
title.title = data.name || t('recipeUntitled');
const meta = appendText(info, 'div', recipeMetaLine(data.params), 'anomalous-wf-meta');
meta.title = meta.textContent;
info.append(modelLine(data.params));
card.append(info);
// Card click for Detail Modal
card.onclick = () => runRecipeCardAction(card, async () => {
const bundle = await services.fetchRecipeBundle(recipe.filename);
const result = await showRecipeDetail(owner, {
@@ -536,14 +194,14 @@ export function createRecipeCard(owner, recipe, services) {
if (result?.mode === 'edit') await services.editRecipe(owner, bundle.data, recipe.filename, bundle.history);
}, 'recipeLoadError');
// 3. 一拖直达画布: "拖入画布后直接打开一个新的画布,就像拖入一个ComfyUI的图片一样"
// Dragged onto the canvas, the card opens its workflow like a dropped ComfyUI picture.
bindMaterialDrag(card, owner, {
payload: () => ({
type: 'recipe',
filename: recipe.filename,
scope: data.workflow_scope || 'complete',
dragHint: t('recipeDragHint') || '按住拖拽至画布打开工作流',
dragTargetHint: t('recipeDragTargetCanvas') || '释放以在画布打开工作流',
dragHint: t('recipeDragHint'),
dragTargetHint: t('recipeDragTargetCanvas'),
}),
accepts: () => false,
dropOnCanvas: async (event, dragData, graph) => {
@@ -554,5 +212,4 @@ export function createRecipeCard(owner, recipe, services) {
});
return card;
}
}
+80 -145
View File
@@ -1,8 +1,9 @@
/** Workflow Recipe catalog shell: navigation, filters, view mode, and loading. */
/** The Workflows page (Workflow Recipe catalog): its bar (search, filter, view), the list, loading. Cards: ui_recipe_cards.js. */
import { translate } from './locales.js';
import { appendText } from './ui_recipe_detail_dom.js';
import { createRecipeCard, ensureRecipeGuideStyles } from './ui_recipe_cards.js';
import { createRecipeCard } from './ui_recipe_cards.js';
import { openPopupMenu } from './ui_popup_menu.js';
import { moveWorkflowMaterials, workflowMaterialCount } from './recipe_save.js';
import { openShareDialog } from './ui_share.js';
import { fetchParameterSetSignatures, showParameterSetCount } from './ui_param_set_keys.js';
@@ -34,57 +35,52 @@ function buildMoveStrip(owner) {
return strip;
}
export function updateRecipeFilterControls(owner, recipes) {
if (!owner.recipeTagSelect) return;
const tags = [...new Set((recipes || []).flatMap(item => item?.data?.tags || []))]
/** The page's tags, for the filter menu. */
function recipeTags(owner) {
return [...new Set((owner.recipeRecords || []).flatMap(item => item?.data?.tags || []))]
.filter(Boolean)
.sort((a, b) => a.localeCompare(b));
const selected = [...(owner.recipeSelectedTags || [])][0] || '';
owner.recipeSelectedTags = new Set(selected ? [selected] : []);
owner.recipeTagSelect.replaceChildren();
appendText(owner.recipeTagSelect, 'option', t('materialAllTags')).value = '';
if (selected && !tags.includes(selected)) tags.push(selected);
for (const tag of tags) appendText(owner.recipeTagSelect, 'option', tag).value = tag;
owner.recipeTagSelect.value = selected;
}
function buildRecipeStudioTopbar(owner) {
const topbar = document.createElement('div');
topbar.className = 'anomalous-recipe-topbar';
/** The filter button says how many filters are on. */
export function updateRecipeFilterControls(owner) {
const button = owner.recipeFilterButton;
if (!button) return;
const active = (owner.recipeScopeFilter && owner.recipeScopeFilter !== 'all' ? 1 : 0) + (owner.recipeSelectedTags?.size || 0);
button.textContent = active ? t('workflowsFilterOn', { count: active }) : t('workflowsFilter');
button.classList.toggle('is-active', Boolean(active));
}
const left = document.createElement('div');
left.className = 'anomalous-recipe-topbar-left';
const pillsWrap = document.createElement('div');
pillsWrap.className = 'anomalous-recipe-pills';
const scopes = [
{ id: 'all', label: t('recipeScopePill_all') },
{ id: 'complete', label: t('recipeScopePill_complete') },
{ id: 'partial', label: t('recipeScopePill_partial') },
];
owner.recipeScopeFilter = owner.recipeScopeFilter || 'all';
for (const { id, label } of scopes) {
const scopeButton = appendText(pillsWrap, 'button', label, 'anomalous-recipe-pill');
scopeButton.type = 'button';
if (owner.recipeScopeFilter === id) scopeButton.classList.add('is-active');
scopeButton.onclick = () => {
if (owner.recipeScopeFilter === id) return;
owner.recipeScopeFilter = id;
pillsWrap.querySelectorAll('.anomalous-recipe-pill').forEach(element => element.classList.remove('is-active'));
scopeButton.classList.add('is-active');
owner.renderRecipeList(owner.recipeRecords || []);
};
/** The filter menu: what kind of workflow, then one tag. */
function openFilterMenu(owner, anchor) {
const rerender = () => owner.renderRecipeList(owner.recipeRecords || []);
const scope = owner.recipeScopeFilter || 'all';
const tag = [...(owner.recipeSelectedTags || [])][0] || '';
const items = [{ heading: t('workflowsFilterKind') }];
for (const id of ['all', 'complete', 'partial']) {
items.push({ label: t(`recipeScopePill_${id}`), checked: scope === id, run: () => { owner.recipeScopeFilter = id; rerender(); } });
}
left.appendChild(pillsWrap);
topbar.appendChild(left);
const tags = recipeTags(owner);
if (tags.length) {
items.push({ heading: t('workflowsFilterTag') });
items.push({ label: t('materialAllTags'), checked: !tag, run: () => { owner.recipeSelectedTags = new Set(); rerender(); } });
for (const name of tags) {
items.push({ label: `#${name}`, checked: tag === name, run: () => { owner.recipeSelectedTags = new Set([name]); rerender(); } });
}
}
openPopupMenu(anchor, items);
}
const center = document.createElement('div');
center.className = 'anomalous-recipe-topbar-center';
const searchWrap = document.createElement('div');
searchWrap.className = 'anomalous-recipe-search-wrap';
appendText(searchWrap, 'span', '🔍', 'anomalous-recipe-search-icon');
/** The one row over the list: search, filter, count, view, share and import, save the canvas. */
function buildWorkflowBar(owner) {
const bar = document.createElement('div');
bar.className = 'anomalous-wf-bar';
const search = document.createElement('label');
search.className = 'anomalous-wf-search';
search.innerHTML = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="11" cy="11" r="7"/><line x1="16.5" y1="16.5" x2="21" y2="21"/></svg>'; // static markup only
const searchInput = document.createElement('input');
searchInput.type = 'search';
searchInput.className = 'anomalous-recipe-search-input';
searchInput.placeholder = t('recipeSearchPlaceholder');
searchInput.value = owner.recipeSearchQuery || '';
searchInput.oninput = () => {
@@ -92,64 +88,46 @@ function buildRecipeStudioTopbar(owner) {
owner.renderRecipeList(owner.recipeRecords || []);
};
owner.recipeSearchInput = searchInput;
searchWrap.appendChild(searchInput);
center.appendChild(searchWrap);
search.append(searchInput);
const tagSelect = document.createElement('select');
tagSelect.className = 'anomalous-recipe-tag-select';
tagSelect.onchange = () => {
owner.recipeSelectedTags = new Set(tagSelect.value ? [tagSelect.value] : []);
owner.renderRecipeList(owner.recipeRecords || []);
};
owner.recipeTagSelect = tagSelect;
center.appendChild(tagSelect);
owner.recipeFilterSummary = appendText(center, 'small', '0/0', 'anomalous-recipe-filter-summary');
topbar.appendChild(center);
const filter = appendText(bar, 'button', '', 'anomalous-wf-filter');
filter.type = 'button';
filter.onclick = () => openFilterMenu(owner, filter);
owner.recipeFilterButton = filter;
bar.prepend(search);
owner.recipeFilterSummary = appendText(bar, 'span', '0/0', 'anomalous-wf-count');
const right = document.createElement('div');
right.className = 'anomalous-recipe-topbar-right';
const viewSwitch = document.createElement('div');
viewSwitch.className = 'anomalous-recipe-view-switch';
owner.recipeViewMode = owner.recipeViewMode || 'grid';
const gridButton = document.createElement('button');
gridButton.type = 'button';
gridButton.className = 'anomalous-recipe-view-btn' + (owner.recipeViewMode === 'grid' ? ' is-active' : '');
gridButton.title = t('recipeViewGrid');
gridButton.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M3 3h7v7H3V3zm11 0h7v7h-7V3zm-11 11h7v7H3v-7zm11 0h7v7h-7v-7z"/></svg>';
const listButton = document.createElement('button');
listButton.type = 'button';
listButton.className = 'anomalous-recipe-view-btn' + (owner.recipeViewMode === 'list' ? ' is-active' : '');
listButton.title = t('recipeViewList');
listButton.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M3 4h18v3H3V4zm0 7h18v3H3v-3zm0 7h18v3H3v-3z"/></svg>';
const setViewMode = mode => {
if (owner.recipeViewMode === mode) return;
owner.recipeViewMode = mode;
gridButton.classList.toggle('is-active', mode === 'grid');
listButton.classList.toggle('is-active', mode === 'list');
owner.recipeListContainer?.classList.toggle('is-grid', mode === 'grid');
owner.recipeListContainer?.classList.toggle('is-list', mode === 'list');
const view = appendText(bar, 'button', '', 'anomalous-wf-icon-btn');
view.type = 'button';
const showViewButton = () => {
const list = owner.recipeViewMode === 'list';
view.textContent = list ? '▦' : '☰';
view.title = t(list ? 'recipeViewGrid' : 'recipeViewList');
view.setAttribute('aria-label', view.title);
};
gridButton.onclick = () => setViewMode('grid');
listButton.onclick = () => setViewMode('list');
viewSwitch.append(gridButton, listButton);
right.appendChild(viewSwitch);
view.onclick = () => {
owner.recipeViewMode = owner.recipeViewMode === 'list' ? 'grid' : 'list';
owner.recipeListContainer?.classList.toggle('is-grid', owner.recipeViewMode === 'grid');
owner.recipeListContainer?.classList.toggle('is-list', owner.recipeViewMode === 'list');
showViewButton();
};
showViewButton();
// Share and import (ui_share.js): share codes, workflow files, images, backups.
const shareButton = appendText(right, 'button', '⇅', 'anomalous-recipe-topbar-btn anomalous-tooltip-target');
shareButton.type = 'button';
shareButton.setAttribute('data-tooltip', t('toolWorkflowTransferTitle'));
shareButton.setAttribute('aria-label', t('toolWorkflowTransferTitle'));
shareButton.onclick = () => openShareDialog(owner);
const share = appendText(bar, 'button', '⇅', 'anomalous-wf-icon-btn');
share.type = 'button';
share.title = t('toolWorkflowTransferTitle');
share.setAttribute('aria-label', share.title);
share.onclick = () => openShareDialog(owner);
const saveButton = appendText(right, 'button', '', 'anomalous-recipe-topbar-btn is-primary');
saveButton.dataset.recipeSaveCurrent = 'true';
saveButton.type = 'button';
saveButton.innerHTML = `<svg style="width:14px;height:14px;margin-right:6px;vertical-align:-2px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>${t('recipeSaveCurrent')}`;
saveButton.onclick = () => owner.handleSaveRecipe();
topbar.appendChild(right);
return topbar;
const save = appendText(bar, 'button', `+ ${t('recipeSaveCurrent')}`, 'anomalous-wf-save');
save.type = 'button';
save.dataset.recipeSaveCurrent = 'true';
save.onclick = () => owner.handleSaveRecipe();
updateRecipeFilterControls(owner);
return bar;
}
function normaliseSearchText(value) {
const text = String(value || '').trim().toLocaleLowerCase();
try { return text.normalize('NFKC'); } catch (error) { return text; }
@@ -200,6 +178,7 @@ function renderRecipeEmptyGuide(owner) {
export function renderRecipeList(recipes, services) {
this.recipeListContainer.replaceChildren();
updateRecipeFilterControls(this);
const records = Array.isArray(recipes) ? recipes : [];
const selectedTags = this.recipeSelectedTags || new Set();
const scopeFilter = this.recipeScopeFilter || 'all';
@@ -215,14 +194,7 @@ export function renderRecipeList(recipes, services) {
});
if (this.recipeFilterSummary) this.recipeFilterSummary.textContent = `${filtered.length}/${records.length}`;
if (this.recipeHintStrip) {
const isDismissed = (() => {
try { return localStorage.getItem('amb_hide_recipe_drag_hint') === 'true'; } catch (_) { return false; }
})();
this.recipeHintStrip.style.display = (records.length > 0 && !isDismissed) ? 'flex' : 'none';
}
if (!records.length) {
ensureRecipeGuideStyles();
if (this.recipeSearchQuery || (this.recipeSelectedTags && this.recipeSelectedTags.size > 0) || (this.recipeScopeFilter && this.recipeScopeFilter !== 'all')) {
appendText(this.recipeListContainer, 'p', t('recipeNoMatches'), 'anomalous-recipe-empty');
} else {
@@ -256,13 +228,6 @@ export async function showRecipes() {
if (!this.recipeContainer) {
this.recipeContainer = document.createElement('div');
this.recipeContainer.className = 'anomalous-nb-container anomalous-recipe-container';
const modalClose = document.createElement('button');
modalClose.type = 'button';
modalClose.className = 'anomalous-recipe-modal-close';
modalClose.innerHTML = '&times;';
modalClose.title = t('close');
modalClose.onclick = () => this.closeWorkspace();
this.recipeContainer.appendChild(modalClose);
this.nbPanel.appendChild(this.recipeContainer);
}
this.recipeContainer.style.display = 'flex';
@@ -279,21 +244,15 @@ export async function showRecipes() {
this.recipeDetailView.remove();
this.recipeDetailView = null;
if (this.recipeListContainer) this.recipeListContainer.style.display = '';
const topbar = this.recipeView?.querySelector('.anomalous-recipe-topbar');
if (topbar) topbar.style.display = '';
const bar = this.recipeView?.querySelector('.anomalous-wf-bar');
if (bar) bar.style.display = '';
}
if (this.recipeView) {
this.recipeView.style.display = 'flex';
if (!this.recipeDetailView) {
if (this.recipeListContainer) this.recipeListContainer.style.display = '';
const topbar = this.recipeView?.querySelector('.anomalous-recipe-topbar');
if (topbar) topbar.style.display = '';
const isDismissed = (() => {
try { return localStorage.getItem('amb_hide_recipe_drag_hint') === 'true'; } catch (_) { return false; }
})();
if (this.recipeHintStrip && (this.recipeRecords || []).length > 0 && !isDismissed) {
this.recipeHintStrip.style.display = 'flex';
}
const bar = this.recipeView?.querySelector('.anomalous-wf-bar');
if (bar) bar.style.display = '';
this.recipeReturnState = null;
delete this.recipeDetailPayload;
}
@@ -302,7 +261,6 @@ export async function showRecipes() {
await this.refreshRecipes();
return;
}
ensureRecipeGuideStyles();
this.recipesInitialized = true;
this.recipeSelectedTags = this.recipeSelectedTags || new Set();
this.recipeSearchQuery = this.recipeSearchQuery || '';
@@ -310,34 +268,11 @@ export async function showRecipes() {
this.recipeViewMode = this.recipeViewMode || 'grid';
this.recipeView = document.createElement('div');
this.recipeView.className = 'anomalous-recipe-body';
this.recipeView.appendChild(buildRecipeStudioTopbar(this));
this.recipeHintStrip = document.createElement('div');
this.recipeHintStrip.className = 'anomalous-recipe-actionbar anomalous-recipe-drag-hint-strip';
this.recipeHintStrip.style.display = 'none';
const hintContent = document.createElement('div');
hintContent.className = 'anomalous-recipe-drag-hint-content';
hintContent.innerHTML = `<span class="anomalous-recipe-drag-hint-icon">💡</span><span class="anomalous-recipe-drag-hint-text">${t('recipeDragGlobalHint') || '提示:按住卡片直接拖拽到 ComfyUI 画布空白处释放,即可立即载入工作流'}</span>`;
this.recipeHintStrip.appendChild(hintContent);
const hintClose = document.createElement('button');
hintClose.type = 'button';
hintClose.className = 'anomalous-recipe-drag-hint-close';
hintClose.innerHTML = '&times;';
hintClose.title = t('close') || (window.anomalous_browser_lang === 'zh' ? '关闭提示' : 'Dismiss tip');
hintClose.onclick = (e) => {
e.stopPropagation();
this.recipeHintStrip.style.display = 'none';
try { localStorage.setItem('amb_hide_recipe_drag_hint', 'true'); } catch (_) {}
};
this.recipeHintStrip.appendChild(hintClose);
this.recipeView.appendChild(this.recipeHintStrip);
this.recipeView.appendChild(buildWorkflowBar(this));
this.recipeView.appendChild(buildMoveStrip(this));
this.recipeListContainer = document.createElement('div');
this.recipeListContainer.className = `anomalous-recipe-list ${this.recipeViewMode === 'list' ? 'is-list' : 'is-grid'}`;
this.recipeListContainer.className = `anomalous-wf-list ${this.recipeViewMode === 'list' ? 'is-list' : 'is-grid'}`;
this.recipeView.appendChild(this.recipeListContainer);
this.recipeContainer.appendChild(this.recipeView);
await this.refreshRecipes();
@@ -408,7 +343,7 @@ export async function refreshRecipes() {
if (!response.ok) throw new Error('recipe list request failed');
const payload = await response.json();
this.recipeRecords = payload.recipes || [];
updateRecipeFilterControls(this, this.recipeRecords);
updateRecipeFilterControls(this);
} catch (error) {
console.error('Could not load Workflow Recipes from server:', error);
this.recipeListContainer.replaceChildren();
+1 -1
View File
@@ -95,7 +95,7 @@ export function showRecipeDetail(owner, { recipe, filename, history = [] }) {
owner.recipeDetailPayload = { recipe, filename, history };
owner.recipeDetailFilename = filename;
owner.recipeListContainer.style.display = 'none';
const topbars = owner.recipeView ? Array.from(owner.recipeView.querySelectorAll('.anomalous-recipe-topbar, .anomalous-recipe-actionbar')) : [];
const topbars = owner.recipeView ? Array.from(owner.recipeView.querySelectorAll('.anomalous-wf-bar')) : [];
topbars.forEach(bar => { bar.style.display = 'none'; });
if (owner.recipeDetailView) owner.recipeDetailView.remove();
+6 -5
View File
@@ -1,12 +1,12 @@
@import url("./styles/00-foundation-models.css?v=20261007-theme-2");
@import url("./styles/00-foundation-models.css?v=20261010-wf-1");
@import url("./styles/01-shell-tools.css?v=20261007-theme-2");
@import url("./styles/02-notebooks.css?v=20261009-click-1");
@import url("./styles/03-gallery-recipes.css?v=20261009-click-1");
@import url("./styles/04-dialogs-materials.css?v=20261007-theme-2");
@import url("./styles/03-gallery-recipes.css?v=20261010-wf-1");
@import url("./styles/04-dialogs-materials.css?v=20261010-wf-1");
@import url("./styles/05-recipe-studio-overrides.css?v=20261007-theme-2");
@import url("./styles/06-material-workbench.css?v=20261007-theme-2");
@import url("./styles/07-prompt-studio.css?v=20261007-theme-2");
@import url("./styles/08-theme-gallery-overrides.css?v=20261007-theme-2");
@import url("./styles/08-theme-gallery-overrides.css?v=20261010-wf-1");
@import url("./styles/09-prompt-drawers.css?v=20261007-theme-2");
@import url("./styles/10-model-sources.css?v=20261007-theme-2");
@import url("./styles/11-audio-studio.css?v=20261010-search-1");
@@ -25,4 +25,5 @@
@import url("./styles/24-backup.css?v=20261007-theme-2");
@import url("./styles/25-model-import.css?v=20261008-import");
@import url("./styles/26-model-tidy.css?v=20261008-tidy");
@import url("./styles/27-param-compare.css?v=20261010-compare-7");
@import url("./styles/27-param-compare.css?v=20261010-compare-8");
@import url("./styles/28-workflows.css?v=20261010-wf-2");
+1 -2
View File
@@ -138,8 +138,7 @@ html.amb-theme-light {
html.amb-theme-light :is(.anomalous-workbench-overlay, .anomalous-gallery-viewer,
.anomalous-card-labels, .anomalous-card-action-btn, .anomalous-card-badge.is-unscanned,
.anomalous-gallery-details, .anomalous-gallery-keep, .anomalous-gallery-compare,
.anomalous-recipe-readiness-pill, .anomalous-recipe-cover-pill, .anomalous-recipe-scope-pill,
.anomalous-recipe-drag-badge, .anomalous-recipe-model-preview small, .anomalous-preset-compact-gallery-badge) {
.anomalous-recipe-scope-pill, .anomalous-recipe-model-preview small, .anomalous-preset-compact-gallery-badge) {
--amb-bg-page: #131315;
--amb-bg-panel: #1a1a1d;
--amb-bg-card: #202024;
-220
View File
@@ -380,210 +380,6 @@ dialog.anomalous-gallery-viewer[open] {
overflow: hidden;
}
/* 一体化 Studio 沉浸顶栏 (无厚重底色,细微下划线,支持弹性折行) */
.anomalous-recipe-topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 48px;
height: auto;
padding: 8px 52px 8px 16px;
background: var(--amb-lt-panel, rgba(18, 22, 32, 0.65));
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-bottom: 1px solid var(--amb-border);
flex-shrink: 0;
flex-wrap: wrap;
z-index: 10;
}
.anomalous-recipe-topbar-left {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
flex-shrink: 0;
}
.anomalous-recipe-pills {
display: inline-flex;
align-items: center;
gap: 3px;
background: rgba(var(--amb-ink-rgb), 0.02);
padding: 3px 4px;
border-radius: 20px;
border: 1px solid var(--amb-border);
}
.anomalous-recipe-pill {
padding: 3px 11px;
border-radius: 14px;
font-size: 12px;
color: var(--amb-text-muted);
background: transparent;
border: none;
cursor: pointer;
transition: all 0.15s ease;
font-weight: 500;
}
.anomalous-recipe-pill:hover {
color: var(--amb-text-main);
background: rgba(var(--amb-ink-rgb), 0.05);
}
.anomalous-recipe-pill.is-active {
color: var(--amb-text-main);
text-shadow: none;
font-weight: 600;
box-shadow: none;
border: 1px solid color-mix(in srgb, var(--amb-link) 35%, transparent);
background: var(--amb-bg-card-hover);
border-color: var(--amb-border-hover);
}
.anomalous-recipe-topbar-center {
display: flex;
align-items: center;
gap: 8px;
flex: 1 1 260px;
max-width: 460px;
flex-wrap: wrap;
}
.anomalous-recipe-search-wrap {
position: relative;
display: flex;
align-items: center;
flex: 1;
}
.anomalous-recipe-search-icon {
position: absolute;
left: 10px;
pointer-events: none;
color: var(--amb-text-dim);
display: flex;
align-items: center;
font-size: 13px;
}
.anomalous-recipe-search-input {
width: 100%;
height: 30px;
padding: 0 12px 0 30px;
background: var(--amb-lt-sunken, rgba(0, 0, 0, 0.28));
border: 1px solid var(--amb-border);
border-radius: 16px;
color: var(--amb-text-main);
font-size: 12px;
outline: none;
transition: border-color 0.15s ease, background 0.15s ease;
}
.anomalous-recipe-search-input:focus {
border-color: color-mix(in srgb, var(--amb-link) 45%, transparent);
background: var(--amb-lt-sunken, rgba(0, 0, 0, 0.4));
}
.anomalous-recipe-tag-select {
height: 30px;
padding: 0 10px;
background: var(--amb-lt-sunken, rgba(0, 0, 0, 0.28));
border: 1px solid var(--amb-border);
border-radius: 16px;
color: var(--amb-text-muted);
font-size: 12px;
outline: none;
cursor: pointer;
max-width: 130px;
}
.anomalous-recipe-tag-select:hover {
border-color: var(--amb-border-strong);
}
.anomalous-recipe-topbar-right {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
flex-shrink: 0;
}
.anomalous-recipe-view-switch {
display: inline-flex;
align-items: center;
background: var(--amb-lt-sunken, rgba(0, 0, 0, 0.32));
border: 1px solid var(--amb-border);
border-radius: 8px;
padding: 2px;
}
.anomalous-recipe-view-btn {
width: 26px;
height: 26px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
color: var(--amb-text-dim);
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
}
.anomalous-recipe-view-btn:hover {
color: var(--amb-text-soft);
}
.anomalous-recipe-view-btn.is-active {
background: rgba(var(--amb-ink-rgb), 0.09);
color: var(--amb-link);
}
.anomalous-recipe-topbar-btn {
height: 30px;
padding: 0 12px;
display: inline-flex;
align-items: center;
gap: 6px;
border-radius: 8px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
border: 1px solid var(--amb-border);
background: rgba(var(--amb-ink-rgb), 0.03);
color: var(--amb-text-soft);
}
.anomalous-recipe-topbar-btn:hover {
background: rgba(var(--amb-ink-rgb), 0.08);
color: var(--amb-text-main);
}
.anomalous-recipe-topbar-btn.is-primary {
background: var(--amb-btn-primary-bg);
border-color: var(--amb-border-hover);
color: var(--amb-btn-primary-text);
}
.anomalous-recipe-topbar-btn.is-primary:hover {
background: var(--amb-btn-primary-hover);
border-color: var(--amb-border-hover);
color: var(--amb-btn-primary-text);
}
/* 列表容器 */
.anomalous-recipe-list {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.anomalous-recipe-node-details {
margin-top: 2px;
border-top: 1px solid var(--amb-border-strong);
@@ -1712,22 +1508,6 @@ dialog.anomalous-gallery-viewer[open] {
}
/* Recipe library discovery and semantic version diff. */
.anomalous-recipe-actionbar {
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.anomalous-recipe-search {
min-width: 220px;
flex: 1 1 240px;
}
.anomalous-recipe-filter-summary {
color: var(--amb-lt-muted, #9caec4);
white-space: nowrap;
}
.anomalous-recipe-badge-tag {
border: 0;
cursor: pointer;
+2 -453
View File
@@ -118,125 +118,8 @@ dialog.anomalous-gallery-viewer::backdrop {
background: rgba(0, 0, 0, 0.95);
}
/* ========================================================================== */
/* 🎨 Workflow Recipes Modern UI/UX Redesign Overrides */
/* ========================================================================== */
.anomalous-recipe-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 360px));
gap: 18px;
overflow-y: auto;
padding: 18px;
}
.anomalous-recipe-list[style*="display: none"],
.anomalous-recipe-actionbar[style*="display: none"] {
display: none !important;
}
/* ==========================================================================
Recipe Cards: Uniform 264px Bento Grid & 44px Compact List System
========================================================================== */
/* 1. 网格模式下的卡片几何规范 (Bento 架构 + 发光电路回路隐喻) */
.anomalous-recipe-list.is-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
grid-auto-rows: 264px;
align-content: start;
gap: 14px;
padding: 16px;
background-image:
radial-gradient(circle at 50% 50%, rgba(99, 102, 241, 0.06) 1px, transparent 1px),
linear-gradient(rgba(var(--amb-ink-rgb), 0.015) 1px, transparent 1px),
linear-gradient(90deg, rgba(var(--amb-ink-rgb), 0.015) 1px, transparent 1px);
background-size: 24px 24px, 48px 48px, 48px 48px;
}
.anomalous-recipe-list.is-list {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px 16px;
}
/* 卡片基础骨架 */
.anomalous-recipe-card {
position: relative;
display: flex;
min-width: 0;
overflow: hidden;
border: 1px solid var(--amb-border, var(--amb-border));
background: var(--amb-bg-card, var(--amb-bg-card-hover));
border-radius: var(--amb-radius-card, 10px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
cursor: pointer;
transition: border-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease;
box-sizing: border-box;
}
.anomalous-recipe-card:hover {
border-color: var(--amb-border-hover, var(--amb-border-strong));
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.3);
}
.anomalous-recipe-card:focus-visible {
outline: 2px solid var(--amb-accent-gold, var(--amb-link));
outline-offset: 2px;
}
/* 网格模式卡片 */
.anomalous-recipe-list.is-grid .anomalous-recipe-card {
height: 264px;
max-height: 264px;
flex-direction: column;
border-radius: var(--amb-radius-card, 10px);
padding: 0;
}
/* Bento Wide 横卡支持 (Featured / Pinned Recipes) */
.anomalous-recipe-list.is-grid .anomalous-recipe-card.is-featured,
.anomalous-recipe-list.is-grid .anomalous-recipe-card.is-wide {
grid-column: span 2;
flex-direction: row;
}
.anomalous-recipe-list.is-grid .anomalous-recipe-card.is-featured .anomalous-recipe-card-media-wrap,
.anomalous-recipe-list.is-grid .anomalous-recipe-card.is-wide .anomalous-recipe-card-media-wrap {
width: 44%;
height: 100%;
}
.anomalous-recipe-list.is-grid .anomalous-recipe-card:hover {
transform: translateY(-3px);
}
/* 顶部封面展示区 (标准黄金高度 136px) */
.anomalous-recipe-list.is-grid .anomalous-recipe-card-media-wrap {
position: relative;
width: 100%;
height: 136px;
flex-shrink: 0;
background: var(--amb-bg-page);
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.anomalous-recipe-list.is-grid .anomalous-recipe-card-media-wrap img,
.anomalous-recipe-list.is-grid .anomalous-recipe-card-media-wrap video {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.25s ease;
}
.anomalous-recipe-list.is-grid .anomalous-recipe-card:hover .anomalous-recipe-card-media-wrap img,
.anomalous-recipe-list.is-grid .anomalous-recipe-card:hover .anomalous-recipe-card-media-wrap video {
transform: scale(1.03);
}
/* Workflow Recipes: the cover placeholder and the scope pill the recipe details use
(the Workflows page and its cards: 28-workflows.css). */
/* 默认封面占位图微光与材质渲染 (AI Abstract Art Blind Box) */
.anomalous-recipe-thumbnail.is-default-placeholder {
@@ -245,71 +128,6 @@ dialog.anomalous-gallery-viewer::backdrop {
transition: filter 0.25s ease, transform 0.25s ease, opacity 0.25s ease;
}
.anomalous-recipe-card:hover .anomalous-recipe-thumbnail.is-default-placeholder {
filter: brightness(1.08) contrast(1.15);
opacity: 1;
}
.anomalous-recipe-card-media-wrap:not(:has(img, video)) {
background-image: url('assets/default_cover_1.webp');
background-size: cover;
background-position: center;
}
/* 封面内嵌浮动底模与就绪微胶囊组 (Frosted Glass Chips at Bottom-Left) */
.anomalous-recipe-cover-bottom-chips {
position: absolute;
bottom: 8px;
left: 8px;
display: flex;
align-items: center;
gap: 6px;
max-width: calc(100% - 16px);
pointer-events: none;
z-index: 2;
}
.anomalous-recipe-readiness-pill {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 2px 7px;
border-radius: 12px;
background: rgba(12, 14, 20, 0.78);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 1px solid var(--amb-border-strong);
font-size: 0.65rem;
font-weight: 500;
color: var(--amb-text-main);
white-space: nowrap;
box-shadow: none;
}
.anomalous-recipe-readiness-text {
font-size: 0.64rem;
line-height: 1;
color: var(--amb-text-soft);
}
.anomalous-recipe-cover-pill {
position: relative;
padding: 2px 7px;
border-radius: 6px;
background: rgba(12, 14, 18, 0.82);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 1px solid var(--amb-border-strong);
color: var(--amb-text-main);
font-size: 0.66rem;
font-weight: 500;
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
box-shadow: none;
}
/* 封面右上角作用域徽章 */
.anomalous-recipe-scope-pill {
position: absolute;
@@ -336,98 +154,6 @@ dialog.anomalous-gallery-viewer::backdrop {
color: var(--amb-link);
}
/* 网格卡片信息区 */
.anomalous-recipe-list.is-grid .anomalous-recipe-card-body {
flex: 1;
min-height: 0;
padding: 9px 12px;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 4px;
}
.anomalous-recipe-list.is-grid .anomalous-recipe-card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
width: 100%;
}
.anomalous-recipe-list.is-grid .anomalous-recipe-card-title {
flex: 1;
margin: 0;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
overflow-wrap: anywhere;
font-size: 13px;
line-height: 17px;
color: var(--amb-text-main, var(--amb-text-main));
font-weight: 500;
}
/* 环境就绪指示灯微点 */
.anomalous-recipe-readiness-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
box-shadow: 0 0 6px currentColor;
}
.anomalous-recipe-readiness-dot.is-ready {
background-color: #10b981;
color: rgba(16, 185, 129, 0.7);
}
.anomalous-recipe-readiness-dot.is-warning {
background-color: #f59e0b;
color: rgba(245, 158, 11, 0.7);
}
.anomalous-recipe-readiness-dot.is-missing {
background-color: #ef4444;
color: rgba(239, 68, 68, 0.7);
}
/* 规格微胶囊行 */
.anomalous-recipe-specs-strip {
display: flex;
align-items: center;
gap: 4px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-size: 10px;
}
.anomalous-recipe-spec-tag {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 1px 5px;
border-radius: 4px;
background: rgba(var(--amb-ink-rgb), 0.04);
border: 1px solid var(--amb-border);
color: var(--amb-text-muted);
white-space: nowrap;
}
.anomalous-recipe-spec-tag.is-lora {
color: var(--amb-link);
background: color-mix(in srgb, var(--amb-link) 8%, transparent);
border-color: color-mix(in srgb, var(--amb-link) 20%, transparent);
}
.anomalous-recipe-spec-tag.is-step {
color: var(--amb-ok-text);
background: rgba(16, 185, 129, 0.08);
border-color: rgba(16, 185, 129, 0.2);
}
/* 配方单行文本标签 (收拢至单行 Flex,溢出截断) */
.anomalous-recipe-tags {
display: flex;
@@ -461,17 +187,6 @@ dialog.anomalous-gallery-viewer::backdrop {
color: var(--amb-text-main);
}
/* 网格卡片底部主动作与悬浮微操作 */
.anomalous-recipe-list.is-grid .anomalous-recipe-card-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
margin-top: 2px;
padding-top: 6px;
border-top: 1px solid var(--amb-border);
}
.anomalous-recipe-btn-primary-action {
position: relative;
flex: 1;
@@ -508,120 +223,6 @@ dialog.anomalous-gallery-viewer::backdrop {
filter: brightness(0.95);
}
.anomalous-recipe-card-mini-actions {
display: flex;
align-items: center;
gap: 3px;
opacity: 0;
transition: opacity 0.15s ease;
}
.anomalous-recipe-card:hover .anomalous-recipe-card-mini-actions {
opacity: 1;
}
.anomalous-recipe-card-mini-btn {
width: 24px;
height: 24px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 5px;
background: rgba(var(--amb-ink-rgb), 0.05);
border: 1px solid var(--amb-border);
color: var(--amb-text-muted);
cursor: pointer;
font-size: 11px;
transition: all 0.15s ease;
}
.anomalous-recipe-card-mini-btn:hover {
background: rgba(var(--amb-ink-rgb), 0.12);
color: var(--amb-text-main);
transform: translateY(-1px);
}
.anomalous-recipe-card-mini-btn.is-delete:hover {
background: rgba(239, 68, 68, 0.3);
border-color: rgba(239, 68, 68, 0.5);
color: var(--amb-danger-text);
}
/* 2. 紧凑行列表模式 (44px 极速视图) */
.anomalous-recipe-list.is-list .anomalous-recipe-card {
height: 44px;
min-height: 44px;
flex-direction: row;
align-items: center;
padding: 0 12px;
gap: 12px;
border-radius: 8px;
}
.anomalous-recipe-list.is-list .anomalous-recipe-card:hover {
transform: translateX(2px);
}
.anomalous-recipe-list.is-list .anomalous-recipe-card-media-wrap {
width: 32px;
height: 32px;
border-radius: 6px;
flex-shrink: 0;
overflow: hidden;
background: var(--amb-bg-page);
}
.anomalous-recipe-list.is-list .anomalous-recipe-cover-pill,
.anomalous-recipe-list.is-list .anomalous-recipe-scope-pill,
.anomalous-recipe-list.is-list .anomalous-recipe-specs-strip {
display: none;
}
.anomalous-recipe-list.is-list .anomalous-recipe-card-body {
flex: 1;
min-width: 0;
padding: 0;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.anomalous-recipe-list.is-list .anomalous-recipe-card-header {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
}
.anomalous-recipe-list.is-list .anomalous-recipe-card-title {
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 13px;
color: var(--amb-text-main);
}
.anomalous-recipe-list.is-list .anomalous-recipe-card-footer {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
margin: 0;
padding: 0;
border: none;
}
.anomalous-recipe-list.is-list .anomalous-recipe-btn-primary-action {
flex: 0 0 auto;
height: 26px;
padding: 0 10px;
}
/* Detail View: Readiness Banner */
.anomalous-recipe-readiness-banner {
display: flex;
@@ -697,34 +298,6 @@ dialog.anomalous-gallery-viewer::backdrop {
border-color: var(--amb-border-strong);
}
.anomalous-recipe-readiness-pill.is-ready {
background: rgba(16, 185, 129, 0.16);
border-color: rgba(16, 185, 129, 0.45);
color: var(--amb-ok-soft);
}
.anomalous-recipe-readiness-pill.is-warning {
background: rgba(245, 158, 11, 0.16);
border-color: rgba(245, 158, 11, 0.45);
color: var(--amb-warn-text);
}
.anomalous-recipe-readiness-pill.is-missing {
background: rgba(239, 68, 68, 0.16);
border-color: rgba(239, 68, 68, 0.45);
color: var(--amb-danger-text);
}
.anomalous-recipe-card-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: auto;
padding-top: 8px;
border-top: 1px solid var(--amb-border);
}
.anomalous-recipe-btn-primary-action {
flex: 1;
padding: 8px 14px;
@@ -745,30 +318,6 @@ dialog.anomalous-gallery-viewer::backdrop {
color: var(--amb-text-main);
}
.anomalous-recipe-card-mini-actions {
display: flex;
align-items: center;
gap: 4px;
}
.anomalous-recipe-card-mini-btn {
padding: 6px 8px;
border-radius: 6px;
background: rgba(var(--amb-ink-rgb), 0.04);
border: 1px solid var(--amb-border);
color: var(--amb-text-muted);
cursor: pointer;
font-size: 0.85rem;
line-height: 1;
transition: all 0.15s ease;
}
.anomalous-recipe-card-mini-btn:hover {
background: rgba(var(--amb-ink-rgb), 0.1);
color: var(--amb-text-main);
border-color: var(--amb-border-strong);
}
/* Metric Cards for Sampling Parameters */
/* Lazy Loaded Raw Nodes Container */
+1 -46
View File
@@ -204,8 +204,7 @@ html.theme-abyssal-scarlet,
background: linear-gradient(180deg, rgba(25, 12, 20, 0.9) 0%, rgba(14, 6, 14, 0.98) 100%) !important;
}
.theme-abyssal-scarlet .anomalous-card:hover,
.theme-abyssal-scarlet .anomalous-recipe-card:hover {
.theme-abyssal-scarlet .anomalous-card:hover {
border-color: #dc143c !important;
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.95), inset 0 0 12px rgba(0, 0, 0, 0.7) !important;
}
@@ -274,24 +273,6 @@ html.theme-abyssal-scarlet,
box-shadow: none !important;
}
.theme-abyssal-scarlet .anomalous-recipe-card {
border-color: rgba(220, 20, 60, 0.22) !important;
background: rgba(18, 10, 26, 0.88) !important;
}
/* 6. Active Pills in Deep Velvet Wine (Inner Shadow & Sharp Border) */
.theme-abyssal-scarlet .anomalous-recipe-pill.is-active {
background-color: rgba(60, 10, 20, 0.85) !important;
border-color: #dc143c !important;
color: #e0d8db !important;
box-shadow: none !important;
}
.theme-abyssal-scarlet .anomalous-recipe-readiness-pill {
border-color: rgba(220, 20, 60, 0.35) !important;
color: #e0d8db !important;
}
/* 7. Error State Abyssal Corruption Animations */
@keyframes abyssal-corruption {
0% {
@@ -857,29 +838,3 @@ button:hover .anomalous-btn-icon {
position: relative;
}
.anomalous-recipe-modal-close {
position: absolute;
top: 8px;
right: 14px;
z-index: 100;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
color: var(--amb-lt-danger, #ff4444);
font-size: 1.8rem;
line-height: 1;
cursor: pointer;
border-radius: 6px;
transition: all 0.18s ease;
user-select: none;
}
.anomalous-recipe-modal-close:hover {
background: rgba(239, 68, 68, 0.16);
color: #ff6666;
transform: scale(1.08);
}
+448
View File
@@ -0,0 +1,448 @@
/* The Workflows page (ui_recipe_catalog.js, ui_recipe_cards.js): one bar, then cards as a grid
or a list; the small menu of ui_popup_menu.js; the empty page's guide. */
.anomalous-wf-bar {
display: flex;
flex-shrink: 0;
align-items: center;
gap: 8px;
padding: 12px 16px;
border-bottom: 1px solid var(--amb-border);
}
.anomalous-wf-search {
display: flex;
flex: 1 1 auto;
align-items: center;
gap: 8px;
min-width: 0;
height: 32px;
padding: 0 10px;
border: 1px solid var(--amb-border-strong);
border-radius: var(--amb-radius-control);
background: var(--amb-bg-input);
color: var(--amb-text-dim);
}
.anomalous-wf-search:focus-within { border-color: var(--amb-link); }
.anomalous-wf-search input {
flex: 1;
min-width: 0;
border: none;
outline: none;
background: transparent;
color: var(--amb-text-main);
font-size: 13px;
}
.anomalous-wf-filter,
.anomalous-wf-icon-btn,
.anomalous-wf-save {
flex: 0 0 auto;
height: 32px;
padding: 0 12px;
border: 1px solid var(--amb-border-strong);
border-radius: var(--amb-radius-control);
background: transparent;
color: var(--amb-text-soft);
font-size: 13px;
white-space: nowrap;
cursor: pointer;
}
.anomalous-wf-icon-btn {
width: 32px;
padding: 0;
font-size: 15px;
}
.anomalous-wf-filter:hover,
.anomalous-wf-icon-btn:hover {
border-color: var(--amb-border-hover);
background: var(--amb-bg-card-hover);
color: var(--amb-text-main);
}
.anomalous-wf-filter.is-active {
border-color: var(--amb-link);
color: var(--amb-link);
}
.anomalous-wf-count {
flex: 0 0 auto;
color: var(--amb-text-dim);
font-size: 12px;
}
.anomalous-wf-save {
border-color: transparent;
background: var(--amb-btn-primary-bg);
color: var(--amb-btn-primary-text);
font-weight: 600;
}
.anomalous-wf-save:hover { background: var(--amb-btn-primary-hover); }
.anomalous-wf-list {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 16px;
}
.anomalous-wf-list.is-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
align-content: start;
gap: 14px;
}
.anomalous-wf-list.is-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.anomalous-wf-card {
--wf-cover-height: 150px;
position: relative;
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
border: 1px solid var(--amb-border);
border-radius: var(--amb-radius-panel);
background: var(--amb-bg-card);
cursor: grab;
transition: border-color 0.15s ease, background-color 0.15s ease;
}
.anomalous-wf-card:active { cursor: grabbing; }
.anomalous-wf-card button { cursor: pointer; }
.anomalous-wf-card:hover,
.anomalous-wf-card:focus-within {
border-color: var(--amb-border-hover);
background: var(--amb-bg-card-hover);
}
.anomalous-wf-cover {
position: relative;
flex: 0 0 var(--wf-cover-height);
height: var(--wf-cover-height);
overflow: hidden;
background: var(--amb-bg-sunken);
}
.anomalous-wf-cover img,
.anomalous-wf-cover video {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.anomalous-wf-status {
position: absolute;
top: 8px;
right: 8px;
width: 10px;
height: 10px;
border-radius: 50%;
box-shadow: 0 0 0 2px rgba(10, 12, 18, 0.7);
background: var(--amb-ok);
}
.anomalous-wf-status.is-warning { background: var(--amb-text-muted); }
.anomalous-wf-status.is-missing {
width: auto;
height: auto;
padding: 2px 8px;
border-radius: 999px;
box-shadow: none;
background: rgba(127, 29, 29, 0.9);
color: #fecaca;
font-size: 11px;
font-weight: 600;
}
.anomalous-wf-kind {
position: absolute;
top: 8px;
left: 8px;
padding: 2px 8px;
border-radius: 999px;
background: rgba(10, 12, 18, 0.78);
color: #fde68a;
font-size: 11px;
}
.anomalous-wf-actions {
position: absolute;
top: calc(var(--wf-cover-height) - 42px);
right: 8px;
left: 8px;
display: flex;
gap: 6px;
opacity: 0;
transform: translateY(4px);
pointer-events: none;
transition: opacity 0.15s ease, transform 0.15s ease;
}
.anomalous-wf-card:hover .anomalous-wf-actions,
.anomalous-wf-card:focus-within .anomalous-wf-actions {
opacity: 1;
transform: none;
pointer-events: auto;
}
.anomalous-wf-open,
.anomalous-wf-more {
height: 32px;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: var(--amb-radius-control);
background: rgba(15, 20, 30, 0.88);
color: #f3f4f6;
font-size: 12.5px;
font-weight: 600;
}
.anomalous-wf-open { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.anomalous-wf-more { flex: 0 0 34px; font-size: 16px; }
.anomalous-wf-open:hover,
.anomalous-wf-more:hover { background: rgba(30, 38, 56, 0.96); }
.anomalous-wf-info {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
padding: 10px 12px 10px;
}
.anomalous-wf-title {
margin: 0;
overflow: hidden;
color: var(--amb-text-main);
font-size: 14px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.anomalous-wf-meta {
overflow: hidden;
color: var(--amb-text-muted);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.anomalous-wf-meta:empty { display: none; }
.anomalous-wf-extras {
display: flex;
align-items: center;
gap: 6px;
min-height: 20px;
overflow: hidden;
}
.anomalous-wf-model {
flex: 1;
min-width: 0;
overflow: hidden;
color: var(--amb-text-dim);
font-size: 11.5px;
text-overflow: ellipsis;
white-space: nowrap;
}
.anomalous-wf-extras .anomalous-recipe-set-chip { margin-left: auto; }
.anomalous-wf-list.is-list .anomalous-wf-card {
--wf-cover-height: 64px;
flex-direction: row;
align-items: center;
height: 64px;
}
.anomalous-wf-list.is-list .anomalous-wf-cover { flex: 0 0 96px; width: 96px; }
.anomalous-wf-list.is-list .anomalous-wf-kind { display: none; }
.anomalous-wf-list.is-list .anomalous-wf-info {
flex: 1;
padding: 6px 220px 6px 14px;
}
.anomalous-wf-list.is-list .anomalous-wf-extras { display: none; }
.anomalous-wf-list.is-list .anomalous-wf-actions {
top: 16px;
right: 12px;
left: auto;
width: 200px;
}
/* Abyssal Scarlet: wine cards, crimson on hover. */
.theme-abyssal-scarlet .anomalous-wf-card {
border-color: rgba(220, 20, 60, 0.22);
background: rgba(18, 10, 26, 0.88);
}
.theme-abyssal-scarlet .anomalous-wf-card:hover,
.theme-abyssal-scarlet .anomalous-wf-card:focus-within { border-color: #dc143c; }
.theme-abyssal-scarlet .anomalous-wf-filter.is-active {
border-color: #dc143c;
color: #e0d8db;
}
/* The small menu (ui_popup_menu.js) over the page and its dialogs. */
.anomalous-popup-menu {
position: fixed;
z-index: 1000003;
display: flex;
flex-direction: column;
min-width: 168px;
max-height: 60vh;
padding: 4px;
overflow-y: auto;
border: 1px solid var(--amb-border-strong);
border-radius: var(--amb-radius-card);
background: var(--amb-bg-panel);
box-shadow: 0 12px 32px rgba(var(--amb-shadow-rgb), 0.45);
}
.anomalous-popup-menu-heading {
padding: 6px 10px 2px;
color: var(--amb-text-dim);
font-size: 11px;
}
.anomalous-popup-menu-item {
display: flex;
align-items: center;
gap: 6px;
padding: 7px 10px;
border: none;
border-radius: var(--amb-radius-control);
background: transparent;
color: var(--amb-text-main);
font-size: 13px;
text-align: left;
cursor: pointer;
}
.anomalous-popup-menu-item:hover { background: var(--amb-bg-card-hover); }
.anomalous-popup-menu-item.is-danger { color: var(--amb-danger-text); }
.anomalous-popup-menu-tick {
width: 12px;
color: var(--amb-link);
}
/* The empty page: what Workflows are for, three steps and Save. */
.anomalous-recipe-empty-guide {
grid-column: 1 / -1;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
max-width: 580px;
margin: 32px auto;
padding: 28px 24px;
box-sizing: border-box;
border: 1px solid var(--amb-border);
border-radius: 16px;
background: rgba(var(--amb-ink-rgb), 0.025);
text-align: center;
}
.anomalous-recipe-empty-icon {
display: flex;
align-items: center;
justify-content: center;
width: 52px;
height: 52px;
border: 1px solid color-mix(in srgb, var(--amb-link) 25%, transparent);
border-radius: 50%;
background: color-mix(in srgb, var(--amb-link) 10%, transparent);
font-size: 24px;
}
.anomalous-recipe-empty-title {
margin: 0;
color: var(--amb-text-main);
font-size: 16px;
font-weight: 600;
}
.anomalous-recipe-empty-subtitle {
max-width: 440px;
margin: 0;
color: var(--amb-text-muted);
font-size: 12.5px;
line-height: 1.55;
}
.anomalous-recipe-empty-steps {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 12px;
width: 100%;
margin-top: 8px;
text-align: left;
}
.anomalous-recipe-empty-step {
display: flex;
flex-direction: column;
gap: 6px;
padding: 14px;
border: 1px solid var(--amb-border);
border-radius: 10px;
background: rgba(var(--amb-ink-rgb), 0.03);
}
.anomalous-recipe-step-icon { font-size: 20px; line-height: 1; }
.anomalous-recipe-empty-step strong {
color: var(--amb-text-main);
font-size: 13px;
}
.anomalous-recipe-empty-step p {
margin: 0;
color: var(--amb-text-muted);
font-size: 11.5px;
line-height: 1.5;
}
.anomalous-recipe-empty-action-btn {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 6px;
padding: 8px 18px;
border: none;
border-radius: 8px;
background: var(--amb-btn-primary-bg);
color: var(--amb-btn-primary-text);
font-size: 13px;
font-weight: 500;
cursor: pointer;
}
.anomalous-recipe-empty-action-btn:hover { background: var(--amb-btn-primary-hover); }
@media (max-width: 560px) {
.anomalous-wf-bar { flex-wrap: wrap; }
.anomalous-wf-search { flex-basis: 100%; }
.anomalous-wf-list.is-list .anomalous-wf-info { padding-right: 14px; }
.anomalous-wf-list.is-list .anomalous-wf-actions { display: none; }
}