diff --git a/e2e/control-names.spec.js b/e2e/control-names.spec.js index d331cb54..28ac8fa7 100644 --- a/e2e/control-names.spec.js +++ b/e2e/control-names.spec.js @@ -88,24 +88,33 @@ test('Settings group titles are headings', async ({ page }) => { await expect(page.locator('#sec-general').getByRole('heading', { level: 2, name: 'Sprache' })).toBeVisible(); }); -test('the selected colour swatch is announced as pressed', async ({ page }) => { +test('the icon colour Style is a named drop-down', async ({ page }) => { await openDashboardList(page); await rowByName(page, 'Alpha') .getByRole('button', { name: /^Bearbeiten/ }) .click(); + const style = page.getByRole('button', { name: /^Stil/ }); + await expect(style).toHaveAccessibleName('Stil Dunkel'); + const pick = async v => { + await style.click(); + await page.locator(`#icon-col-style-list [data-val="${v}"]`).click(); + }; const swatch = v => page.locator(`#icon-color-slot .cc-swatch[data-v="${v}"]`); - await expect(swatch('dark')).toHaveAttribute('aria-pressed', 'true'); - await expect(swatch('light')).toHaveAttribute('aria-pressed', 'false'); - await swatch('light').click(); - await expect(swatch('light')).toHaveAttribute('aria-pressed', 'true'); - await expect(swatch('dark')).toHaveAttribute('aria-pressed', 'false'); - await swatch('custom').click(); - await expect(swatch('custom')).toHaveAttribute('aria-expanded', 'true'); - await expect(swatch('custom')).toHaveAttribute('aria-pressed', 'true'); + await expect(swatch('red')).toBeHidden(); + await pick('color'); + await expect(style).toHaveAccessibleName('Stil Farbe'); + await expect(swatch('blue')).toHaveAttribute('aria-pressed', 'true'); await swatch('red').click(); await expect(swatch('red')).toHaveAttribute('aria-pressed', 'true'); + await expect(swatch('blue')).toHaveAttribute('aria-pressed', 'false'); await expect(swatch('custom')).toHaveAttribute('aria-pressed', 'false'); - await expect(swatch('custom')).toHaveAttribute('aria-expanded', 'true'); + await pick('light'); + await expect(swatch('red')).toBeHidden(); + await pick('color'); + await expect(swatch('red')).toHaveAttribute('aria-pressed', 'true'); + await swatch('custom').click(); + await expect(swatch('custom')).toHaveAttribute('aria-pressed', 'true'); + await expect(swatch('red')).toHaveAttribute('aria-pressed', 'false'); }); test('the Test result is announced', async ({ page }) => { diff --git a/e2e/reflow.spec.js b/e2e/reflow.spec.js index be08028b..b24b4f5a 100644 --- a/e2e/reflow.spec.js +++ b/e2e/reflow.spec.js @@ -71,7 +71,8 @@ test.describe('on a touch screen', () => { await page.setViewportSize({ width: 320, height: 700 }); await page.goto('/admin/#dashboard'); await page.locator('#al [data-act="edit"]').first().click(); - await page.locator('#icon-color-slot .cc-swatch[data-v="custom"]').click(); + await page.locator('#icon-col-style-btn').click(); + await page.locator('#icon-col-style-list [data-val="color"]').click(); const picker = page.locator('#icon-color-slot'); await picker.locator('.cc-hues').waitFor({ state: 'visible' }); await animationsDone(page); @@ -104,7 +105,8 @@ test('the colour sliders share one length', async ({ page, request }) => { await page.setViewportSize({ width: 1280, height: 900 }); await openDashboardList(page); await page.locator('#al [data-act="edit"]').first().click(); - await page.locator('#icon-color-slot .cc-swatch[data-v="custom"]').click(); + await page.locator('#icon-col-style-btn').click(); + await page.locator('#icon-col-style-list [data-val="color"]').click(); const sliders = page.locator('#icon-color-slot .hsb-range'); await expect(sliders).toHaveCount(3); await animationsDone(page); diff --git a/ui/admin/index.html b/ui/admin/index.html index cc9e837a..ea8cb997 100644 --- a/ui/admin/index.html +++ b/ui/admin/index.html @@ -11,7 +11,7 @@ - + @@ -302,6 +302,6 @@

General

- + diff --git a/ui/css/admin.css b/ui/css/admin.css index 08b2ac4d..3a3a8870 100644 --- a/ui/css/admin.css +++ b/ui/css/admin.css @@ -1,10 +1,5 @@ /* admin.css v33 */ :root{ - /* The two plate previews in the colour control. Fixed in both themes: each - shows the plate it selects, not the theme the page is in. */ - --cc-plate-dark:#1C1C1E; - --cc-plate-light:#F2F2F7; - /* Surfaces and borders, darkest first, from the system greys. */ --bg-outer:var(--sy-gray6); --sb:#141517; @@ -353,9 +348,7 @@ button.rnm{font-family:inherit;color:inherit;background:none;border:0;padding:0; /* ── Color control: swatch row + HSB sliders + code (PSD Icon/Label/Activity) ── */ .cc-row{padding-top:13px;padding-bottom:13px} -/* Grid, not a wrapping flex row. space-between spreads whatever wraps to the - far edges. */ -.cc-sw{display:grid;grid-template-columns:repeat(auto-fit,26px);gap:10px;margin-inline-start:auto;flex:2;max-width:none;justify-content:space-between} +.cc-sw{display:flex;flex-wrap:wrap;gap:10px;justify-content:flex-end;margin-inline-start:auto;flex:0 1 auto} .cc-swatch{width:26px;height:26px;border-radius:50%;cursor:pointer;border:none;padding:0;outline:2px solid transparent;outline-offset:2px;transition:outline-color var(--t);flex-shrink:0} .cc-swatch.on{outline-color:var(--swatch-ring)} @media (forced-colors: active){ @@ -424,12 +417,6 @@ input::placeholder,textarea::placeholder{color:var(--dm-strong)} .dash-load-fail{padding:32px;text-align:center;color:var(--dm);font-size:14px} .dash-load-fail .retry-btn{padding:8px 20px;border-radius:16px;background:var(--btn-fill);border:0;color:var(--tx);cursor:pointer;font-size:14px;font-family:inherit} .wcf-add-label{color:var(--ac2)} -.cc-kw-auto{background:linear-gradient(135deg,var(--cc-plate-dark) 50%,var(--cc-plate-light) 50%);box-shadow:inset 0 0 0 .5px var(--separator)} -.cc-kw-light,.cc-kw-dark{box-shadow:inset 0 0 0 1px var(--separator)} -.cc-kw-clear{background:repeating-conic-gradient(var(--sy-gray2) 0 25%,var(--sy-gray4) 0 50%) 50% 50%/8px 8px;box-shadow:inset 0 0 0 1px var(--separator)} -html[data-theme="light"] .cc-kw-clear{background-image:repeating-conic-gradient(var(--sy-gray4) 0 25%,#fff 0 50%)} -.cc-hues .cc-sw{grid-template-columns:repeat(auto-fill,22px);gap:8px;justify-content:end} -.cc-hues .cc-swatch{width:22px;height:22px} /* App form: icon source row, poll, value list */ .icon-src-row{gap:12px} .icon-prev{box-shadow:inset 0 0 0 1px var(--separator);width:34px;height:34px;border-radius:9px;display:flex;align-items:center;justify-content:center;color:var(--on-tint);font-size:13px;font-weight:600;flex-shrink:0;overflow:hidden} @@ -736,10 +723,6 @@ a:focus-visible,button:focus-visible,[role="button"]:focus-visible{outline:2px s .adm-range { height:44px; padding-block:19px; background-clip:content-box; border-radius:3px / 22px; } .hsb-range { height:44px; padding-block:19px; background-clip:content-box; border-radius:3px / 22px; } .cc-swatch { width:34px; height:34px; } - .cc-sw { grid-template-columns:repeat(auto-fit,34px); } - .cc-kw-clear { background-size:10px 10px; } - .cc-hues .cc-sw { grid-template-columns:repeat(7,34px); } - .cc-hues .cc-swatch { width:34px; height:34px; } } /* ─────────────── Mobile: phone settings + bottom tab bar ─────────────── @@ -758,7 +741,7 @@ html.is-mobile .sec{padding-block:16px calc(132px + env(safe-area-inset-bottom)) html.is-mobile .sec-title{font-size:var(--fs-large-title);line-height:var(--lh-large-title);letter-spacing:var(--tr-large-title)} html.is-mobile .cc-row{flex-wrap:wrap;row-gap:10px} html.is-mobile .cc-row .rl{flex:0 0 100%} -html.is-mobile .cc-sw{flex:1 1 100%;grid-template-columns:repeat(7,auto);column-gap:0;justify-content:space-between} +html.is-mobile .cc-sw{display:grid;flex:1 1 100%;grid-template-columns:repeat(7,auto);column-gap:0;justify-content:space-between} html.is-mobile .sec-hdr{padding:10px 0 10px;flex-wrap:wrap;row-gap:6px} html.is-mobile .sec-title{order:1;flex-basis:100%;min-width:0;overflow-wrap:break-word} html.is-mobile .sec-hdr-actions{margin-inline-start:auto} diff --git a/ui/i18n/de.json b/ui/i18n/de.json index 33af4c79..864451ce 100644 --- a/ui/i18n/de.json +++ b/ui/i18n/de.json @@ -538,9 +538,10 @@ "hidden": "Ausgeblendet" }, "color": { + "style": "Stil", "dark": "Dunkel", "light": "Weiß", - "auto": "Automatisch (Dunkel oder Weiß)", + "auto": "Automatisch", "clear": "Klar", "system": "System", "accent": "Akzent", diff --git a/ui/i18n/en.json b/ui/i18n/en.json index a75df991..55a82535 100644 --- a/ui/i18n/en.json +++ b/ui/i18n/en.json @@ -538,9 +538,10 @@ "hidden": "Hidden" }, "color": { + "style": "Style", "dark": "Dark", "light": "White", - "auto": "Auto (Dark or White)", + "auto": "Auto", "clear": "Clear", "system": "System", "accent": "Accent", diff --git a/ui/i18n/es.json b/ui/i18n/es.json index 0080c981..b752d08d 100644 --- a/ui/i18n/es.json +++ b/ui/i18n/es.json @@ -550,9 +550,10 @@ "hidden": "Oculto" }, "color": { + "style": "Estilo", "dark": "Oscuro", "light": "Blanco", - "auto": "Automático (oscuro o blanco)", + "auto": "Automático", "clear": "Transparente", "system": "Sistema", "accent": "Acento", diff --git a/ui/i18n/fa.json b/ui/i18n/fa.json index 86d35bd7..5447effa 100644 --- a/ui/i18n/fa.json +++ b/ui/i18n/fa.json @@ -538,9 +538,10 @@ "hidden": "پنهان" }, "color": { + "style": "سبک", "dark": "تیره", "light": "سفید", - "auto": "خودکار (تیره یا سفید)", + "auto": "خودکار", "clear": "شفاف", "system": "سیستم", "accent": "رنگ اصلی", diff --git a/ui/i18n/fr.json b/ui/i18n/fr.json index 13106d20..552dedb0 100644 --- a/ui/i18n/fr.json +++ b/ui/i18n/fr.json @@ -550,9 +550,10 @@ "hidden": "Masqué" }, "color": { + "style": "Style", "dark": "Sombre", "light": "Blanc", - "auto": "Automatique (sombre ou blanc)", + "auto": "Automatique", "clear": "Transparent", "system": "Système", "accent": "Accent", diff --git a/ui/i18n/zh-Hans.json b/ui/i18n/zh-Hans.json index 147bf20a..d6c8ce98 100644 --- a/ui/i18n/zh-Hans.json +++ b/ui/i18n/zh-Hans.json @@ -526,9 +526,10 @@ "hidden": "已隐藏" }, "color": { + "style": "样式", "dark": "深色", "light": "白色", - "auto": "自动(深色或白色)", + "auto": "自动", "clear": "透明", "system": "系统", "accent": "强调色", diff --git a/ui/js/admin-app-form.js b/ui/js/admin-app-form.js index 1f51ea1c..b1f0bcca 100644 --- a/ui/js/admin-app-form.js +++ b/ui/js/admin-app-form.js @@ -34,7 +34,7 @@ import { } from '/js/admin-shared.js?v=1c01be9a'; import { createListbox } from '/js/listbox.js?v=f814170f'; import { MAX_LABELS } from '/js/badge-logic.js?v=5c72bd4c'; -import { renderColorControl, BADGE_DEFAULT } from '/js/admin-color-control.js?v=ef504f96'; +import { renderColorControl, BADGE_DEFAULT } from '/js/admin-color-control.js?v=4083ea3b'; import { badgeErrorAdvice, TONE } from '/js/admin-error.js?v=b0881957'; import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=cb886e86'; import { iconSvg } from '/js/icon-set.js?v=628341d4'; diff --git a/ui/js/admin-color-control.js b/ui/js/admin-color-control.js index 87c16bd3..cd548108 100644 --- a/ui/js/admin-color-control.js +++ b/ui/js/admin-color-control.js @@ -3,6 +3,7 @@ import { t } from '/js/i18n.js?v=ed7c5565'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { qa, q } from '/js/utils.js?v=705ca896'; import { iconSvg } from '/js/icon-set.js?v=628341d4'; +import { createListbox } from '/js/listbox.js?v=f814170f'; import { HUE_NAMES, ROLE_NAMES, TILE_KEYWORDS, pageTheme, tileColor } from '/js/palette.js?v=3fb8ae43'; const CC_SWATCHES = ['#1c1c1e', '#8e8e93', '#f2f2f7', '#ff393c', '#ffcd00', '#35c759', '#0289ff', '#cb30df']; @@ -147,9 +148,11 @@ export function renderColorControl( container, { value = '#0289ff', idPrefix, onChange, variant, swatchColors = CC_SWATCHES, label = t('common.color') } = {}, ) { - const top = variant === 'tile' ? TILE_KEYWORDS : variant === 'badge' ? ROLE_NAMES : []; + const styled = variant === 'tile'; + const styles = styled ? TILE_KEYWORDS : []; + const top = variant === 'badge' ? ROLE_NAMES : []; const hues = variant ? HUE_NAMES : []; - const isKw = v => typeof v === 'string' && (top.includes(v) || hues.includes(v)); + const isKw = v => typeof v === 'string' && (styles.includes(v) || top.includes(v) || hues.includes(v)); const kwName = v => t(COLOR_KEYS[v]); const init = _hexToHsv(isKw(value) ? _swatchFill(value) || '#0289ff' : value) || { h: 212, s: 99, v: 100 }; const kwSwatch = v => @@ -160,13 +163,18 @@ export function renderColorControl( const wrap = document.createElement('div'); const slider = (name, cls, id, max, val, lo, hi) => html`
${name}
${raw(lo)}${raw(hi)}
`; - const hueRow = hues.length - ? html`
${t('color.system')}
${hues.map(kwSwatch)}
` - : ''; + const hueRow = !hues.length + ? '' + : styled + ? html`
${label}
${hues.map(kwSwatch)}${rainbow}
` + : html`
${t('color.system')}
${hues.map(kwSwatch)}
`; + const topRow = styled + ? html`
${t('color.style')}
` + : html`
${label}
${swatches}
`; setHtml( wrap, html` -
${label}
${swatches}
+ ${topRow}
${hueRow} ${slider(t('appearance.hue'), 'hsb-range hsb-hue', `${idPrefix}-h`, 360, init.h, _ccIco.hueLo, _ccIco.hueHi)} @@ -179,7 +187,7 @@ export function renderColorControl( 'unsafe-inline'. A swatch paints itself from its own value. */ for (const b of wrap.querySelectorAll('.cc-swatch[data-v]')) { const v = b.getAttribute('data-v') || ''; - const fill = v[0] === '#' ? v : v === 'auto' || v === 'clear' ? '' : _swatchFill(v); + const fill = v[0] === '#' ? v : _swatchFill(v); if (fill) /** @type {HTMLElement} */ (b).style.background = fill; } const rows = /** @type {HTMLElement[]} */ ([...wrap.children]); @@ -196,6 +204,33 @@ export function renderColorControl( container.appendChild(hidden); let mode = isKw(value) ? value : 'color'; let showTune = hues.includes(mode); + /* The colour a Style other than Color replaced, so choosing Color brings it back. */ + let resume = null; + const styleOf = () => (styles.includes(mode) ? mode : 'color'); + const styleBox = styled + ? createListbox({ + id: `${idPrefix}-style`, + label: t('color.style'), + options: [...styles, 'color'].map(v => ({ value: v, label: v === 'color' ? label : kwName(v) })), + value: styleOf(), + onChange: next => { + if (next !== 'color') { + if (!styles.includes(mode)) resume = { mode, hex: curHex(), pristine }; + mode = next; + showTune = false; + commit(); + return; + } + if (styles.includes(mode)) { + mode = resume?.mode ?? 'blue'; + setSliders(resume?.hex ?? _swatchFill('blue')); + } + showTune = true; + commit(resume?.pristine); + }, + }) + : null; + if (styleBox) rows[0].appendChild(styleBox.el); /* The first paint restores stored state, so it must not animate. */ let _painted = false; /* The value as it was stored. The sliders are integers, so reading a colour @@ -235,13 +270,15 @@ export function renderColorControl( b.classList.toggle('on', on); }); const rb = q('.cc-rainbow', container); - if (rb) rb.classList.toggle('on', showTune && (mode === 'color' || hues.includes(mode))); + if (rb) + rb.classList.toggle('on', styled ? mode === 'color' : showTune && (mode === 'color' || hues.includes(mode))); const sw = qa('.cc-swatch', container); const preset = sw.some(b => b !== rb && b.classList.contains('on')); sw.forEach(b => b.setAttribute('aria-pressed', String(b === rb ? mode === 'color' && !preset : b.classList.contains('on'))), ); - rb?.setAttribute('aria-expanded', String(showTune)); + if (!styled) rb?.setAttribute('aria-expanded', String(showTune)); + styleBox?.setValue(styleOf()); reveal(tune, showTune, !_painted); if (!codeRv.closest('.editing')) { codeRv.textContent = mode === 'color' ? hex : kwName(mode); @@ -250,8 +287,8 @@ export function renderColorControl( hidden.value = mode === 'color' ? (pristine ?? hex) : mode; _painted = true; } - const commit = () => { - pristine = null; + const commit = (keep = null) => { + pristine = keep; paint(); onChange?.(hidden.value); }; @@ -265,6 +302,11 @@ export function renderColorControl( qa('.cc-swatch', container).forEach(b => b.addEventListener('click', () => { const v = b.dataset.v || ''; + if (v === 'custom' && styled) { + mode = 'color'; + commit(); + return; + } if (v === 'custom') { showTune = !(showTune && (mode === 'color' || hues.includes(mode))); if (showTune && !hues.includes(mode)) mode = 'color'; diff --git a/ui/js/admin-list.js b/ui/js/admin-list.js index 8bf320dd..00aff25f 100644 --- a/ui/js/admin-list.js +++ b/ui/js/admin-list.js @@ -19,7 +19,7 @@ import { paintIcon } from '/js/admin-shared.js?v=1c01be9a'; import { clr, el, focusFirst, initial, qa, setUserText } from '/js/utils.js?v=705ca896'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { t } from '/js/i18n.js?v=ed7c5565'; -import { sizeLabel } from '/js/admin-widget-form.js?v=b76d944f'; +import { sizeLabel } from '/js/admin-widget-form.js?v=1cb745cf'; import { widgetGlyph } from '/js/widget-glyphs.js?v=b383b7c8'; import { iconSvg } from '/js/icon-set.js?v=628341d4'; diff --git a/ui/js/admin-settings.js b/ui/js/admin-settings.js index 965e7426..7fbea010 100644 --- a/ui/js/admin-settings.js +++ b/ui/js/admin-settings.js @@ -14,7 +14,7 @@ import { confirmText, promptModal } from '/js/modal.js?v=6b0320bd'; import { el, inp, setUserText } from '/js/utils.js?v=705ca896'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; import { serialWrites } from '/js/admin-save-logic.js?v=5613a9fa'; -import { renderColorControl } from '/js/admin-color-control.js?v=ef504f96'; +import { renderColorControl } from '/js/admin-color-control.js?v=4083ea3b'; import { BACKDROP } from '/js/background.js?v=25428bf2'; import { ALLOWED_HOSTS_MAX, diff --git a/ui/js/admin-widget-form.js b/ui/js/admin-widget-form.js index e96ddaf7..e1d45f90 100644 --- a/ui/js/admin-widget-form.js +++ b/ui/js/admin-widget-form.js @@ -1,7 +1,7 @@ import { state } from '/js/admin-state.js?v=af772a1b'; import { PE_SVG, initInlineEdit, swapContent } from '/js/admin-shared.js?v=1c01be9a'; import { createListbox } from '/js/listbox.js?v=f814170f'; -import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=355b495e'; +import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=1807c371'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { sizesForView, widgetConfigMode, rejectionLines, carriesTypedValues } from '/js/admin-logic.js?v=1cca36f5'; import { t } from '/js/i18n.js?v=ed7c5565'; diff --git a/ui/js/admin.js b/ui/js/admin.js index 29c6bea5..e2664fee 100644 --- a/ui/js/admin.js +++ b/ui/js/admin.js @@ -1,7 +1,7 @@ -import { buildAppForm, buildFolderForm, captureActLabels, serializeKvRows } from '/js/admin-app-form.js?v=622c5ce8'; +import { buildAppForm, buildFolderForm, captureActLabels, serializeKvRows } from '/js/admin-app-form.js?v=f0da79cf'; import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=5246ff39'; import { recoveryShown } from '/js/config-recovery.js?v=85138697'; -import { focusRow, initList, render, syncFilterUI } from '/js/admin-list.js?v=61d89772'; +import { focusRow, initList, render, syncFilterUI } from '/js/admin-list.js?v=411d6f37'; import { resolveAdminSection } from '/js/admin-logic.js?v=1cca36f5'; import { afterImport, @@ -23,7 +23,7 @@ import { showBgFields, showWallpaperFile, wireUnsplashKey, -} from '/js/admin-settings.js?v=de5b1723'; +} from '/js/admin-settings.js?v=4b374391'; import { apiGet, apiPost, @@ -39,7 +39,7 @@ import { withheldText, } from '/js/admin-shared.js?v=1c01be9a'; import { collapsedFolders, filter, state } from '/js/admin-state.js?v=af772a1b'; -import { buildWidgetForm } from '/js/admin-widget-form.js?v=b76d944f'; +import { buildWidgetForm } from '/js/admin-widget-form.js?v=1cb745cf'; import { initFluidHover } from '/js/fluid-hover.js?v=cb886e86'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; import { initGlideSelect, syncGlideSelect } from '/js/glide-select.js?v=8b39e9d0'; diff --git a/ui/js/widget-config-form.js b/ui/js/widget-config-form.js index a420a6bf..f405f060 100644 --- a/ui/js/widget-config-form.js +++ b/ui/js/widget-config-form.js @@ -5,7 +5,7 @@ import { t } from '/js/i18n.js?v=ed7c5565'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { reveal, wireInlineEdit } from '/js/admin-shared.js?v=1c01be9a'; import { createListbox } from '/js/listbox.js?v=f814170f'; -import { renderColorControl } from '/js/admin-color-control.js?v=ef504f96'; +import { renderColorControl } from '/js/admin-color-control.js?v=4083ea3b'; import { seedCarried, applyOptionSet, diff --git a/ui/test/i18n-coverage.test.mjs b/ui/test/i18n-coverage.test.mjs index 4da56d6a..924a2d10 100644 --- a/ui/test/i18n-coverage.test.mjs +++ b/ui/test/i18n-coverage.test.mjs @@ -78,6 +78,8 @@ const SAME_ON_PURPOSE = { 'color.orange', 'color.cyan', 'color.indigo', + /* "Style" is the French word too. */ + 'color.style', /* Info and the version line are written the same way in French. */ 'general.logInfo', /* "Image" is the French word too. */