feat(tagging): skipped tags hide matching suggestions (#431)
A Skipped tags list in System > Auto-tagging (D202) hides every suggested tag whose words contain an entry's words, on every suggestion path (Tag image, Auto-tag selected, the System batch, the legacy single and batch routes), before the limit. Jobs snapshot the list at their start. A read-only preview route counts what a list hides among the library's tags and the downloaded WD14 label lists. The draft store compares list and object drafts by value (sameDraft).
This commit is contained in:
@@ -257,11 +257,18 @@ function initialState(source, preset) {
|
||||
image: null, savedTags: [], tagRevision: 0, currentTags: null, manual: '',
|
||||
model: preset?.model ?? MODEL_OPTIONS[0].id, options: {...DEFAULTS, ...preset?.options}, generated: false, suggestions: [],
|
||||
suggestionsText: COPY.modelChanged, statusText: '', generateSupported: false, generateReason: null, toast: null,
|
||||
skippedCount: 0,
|
||||
// Bumped whenever the controller itself rewrites the option fields or the manual draft, never while typing.
|
||||
optionsRevision: 0, draftRevision: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** #431: how many suggestions the saved Skipped tags hid, after the chip hint ('' when none). */
|
||||
export function skippedNote(count) {
|
||||
if (!Number.isSafeInteger(count) || count <= 0) return '';
|
||||
return count === 1 ? ' 1 skipped tag was not suggested.' : ` ${count.toLocaleString('en-US')} skipped tags were not suggested.`;
|
||||
}
|
||||
|
||||
/** Derived labels and permissions; never stored independently of the fields they follow. */
|
||||
function derive(state) {
|
||||
const editable = EDITABLE.has(state.phase);
|
||||
@@ -269,7 +276,7 @@ function derive(state) {
|
||||
...state,
|
||||
primaryLabel: state.generated ? 'Approve selected tags' : 'Save manual tags',
|
||||
countText: state.generated ? `${state.suggestions.length} ${noun(state.suggestions.length, 'suggestion')}` : 'Not generated',
|
||||
chipHint: state.suggestions.length ? COPY.chipHint : '',
|
||||
chipHint: state.suggestions.length ? COPY.chipHint + skippedNote(state.skippedCount) : '',
|
||||
canSave: editable,
|
||||
canGenerate: editable && state.generateSupported,
|
||||
canRetry: RETRYABLE.has(state.phase),
|
||||
@@ -445,13 +452,14 @@ export function createImageTaggingController({api, target, source = 'images', on
|
||||
const options = requestOptions(model, state.options);
|
||||
const stale = () => closed || serial !== generateSerial;
|
||||
set({phase: 'generating', statusText: COPY.generating});
|
||||
let rows;
|
||||
let rows, skippedCount;
|
||||
try {
|
||||
const value = await api.post(SUGGEST_URL, {
|
||||
target: {...imageTarget}, capture_token: capture.token, expected_epoch: epoch, model_type: model, options,
|
||||
}, {signal: controller.signal});
|
||||
if (stale()) return false;
|
||||
rows = validateSuggestions(value, options.suggestion_limit);
|
||||
skippedCount = Number.isSafeInteger(value.skipped) && value.skipped > 0 ? value.skipped : 0;
|
||||
} catch (error) {
|
||||
if (stale()) return false;
|
||||
const phase = isApiError(error) && REOPEN[error.code];
|
||||
@@ -460,7 +468,7 @@ export function createImageTaggingController({api, target, source = 'images', on
|
||||
}
|
||||
generateAbort = null;
|
||||
set({
|
||||
phase: 'ready', generated: true, suggestions: rows, suggestionsText: rows.length ? '' : COPY.empty,
|
||||
phase: 'ready', generated: true, suggestions: rows, suggestionsText: rows.length ? '' : COPY.empty, skippedCount,
|
||||
statusText: `Suggestions from ${modelOf(model).label}. Approve tags to save.`,
|
||||
});
|
||||
return true;
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* System › Auto-tagging "Skipped tags" (#431, D202): words whose suggested tags are never offered, on every suggestion
|
||||
* path (the server filters; hand-typed and stored tags are never touched).
|
||||
*
|
||||
* A `.setting-block` after "Suggestions per image": the label and description, an "Add a word or words" input
|
||||
* (`#tag-skipped-input`) with Add (Enter adds too), the entries as removable chips in the Tag image dialog's D83 chip
|
||||
* markup, and a preview line (`role="status"`) that says how many known tags the list hides
|
||||
* (`POST /prompt_manager/autotag/skipped-tags/preview`: the library's tags plus the downloaded WD14 label lists). The
|
||||
* block carries `data-setting="tag-skipped"` and `data-key="autotag_skipped_tags"`, so a refused save highlights it.
|
||||
* The list is the host draft `autotag_skipped_tags` (an array), saved by the one Save row. The client checks repeat the
|
||||
* server's rules (the server is the authority): an empty, too long, comma or duplicate entry is not added and the line
|
||||
* under the input says why; the list holds at most 200 entries.
|
||||
*/
|
||||
import { createButton } from '../shared/toolkit/core.js';
|
||||
|
||||
export const SKIPPED_KEY = 'autotag_skipped_tags';
|
||||
export const PREVIEW_URL = '/prompt_manager/autotag/skipped-tags/preview';
|
||||
export const MAX_ENTRIES = 200;
|
||||
export const MAX_ENTRY_CHARS = 50;
|
||||
export const PREVIEW_DELAY_MS = 300;
|
||||
export const SAMPLE_LIMIT = 12;
|
||||
|
||||
export const SKIPPED_COPY = Object.freeze({
|
||||
label: 'Skipped tags',
|
||||
description: 'Suggested tags that contain one of these words are hidden. Words are separated by spaces, _ or -; '
|
||||
+ 'other characters such as ( ) belong to the word.',
|
||||
placeholder: 'Add a word or words',
|
||||
add: 'Add',
|
||||
chips: 'Skipped tags',
|
||||
empty: 'Enter a word.',
|
||||
comma: 'No commas or line breaks.',
|
||||
long: `At most ${MAX_ENTRY_CHARS} characters.`,
|
||||
duplicate: 'Already in the list.',
|
||||
full: `The list is full (${MAX_ENTRIES}).`,
|
||||
none: 'No tags are skipped.',
|
||||
checking: 'Checking…',
|
||||
failed: 'The preview could not be read.',
|
||||
unchanged: 'Tags already on images are not changed.',
|
||||
libraryOnly: 'Checked against library tags only; no WD14 label list is downloaded.',
|
||||
hidesNothing: 'Hides no known tag',
|
||||
});
|
||||
|
||||
const plural = (count, word) => `${count.toLocaleString('en-US')} ${word}${count === 1 ? '' : 's'}`;
|
||||
|
||||
/** The words of an entry or a tag as the server splits them: NFC, lower case, split on space, `_` and `-`. */
|
||||
export const entryWords = text => String(text ?? '').normalize('NFC').toLowerCase().split(/[ _-]+/).filter(Boolean);
|
||||
const entryKey = text => entryWords(text).join(' ');
|
||||
|
||||
/** Why `raw` cannot be added to `entries` (the line under the input), or '' when it can. */
|
||||
export function entryProblem(raw, entries) {
|
||||
const entry = String(raw ?? '').trim();
|
||||
if (!entryWords(entry).length) return SKIPPED_COPY.empty;
|
||||
// Commas and control characters (a pasted line break), as the server refuses them.
|
||||
if (/[,\u0000-\u001f\u007f-\u009f\u2028\u2029]/u.test(entry)) return SKIPPED_COPY.comma;
|
||||
if ([...entry].length > MAX_ENTRY_CHARS) return SKIPPED_COPY.long;
|
||||
const key = entryKey(entry);
|
||||
if (entries.some(item => entryKey(item) === key)) return SKIPPED_COPY.duplicate;
|
||||
if (entries.length >= MAX_ENTRIES) return SKIPPED_COPY.full;
|
||||
return '';
|
||||
}
|
||||
|
||||
/** A chip's title and count text for an entry the last preview counted (`count` undefined: not counted yet). */
|
||||
export function chipCount(count) {
|
||||
if (!Number.isSafeInteger(count)) return null;
|
||||
return count ? { title: `Hides ${plural(count, 'tag')}`, text: count.toLocaleString('en-US') }
|
||||
: { title: SKIPPED_COPY.hidesNothing, text: 'none' };
|
||||
}
|
||||
|
||||
/**
|
||||
* The preview line for a preview answer (`{count, sample, sources}`): the bold lead "Hides N tags from suggestions",
|
||||
* then the sample (": a, b and K more. ") and the closing sentences. Returns `{lead, rest}`.
|
||||
*/
|
||||
export function previewText(result) {
|
||||
const count = Number.isSafeInteger(result?.count) && result.count > 0 ? result.count : 0;
|
||||
const sample = Array.isArray(result?.sample) ? result.sample.filter(name => typeof name === 'string') : [];
|
||||
const more = count - sample.length;
|
||||
const listed = count && sample.length ? `: ${sample.join(', ')}${more > 0 ? ` and ${more.toLocaleString('en-US')} more` : ''}. ` : '. ';
|
||||
const labels = Array.isArray(result?.sources?.wd14) && result.sources.wd14.length > 0;
|
||||
return {
|
||||
lead: `Hides ${plural(count, 'tag')} from suggestions`,
|
||||
rest: `${listed}${labels ? '' : `${SKIPPED_COPY.libraryOnly} `}${SKIPPED_COPY.unchanged}`,
|
||||
};
|
||||
}
|
||||
|
||||
function el(tag, text, className) {
|
||||
const node = document.createElement(tag);
|
||||
if (text !== undefined) node.textContent = text;
|
||||
if (className) node.className = className;
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the block; `drafts` is the System draft store, `api` the workspace client. Returns `{node, repaint}`: System
|
||||
* calls `repaint` whenever the drafts change (the chips follow the drafted list, and a changed list is previewed
|
||||
* `PREVIEW_DELAY_MS` after the last change, one request at a time: a newer list aborts the older request).
|
||||
*/
|
||||
export function skippedTagsBlock({ api, scope, drafts, delay = PREVIEW_DELAY_MS }) {
|
||||
const block = el('div', undefined, 'setting-block');
|
||||
block.dataset.setting = 'tag-skipped';
|
||||
block.dataset.key = SKIPPED_KEY;
|
||||
const text = el('div', undefined, 'setting-block-text');
|
||||
const label = el('label', SKIPPED_COPY.label);
|
||||
label.htmlFor = 'tag-skipped-input';
|
||||
text.append(label, el('p', SKIPPED_COPY.description));
|
||||
const addRow = el('div', undefined, 'skip-add');
|
||||
const input = el('input');
|
||||
input.id = 'tag-skipped-input';
|
||||
input.autocomplete = 'off';
|
||||
input.placeholder = SKIPPED_COPY.placeholder;
|
||||
input.maxLength = MAX_ENTRY_CHARS + 10;
|
||||
input.setAttribute('aria-describedby', 'tag-skipped-problem');
|
||||
const add = createButton(SKIPPED_COPY.add);
|
||||
add.id = 'tag-skipped-add';
|
||||
addRow.append(input, add);
|
||||
const problem = el('p', '', 'small skip-problem');
|
||||
problem.id = 'tag-skipped-problem';
|
||||
problem.setAttribute('role', 'alert');
|
||||
const chips = el('div', undefined, 'tags skip-chips');
|
||||
chips.setAttribute('aria-label', SKIPPED_COPY.chips);
|
||||
const preview = el('p', '', 'small skip-preview');
|
||||
preview.setAttribute('role', 'status');
|
||||
preview.setAttribute('aria-live', 'polite');
|
||||
block.append(text, addRow, problem, chips, preview);
|
||||
|
||||
const entries = () => {
|
||||
const value = drafts.get('host', SKIPPED_KEY);
|
||||
return Array.isArray(value) ? value : null;
|
||||
};
|
||||
let counts = new Map();
|
||||
let shown = null; // the list the chips show (JSON)
|
||||
let pending = null; // the list the preview line is (or is being) made for (JSON)
|
||||
let timer = null;
|
||||
let request = null;
|
||||
scope.cleanup(() => { clearTimeout(timer); request?.abort(); });
|
||||
|
||||
const say = message => {
|
||||
problem.textContent = message;
|
||||
if (message) input.setAttribute('aria-invalid', 'true');
|
||||
else input.removeAttribute('aria-invalid');
|
||||
};
|
||||
const setList = next => drafts.set('host', SKIPPED_KEY, next);
|
||||
|
||||
function addEntry() {
|
||||
const list = entries();
|
||||
if (!list) return;
|
||||
const message = entryProblem(input.value, list);
|
||||
say(message);
|
||||
if (message) return;
|
||||
const entry = input.value.trim();
|
||||
input.value = '';
|
||||
setList([...list, entry]);
|
||||
}
|
||||
|
||||
function removeEntry(index) {
|
||||
const list = entries();
|
||||
if (!list) return;
|
||||
const buttons = [...chips.querySelectorAll?.('button') ?? []];
|
||||
const focused = buttons.indexOf(document.activeElement);
|
||||
setList(list.filter((_entry, at) => at !== index));
|
||||
// The removed chip's focus moves to its neighbour's ×, else to the input.
|
||||
if (focused >= 0) {
|
||||
const left = [...chips.querySelectorAll?.('button') ?? []];
|
||||
(left[Math.min(focused, left.length - 1)] ?? input).focus?.();
|
||||
}
|
||||
}
|
||||
|
||||
function renderChips(list) {
|
||||
chips.replaceChildren(...list.map((entry, index) => {
|
||||
const count = chipCount(counts.get(entry));
|
||||
const chip = el('span', undefined, `chip tag-suggestion${count?.text === 'none' ? ' none' : ''}`);
|
||||
if (count) chip.title = count.title;
|
||||
chip.append(el('span', entry));
|
||||
if (count) chip.append(el('span', count.text, 'tag-confidence'));
|
||||
const remove = createButton('×');
|
||||
remove.setAttribute('aria-label', `Remove ${entry}`);
|
||||
scope.listen(remove, 'click', () => removeEntry(index));
|
||||
chip.append(remove);
|
||||
return chip;
|
||||
}));
|
||||
}
|
||||
|
||||
function showPreview(result) {
|
||||
const { lead, rest } = previewText(result);
|
||||
preview.replaceChildren(el('b', lead), rest);
|
||||
}
|
||||
|
||||
async function runPreview(list) {
|
||||
request?.abort();
|
||||
const controller = new AbortController();
|
||||
request = controller;
|
||||
try {
|
||||
const result = await api.post(PREVIEW_URL, { entries: list }, { signal: controller.signal });
|
||||
if (controller.signal.aborted || scope.signal.aborted) return;
|
||||
counts = new Map((Array.isArray(result?.per_entry) ? result.per_entry : [])
|
||||
.filter(row => typeof row?.entry === 'string').map(row => [row.entry, row.count]));
|
||||
showPreview(result);
|
||||
renderChips(entries() ?? list);
|
||||
} catch {
|
||||
if (controller.signal.aborted || scope.signal.aborted) return;
|
||||
preview.textContent = SKIPPED_COPY.failed;
|
||||
} finally {
|
||||
if (request === controller) request = null;
|
||||
}
|
||||
}
|
||||
|
||||
function schedule(list) {
|
||||
clearTimeout(timer);
|
||||
request?.abort();
|
||||
request = null;
|
||||
if (!list.length) {
|
||||
counts = new Map();
|
||||
preview.textContent = SKIPPED_COPY.none;
|
||||
return;
|
||||
}
|
||||
preview.textContent = SKIPPED_COPY.checking;
|
||||
timer = setTimeout(() => runPreview(list), delay);
|
||||
}
|
||||
|
||||
function repaint() {
|
||||
const list = entries();
|
||||
input.disabled = add.disabled = list === null;
|
||||
if (list === null) return;
|
||||
const full = list.length >= MAX_ENTRIES;
|
||||
add.disabled = full;
|
||||
if (full && !problem.textContent) say(SKIPPED_COPY.full);
|
||||
if (!full && problem.textContent === SKIPPED_COPY.full) say('');
|
||||
const key = JSON.stringify(list);
|
||||
if (key !== shown) {
|
||||
shown = key;
|
||||
renderChips(list);
|
||||
}
|
||||
if (key !== pending) {
|
||||
pending = key;
|
||||
schedule(list);
|
||||
}
|
||||
}
|
||||
|
||||
scope.listen(add, 'click', addEntry);
|
||||
scope.listen(input, 'keydown', event => {
|
||||
if (event.key !== 'Enter') return;
|
||||
event.preventDefault?.();
|
||||
addEntry();
|
||||
});
|
||||
scope.listen(input, 'input', () => { if (problem.textContent !== SKIPPED_COPY.full) say(''); });
|
||||
return { node: block, repaint };
|
||||
}
|
||||
@@ -76,6 +76,22 @@ export function statusText(outcome) {
|
||||
return saved.join(' ') || SETTINGS_STATUS.clean;
|
||||
}
|
||||
|
||||
const plain = value => value !== null && typeof value === 'object'
|
||||
&& (Array.isArray(value) || Object.getPrototypeOf(value) === Object.prototype);
|
||||
/** JSON with every object's keys sorted, so key order never makes two values differ. */
|
||||
const canonical = value => JSON.stringify(value, (key, item) => (item && typeof item === 'object' && !Array.isArray(item)
|
||||
? Object.fromEntries(Object.keys(item).sort().map(name => [name, item[name]])) : item));
|
||||
|
||||
/**
|
||||
* #431: whether a draft equals a saved value: `Object.is`, or both are arrays or plain objects with the same JSON (keys
|
||||
* sorted). A list setting (Skipped tags) whose added entry was removed again is clean, not a new array that differs.
|
||||
*/
|
||||
export function sameDraft(a, b) {
|
||||
if (Object.is(a, b)) return true;
|
||||
if (!plain(a) || !plain(b) || Array.isArray(a) !== Array.isArray(b)) return false;
|
||||
return canonical(a) === canonical(b);
|
||||
}
|
||||
|
||||
function requireDomain(domain) {
|
||||
if (!DRAFT_DOMAINS.includes(domain)) throw new TypeError(`Unknown settings domain: ${domain}`);
|
||||
}
|
||||
@@ -112,7 +128,7 @@ export function createSystemDrafts(adapters) {
|
||||
values = await (domain === 'browser' ? adapters.readBrowser() : adapters.readRuntime());
|
||||
}
|
||||
baseline[domain] = { ...(values ?? {}) };
|
||||
for (const [key, value] of drafts[domain]) if (Object.is(baseline[domain][key], value)) drafts[domain].delete(key);
|
||||
for (const [key, value] of drafts[domain]) if (sameDraft(baseline[domain][key], value)) drafts[domain].delete(key);
|
||||
notify();
|
||||
return { ...baseline[domain] };
|
||||
}
|
||||
@@ -124,7 +140,7 @@ export function createSystemDrafts(adapters) {
|
||||
|
||||
function set(domain, key, value) {
|
||||
requireDomain(domain);
|
||||
if (Object.is(baseline[domain][key], value)) drafts[domain].delete(key);
|
||||
if (sameDraft(baseline[domain][key], value)) drafts[domain].delete(key);
|
||||
else drafts[domain].set(key, value);
|
||||
failedKeys[domain].delete(key);
|
||||
outcome = null;
|
||||
@@ -153,7 +169,7 @@ export function createSystemDrafts(adapters) {
|
||||
function commit(domain, written) {
|
||||
for (const [key, value] of written) {
|
||||
baseline[domain][key] = value;
|
||||
if (drafts[domain].has(key) && Object.is(drafts[domain].get(key), value)) drafts[domain].delete(key);
|
||||
if (drafts[domain].has(key) && sameDraft(drafts[domain].get(key), value)) drafts[domain].delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
* - "Existing tags", "General-tag confidence", "Character-tag confidence": the concept's labels and descriptions
|
||||
* verbatim (they state the shipped defaults); host keys `autotag_existing`, `autotag_general_threshold`,
|
||||
* `autotag_character_threshold`. D133 adds "Suggestions per image" (`autotag_suggestion_limit`, 1–100) after them.
|
||||
* - D202 (#431): the "Skipped tags" block after it (system-skipped-tags.mjs, host key `autotag_skipped_tags`).
|
||||
* - The `.field` "LLM tag template" (`#tag-template`, `autotag_template`).
|
||||
* - `.row-actions`: "Preview tagging review" (`ca-primary`) opens the Tag image dialog for the image selected in Images
|
||||
* with the section's current model and values, aria-disabled without one (the D115 sentence) or with an invalid
|
||||
@@ -35,6 +36,7 @@ import { selectedImage } from './images.mjs';
|
||||
import { NO_SELECTION } from './vocabulary.mjs';
|
||||
import { showWorkspaceToast } from './toast.mjs';
|
||||
import { navigateTo } from './state.mjs';
|
||||
import { skippedTagsBlock } from './system-skipped-tags.mjs';
|
||||
|
||||
export const MODELS_URL = '/prompt_manager/autotag/models';
|
||||
export const UNLOAD_URL = '/prompt_manager/autotag/unload';
|
||||
@@ -410,6 +412,9 @@ export function mountTagging(container, { api, activity, scope, drafts, notify =
|
||||
// D133: the persisted suggestion count, which the concept does not show.
|
||||
add(settingRow({ id: 'tag-suggestions', label: 'Suggestions per image', description: SUGGESTIONS_DESCRIPTION,
|
||||
control: { type: 'number', min: 1, max: 100, step: 1 } }), 'tag-suggestions');
|
||||
// D202 (#431): the words whose suggested tags are hidden on every suggestion path.
|
||||
const skipped = skippedTagsBlock({ api, scope, drafts });
|
||||
surface.append(skipped.node);
|
||||
|
||||
const field = el('div', undefined, 'field');
|
||||
const templateLabel = el('label', 'LLM tag template');
|
||||
@@ -454,6 +459,7 @@ export function mountTagging(container, { api, activity, scope, drafts, notify =
|
||||
const known = () => MODEL_IDS.includes(chosen());
|
||||
|
||||
const repaint = () => {
|
||||
skipped.repaint();
|
||||
const values = current();
|
||||
const unread = values.autotag_model_type === undefined;
|
||||
const failed = drafts.hostState?.() === 'failed' && unread;
|
||||
|
||||
@@ -443,12 +443,12 @@ pre.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; margin-bloc
|
||||
suggestions are removable chips, each the concept's `.chip` corner (4px; the later `.tag-suggestion` row rule would
|
||||
give 5px) around the row's 12px text in its checked suggestion row's tokens (`.tag-suggestion:has(input:checked)`,
|
||||
s1655): a name, the confidence and a × button. */
|
||||
#tag-suggestions .chip.tag-suggestion { display: inline-flex; gap: 6px; padding: 2px 2px 2px 8px; margin-bottom: 0; cursor: default; color: inherit; border-color: var(--ca-accent); background: var(--soft-blue); border-radius: 4px; /* D83 */ }
|
||||
#tag-suggestions .chip.tag-suggestion .tag-confidence { margin-inline-start: 0; /* D83 */ }
|
||||
.ca-tools #tag-suggestions .chip.tag-suggestion > button { min-block-size: 24px; min-inline-size: 24px; padding: 0 6px; /* D83 */ }
|
||||
#tag-suggestions .chip.tag-suggestion, .skip-chips .chip.tag-suggestion { display: inline-flex; gap: 6px; padding: 2px 2px 2px 8px; margin-bottom: 0; cursor: default; color: inherit; border-color: var(--ca-accent); background: var(--soft-blue); border-radius: 4px; /* D83 */ }
|
||||
#tag-suggestions .chip.tag-suggestion .tag-confidence, .skip-chips .chip.tag-suggestion .tag-confidence { margin-inline-start: 0; /* D83 */ }
|
||||
.ca-tools #tag-suggestions .chip.tag-suggestion > button, .ca-tools .skip-chips .chip.tag-suggestion > button { min-block-size: 24px; min-inline-size: 24px; padding: 0 6px; /* D83 */ }
|
||||
@media (pointer: coarse) {
|
||||
/* D83: the chip's × keeps the concept's 40px coarse target. */
|
||||
.ca-tools #tag-suggestions .chip.tag-suggestion > button { min-block-size: 40px; min-inline-size: 40px; }
|
||||
.ca-tools #tag-suggestions .chip.tag-suggestion > button, .ca-tools .skip-chips .chip.tag-suggestion > button { min-block-size: 40px; min-inline-size: 40px; }
|
||||
}
|
||||
|
||||
/* Needs review (#249 PR F): the concept's Metadata review page (app.js a749–760) in its own classes. */
|
||||
@@ -492,6 +492,24 @@ pre.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; margin-bloc
|
||||
@media (max-width: 700px) {
|
||||
#workspace-main .setting-row :is(#send-destination, #renderer) { max-width: 160px !important; /* D106 */ }
|
||||
}
|
||||
/* D202 (#431): System › Auto-tagging "Skipped tags", a setting whose control (an add input, the chips and the preview
|
||||
line) is wider than a `.setting-row` column: the row's label and description tokens over the full width, the chips
|
||||
in the Tag image dialog's D83 chip markup (an entry that hides no known tag is muted). */
|
||||
.setting-block { padding: 17px 0; border-top: 1px solid var(--line); /* D202 */ }
|
||||
.setting-block-text label { font-size: 12px; font-weight: 500; display: block; /* D202 */ }
|
||||
.setting-block-text p { font-size: 11px; color: var(--ca-muted); margin-top: 4px; max-width: 640px; /* D202 */ }
|
||||
.setting-block[aria-invalid="true"] { box-shadow: inset 3px 0 0 var(--red); padding-inline-start: 12px; /* D202 */ }
|
||||
.skip-add { display: flex; gap: 8px; margin-top: 12px; max-width: 460px; /* D202 */ }
|
||||
.skip-add input { flex: 1; min-width: 0; font-size: 12px; /* D202 */ }
|
||||
.skip-problem { color: var(--red); margin-top: 6px; /* D202 */ }
|
||||
.skip-problem:empty, .skip-chips:empty { display: none; /* D202 */ }
|
||||
.skip-chips .chip.tag-suggestion.none { border-color: var(--line); background: var(--ca-field); color: var(--ca-muted); /* D202 */ }
|
||||
.skip-preview { margin-top: 12px; max-width: 760px; /* D202 */ }
|
||||
.skip-preview b { font-weight: 500; color: var(--ca-text); /* D202 */ }
|
||||
@media (max-width: 700px) {
|
||||
.skip-add { max-width: none; /* D202 */ }
|
||||
}
|
||||
|
||||
/* D132: at phone width the concept's 120px cap cuts every engine label ("WD14 SwinV2 · ~400 MB"); the Tagging model
|
||||
select alone keeps its natural width there (its widest engine label) and, with no room beside the row text, wraps
|
||||
under it. Existing tags (#398) never shrinks for its row text and, past the cap, keeps its natural width beside the
|
||||
|
||||
Reference in New Issue
Block a user