diff --git a/kikotools/tools/model_resolution_picker/families.py b/kikotools/tools/model_resolution_picker/families.py index 2f7ffaa..cae3bed 100644 --- a/kikotools/tools/model_resolution_picker/families.py +++ b/kikotools/tools/model_resolution_picker/families.py @@ -8,6 +8,7 @@ validation is a startup error. from pathlib import Path from typing import Any, Dict, List, Optional, Tuple +from .logic import size_label from .override import current_table from .table import ( CHIPS, @@ -29,6 +30,7 @@ __all__ = [ "family_labels", "resolve_family", "families_payload", + "resolution_labels", "SHIPPED_DATA", "load_shipped_families", ] @@ -63,6 +65,17 @@ def family_labels() -> List[str]: return [family.label for family in FAMILIES] +def resolution_labels() -> List[str]: + """Return the sorted distinct size labels across the shipped families.""" + labels = { + size_label(width, height, chip) + for family in FAMILIES + for chip, sizes in family.sizes.items() + for width, height in sizes + } + return sorted(labels) + + def resolve_family(label: str) -> Optional[Family]: """Return the family matching ``label`` or one of its aliases, else None.""" for family in _current()[0]: diff --git a/kikotools/tools/model_resolution_picker/logic.py b/kikotools/tools/model_resolution_picker/logic.py index 694883e..5b57f25 100644 --- a/kikotools/tools/model_resolution_picker/logic.py +++ b/kikotools/tools/model_resolution_picker/logic.py @@ -1,6 +1,8 @@ """Pure size math for the Model Resolution Picker node.""" import math +import re +from typing import Optional MEGAPIXEL = 1_048_576 @@ -42,3 +44,22 @@ def ratio_label(width: int, height: int) -> str: divisor = math.gcd(width, height) label = f"{width // divisor}:{height // divisor}" return _ALIASES.get(label, label) + + +_SIZE_LABEL = re.compile(r"^(\d+)×(\d+) - \S+ \(\d+\.\dMP\)$") + + +def size_label(width: int, height: int, ratio_text: str) -> str: + """Return the dropdown label ``W×H - ratio (X.XMP)`` for a size.""" + return f"{width}×{height} - {ratio_text} ({width * height / 1_000_000:.1f}MP)" + + +def parse_size_label(text: object) -> Optional[tuple[int, int]]: + """Return ``(width, height)`` from a :func:`size_label` string, else None.""" + if not isinstance(text, str): + return None + match = _SIZE_LABEL.match(text) + if match is None: + return None + width, height = int(match.group(1)), int(match.group(2)) + return (width, height) if width > 0 and height > 0 else None diff --git a/kikotools/tools/model_resolution_picker/node.py b/kikotools/tools/model_resolution_picker/node.py index ad77e83..cfd8c2f 100644 --- a/kikotools/tools/model_resolution_picker/node.py +++ b/kikotools/tools/model_resolution_picker/node.py @@ -11,9 +11,10 @@ from .families import ( DEFAULT_FAMILY, families_payload, family_labels, + resolution_labels, resolve_family, ) -from .logic import round_to_step +from .logic import parse_size_label, round_to_step logger = logging.getLogger(__name__) @@ -36,6 +37,14 @@ class ModelResolutionPickerNode(ComfyAssetsBaseNode): "tooltip": "Model family; the chips list its preferred sizes", }, ), + "resolution": ( + ["custom"] + resolution_labels(), + { + "default": "custom", + "tooltip": "Sizes for the selected model and ratio; " + "custom uses width/height", + }, + ), "width": ( "INT", { @@ -66,7 +75,7 @@ class ModelResolutionPickerNode(ComfyAssetsBaseNode): } @classmethod - def VALIDATE_INPUTS(cls, model: str) -> bool: + def VALIDATE_INPUTS(cls, model: str, resolution: str) -> bool: """Accept any label so renamed or future families still execute (create() falls back).""" return True @@ -76,9 +85,16 @@ class ModelResolutionPickerNode(ComfyAssetsBaseNode): CATEGORY = "🫶 ComfyAssets/🖼️ Resolution" def create( - self, model: str, width: int, height: int, batch_size: int + self, model: str, resolution: str, width: int, height: int, batch_size: int ) -> Tuple[Dict[str, Any], int, int]: - """Round the size to the family's step and build the empty latent.""" + """Round the size to the family's step and build the empty latent. + + A listed ``resolution`` label overrides ``width``/``height``; ``custom`` + or any label that does not parse leaves them alone. + """ + listed = parse_size_label(resolution) if resolution != "custom" else None + if listed is not None: + width, height = listed family = resolve_family(model) if family is None: logger.warning( diff --git a/tests/browser/workflows.spec.mjs b/tests/browser/workflows.spec.mjs index 26d8984..ed15c40 100644 --- a/tests/browser/workflows.spec.mjs +++ b/tests/browser/workflows.spec.mjs @@ -1846,6 +1846,52 @@ test('resolution picker renders chips from the families route and writes sizes t await expect(panel).toHaveAttribute('data-ca-theme', theme); } expect(await panel.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true); + const box = async name => panel.getByRole('button', { name, exact: true }).locator('.ca-resolution-shape').evaluate(shape => { + const rect = shape.getBoundingClientRect(); + return { wide: rect.width > rect.height, tall: rect.height > rect.width, hidden: shape.getAttribute('aria-hidden') }; + }); + expect(await box('16:9')).toMatchObject({ wide: true, hidden: 'true' }); + expect(await box('9:16')).toMatchObject({ tall: true, hidden: 'true' }); + await expect(panel.getByRole('button', { name: 'Custom ratio', exact: true }).locator('.ca-resolution-shape')).toHaveCount(0); +}); + +test('resolution picker mirrors the panel in a native resolution dropdown', async ({ page }) => { + await openHost(page); + await createNode(page, 'ModelResolutionPicker', { model: 'SDXL', resolution: 'custom', width: 1024, height: 1024, batch_size: 1 }); + const panel = page.locator('.ca-resolution-picker'); + const node = page.locator('[data-node="ModelResolutionPicker"]'); + const resolution = node.getByLabel('resolution', { exact: true }); + await expect(panel.getByRole('button', { name: '1:1', exact: true })).toBeEnabled(); + await expect(resolution).toHaveValue('1024×1024 - 1:1 (1.0MP)'); + await panel.getByRole('button', { name: '3:2', exact: true }).click(); + await expect(resolution).toHaveValue('1216×832 - 3:2 (1.0MP)'); + await resolution.fill('1344×768 - 16:9 (1.0MP)'); + await resolution.dispatchEvent('change'); + await expect(node.getByLabel('width', { exact: true })).toHaveValue('1344'); + await expect(node.getByLabel('height', { exact: true })).toHaveValue('768'); + await expect(panel.getByRole('button', { name: '16:9', exact: true })).toHaveAttribute('aria-pressed', 'true'); + await page.locator('.ca-dimension-swap').getByRole('button', { name: 'Swap width and height', exact: true }).click(); + await expect(resolution).toHaveValue('768×1344 - 9:16 (1.0MP)'); + await node.getByLabel('width', { exact: true }).fill('1000'); + await node.getByLabel('width', { exact: true }).dispatchEvent('change'); + await expect(resolution).toHaveValue('custom'); + await resolution.fill('custom'); + await resolution.dispatchEvent('change'); + await expect(node.getByLabel('width', { exact: true })).toHaveValue('1000'); +}); + +test('resolution picker shows the first families warning in the status line', async ({ page }) => { + await page.route('**/kikotools/model_resolution/families', async route => { + const response = await route.fetch(); + const payload = await response.json(); + await route.fulfill({ response, json: { ...payload, warnings: ['Override ignored: test'] } }); + }); + await openHost(page); + await createNode(page, 'ModelResolutionPicker', { model: 'SDXL', resolution: 'custom', width: 1024, height: 1024, batch_size: 1 }); + const panel = page.locator('.ca-resolution-picker'); + await expect(panel.locator('.ca-resolution-status')).toHaveText('Override ignored: test'); + await panel.getByRole('button', { name: '3:2', exact: true }).click(); + await expect(panel.locator('.ca-resolution-status')).toHaveText('Override ignored: test'); }); test('resolution picker mounting is idempotent and removal disposes its scope', async ({ page }) => { diff --git a/tests/comfyui/resolution-picker.spec.mjs b/tests/comfyui/resolution-picker.spec.mjs index dfb13cd..5517722 100644 --- a/tests/comfyui/resolution-picker.spec.mjs +++ b/tests/comfyui/resolution-picker.spec.mjs @@ -86,17 +86,18 @@ test('resolution picker survives renderers, swap, themes and workflow reload', a const { app } = await import('/scripts/app.js'); const workflow = app.graph.serialize(); const saved = workflow.nodes.find(node => node.type === 'ModelResolutionPicker'); - if (saved.widgets_values.slice(4).some(value => value !== '' && value != null)) throw new Error('Picker UI leaked into workflow values'); + if (saved.widgets_values.slice(5).some(value => value !== '' && value != null)) throw new Error('Picker UI leaked into workflow values'); await app.loadGraphData(workflow); const node = app.graph._nodes.find(node => node.type === 'ModelResolutionPicker'); return { - saved: saved.widgets_values.slice(0, 4), - restored: node.widgets.filter(widget => ['model', 'width', 'height', 'batch_size'].includes(widget.name)).map(widget => widget.value), + saved: saved.widgets_values.slice(0, 5), + restored: node.widgets.filter(widget => ['model', 'resolution', 'width', 'height', 'batch_size'].includes(widget.name)).map(widget => widget.value), size: Array.from(node.size), savedSize: Array.from(saved.size), }; }); expect(restored.restored).toEqual(restored.saved); + expect(restored.restored[1]).toMatch(/^\d+×\d+ - 9:16 \(\d\.\dMP\)$/); expect(restored.size).toEqual(restored.savedSize); await expect(panel.getByRole('button', { name: '9:16', exact: true })).toHaveAttribute('aria-pressed', 'true'); await page.evaluate(async () => { diff --git a/tests/js/resolution-picker.test.mjs b/tests/js/resolution-picker.test.mjs index ce0b032..ecb2c46 100644 --- a/tests/js/resolution-picker.test.mjs +++ b/tests/js/resolution-picker.test.mjs @@ -2,10 +2,13 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { defaultEntry, + dropdownOptions, deriveSelection, effectiveSize, megapixels, parseRatio, + parseSizeLabel, + sizeLabel, sizesForRatio, sizeOnModelChange, } from '../../web/ui/resolution-picker.js'; @@ -59,3 +62,20 @@ test('model change keeps listed sizes, else the same chip default, else the fami assert.deepEqual(sizeOnModelChange(payload, 'HunyuanImage 2.1', 1216, 832, '3:2'), [2048, 2048]); assert.deepEqual(defaultEntry(payload.families[0], '21:9'), null); }); +test('size labels round-trip and mirror the Python format', () => { + assert.equal(sizeLabel(1216, 832, '3:2'), '1216×832 - 3:2 (1.0MP)'); + assert.equal(sizeLabel(2560, 1536, '16:9'), '2560×1536 - 16:9 (3.9MP)'); + assert.deepEqual(parseSizeLabel(sizeLabel(768, 1344, '9:16')), [768, 1344]); + for (const text of ['custom', 'garbage', '', null, undefined, '0×832 - 3:2 (1.0MP)']) assert.equal(parseSizeLabel(text), null); +}); +test('dropdownOptions lists the active chip sizes, or every family size for custom', () => { + assert.deepEqual(dropdownOptions(payload, 'SDXL', '3:2'), ['custom', '1216×832 - 3:2 (1.0MP)']); + assert.deepEqual(dropdownOptions(payload, 'SDXL', null), [ + 'custom', '1024×1024 - 1:1 (1.0MP)', '832×1216 - 2:3 (1.0MP)', '1216×832 - 3:2 (1.0MP)', + '768×1344 - 9:16 (1.0MP)', '1344×768 - 16:9 (1.0MP)', + ]); + assert.deepEqual(dropdownOptions(payload, 'SDXL', 'custom'), dropdownOptions(payload, 'SDXL', null)); + assert.deepEqual(dropdownOptions(payload, 'SDXL', '21:9'), ['custom']); + assert.deepEqual(dropdownOptions(payload, 'nope', '3:2'), ['custom']); + assert.deepEqual(dropdownOptions(null, 'SDXL', null), ['custom']); +}); diff --git a/tests/unit/tools/model_resolution_picker/test_logic.py b/tests/unit/tools/model_resolution_picker/test_logic.py index 24f542a..261fa9e 100644 --- a/tests/unit/tools/model_resolution_picker/test_logic.py +++ b/tests/unit/tools/model_resolution_picker/test_logic.py @@ -1,8 +1,10 @@ from kikotools.tools.model_resolution_picker.logic import ( MEGAPIXEL, generate_size, + parse_size_label, ratio_label, round_to_step, + size_label, ) @@ -27,3 +29,17 @@ def test_ratio_label_reduces_and_aliases(): assert ratio_label(1280, 800) == "16:10" assert ratio_label(1344, 576) == "21:9" assert MEGAPIXEL == 1024 * 1024 + + +def test_size_label_formats_size_ratio_and_megapixels(): + assert size_label(1216, 832, "3:2") == "1216×832 - 3:2 (1.0MP)" + assert size_label(2560, 1536, "16:9") == "2560×1536 - 16:9 (3.9MP)" + + +def test_parse_size_label_round_trips_and_rejects_garbage(): + assert parse_size_label("1216×832 - 3:2 (1.0MP)") == (1216, 832) + assert parse_size_label(size_label(768, 1344, "9:16")) == (768, 1344) + assert parse_size_label("custom") is None + assert parse_size_label("garbage") is None + assert parse_size_label("0×832 - 3:2 (1.0MP)") is None + assert parse_size_label(None) is None diff --git a/tests/unit/tools/model_resolution_picker/test_node.py b/tests/unit/tools/model_resolution_picker/test_node.py index 795d55c..281bb05 100644 --- a/tests/unit/tools/model_resolution_picker/test_node.py +++ b/tests/unit/tools/model_resolution_picker/test_node.py @@ -25,27 +25,70 @@ def test_inputs_and_outputs(): assert ModelResolutionPickerNode.CATEGORY == "🫶 ComfyAssets/🖼️ Resolution" +def test_resolution_options_come_from_shipped_families(): + options, config = ModelResolutionPickerNode.INPUT_TYPES()["required"]["resolution"] + assert options[0] == "custom" + assert config["default"] == "custom" + assert "1216×832 - 3:2 (1.0MP)" in options + assert options[1:] == sorted(set(options[1:])) + + +def test_resolution_input_sits_between_model_and_width(): + assert list(ModelResolutionPickerNode.INPUT_TYPES()["required"])[:3] == [ + "model", + "resolution", + "width", + ] + + +def test_resolution_label_overrides_width_height(): + _, w, h = ModelResolutionPickerNode().create( + "SDXL", "1216×832 - 3:2 (1.0MP)", 1024, 1024, 1 + ) + assert (w, h) == (1216, 832) + + +def test_custom_uses_width_height(): + _, w, h = ModelResolutionPickerNode().create("SDXL", "custom", 1024, 768, 1) + assert (w, h) == (1024, 768) + + +def test_unparseable_resolution_behaves_like_custom(): + _, w, h = ModelResolutionPickerNode().create("SDXL", "stale text", 1024, 768, 1) + assert (w, h) == (1024, 768) + + +def test_validate_inputs_accepts_unlisted_resolution(): + assert ModelResolutionPickerNode.VALIDATE_INPUTS("SDXL", "9999×1 - 1:1 (0.0MP)") + + def test_execution_rounds_to_family_step_and_marks_latent(): - latent, w, h = ModelResolutionPickerNode().create("HunyuanImage 2.1", 70, 1000, 2) + latent, w, h = ModelResolutionPickerNode().create( + "HunyuanImage 2.1", "custom", 70, 1000, 2 + ) assert (w, h) == (64, 992) assert latent["samples"].shape == (2, 4, 124, 8) assert latent["downscale_ratio_spacial"] == 8 def test_exact_sizes_pass_through(): - _, w, h = ModelResolutionPickerNode().create("Flux.2 / Klein / MOSS", 1248, 832, 1) + _, w, h = ModelResolutionPickerNode().create( + "Flux.2 / Klein / MOSS", "custom", 1248, 832, 1 + ) assert (w, h) == (1248, 832) def test_unknown_label_keeps_size_and_warns(caplog): with caplog.at_level(logging.WARNING): - _, w, h = ModelResolutionPickerNode().create("Renamed Family", 1003, 1003, 1) + _, w, h = ModelResolutionPickerNode().create( + "Renamed Family", "custom", 1003, 1003, 1 + ) assert (w, h) == (1000, 1000) # fallback step 8 assert "Renamed Family" in caplog.text def test_validate_inputs_accepts_any_label(): - assert ModelResolutionPickerNode.VALIDATE_INPUTS("Renamed Family") is True + assert ModelResolutionPickerNode.VALIDATE_INPUTS("Renamed Family", "stale") is True def test_alias_label_resolves_to_its_family(): @@ -54,7 +97,9 @@ def test_alias_label_resolves_to_its_family(): others = tuple(f for f in families.FAMILIES if f.label != "SDXL") with mock.patch.object(families, "FAMILIES", (patched,) + others): assert families.resolve_family("SDXL 1.0").label == "SDXL" - latent, w, h = ModelResolutionPickerNode().create("SDXL 1.0", 1216, 832, 1) + latent, w, h = ModelResolutionPickerNode().create( + "SDXL 1.0", "custom", 1216, 832, 1 + ) assert (w, h) == (1216, 832) assert latent["samples"].shape[-2:] == (104, 152) diff --git a/web/model_resolution_picker.js b/web/model_resolution_picker.js index 1c697c4..5ad2328 100644 --- a/web/model_resolution_picker.js +++ b/web/model_resolution_picker.js @@ -3,7 +3,7 @@ import { api } from '../../scripts/api.js'; import { createScope, mountTheme } from './ui/core.js'; import { swapDimensions } from './ui/dimensions.js'; import { mountDimensionSwap } from './ui/dimension-swap.js'; -import { defaultEntry, deriveSelection, effectiveSize, megapixels, parseRatio, sizeOnModelChange, sizesForRatio } from './ui/resolution-picker.js'; +import { defaultEntry, deriveSelection, dropdownOptions, effectiveSize, megapixels, parseRatio, parseSizeLabel, sizeLabel, sizeOnModelChange, sizesForRatio } from './ui/resolution-picker.js'; import { createResolutionPickerView } from './views/resolution-picker.js'; const FAMILIES_ROUTE = '/kikotools/model_resolution/families'; @@ -14,7 +14,7 @@ const RATIO_ERROR = 'Enter a ratio like 16:9'; const FALLBACK_STEP = 8; const NODE_SIZE = [340, 600]; const MIN_PANEL_HEIGHT = 320; -const WRAPPED_WIDGETS = ['model', 'width', 'height']; +const WRAPPED_WIDGETS = ['model', 'resolution', 'width', 'height']; let familiesPromise; @@ -52,6 +52,7 @@ function mountPicker(node) { let modelLabel = widgetOf(node, 'model')?.value; let pendingModel = null; let view; + let optionsKey = ''; const readSize = () => ['width', 'height'].map(name => Number(widgetOf(node, name)?.value)); @@ -95,10 +96,26 @@ function mountPicker(node) { }; } + function syncResolution(state) { + const widget = widgetOf(node, 'resolution'); + const selection = state.selection; + if (!widget || !selection) return; + const chip = state.customOpen || selection.custom ? null : selection.chip; + const key = `${selection.family?.label}|${chip}`; + if (key !== optionsKey) { + optionsKey = key; + widget.options.values = dropdownOptions(payload, selection.family?.label, chip); + } + const [width, height] = readSize(); + const label = selection.entryIndex >= 0 ? sizeLabel(width, height, selection.chip) : 'custom'; + widget.value = widget.options.values.includes(label) ? label : 'custom'; + } + function render() { if (scope.signal.aborted) return; const state = buildState(); view.render(state); + syncResolution(state); const swap = node.swapButtonWidget?.element?.querySelector('button'); if (swap) swap.disabled = state.wired; } @@ -189,7 +206,12 @@ function mountPicker(node) { widget.callback = function () { const result = callback?.apply(this, arguments); if (writing) return result; - if (name === 'model') { + if (name === 'resolution') { + const size = isWiredIn(node, 'width') || isWiredIn(node, 'height') + ? null : parseSizeLabel(arguments[0] ?? this.value); + if (size) writeSize(size[0], size[1]); + else render(); + } else if (name === 'model') { const label = arguments[0] ?? this.value; if (!payload && !failed) { pendingModel = { from: pendingModel ? pendingModel.from : modelLabel, to: label }; @@ -229,6 +251,7 @@ function mountPicker(node) { loadFamilies(api).then( loaded => { payload = loaded; + view.setWarning(loaded.warnings?.[0] || ''); if (pendingModel) applyPendingModelChange(); else render(); }, diff --git a/web/ui/controls.css b/web/ui/controls.css index 01661ab..9ebb246 100644 --- a/web/ui/controls.css +++ b/web/ui/controls.css @@ -235,6 +235,7 @@ .ca-resolution-header { margin: 0; font-size: 12px; color: var(--ca-muted); overflow-wrap: anywhere; } .ca-resolution-chips { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; } .ca-resolution-chips button { min-inline-size: 0; padding-inline: 4px; white-space: normal; overflow-wrap: anywhere; } +.ca-resolution-shape { display: inline-block; border: 1px solid currentColor; border-radius: 2px; margin-inline-end: 6px; vertical-align: middle; } .ca-resolution-sizes { display: flex; flex-direction: column; gap: 6px; } .ca-resolution-sizes button { inline-size: 100%; } .ca-resolution-picker :where(.ca-resolution-chips, .ca-resolution-sizes) button[aria-pressed="true"] { background: var(--ca-accent); border-color: var(--ca-accent); color: #fff; } diff --git a/web/ui/resolution-picker.js b/web/ui/resolution-picker.js index fe4ad01..0765762 100644 --- a/web/ui/resolution-picker.js +++ b/web/ui/resolution-picker.js @@ -114,3 +114,24 @@ export function sizeOnModelChange(payload, newLabel, width, height, chip) { if (listed) return [width, height]; return defaultEntry(family, chip) || family.default; } + +const SIZE_LABEL = /^(\d+)×(\d+) - \S+ \(\d+\.\dMP\)$/; + +export function sizeLabel(width, height, ratioText) { + return `${width}×${height} - ${ratioText} (${((width * height) / 1000000).toFixed(1)}MP)`; +} + +export function parseSizeLabel(text) { + const match = typeof text === 'string' ? SIZE_LABEL.exec(text) : null; + if (!match) return null; + const [width, height] = [Number(match[1]), Number(match[2])]; + return width > 0 && height > 0 ? [width, height] : null; +} + +export function dropdownOptions(payload, label, chip) { + const family = findFamily(payload, label); + if (!family) return ['custom']; + const chips = chip && chip !== 'custom' ? [chip] : payload.chips || []; + const labels = chips.flatMap((id) => (family.sizes[id] || []).map(([w, h]) => sizeLabel(w, h, id))); + return ['custom', ...new Set(labels)]; +} diff --git a/web/views/resolution-picker.js b/web/views/resolution-picker.js index de50dc4..210ce25 100644 --- a/web/views/resolution-picker.js +++ b/web/views/resolution-picker.js @@ -9,6 +9,20 @@ function createToggle(text, pressed, disabled) { return button; } +const SHAPE_LONG_SIDE = 18; +const SHAPE_MIN_SIDE = 6; + +function createShape(chipId) { + const [w, h] = chipId.split(':').map(Number); + const short = Math.max(SHAPE_MIN_SIDE, Math.round((SHAPE_LONG_SIDE * Math.min(w, h)) / Math.max(w, h))); + const shape = document.createElement('span'); + shape.className = 'ca-resolution-shape'; + shape.setAttribute('aria-hidden', 'true'); + shape.style.width = `${w >= h ? SHAPE_LONG_SIDE : short}px`; + shape.style.height = `${h >= w ? SHAPE_LONG_SIDE : short}px`; + return shape; +} + export function createResolutionPickerView(scope, actions) { const element = document.createElement('div'); element.className = 'ca-tools ca-resolution-picker'; @@ -48,11 +62,24 @@ export function createResolutionPickerView(scope, actions) { }); scope.listen(input, 'change', () => actions.commitRatio(input.value)); + let message = ''; + let isMessageError = false; + let warning = ''; + const showStatus = () => { + status.textContent = message || warning; + status.dataset.error = String(Boolean(message) && isMessageError); + }; + return { element, input, setStatus(text, isError = false) { - status.textContent = text; - status.dataset.error = String(Boolean(isError)); + message = text; + isMessageError = Boolean(isError); + showStatus(); + }, + setWarning(text) { + warning = text; + showStatus(); }, render(state) { const focused = document.activeElement; @@ -62,6 +89,7 @@ export function createResolutionPickerView(scope, actions) { chips.replaceChildren(...state.chips.map(chip => { const button = createToggle(chip.label, chip.active, state.disabled || chip.disabled); button.dataset.chip = chip.id; + if (chip.id !== CUSTOM_CHIP) button.prepend(createShape(chip.id)); return button; })); input.hidden = !state.customOpen;