Files
stack/packages/webui/tests/workflows-browser.test.mjs

289 lines
23 KiB
JavaScript

// Single pane SP9 (#1873): an organization's workflows, read only. #/orgs/<id>/workflows lists them,
// #/orgs/<id>/workflows/<name> draws one as an inline-SVG graph (one box per step, an arrowed line per
// hand-off), and the organization page carries a small card. The data is what GET /api/orgs/<id>/workflows
// returns from the organization file: steps in order, each with a title, a state and its roles. Nothing
// else is drawn. A stopped organization is not read and says why.
import test from 'node:test';
import assert from 'node:assert/strict';
import { startServer } from '../src/serve.mjs';
import { createDevOwner } from '../src/dev-owner.mjs';
import { browser } from './browser.mjs';
import { close } from './fixture.mjs';
const XSS = '<img src=x onerror=window.__sp9pwn=1>';
const at = minutes => new Date(Date.now() - minutes * 60000).toISOString();
const waitFor = b => (expression, what = expression) => b.evaluate(`(async()=>{for(let i=0;i<300;i++){if(${expression})return true;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(what)}+': '+document.body.innerText.slice(0,1500))})()`);
// The coding starter pack's workflow as pack.apply writes it (the pack's step ids are not kept).
const CHANGE = {
description: 'One change from task to merge.',
steps: [
{ title: 'Write the task', roles: ['pm'], state: 'todo' },
{ title: 'Implement the change', roles: ['coder'], state: 'in-progress' },
{ title: 'Review the change', roles: ['reviewer'], state: 'in-review' },
{ title: 'Approve the merge', roles: ['human', 'merge-gate'], state: 'done' },
],
};
const DOCS = {
acme: {
definitions: { pm: {}, coder: {}, reviewer: {} },
workflows: { change: CHANGE, intake: { title: `Intake ${XSS}`, steps: [{ title: `Sort ${XSS}`, state: 'todo', roles: ['human'] }] } },
projects: { site: { workflows: { change: { title: 'Site release', steps: [{ title: 'Build', state: 'in-progress', roles: ['coder'] }, { title: 'Ship', state: 'done', roles: ['human'] }] } } }, notes: { kind: 'library', workflows: { hidden: { steps: [{ title: 'x', state: 'todo', roles: ['human'] }] } } } },
},
bare: { definitions: {}, projects: {} },
};
const LIST = [
{ id: 'acme', label: 'Acme Studio', theme: 'cobalt', busHealthy: true },
{ id: 'bare', label: 'Bare Works', theme: 'grove', busHealthy: true },
{ id: 'gamma', label: 'Gamma Home', theme: 'rosewood', busHealthy: false, busStopped: true },
];
function organizations(reads) {
const member = org => ({
id: org.id, doc: { appearance: { theme: org.theme }, roles: {}, projects: {} },
status: async () => (org.busHealthy ? { healthy: true, pid: 4242, boot: null } : { healthy: false, boot: { ok: false, at: at(90), lastGood: null, reason: 'stopped by the owner' } }),
options: () => ({ bus: { inbox: async () => [], agents: async () => [], 'thread.claims': async () => [], tasks: async () => [] }, chat: { threads: async () => [], projects: async () => [] } }),
});
return {
list: async () => LIST,
members: async () => LIST.map(member),
resolve: async id => {
const org = LIST.find(o => o.id === id); if (!org) return null;
const workflowsRead = async () => { reads.push(id); return { revision: 'a'.repeat(64), document: structuredClone(DOCS[id] ?? {}), templates: ['writer'] }; };
if (!org.busHealthy) return { busHealthy: false, reads: null, bus: {}, workflowsRead, chat: { projects: async () => [], threads: async () => [] } };
return {
busHealthy: true, workflowsRead, packsWrite: async () => ({}), decisionWrite: async () => ({}), send: async () => ({}),
requestRead: async verb => { if (verb === 'packs.console') return { packs: [] }; if (verb === 'autonomy.get.console') return { revision: 'e'.repeat(64), autonomy: {}, levels: {} }; throw Object.assign(new Error('not-configured'), { code: 'not-configured' }); },
reads: { business: async () => ({ business: { id, projects: [], instances: [] } }), settings: async () => ({}), queue: async () => ({ rows: [] }) },
bus: { instances: async () => ({ instances: {} }), agents: async () => [], 'thread.claims': async () => [], tasks: async () => [], inbox: async () => [], messages: async () => ({ messages: [] }) },
chat: { projects: async () => [], threads: async () => [] },
};
},
};
}
async function setup(t, { path, viewport = [1440, 900], mode = null } = {}) {
const reads = [];
const server = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), log: () => {}, organizations: organizations(reads) });
t.after(() => close(server));
const b = await browser({ machine: false, args: ['--disable-extensions'] });
t.after(() => b.close());
await b.viewport(...viewport);
// The Console's own mode wins over the OS setting (Harbor in the dark by default), so a theme test sets it per organization.
if (mode) await b.call('Page.addScriptToEvaluateOnNewDocument', { source: `try { for (const o of ['acme','bare','gamma']) localStorage.setItem('mosaic-console-appearance:' + o, JSON.stringify({ theme: 'harbor', mode: ${JSON.stringify(mode)} })); } catch {}` });
await b.navigate(`http://127.0.0.1:${server.address().port}/${path}`);
return { b, reads, wait: waitFor(b) };
}
const boxes = `[...document.querySelectorAll('[data-wfg-graph] [data-wfg-step]')]`;
test('the list names each workflow by scope with its steps in order, links to its graph, and keeps titles as text', { timeout: 60000 }, async t => {
const { b, wait } = await setup(t, { path: '#/orgs/acme/workflows' });
await wait(`document.querySelector('[data-wfg-list] [data-wfg-item="change"]')`, 'workflow list');
assert.equal(await b.evaluate(`document.querySelector('#bus-view h1').textContent`), 'Workflows');
// Organization first, then each code project; a library project has no workflows.
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-wfg-scope-list]')].map(n=>[n.dataset.wfgScopeList,n.querySelector('h2').textContent])`), [['', 'Organization'], ['site', 'Project site']]);
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-wfg-scope-list=""] [data-wfg-item]')].map(n=>n.dataset.wfgItem)`), ['change', 'intake']);
const change = `[data-wfg-scope-list=""] [data-wfg-item="change"]`;
assert.equal(await b.evaluate(`document.querySelector('${change} a[data-wfg-open]').getAttribute('href')`), '#/orgs/acme/workflows/change');
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-scope-list="site"] [data-wfg-item="change"] a[data-wfg-open]').getAttribute('href')`), '#/orgs/acme/workflows/change?project=site');
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('${change} [data-wfg-chain] li')].map(l=>l.textContent)`), ['Write the task', 'Implement the change', 'Review the change', 'Approve the merge']);
assert.match(await b.evaluate(`document.querySelector('${change}').textContent`), /4 steps/);
assert.match(await b.evaluate(`document.querySelector('${change}').textContent`), /One change from task to merge\./);
// The title is shown as written.
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-item="intake"] a[data-wfg-open]').textContent`), `Intake ${XSS}`);
assert.equal(await b.evaluate(`document.querySelectorAll('#bus-view img').length`), 0);
assert.equal(await b.evaluate(`window.__sp9pwn===undefined`), true);
assert.equal(await b.evaluate(`document.querySelector('#bus-view [data-ws-unread]')`), null, 'a running bus has no unread note');
// The trail names the organization and the page.
await wait(`document.querySelector('#breadcrumbs [data-crumb-org]')?.textContent === 'Acme Studio'`, 'org crumb');
// A click opens the graph.
await b.evaluate(`document.querySelector('${change} a[data-wfg-open]').click();true`);
await wait(`location.hash === '#/orgs/acme/workflows/change' && document.querySelectorAll('[data-wfg-graph] [data-wfg-step]').length === 4`, 'graph after click');
});
test('the graph draws one box per step in order with its roles and one arrowed edge per hand-off', { timeout: 60000 }, async t => {
const { b, wait } = await setup(t, { path: '#/orgs/acme/workflows/change' });
await wait(`${boxes}.length === 4`, 'four boxes');
assert.equal(await b.evaluate(`document.querySelector('#bus-view h1').textContent`), 'change');
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-graph]').tagName.toLowerCase()`), 'svg');
assert.deepEqual(await b.evaluate(`${boxes}.map(g=>g.dataset.wfgStep)`), ['1', '2', '3', '4']);
assert.deepEqual(await b.evaluate(`${boxes}.map(g=>g.querySelector('[data-wfg-title]').textContent)`), ['Write the task', 'Implement the change', 'Review the change', 'Approve the merge']);
// One role per line.
assert.deepEqual(await b.evaluate(`${boxes}.map(g=>[...g.querySelectorAll('[data-wfg-roles] tspan')].map(t=>t.textContent))`), [['pm'], ['coder'], ['reviewer'], ['human', 'merge-gate']]);
// A role that resolves to no definition or template (and is not human) is marked on its box.
assert.deepEqual(await b.evaluate(`${boxes}.map(g=>g.querySelectorAll('[data-wfg-missing]').length)`), [0, 0, 0, 1]);
// At 1440 the steps run left to right.
const xs = await b.evaluate(`${boxes}.map(g=>g.querySelector('rect').getBoundingClientRect().left)`);
assert.deepEqual([...xs].sort((p, q) => p - q), xs);
assert.equal(new Set(xs).size, 4);
// One edge per hand-off, from each box to the next, with an arrowhead.
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-wfg-graph] [data-wfg-edge]')].map(e=>e.dataset.wfgEdge)`), ['1-2', '2-3', '3-4']);
const touches = await b.evaluate(`[...document.querySelectorAll('[data-wfg-graph] [data-wfg-edge]')].map(e=>{
const [f,t]=e.dataset.wfgEdge.split('-'), box=n=>document.querySelector('[data-wfg-graph] [data-wfg-step="'+n+'"] rect').getBBox();
const p=e.getPointAtLength(0), q=e.getPointAtLength(e.getTotalLength()), a=box(f), z=box(t), near=(pt,r)=>pt.x>=r.x-14&&pt.x<=r.x+r.width+14&&pt.y>=r.y-14&&pt.y<=r.y+r.height+14;
return near(p,a)&&near(q,z)&&!!e.getAttribute('marker-end');})`);
assert.deepEqual(touches, [true, true, true]);
// The graph scrolls inside its own box, never the page.
assert.equal(await b.evaluate(`document.documentElement.scrollWidth<=innerWidth`), true);
// Nothing running is drawn: no read links a task to a step.
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-running]')`), null);
});
test('selecting a step shows what the data holds for it and survives a repaint', { timeout: 60000 }, async t => {
const { b, wait, reads } = await setup(t, { path: '#/orgs/acme/workflows/change' });
await wait(`${boxes}.length === 4`, 'four boxes');
assert.match(await b.evaluate(`document.querySelector('[data-wfg-detail]').textContent`), /Select a step/);
await b.evaluate(`document.querySelector('[data-wfg-step="2"]').dispatchEvent(new MouseEvent('click',{bubbles:true,cancelable:true}));true`);
await wait(`/[?&]step=2/.test(location.hash) && document.querySelector('[data-wfg-detail] [data-wfg-detail-title]')?.textContent === 'Implement the change'`, 'detail for step 2');
assert.equal(await b.evaluate(`location.hash`), '#/orgs/acme/workflows/change?step=2');
const field = name => b.evaluate(`document.querySelector('[data-wfg-detail] [data-wfg-field="${name}"]')?.textContent.replace(/\\s+/g,' ').trim() ?? null`);
assert.equal(await field('position'), 'Step 2 of 4');
assert.equal(await field('roles'), 'coder');
assert.equal(await field('state'), 'In progress');
assert.equal(await field('after'), 'Step 1, Write the task');
assert.equal(await field('next'), 'Step 3, Review the change');
// The workflow file holds no gate or inputs for a step; the panel says so once instead of inventing them.
assert.match(await b.evaluate(`document.querySelector('[data-wfg-detail]').textContent`), /records no gate or inputs/);
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-step="2"]').getAttribute('aria-current')`), 'step');
assert.equal(await b.evaluate(`document.querySelectorAll('[data-wfg-graph] [aria-current]').length`), 1);
// The last step hands on to nothing; an unknown role is named in the panel too.
await b.evaluate(`document.querySelector('[data-wfg-step="4"]').dispatchEvent(new MouseEvent('click',{bubbles:true,cancelable:true}));true`);
await wait(`document.querySelector('[data-wfg-detail] [data-wfg-detail-title]')?.textContent === 'Approve the merge'`, 'detail for step 4');
assert.equal(await field('next'), 'Nothing: this is the last step');
assert.match(await field('roles'), /human \(a person\)/);
assert.match(await field('roles'), /merge-gate names no current role/);
// Keyboard: a step box takes focus and opens on Enter, and keeps focus through the repaint.
await b.evaluate(`document.querySelector('[data-wfg-step="1"]').focus();true`);
assert.equal(await b.evaluate(`document.activeElement.dataset.focusKey`), 'wfg-step:1');
await b.key('Enter');
await wait(`document.querySelector('[data-wfg-detail] [data-wfg-detail-title]')?.textContent === 'Write the task' && document.activeElement?.dataset.focusKey === 'wfg-step:1'`, 'Enter opens step 1 and keeps focus');
assert.equal(await b.evaluate(`location.hash`), '#/orgs/acme/workflows/change?step=1');
await b.evaluate(`document.querySelector('[data-wfg-step="4"]').focus();true`);
await b.key('Enter');
await wait(`document.querySelector('[data-wfg-detail] [data-wfg-detail-title]')?.textContent === 'Approve the merge'`, 'Enter opens step 4');
// A repaint (the poll) keeps the selection.
const before = reads.length;
await b.evaluate(`document.dispatchEvent(new CustomEvent('mosaic-workspace-render'));true`);
await wait(`document.querySelector('[data-wfg-detail] [data-wfg-detail-title]')?.textContent === 'Approve the merge'`, 'kept after repaint');
assert.ok(reads.length >= before);
// A project's workflow of the same name is its own graph.
await b.evaluate(`location.hash = '#/orgs/acme/workflows/change?project=site'`);
await wait(`${boxes}.length === 2`, 'project graph');
assert.match(await b.evaluate(`document.querySelector('#bus-view').textContent`), /Project site/);
// A name the scope does not hold says so.
await b.evaluate(`location.hash = '#/orgs/acme/workflows/nope'`);
await wait(`/No workflow named nope/.test(document.querySelector('#bus-view').textContent)`, 'unknown workflow');
});
test('a step with more roles than fit shows three lines, the last counting the rest, all inside its box', { timeout: 60000 }, async t => {
// The business-management pack's first step names four long roles.
const roles = ['chief-executive-officer', 'chief-financial-officer', 'chief-information-officer', 'chief-information-security-officer'];
DOCS.acme.workflows.brief = { steps: [{ title: 'Draft the brief', state: 'todo', roles }, { title: 'WWWWWWWWWWWWWWWWWWWWWWWW WWWWWWWWWWWWWWWWWWWWWWWW WWWWWWWWWWWWWWWWWWWW', state: 'done', roles: ['human'] }] };
t.after(() => { delete DOCS.acme.workflows.brief; });
for (const viewport of [[1440, 900], [390, 844]]) {
await t.test(String(viewport[0]), async t2 => {
const { b, wait } = await setup(t2, { path: '#/orgs/acme/workflows/brief', viewport });
await wait(`${boxes}.length === 2`, 'two boxes');
const one = `document.querySelector('[data-wfg-step="1"]')`;
// A role longer than the box is shortened with an ellipsis (the full name is in the box's title and the step panel).
const lines = await b.evaluate(`[...${one}.querySelectorAll('[data-wfg-roles] tspan')].map(t=>t.textContent)`);
assert.equal(lines.length, 3);
assert.equal(lines[2], '+2 more');
lines.slice(0, 2).forEach((line, i) => assert.ok(line === roles[i] || (line.endsWith('…') && roles[i].startsWith(line.slice(0, -1))), line));
if (viewport[0] === 390) assert.deepEqual(lines.slice(0, 2), roles.slice(0, 2), 'the 390 column is wide enough for the whole name');
assert.equal(await b.evaluate(`${one}.querySelector('[data-wfg-roles] [data-wfg-more]').textContent`), '+2 more');
// The box names every role for a pointer or a screen reader.
const named = await b.evaluate(`${one}.querySelector('title').textContent + ' ' + (${one}.getAttribute('aria-label') ?? '')`);
for (const r of roles) assert.ok(named.includes(r), r);
// Every text line ends inside its own box.
const out = await b.evaluate(`${boxes}.flatMap(g=>{const r=g.querySelector('rect').getBoundingClientRect();return [...g.querySelectorAll('text tspan, text:not(:has(tspan))')].filter(t=>{const q=t.getBoundingClientRect();const c=g.querySelector('clipPath rect')?.getBoundingClientRect()??r;return Math.min(q.right,c.right)>r.right+0.5||Math.min(q.bottom,c.bottom)>r.bottom+0.5}).map(t=>t.textContent)})`);
assert.deepEqual(out, []);
assert.equal(await b.evaluate(`document.documentElement.scrollWidth<=innerWidth`), true);
});
}
});
test('an organization with no workflows says so on its page and its card, and links to apply a pack', { timeout: 60000 }, async t => {
const { b, wait } = await setup(t, { path: '#/orgs/bare/workflows' });
await wait(`document.querySelector('[data-wfg-list] [data-wfg-none]')`, 'none note');
assert.match(await b.evaluate(`document.querySelector('[data-wfg-none]').textContent`), /No workflows in Bare Works yet/);
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-list] [data-wfg-apply]').getAttribute('href')`), '#/orgs/bare/organization');
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-list] [data-wfg-item]')`), null);
// The organization page's card says the same and links the list.
await b.evaluate(`location.hash = '#/orgs/bare/organization'`);
await wait(`document.querySelector('[data-org-dashboard] [data-wfg-card] [data-wfg-none]')`, 'card none');
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-card] [data-wfg-all]').getAttribute('href')`), '#/orgs/bare/workflows');
// Apply a pack takes the person to the pack picker on the same page.
await wait(`document.querySelector('[data-packs]')`, 'packs section');
await b.evaluate(`document.querySelector('[data-wfg-card] [data-wfg-apply]').click();true`);
await wait(`(()=>{const r=document.querySelector('[data-packs]').getBoundingClientRect();return r.top>=0&&r.top<innerHeight})()`, 'packs in view');
assert.equal(await b.evaluate(`location.hash`), '#/orgs/bare/organization');
});
test('the organization page card lists each workflow as a link to its graph', { timeout: 60000 }, async t => {
const { b, wait, reads } = await setup(t, { path: '#/orgs/acme/organization' });
await wait(`document.querySelectorAll('[data-org-dashboard] [data-wfg-card] [data-wfg-card-item]').length === 3`, 'card items');
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-wfg-card] [data-wfg-card-item]')].map(a=>[a.getAttribute('href'),a.textContent.replace(/\\s+/g,' ').trim()])`), [
['#/orgs/acme/workflows/change', 'change 4 steps'],
['#/orgs/acme/workflows/intake', `Intake ${XSS} 1 step`],
['#/orgs/acme/workflows/change?project=site', 'Site release 2 steps, project site'],
]);
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-card] [data-wfg-all]').getAttribute('href')`), '#/orgs/acme/workflows');
assert.equal(await b.evaluate(`document.querySelectorAll('[data-org-dashboard] img').length`), 0);
assert.equal(await b.evaluate(`window.__sp9pwn===undefined`), true);
// The card reuses the page's one workflow read; it sends no second one.
assert.equal(reads.filter(id => id === 'acme').length, 1);
await b.evaluate(`document.querySelector('[data-wfg-card] [data-wfg-card-item]').click();true`);
await wait(`location.hash === '#/orgs/acme/workflows/change' && document.querySelectorAll('[data-wfg-graph] [data-wfg-step]').length === 4`, 'graph from card');
});
test('a stopped organization is not read: the page and card say the bus is stopped with the reason, never "no workflows"', { timeout: 60000 }, async t => {
const { b, wait, reads } = await setup(t, { path: '#/orgs/gamma/workflows' });
await wait(`document.querySelector('[data-wfg-list] [data-ws-unread]')`, 'unread note');
const note = await b.evaluate(`document.querySelector('[data-wfg-list] [data-ws-unread]').textContent`);
assert.match(note, /^Not read: Gamma Home/);
assert.match(note, /stopped by the owner/);
assert.doesNotMatch(await b.evaluate(`document.querySelector('#bus-view').textContent`), /No workflows/);
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-none],[data-wfg-item]')`), null);
// The graph address says the same.
await b.evaluate(`location.hash = '#/orgs/gamma/workflows/change'`);
await wait(`document.querySelector('[data-wfg-page] [data-ws-unread]')`, 'graph unread note');
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-graph]')`), null);
assert.doesNotMatch(await b.evaluate(`document.querySelector('#bus-view').textContent`), /No workflow/);
// The organization page card.
await b.evaluate(`location.hash = '#/orgs/gamma/organization'`);
await wait(`document.querySelector('[data-org-dashboard] [data-wfg-card][data-bus-stopped]')`, 'stopped card');
assert.match(await b.evaluate(`document.querySelector('[data-wfg-card]').textContent`), /Not read: the bus is stopped\./);
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-card] [data-wfg-none]')`), null);
assert.equal(reads.filter(id => id === 'gamma').length, 0, 'a stopped organization is not read');
});
test('at 390 the graph runs top to bottom inside its box and the page does not scroll sideways', { timeout: 60000 }, async t => {
for (const mode of ['light', 'dark']) {
const dark = mode === 'dark';
await t.test(mode, async t2 => {
const { b, wait } = await setup(t2, { path: '#/orgs/acme/workflows/change?step=3', viewport: [390, 844], mode });
await wait(`${boxes}.length === 4 && document.querySelector('[data-wfg-detail-title]')`, 'graph at 390');
assert.equal(await b.evaluate(`document.documentElement.dataset.mode`), mode, 'the page shows the mode under test');
const ys = await b.evaluate(`${boxes}.map(g=>g.querySelector('rect').getBoundingClientRect().top)`);
assert.deepEqual([...ys].sort((p, q) => p - q), ys);
assert.equal(new Set(ys).size, 4);
assert.equal(await b.evaluate(`document.documentElement.scrollWidth<=innerWidth`), true, 'no sideways page scroll');
// Every box fits the width of its own box.
assert.equal(await b.evaluate(`(()=>{const w=document.querySelector('[data-wfg-graph]').closest('[data-wfg-frame]').getBoundingClientRect();return ${boxes}.every(g=>{const r=g.querySelector('rect').getBoundingClientRect();return r.left>=w.left-1&&r.right<=w.right+1})})()`), true);
// The box text and the edges read in the theme's colours, not the browser default black on dark.
const fill = await b.evaluate(`getComputedStyle(document.querySelector('[data-wfg-step="1"] [data-wfg-title]')).fill`);
const bg = await b.evaluate(`getComputedStyle(document.body).backgroundColor`);
assert.notEqual(fill, bg);
assert.notEqual(fill, dark ? 'rgb(0, 0, 0)' : 'rgb(255, 255, 255)');
// An SVG rect with no theme fill is drawn black.
assert.notEqual(await b.evaluate(`getComputedStyle(document.querySelector('[data-wfg-step="1"] rect')).fill`), 'rgb(0, 0, 0)');
// The list and the card fit too.
await b.evaluate(`location.hash = '#/orgs/acme/workflows'`);
await wait(`document.querySelector('[data-wfg-list] [data-wfg-item]')`, 'list at 390');
assert.equal(await b.evaluate(`document.documentElement.scrollWidth<=innerWidth`), true, 'list: no sideways page scroll');
});
}
});