From c0e08f81ea0cc309dcc1be143b23078d10640551 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Tue, 10 Nov 2026 22:39:30 -0600 Subject: [PATCH 1/3] fix(webui): reopen Archive conflicts on the current task (#1873) --- packages/webui/src/public/tasks-board.js | 47 +++--- .../webui/tests/tasks-board-browser.test.mjs | 136 +++++++++++------- 2 files changed, 110 insertions(+), 73 deletions(-) diff --git a/packages/webui/src/public/tasks-board.js b/packages/webui/src/public/tasks-board.js index 04bc93e42..7842f3c1b 100644 --- a/packages/webui/src/public/tasks-board.js +++ b/packages/webui/src/public/tasks-board.js @@ -371,6 +371,25 @@ return (Array.isArray(b?.tasks) && b.tasks.find(x => x.org === t.org && x.ref === t.ref)) || null; } catch { return null; } } + // A conflict replaces the stale form only once the current task has been read. + async function reopenConflict(form, t, message) { + formStatus(form, 'Reading the current task…'); + // A read a poll overtook leaves the board's old read in place, so only a read that landed counts. + const fresh = await reload().catch(() => false); + const now = (fresh && find(key(t))) || await current(t); + // Closed (Escape, Cancel) while the task was read: the dialog stays closed. close() clears `open` + // at once but queues the event that empties the dialog, so `open` is the check that holds. + const d = dialogEl(); + if (!d.open || !d.contains(form)) { paint(); return; } + if (now) { openDialog(editForm(now), dialogEl().dataset.return, now); const f2 = dialogEl().querySelector('form'); if (f2) formStatus(f2, message); } + else { + // The current task could not be read: the form still holds the old one, so it takes no save. + opened = null; + formStatus(form, 'The task changed after the board read it. Nothing was written, and the current task could not be read; close this form and open the task again.'); + form.querySelectorAll('[data-tb-cancel]').forEach(b => { b.disabled = false; }); + } + paint(); + } async function submit(form) { const kind = form.dataset.tbForm, btns = form.querySelectorAll('button'); // A form whose Save is disabled is sending, or waiting on the current task: it takes no second save. @@ -390,28 +409,7 @@ } btns.forEach(b => { b.disabled = true; }); formStatus(form, 'Sending…'); const r = await write(body); - // The task changed under the form: the form reopens on the current task and keeps the refusal. - // Until the current task is read the old form stays disabled and does not show the refusal, so - // the refusal never stands beside the old task and no save goes out against its digest. - if (!r.ok && r.error === 'task-conflict' && t) { - formStatus(form, 'Reading the current task…'); - // A read a poll overtook leaves the board's old read in place, so only a read that landed counts. - const fresh = await reload().catch(() => false); - const now = (fresh && find(key(t))) || await current(t); - // Closed (Escape, Cancel) while the task was read: the dialog stays closed. close() clears `open` - // at once but queues the event that empties the dialog, so `open` is the check that holds. - const d = dialogEl(); - if (!d.open || !d.contains(form)) { paint(); return; } - if (now) { openDialog(editForm(now), dialogEl().dataset.return, now); const f2 = dialogEl().querySelector('form'); if (f2) formStatus(f2, r.message); } - else { - // The current task could not be read: the form still holds the old one, so it takes no save. - opened = null; - formStatus(form, 'The task changed after the board read it. Nothing was written, and the current task could not be read; close this form and open the task again.'); - form.querySelectorAll('[data-tb-cancel]').forEach(b => { b.disabled = false; }); - } - paint(); - return; - } + if (!r.ok && r.error === 'task-conflict' && t) { await reopenConflict(form, t, r.message); return; } btns.forEach(b => { b.disabled = false; }); if (!r.ok) { formStatus(form, r.message); @@ -425,6 +423,7 @@ paint(); } async function archive(form) { + if (form.querySelector('[data-tb-archive]')?.disabled) return; const t = opened && key(opened) === form.dataset.tbKey ? opened : null; if (!t) return; if (!form.querySelector('[data-tb-archive]').hasAttribute('data-armed')) { @@ -432,7 +431,11 @@ form.querySelector('[data-tb-archive]').textContent = 'Archive: press again to confirm'; return; } + const btns = form.querySelectorAll('button'); + btns.forEach(b => { b.disabled = true; }); formStatus(form, 'Sending…'); const r = await write({ op: 'archive', org: t.org, task_ref: t.ref, expect: t.digest }); + if (!r.ok && r.error === 'task-conflict') { await reopenConflict(form, t, r.message); return; } + btns.forEach(b => { b.disabled = false; }); if (!r.ok) { formStatus(form, r.message); if (r.unknown) { try { await reload(); } catch {} paint(); } return; } notice = { text: `${shortId(t.ref)} archived in ${orgOf(t.org).label}.` }; dialogEl().close(); diff --git a/packages/webui/tests/tasks-board-browser.test.mjs b/packages/webui/tests/tasks-board-browser.test.mjs index 95be63d1a..7d20af627 100644 --- a/packages/webui/tests/tasks-board-browser.test.mjs +++ b/packages/webui/tests/tasks-board-browser.test.mjs @@ -242,6 +242,36 @@ const HOLD_CONFLICT_READ = `(() => { const SAVE_AS = title => `(() => { const f = document.querySelector("#tb-dialog form"); f.title.value = ${JSON.stringify(title)}; f.requestSubmit(); })()`; const STATUS = 'document.querySelector("#tb-dialog [data-tb-form-status]")?.textContent'; +test('an Archive conflict reopens on the current task and never sends the old digest again', async t => { + const { b, wait, orgs } = await open(t); + await b.evaluate('document.querySelector("[data-tb-open=\'forge|task:42\']").click()'); + const t42 = orgs.store.find(s => s.n === 42); + Object.assign(t42, { title: 'Invoice export (renamed elsewhere)', assignees: ['coder'], rev: t42.rev + 1 }); + await b.evaluate(HOLD_CONFLICT_READ); + await b.evaluate('document.querySelector("#tb-dialog [data-tb-archive]").click(); document.querySelector("#tb-dialog [data-tb-archive]").click()'); + await wait('window.__held'); + assert.equal(await b.evaluate(STATUS), 'Reading the current task…'); + assert.equal(await b.evaluate('document.querySelector("#tb-dialog form").title.value'), 'Invoice export'); + assert.equal(await b.evaluate('document.querySelector("#tb-dialog [data-tb-archive]").disabled'), true); + // Even a synthetic click past the disabled control cannot send another stale write. + await b.evaluate('document.querySelector("#tb-dialog [data-tb-archive]").dispatchEvent(new MouseEvent("click", { bubbles: true }))'); + assert.deepEqual(orgs.writes, [['forge', 'tasks.archive', { task_ref: 'task:42', expect: D(2) }]]); + await b.evaluate('window.__release()'); + await wait('/changed after the board read it/.test(' + STATUS + ')'); + assert.equal(await b.evaluate('document.querySelector("#tb-dialog form").title.value'), 'Invoice export (renamed elsewhere)'); + assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#tb-dialog input[name=assignees]:checked")].map(i => i.value)'), ['coder']); + assert.equal(await b.evaluate('document.querySelector("#tb-dialog [data-tb-archive]").textContent'), 'Archive'); + // The refreshed task must be confirmed anew; its archive uses only the current version. + await b.evaluate('document.querySelector("#tb-dialog [data-tb-archive]").click()'); + assert.equal(orgs.writes.length, 1); + await b.evaluate('document.querySelector("#tb-dialog [data-tb-archive]").click()'); + await wait('/#42 archived in Forge/.test(document.querySelector("[data-tb-notice]").textContent)'); + assert.deepEqual(orgs.writes, [ + ['forge', 'tasks.archive', { task_ref: 'task:42', expect: D(2) }], + ['forge', 'tasks.archive', { task_ref: 'task:42', expect: D(3) }], + ]); +}); + test('after a conflict the dialog shows the refusal only with the current task, and takes no save while it reads it', async t => { const { b, wait, orgs } = await open(t); await b.evaluate('document.querySelector("[data-tb-open=\'forge|task:42\']").click()'); @@ -272,58 +302,62 @@ test('after a conflict the dialog shows the refusal only with the current task, assert.equal(orgs.writes.length, 2); }); -test('after a conflict a task that can no longer be read leaves the dialog unable to save', async t => { - const { b, wait, orgs } = await open(t); - await b.evaluate('document.querySelector("[data-tb-open=\'forge|task:42\']").click()'); - await wait('document.querySelector("#tb-dialog[open] form[data-tb-form=edit]")'); - const t42 = orgs.store.find(s => s.n === 42); - t42.rev++; - await b.evaluate(HOLD_CONFLICT_READ); - await b.evaluate(SAVE_AS('Invoice export, CSV')); - await wait('window.__held'); - // The task leaves the store before the dialog's read answers, so neither read finds it. - orgs.store.splice(orgs.store.indexOf(t42), 1); - await b.evaluate('window.__release()'); - await wait('/could not be read/.test(' + STATUS + ')'); - assert.match(await b.evaluate(STATUS), /^The task changed after the board read it\. Nothing was written, and the current task could not be read; close this form and open the task again\.$/); - assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#tb-dialog button")].map(x => [x.textContent, x.disabled])'), [['Archive', true], ['Cancel', false], ['Save', true]]); - await b.evaluate(SAVE_AS('Invoice export, CSV again')); - await b.evaluate('new Promise(r => setTimeout(r, 200))'); - assert.equal(orgs.writes.length, 1); - await b.evaluate('document.querySelector("#tb-dialog [data-tb-cancel]").click()'); - await wait('!document.querySelector("#tb-dialog").open'); -}); +for (const operation of ['Save', 'Archive']) { + const send = operation === 'Save' ? SAVE_AS('Invoice export, CSV') : 'document.querySelector("#tb-dialog [data-tb-archive]").click(); document.querySelector("#tb-dialog [data-tb-archive]").click()'; + test(`${operation}: after a conflict a task that can no longer be read leaves the dialog unable to write`, async t => { + const { b, wait, orgs } = await open(t); + await b.evaluate('document.querySelector("[data-tb-open=\'forge|task:42\']").click()'); + await wait('document.querySelector("#tb-dialog[open] form[data-tb-form=edit]")'); + const t42 = orgs.store.find(s => s.n === 42); + t42.rev++; + await b.evaluate(HOLD_CONFLICT_READ); + await b.evaluate(send); + await wait('window.__held'); + // The task leaves the store before the dialog's read answers, so neither read finds it. + orgs.store.splice(orgs.store.indexOf(t42), 1); + await b.evaluate('window.__release()'); + await wait('/could not be read/.test(' + STATUS + ')'); + assert.match(await b.evaluate(STATUS), /^The task changed after the board read it\. Nothing was written, and the current task could not be read; close this form and open the task again\.$/); + assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#tb-dialog button")].map(x => [x.textContent, x.disabled])'), [[operation === 'Archive' ? 'Archive: press again to confirm' : 'Archive', true], ['Cancel', false], ['Save', true]]); + await b.evaluate(SAVE_AS('Invoice export, CSV again')); + await b.evaluate('new Promise(r => setTimeout(r, 200))'); + assert.equal(orgs.writes.length, 1); + await b.evaluate('document.querySelector("#tb-dialog [data-tb-cancel]").click()'); + await wait('!document.querySelector("#tb-dialog").open'); + }); -test('a dialog closed just before the conflict read answers stays closed', async t => { - const { b, wait, orgs } = await open(t); - await b.evaluate('document.querySelector("[data-tb-open=\'forge|task:42\']").click()'); - await wait('document.querySelector("#tb-dialog[open] form[data-tb-form=edit]")'); - orgs.store.find(s => s.n === 42).rev++; - // Hold the read's body, not the request, so the close and the read's answer land in one task: - // close() clears `open` at once, but the event that empties the dialog runs after the read resumes. - await b.evaluate(`(() => { - const real = window.fetch; - let armed = false, held = false; - window.fetch = (u, o) => { - if (String(u) === '/api/workspace/tasks') return real(u, o).then(r => { armed = true; return r; }); - if (String(u).startsWith('/api/workspace?') && armed && !held) { - held = true; - return real(u, o).then(async r => { - const body = await r.json(); - r.json = () => new Promise(go => { window.__releaseJson = () => go(structuredClone(body)); }); - return r; - }); - } - return real(u, o); - }; - })()`); - await b.evaluate(SAVE_AS('Invoice export, CSV')); - await wait('typeof window.__releaseJson === "function"'); - await b.evaluate('document.querySelector("#tb-dialog").close(); window.__releaseJson()'); - await b.evaluate('new Promise(r => setTimeout(r, 250))'); - assert.deepEqual(await b.evaluate('[document.querySelector("#tb-dialog").open, document.querySelector("#tb-dialog").innerHTML]'), [false, '']); - assert.equal(orgs.writes.length, 1); -}); + test(`${operation}: a dialog closed just before the conflict read answers stays closed`, async t => { + const { b, wait, orgs } = await open(t); + await b.evaluate('document.querySelector("[data-tb-open=\'forge|task:42\']").click()'); + await wait('document.querySelector("#tb-dialog[open] form[data-tb-form=edit]")'); + orgs.store.find(s => s.n === 42).rev++; + // Hold the read's body, not the request, so the close and the read's answer land in one task: + // close() clears `open` at once, but the event that empties the dialog runs after the read resumes. + await b.evaluate(`(() => { + const real = window.fetch; + let armed = false, held = false; + window.fetch = (u, o) => { + if (String(u) === '/api/workspace/tasks') return real(u, o).then(r => { armed = true; return r; }); + if (String(u).startsWith('/api/workspace?') && armed && !held) { + held = true; + return real(u, o).then(async r => { + const body = await r.json(); + r.json = () => new Promise(go => { window.__releaseJson = () => go(structuredClone(body)); }); + return r; + }); + } + return real(u, o); + }; + })()`); + await b.evaluate(send); + await wait('typeof window.__releaseJson === "function"'); + await b.evaluate('document.querySelector("#tb-dialog").close(); window.__releaseJson()'); + await b.evaluate('new Promise(r => setTimeout(r, 250))'); + assert.deepEqual(await b.evaluate('[document.querySelector("#tb-dialog").open, document.querySelector("#tb-dialog").innerHTML]'), [false, '']); + assert.equal(orgs.writes.length, 1); + }); + +} test('a poll that answers after focus has moved leaves focus where the person put it', async t => { const { b, wait } = await open(t); -- 2.54.0 From cb9580f6fb322fdc07d5e9a1eded315c940e9a9b Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Tue, 10 Nov 2026 22:41:21 -0600 Subject: [PATCH 2/3] fix(webui): explain conflicts that reopen a read-only task (#1873) --- packages/webui/src/public/tasks-board.js | 8 +++-- .../webui/tests/tasks-board-browser.test.mjs | 33 ++++++++++++++++++- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/packages/webui/src/public/tasks-board.js b/packages/webui/src/public/tasks-board.js index 7842f3c1b..e99c688d0 100644 --- a/packages/webui/src/public/tasks-board.js +++ b/packages/webui/src/public/tasks-board.js @@ -294,7 +294,7 @@ d = document.createElement('dialog'); d.id = 'tb-dialog'; d.className = 'tb-dialog'; d.setAttribute('aria-labelledby', 'tb-dialog-h'); document.body.append(d); - d.addEventListener('close', () => { const back = d.dataset.return; d.innerHTML = ''; opened = null; if (back) document.querySelector(`[data-focus-key="${CSS.escape(back)}"]`)?.focus(); }); + d.addEventListener('close', () => { const back = d.dataset.return; d.innerHTML = ''; d.removeAttribute('aria-describedby'); opened = null; if (back) document.querySelector(`[data-focus-key="${CSS.escape(back)}"]`)?.focus(); }); } return d; } @@ -322,7 +322,7 @@
`; } - function editForm(t) { + function editForm(t, refusal = null) { const o = orgOf(t.org), w = taskWritable(t); const why = t.tracker === 'vikunja' ? 'This task is in Vikunja. The board reads it but does not change it; change it in Vikunja.' : !canWrite ? 'This Console takes no task changes.' : o.state !== 'ok' ? 'This organization\'s bus did not answer.' : !w ? 'The bus did not give this task a version to check a change against.' : ''; @@ -331,6 +331,7 @@ return `

${title(t)}

${facts}
State
${esc(STATE_LABEL[t.state] || 'Not read')}
Assignees
${t.assignees.length ? esc(t.assignees.join(', ')) : 'nobody'}
Priority
${prio(t) || 'none'}
Due
${due(t) || 'none'}

${esc(why)}

+ ${refusal ? `

${esc(refusal)}

` : ''}
`; } return `
@@ -345,6 +346,7 @@ function openDialog(html, back, task = null) { const d = dialogEl(); d.innerHTML = html; d.dataset.return = back || ''; opened = task ? structuredClone(task) : null; + if (d.querySelector('#tb-dialog-status')) d.setAttribute('aria-describedby', 'tb-dialog-status'); else d.removeAttribute('aria-describedby'); if (!d.open) d.showModal(); d.querySelector('input:not([type=checkbox]),select,button')?.focus(); } @@ -381,7 +383,7 @@ // at once but queues the event that empties the dialog, so `open` is the check that holds. const d = dialogEl(); if (!d.open || !d.contains(form)) { paint(); return; } - if (now) { openDialog(editForm(now), dialogEl().dataset.return, now); const f2 = dialogEl().querySelector('form'); if (f2) formStatus(f2, message); } + if (now) { openDialog(editForm(now, message), dialogEl().dataset.return, now); const f2 = dialogEl().querySelector('form'); if (f2) formStatus(f2, message); } else { // The current task could not be read: the form still holds the old one, so it takes no save. opened = null; diff --git a/packages/webui/tests/tasks-board-browser.test.mjs b/packages/webui/tests/tasks-board-browser.test.mjs index 7d20af627..23eda5d42 100644 --- a/packages/webui/tests/tasks-board-browser.test.mjs +++ b/packages/webui/tests/tasks-board-browser.test.mjs @@ -34,7 +34,7 @@ function boardOrgs({ refuse = null, tasksFail = [] } = {}) { // A task's digest changes with every write to it, as the store's does. const digest = t => D((t.n + t.rev) % 10); const TASKS = { - forge: () => store.map(t => ({ source: 'store', task_ref: `task:${t.n}`, at: '2026-11-09T12:00:00.000Z', priority: t.priority, digest: digest(t), requirement: 'REQ-API-1', + forge: () => store.map(t => ({ source: 'store', task_ref: `task:${t.n}`, at: '2026-11-09T12:00:00.000Z', priority: t.priority, digest: t.readOnly ? undefined : digest(t), requirement: 'REQ-API-1', fields: { title: t.title, state: t.state, group: t.group, assignees: t.assignees, due_date: t.due } })), ddigital: () => [ { task_ref: 'vikunja:3/112', project_key: 'spring', priority: 3, at: '2026-11-09T11:00:00.000Z', fields: { title: 'Launch email, draft 2', done: false, assignees: [7], bucket: 12, labels: ['launch'] } }, @@ -253,6 +253,8 @@ test('an Archive conflict reopens on the current task and never sends the old di assert.equal(await b.evaluate(STATUS), 'Reading the current task…'); assert.equal(await b.evaluate('document.querySelector("#tb-dialog form").title.value'), 'Invoice export'); assert.equal(await b.evaluate('document.querySelector("#tb-dialog [data-tb-archive]").disabled'), true); + assert.equal(await b.evaluate('document.querySelector("#tb-dialog [data-tb-submit]").disabled'), true); + await b.evaluate(SAVE_AS('A stale Save while Archive refreshes')); // Even a synthetic click past the disabled control cannot send another stale write. await b.evaluate('document.querySelector("#tb-dialog [data-tb-archive]").dispatchEvent(new MouseEvent("click", { bubbles: true }))'); assert.deepEqual(orgs.writes, [['forge', 'tasks.archive', { task_ref: 'task:42', expect: D(2) }]]); @@ -272,6 +274,35 @@ test('an Archive conflict reopens on the current task and never sends the old di ]); }); +for (const operation of ['Save', 'Archive']) { + test(`${operation}: a conflict onto a read-only task explains the refusal only until the dialog closes`, async t => { + const { b, wait, orgs } = await open(t); + await b.evaluate('document.querySelector("[data-tb-open=\'forge|task:42\']").click()'); + const t42 = orgs.store.find(s => s.n === 42); + Object.assign(t42, { title: 'Invoice export (now read only)', readOnly: true, rev: t42.rev + 1 }); + await b.evaluate(operation === 'Save' ? SAVE_AS('Invoice export, CSV') + : 'document.querySelector("#tb-dialog [data-tb-archive]").click(); document.querySelector("#tb-dialog [data-tb-archive]").click()'); + await wait('document.querySelector("#tb-dialog[open] [data-tb-form=view]")'); + assert.equal(await b.evaluate('document.querySelector("#tb-dialog form")'), null); + assert.equal(await b.evaluate('document.querySelector("#tb-dialog h2").textContent'), 'Invoice export (now read only)'); + const status = await b.evaluate(`(() => { const s = document.querySelector('#tb-dialog [data-tb-form-status]'); + return s && { role: s.getAttribute('role'), text: s.textContent, styled: s.classList.contains('tb-form-status'), visible: s.getClientRects().length > 0 }; })()`); + assert.ok(status, 'a refused write needs a status in the read-only view'); + assert.equal(status.role, 'status'); + assert.equal(await b.evaluate('document.querySelector("#tb-dialog").getAttribute("aria-describedby")'), 'tb-dialog-status'); + assert.equal(await b.evaluate('document.getElementById("tb-dialog-status").textContent'), status.text); + assert.equal(status.styled, true); assert.equal(status.visible, true); + assert.equal(status.text, 'The task changed after the board read it. Nothing was written; the board now shows the current task.'); + assert.equal(orgs.writes.length, 1); + await b.evaluate('document.querySelector("#tb-dialog [data-tb-cancel]").click()'); + await wait('!document.querySelector("#tb-dialog").open && !document.querySelector("#tb-dialog").innerHTML'); + await b.evaluate('document.querySelector("[data-tb-open=\'forge|task:42\']").click()'); + await wait('document.querySelector("#tb-dialog[open] [data-tb-form=view]")'); + assert.equal(await b.evaluate('document.querySelector("#tb-dialog [data-tb-form-status]")'), null, 'the next ordinary open has no refusal line'); + assert.equal(await b.evaluate('document.querySelector("#tb-dialog").getAttribute("aria-describedby")'), null); + }); +} + test('after a conflict the dialog shows the refusal only with the current task, and takes no save while it reads it', async t => { const { b, wait, orgs } = await open(t); await b.evaluate('document.querySelector("[data-tb-open=\'forge|task:42\']").click()'); -- 2.54.0 From 15225c5e1c132c7c53611793f757fc5820842642 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Tue, 10 Nov 2026 22:43:47 -0600 Subject: [PATCH 3/3] test(webui): verify applied light overrides and the U5 shot recipe (#1873) --- .../webui/tests/appearance-browser.test.mjs | 26 +++++++++++++++++++ .../webui/tests/side-by-side-browser.test.mjs | 25 ++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/packages/webui/tests/appearance-browser.test.mjs b/packages/webui/tests/appearance-browser.test.mjs index e82a4ae86..04df7d6b0 100644 --- a/packages/webui/tests/appearance-browser.test.mjs +++ b/packages/webui/tests/appearance-browser.test.mjs @@ -28,6 +28,32 @@ const state = b => b.evaluate('JSON.parse(JSON.stringify(window.MosaicAppearance const stored = (b, key) => b.evaluate(`localStorage.getItem(${JSON.stringify(key)})`); const inline = b => b.evaluate('document.documentElement.getAttribute("style")'); +test('an organization browser override draws system and explicit light from the light colours, including after reload', { timeout: 90000 }, async t => { + const { b, f } = await open(t, { saved: { theme: 'rosewood', mode: 'dark' } }); + const rgb = hex => `rgb(${hex.slice(1).match(/../g).map(n => parseInt(n, 16)).join(', ')})`; + const theme = THEMES.find(t => t.id === 'rosewood'); + for (const scheme of ['light', 'dark']) { + await b.call('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-color-scheme', value: scheme }] }); + for (const mode of ['system', 'light']) { + await b.evaluate(`window.MosaicAppearance.set({ mode: ${JSON.stringify(mode)} })`); + // Read the actual scoped storage on a new document, not only the in-memory choice. + await b.navigate(`${f.base}/?appearance=${scheme}-${mode}`); + await b.evaluate('window.MosaicAppearance.ready.then(() => true)'); + const expected = mode === 'system' ? scheme : mode; + const drawn = await b.evaluate(`({ + palette: document.documentElement.dataset.palette, + mode: document.documentElement.getAttribute('data-mode'), + canvas: getComputedStyle(document.body).backgroundColor, + source: window.MosaicAppearance.state().modeSource, + override: JSON.parse(localStorage.getItem('mosaic-console-appearance:acme')), + })`); + assert.deepEqual(drawn, { palette: 'rosewood', mode: mode === 'system' ? null : mode, + canvas: rgb(theme.tokens[expected]['--canvas']), source: 'browser', override: { mode } }, `${scheme}/${mode}`); + t.diagnostic(JSON.stringify({ scheme, requested: mode, ...drawn })); + } + } +}); + test('the built-in default is drawn by tokens.css alone: Harbor, dark, no inline colour, no warning, no cue, nothing stored', { timeout: 90000 }, async t => { const { b } = await open(t, { org: null }); assert.equal(await b.evaluate('document.documentElement.dataset.palette'), 'harbor'); diff --git a/packages/webui/tests/side-by-side-browser.test.mjs b/packages/webui/tests/side-by-side-browser.test.mjs index 5174f257c..84bc9496c 100644 --- a/packages/webui/tests/side-by-side-browser.test.mjs +++ b/packages/webui/tests/side-by-side-browser.test.mjs @@ -53,6 +53,31 @@ const title = key => `(document.querySelector("#pane-${key} [data-pane-ttl]")?.t const query = '(() => Object.fromEntries(new URLSearchParams(location.hash.split("?")[1] || "")))()'; const pick = (b, key, value) => b.evaluate(`(() => { const s = document.querySelector("#pane-${key}-pick"); s.value = ${JSON.stringify(value)}; if (s.value !== ${JSON.stringify(value)}) throw Error("no option " + ${JSON.stringify(value)}); s.dispatchEvent(new Event("change", { bubbles: true })); })()`); +test('side by side stays dark by default under light media, and applying system or light really draws light', async t => { + const { b, wait, base } = await open(t); + await wait(`${title('a')}.length > 0 && ${title('b')}.length > 0`); + await b.evaluate('window.MosaicAppearance.ready.then(() => true)'); + await b.call('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-color-scheme', value: 'light' }] }); + const drawn = () => b.evaluate(`({ mode: document.documentElement.getAttribute('data-mode'), + palette: document.documentElement.dataset.palette, canvas: getComputedStyle(document.body).backgroundColor, + source: window.MosaicAppearance.state().modeSource })`); + assert.deepEqual(await drawn(), { mode: 'dark', palette: 'rosewood', canvas: 'rgb(26, 20, 23)', source: 'default' }); + for (const mode of ['system', 'light']) { + // U5's corrected shot recipe seeds the per-org key, then loads a fresh document. + const before = await drawn(); + await b.evaluate(`localStorage.setItem('mosaic-console-appearance:ddigital', JSON.stringify({ mode: ${JSON.stringify(mode)} }))`); + assert.deepEqual(await drawn(), before, 'writing storage alone does not apply an override to this document'); + await b.navigate('about:blank'); + await b.navigate(`${base}?appearance=${mode}#/orgs/ddigital/side-by-side`); + await wait(`${title('a')}.length > 0 && ${title('b')}.length > 0`); + await b.evaluate('window.MosaicAppearance.ready.then(() => true)'); + const result = await drawn(); + assert.deepEqual(result, { mode: mode === 'system' ? null : 'light', palette: 'rosewood', + canvas: 'rgb(246, 243, 245)', source: 'browser' }); + t.diagnostic(JSON.stringify({ scheme: 'light', requested: mode, ...result })); + } +}); + test('the Side by side nav item opens two panes with different organizations, and the URL restores both after a reload', async t => { const state = { flip: false, seen: [] }; const { b, base, wait } = await open(t, { hash: '#/orgs/ddigital/organization', state }); -- 2.54.0