diff --git a/ui/test/weather-artwork.test.mjs b/ui/test/weather-artwork.test.mjs new file mode 100644 index 00000000..a3f1eda5 --- /dev/null +++ b/ui/test/weather-artwork.test.mjs @@ -0,0 +1,196 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { test } from 'node:test'; + +const page = fs.readFileSync(new URL('../widgets/weather/index.html', import.meta.url), 'utf8'); +const art = /\/\* ---- artwork[\s\S]*?\nfunction buildIcon\([\s\S]*?\n\}/.exec(page)?.[0]; +const { buildIcon, cloudPath } = new Function(`${art}; return { buildIcon, cloudPath };`)(); + +const CONDITIONS = ['clear', 'few', 'scattered', 'broken', 'mist', 'shower', 'rain', 'thunder', 'snow']; + +test('every condition draws by day and by night', () => { + assert.ok(art, 'the artwork block is not in the weather page'); + for (const cond of CONDITIONS) + for (const day of [true, false]) { + const svg = buildIcon(cond, day); + assert.ok(svg.length > 100, `${cond} drew nothing`); + assert.doesNotMatch(svg, /NaN|undefined/, `${cond} ${day ? 'day' : 'night'} has a broken value`); + } +}); + +test('the cloud rests both puffs on a flat base', () => { + const d = cloudPath(2, 14, 96); + const [, sx, base, bx] = /^M([\d.]+) ([\d.]+)H([\d.]+)A/.exec(d).map(Number); + assert.ok(bx > sx, 'the base runs left to right'); + assert.ok(Math.abs(base - (14 + 0.702 * 96)) < 0.01); + assert.match(d, /z$/); +}); + +const ORIGIN_RULE = /\.ico (\.wx-[\w-]+(?:,\.ico \.wx-[\w-]+)*)\{transform-box:fill-box;transform-origin:center\}/; + +test('no part with its own transform gets a centred transform origin', () => { + const classes = ORIGIN_RULE.exec(page)?.[1] + .split(',.ico .') + .map(c => c.replace(/^\./, '')); + assert.ok(classes?.length, 'the transform-origin rule is gone'); + for (const cond of CONDITIONS) + for (const day of [true, false]) + for (const [tag] of buildIcon(cond, day).matchAll(/<[a-z]+\b[^>]*>/g)) { + const cls = /class="([^"]+)"/.exec(tag)?.[1].split(' ') ?? []; + if (cls.some(c => classes.includes(c))) + assert.doesNotMatch(tag, /\stransform=/, `${cond}: ${tag} would move off its place`); + } +}); + +test('css motion stops for reduced motion and while the card is off screen', () => { + assert.match(page, /@media\(prefers-reduced-motion:reduce\)\{\.ico \*\{animation:none\}/); + assert.match(page, /\.ico\.off \*\{animation-play-state:paused\}/); + assert.match( + page, + /whileOnScreen\(icoEl,\s*v\s*=>\s*\{onScreen=v;icoEl\.classList\.toggle\('off',!v\);runAmbient\(\);\}\)/, + ); +}); + +test('nothing blurred is moved by a frame-rate css animation', () => { + for (const cls of ['wx-front', 'wx-back', 'wx-mist', 'wx-orb', 'wx-cloud']) + assert.doesNotMatch(page, new RegExp(`\\.${cls}[^{]*\\{[^}]*animation`), `.${cls} has a css animation`); + for (const cond of CONDITIONS) + for (const day of [true, false]) + for (const [part] of buildIcon(cond, day).matchAll(/ { + const handler = /icoEl\.addEventListener\('animationiteration',(e=>\{[\s\S]*?\n\})\);/.exec(page)?.[1]; + assert.ok(handler, 'the restart handler is gone'); + const restart = new Function(`return ${handler}`)(); + const seen = new Set(); + let transform; + let strikes = 0; + const lane = (flip = false) => ({ + classList: { contains: c => c === 'wx-pos' || (flip && c === 'wx-flip') }, + dataset: { lo: '30', hi: '64' }, + setAttribute: (_, v) => { + transform = v; + }, + querySelector: () => ({ + animate: () => { + strikes++; + }, + }), + }); + for (let i = 0; i < 40; i++) { + restart({ target: { parentNode: lane(i % 2 === 1) } }); + const x = +/^translate\(([\d.]+) 0\)/.exec(transform)[1]; + assert.ok(x >= 30 && x <= 64, `${x} left the lane`); + seen.add(x); + if (i % 2 === 0) assert.doesNotMatch(transform, /scale/); + } + assert.ok(seen.size > 1, 'the spot never changes'); + assert.equal(strikes, 20, 'each new strike draws the bolt'); + transform = undefined; + restart({ target: { parentNode: { classList: { contains: () => false } } } }); + assert.equal(transform, undefined); +}); + +test('the lightning flash is hidden at rest', () => { + for (const day of [true, false]) + assert.match(buildIcon('thunder', day), /]*opacity="0"/); +}); + +test('the lightning flash drifts with the cloud it lights', () => { + for (const day of [true, false]) assert.match(buildIcon('thunder', day), / { + for (const cond of ['shower', 'rain', 'snow']) + for (const day of [true, false]) { + const svg = buildIcon(cond, day); + assert.match(svg, /]*opacity="0\.5+"/, `${cond} has no faint far layer`); + assert.doesNotMatch( + svg, + /]*opacity=/, + `${cond}: the animation overrides the faint layer`, + ); + } +}); + +test('falling parts start in separate lanes, so a still picture shows each one', () => { + for (const cond of ['shower', 'rain', 'snow']) + for (const day of [true, false]) { + const near = [...buildIcon(cond, day).matchAll(/]*translate\(([\d.]+) 0\)"> +m[1], + ); + const gaps = near + .sort((a, b) => a - b) + .slice(1) + .map((x, i) => x - near[i]); + assert.ok(Math.min(...gaps) >= 8, `${cond}: two parts start ${Math.min(...gaps)} apart`); + } +}); + +test('reduced motion shows rain and snow below the cloud, not behind it', () => { + const still = /@media\(prefers-reduced-motion:reduce\)\{([^@]*?)\}\s*\n/.exec(page)?.[1]; + assert.match(still, /\.ico \.wx-drop\{transform:translate\(-?[\d.]+px,[\d.]+px\)\}/); + assert.match(still, /\.ico \.wx-flake\{transform:translate\(-?[\d.]+px,[\d.]+px\)\}/); +}); diff --git a/ui/test/widget-colours.test.mjs b/ui/test/widget-colours.test.mjs index 6f7a0f61..c99caa91 100644 --- a/ui/test/widget-colours.test.mjs +++ b/ui/test/widget-colours.test.mjs @@ -57,18 +57,6 @@ const BESPOKE = new Map( '#E5E5EA': 'Analog clock dial in the dark theme', '#007CA6': 'Jellyfin brand', '#4CAF50': 'Emby brand', - /* An embedded illustration set, one path list per condition. Artwork, not - interface colour. */ - '#DAD6CB': 'weather illustration', - '#E9E5D9': 'weather illustration', - '#3E4962': 'weather illustration', - '#5C6C91': 'weather illustration', - '#7388B6': 'weather illustration', - '#A2BFFF': 'weather illustration', - '#1F9FB0': 'weather illustration', - '#BEBEBE': 'weather illustration', - '#A2A2A2': 'weather, cloud by day', - '#BCBCBC': 'weather, cloud by night', '#F0852A': 'weather, temperature reading', /* Drawn from the title when a book has no colour of its own. */ '#E7E0D2': 'books, spine', diff --git a/ui/widgets/weather/index.html b/ui/widgets/weather/index.html index 7fa61f0a..1ccc01bf 100644 --- a/ui/widgets/weather/index.html +++ b/ui/widgets/weather/index.html @@ -17,15 +17,19 @@ .cond{font-size:15px;font-weight:600;color:#2C2C2E;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .temp{font-size:42px;font-weight:700;line-height:1;letter-spacing:-0.01em;color:#f0852a} .msg{margin-top:3px} - @keyframes wxdrop{0%{transform:translateY(-6px);opacity:0}25%{opacity:1}75%{opacity:1}100%{transform:translateY(14px);opacity:0}} - .wx-drop{animation:wxdrop 0.9s linear infinite;animation-delay:calc(var(--i)*-0.28s);transform-origin:center} - @keyframes wxflake{0%{transform:translate(-1.5px,-6px) rotate(0);opacity:0}20%{opacity:1}80%{opacity:1}100%{transform:translate(1.5px,14px) rotate(40deg);opacity:0}} - .wx-flake{animation:wxflake 2.7s linear infinite;animation-delay:calc(var(--i)*-0.9s);transform-origin:center} - @keyframes wxbolt{0%,66%,100%{opacity:0.9}70%{opacity:1}73%{opacity:0.4}76%{opacity:1}80%{opacity:0.85}} - .wx-bolt{animation:wxbolt 2.5s ease-in-out infinite} - @keyframes wxmist{0%,100%{transform:translateX(-3px)}50%{transform:translateX(4px)}} - .wx-mist{animation:wxmist 5s ease-in-out infinite;animation-delay:calc(var(--i)*0.5s)} - @media(prefers-reduced-motion:reduce){.wx-drop,.wx-flake,.wx-bolt,.wx-mist{animation:none;opacity:1}} + /* Only on elements without a transform attribute. A transform-origin on one moves it. */ + .ico .wx-drop,.ico .wx-flake{transform-box:fill-box;transform-origin:center} + @keyframes wxdrop{0%{transform:translate(0,0);opacity:0}22%{opacity:1}80%{opacity:1}100%{transform:translate(-7.5px,15.2px);opacity:0}} + @keyframes wxflake{0%{transform:translate(0,-2px) rotate(0);opacity:0}18%{opacity:1}50%{transform:translate(2px,6px) rotate(45deg)}85%{opacity:1}100%{transform:translate(-1px,15px) rotate(90deg);opacity:0}} + @keyframes wxflash{0%,100%{opacity:0}1.5%{opacity:.5}4%{opacity:.08}6.5%{opacity:.35}12%{opacity:0}} + @keyframes wxbolt{0%{opacity:1}5%{opacity:.7}7%{opacity:1}20%,100%{opacity:.6}} + .wx-drop{animation:wxdrop 1.1s cubic-bezier(.45,0,.9,.6) infinite} + .wx-flake{animation:wxflake 4.2s linear infinite} + .wx-flash{animation:wxflash 6.5s linear infinite} + .wx-bolt{animation:wxbolt 6.5s linear infinite} + .wx-draw{stroke-dasharray:1} + .ico.off *{animation-play-state:paused} + @media(prefers-reduced-motion:reduce){.ico *{animation:none}.ico .wx-drop{transform:translate(-3.75px,7.6px)}.ico .wx-flake{transform:translate(1px,6px)}} .ico .ph{fill:rgba(235,235,245,0.16)} html[data-theme="light"] .ico .ph{fill:rgba(60,60,67,0.12)} .sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)} @@ -42,10 +46,9 @@