diff --git a/css/kit.css b/css/kit.css index f9a0876..0bc6630 100644 --- a/css/kit.css +++ b/css/kit.css @@ -78,10 +78,21 @@ --focus-ring-width: 2px; --focus-ring-offset: 2px; - /* -- Scrollbar ---------------------------------------------------------- */ - --scrollbar-width: 6px; - --scrollbar-thumb: rgba(128, 128, 128, 0.3); - --scrollbar-thumb-hover: rgba(128, 128, 128, 0.5); + /* -- Scrollbar ---------------------------------------------------------- + Every scroller's (Scrollbars, below): how thick it is. Its thumb is + each theme's (the theme's own tokens, next). */ + --scrollbar-width: 6px; +} + +/* The scrollbar's thumb: the theme's text colour at half, 3:1 or more against + the page in both themes (3.12:1 light, 4.07:1 dark), and darker (lighter, + in the dark theme) while hovered. Set on every theme's root, so it's + worked out from the text colour of the theme it's in, not the one at the + top of the page. */ +:root, +[data-bs-theme] { + --scrollbar-thumb: rgba(var(--bs-body-color-rgb), 0.5); + --scrollbar-thumb-hover: rgba(var(--bs-body-color-rgb), 0.7); } /* The accent's text and link shades for the theme in force. accent.css gives @@ -127,6 +138,33 @@ a:hover { color: var(--bs-link-hover-color); } +/* -- Scrollbars --------------------------------------------------------------- + Thin, in place of Chromium's own wide one with its arrow buttons: the thumb + on a track that isn't drawn, vertical and horizontal alike, on every + element that scrolls. Drawn through ::-webkit-scrollbar, not the standard + scrollbar-width: thin, which still draws an arrow button at each end on + Windows and Linux. The standard properties are left unset: Chromium ignores + these wherever either is set, so an app that sets one on an element gets + Chromium's own bar there. */ +::-webkit-scrollbar { + width: var(--scrollbar-width); + height: var(--scrollbar-width); + background-color: transparent; +} + +::-webkit-scrollbar-thumb { + border-radius: var(--radius-pill); + background-color: var(--scrollbar-thumb); +} + +::-webkit-scrollbar-thumb:hover { + background-color: var(--scrollbar-thumb-hover); +} + +::-webkit-scrollbar-corner { + background-color: transparent; +} + /* Bootstrap's progress bar sets --bs-progress-bar-bg to a literal #0d6efd rather than to var(--bs-primary), so the accent is applied directly. A label inside the bar sits on the fill. */ @@ -757,15 +795,15 @@ a:hover { } /* -- Update tab --------------------------------------------------------------- - Two cards in a readable column. The first: the version running, the status - line, then Check for Updates and Download Update side by side. The status - line keeps its height, so the buttons don't jump. The second: the - automatic downloads switch and the channel, each a row with a help line. */ + Two cards, one under the other, filling the pane as every Settings tab's + cards do. The first: the version running, the status line, then Check for + Updates and Download Update side by side. The status line keeps its + height, so the buttons don't jump. The second: the automatic downloads + switch and the channel, each a row with a help line. */ .update-tab { display: flex; flex-direction: column; gap: 1rem; - max-width: 36rem; } .update-card { diff --git a/docs/README.md b/docs/README.md index 280604a..8bb6069 100644 --- a/docs/README.md +++ b/docs/README.md @@ -17,6 +17,6 @@ and links here, so these can be corrected without a new release. | [The Main Process](main-process.md) | The main window, isolated sessions, spell checking, one instance and the files an app is opened with, the settings (those only main changes, their checks, hearing a change) and their migration, the redacted log, and the menu | | [Updates](updates.md) | The updater: when it checks, Stable, Beta and Alpha, automatic downloads, its state, and a proxy | | [Building](building.md) | `config()`, the electron-builder config every app builds with; the files an app opens, on Linux; the update files every release carries; and naming an app's files | -| [Theme, Accent and Tokens](theming.md) | The OS theme, an app's accent and its WCAG 2.2 AA check, the house tokens, focus rings and controls | +| [Theme, Accent and Tokens](theming.md) | The OS theme, an app's accent and its WCAG 2.2 AA check, the house tokens, scrollbars, focus rings and controls | | [Testing an App](testing.md) | The helpers for an app's own tests: nothing reached beyond the machine, the preload, the menu, the accent and the build config | | [Development](development.md) | Working on the kit: its tests, the demo app, CI and the visual baselines | diff --git a/docs/shell.md b/docs/shell.md index c9baf56..775ed1e 100644 --- a/docs/shell.md +++ b/docs/shell.md @@ -100,7 +100,13 @@ Update's text, moves nothing). There's no save button anywhere: a tab keeps each `setSettings()` as it's made. It's reached from the rail, and from the menu's `Settings` (`CmdOrCtrl+,`), which puts focus on the selected tab. -**Update** is two cards in a readable column (36rem at most). The first is headed with the version running +**A tab's cards fill the pane**, edge to edge, as the tab bar above them does, however wide the window: the kit's +tabs hold nothing to a column, and an app's own tabs shouldn't either, so they all look alike. What's inside a card +keeps its own layout (buttons side by side, a switch or a select at its usual size). Where a readable width is +wanted, it's the text inside a card, a help line, never the card. A pane taller than the window scrolls, with the +kit's thin scrollbar ([Theming](theming.md#scrollbars)). + +**Update** is two cards, one under the other. The first is headed with the version running (`Version 2.0.0`), then a status line (`role="status"`, its height kept), then `Check for Updates` (disabled while a check or download runs, or with no updater; busy, "Checking…" with a spinner, while a check runs, however it was started, through `kit.ui.busy()`) and, when there's an update to download, `Download diff --git a/docs/theming.md b/docs/theming.md index 7bca0df..476c515 100644 --- a/docs/theming.md +++ b/docs/theming.md @@ -61,10 +61,23 @@ shade 20% lighter again. White on the fill needs 4.5:1, so the hover goes darker `--dur-ambient` and the `--ease-*` curves, `--ease-spring` among them), the wash ladder (`--wash-*`, alphas for `rgba(var(--accent-rgb), …)`), radii (`--radius-*`), opacity (`--opacity-disabled`, `--opacity-muted`), the timings the JS side shares (`--timing-*`), the focus ring (`--focus-ring-width`, `--focus-ring-offset`) and -the scrollbar. It binds the accent into Bootstrap's primary (`--bs-primary`, `.btn-primary`, links and +the scrollbar (`--scrollbar-width`, `--scrollbar-thumb`, `--scrollbar-thumb-hover`; [Scrollbars](#scrollbars)). It binds the accent into Bootstrap's primary (`--bs-primary`, `.btn-primary`, links and `.progress`) and its form controls: a checked box or switch is the fill, and so is every range's knob (Bootstrap's is a literal blue), darker while pressed, with the house's focus ring rather than Bootstrap's glow. +## Scrollbars + +Every element that scrolls, in every app on the kit, has a thin scrollbar, vertical and horizontal alike, with no +CSS of its own: 6px (`--scrollbar-width`), no arrow buttons, the thumb on a track that isn't drawn. The thumb is +the theme's text colour at half (`--scrollbar-thumb`), 3:1 or more against the page in both themes (3.12:1 light, +4.07:1 dark), and darker (lighter, in the dark theme) while hovered (`--scrollbar-thumb-hover`, 0.7). Each theme +sets the two thumb tokens on its own root, so a part of the page in the other theme has its own. + +It's drawn through `::-webkit-scrollbar`, not the standard `scrollbar-width: thin`, which in Chromium still draws +an arrow button at each end, on Windows and on Linux. Chromium ignores `::-webkit-scrollbar` on an element that sets +`scrollbar-width` or `scrollbar-color`, so `kit.css` sets neither, and an app shouldn't either: one that does gets +Chromium's own bar on that element. + ## Focus Rings and Controls Bootstrap draws focus as a soft glow, a quarter-opaque ring, which is well under the 3:1 a focus indicator diff --git a/e2e/scrollbars.spec.js b/e2e/scrollbars.spec.js new file mode 100644 index 0000000..37c5f3a --- /dev/null +++ b/e2e/scrollbars.spec.js @@ -0,0 +1,169 @@ +"use strict"; + +// The kit's scrollbars, in real Electron: every element that scrolls has a +// thin bar, 6px, with no arrow buttons, its thumb the theme's text colour at +// half on a track that isn't drawn, 3:1 or more against the page in both +// themes. Measured on what the window draws, not only on what the page +// computes: Chromium's standard scrollbar-width: thin is thin too, but still +// draws an arrow button at each end, on Windows and on Linux. + +const { test, expect } = require("./helpers/demo"); +const { contrastRatio, over, parseColor } = require("../testing/contrast"); + +// One device pixel to a CSS pixel, so a screenshot's rows are the page's. +test.use({ demoSwitches: ["--force-device-scale-factor=1"] }); + +/** A scroller of each kind the kit draws, reached and made to scroll. */ +const SCROLLERS = { + // The Settings pane, on the Update tab, taller than the window: it scrolls as it is. + "the Settings pane": async (main) => { + await main.getByRole("button", { name: "Settings", exact: true }).click(); + await main.getByRole("tab", { name: /^Update/ }).click(); + await main.locator("#settings-pane-update").evaluate((pane) => { + pane.querySelector(".zz-filler")?.remove(); + pane.append(Object.assign(document.createElement("div"), { className: "zz-filler", style: "height: 2000px" })); + }); + return main.locator(".settings-panes"); + }, + // The Queue's card grid, held to a height, as an app's list of transfers is. + "a card grid": async (main) => { + await main.getByRole("button", { name: "Queue", exact: true }).click(); + const grid = main.locator("#queue"); + await expect(grid).toHaveClass(/kit-card-grid/); + await grid.evaluate((el) => Object.assign(el.style, { maxHeight: "200px", overflowY: "auto" })); + return grid; + }, + // The bottom bar's panel, held to a height, its content taller. + "the bar's panel": async (main) => { + await main.getByRole("button", { name: "Controls", exact: true }).click(); + await main.evaluate(() => window.demoShell.statusBar.show(true)); + await main.locator("#kit-statusbar-checks").click(); + const panel = main.getByRole("dialog", { name: "Check Details" }); + await expect(panel).toBeFocused(); + await panel.evaluate((el) => { + Object.assign(el.style, { maxHeight: "180px", overflowY: "auto" }); + el.append(Object.assign(document.createElement("div"), { style: "height: 1000px" })); + }); + return panel; + }, +}; + +/** + * What the page computes for a scroller's bar, and the thumb's contrast + * against what's under it: each layer from the page up, laid over the one + * below. + */ +function computedBar(scroller) { + return scroller.evaluate((el) => { + const own = getComputedStyle(el); + const bar = getComputedStyle(el, "::-webkit-scrollbar"); + const layers = []; + for (let node = el; node; node = node.parentElement) layers.push(getComputedStyle(node).backgroundColor); + return { + standard: [own.scrollbarWidth, own.scrollbarColor], + width: bar.width, + height: bar.height, + track: bar.backgroundColor, + thumb: getComputedStyle(el, "::-webkit-scrollbar-thumb").backgroundColor, + text: getComputedStyle(document.body).color, + thickness: el.offsetWidth - el.clientWidth - parseFloat(own.borderLeftWidth) - parseFloat(own.borderRightWidth), + layers, + }; + }); +} + +/** + * The vertical bar as the window draws it: the column of pixels down its + * middle, from the top of the scroller to the bottom, scrolled to the top. + * A rounded scroller's corners clip its bar, so they're left out. + */ +async function drawnBar(main, scroller) { + const box = await scroller.evaluate((el) => { + el.scrollTop = 0; + const r = el.getBoundingClientRect(); + const s = getComputedStyle(el); + const right = r.right - parseFloat(s.borderRightWidth); + const width = el.offsetWidth - el.clientWidth - parseFloat(s.borderLeftWidth) - parseFloat(s.borderRightWidth); + const top = parseFloat(s.borderTopRightRadius); + const bottom = parseFloat(s.borderBottomRightRadius); + return { x: right - width, y: r.top + parseFloat(s.borderTopWidth) + top, width, height: el.clientHeight - top - bottom }; + }); + // Parked where nothing hovers, so the thumb is at rest. + await main.mouse.move(380, 12); + const png = await main.screenshot({ clip: box, animations: "disabled" }); + return main.evaluate(async ({ bytes, x }) => { + const image = await createImageBitmap(new Blob([new Uint8Array(bytes)]), { colorSpaceConversion: "none" }); + const canvas = new OffscreenCanvas(image.width, image.height); + const context = canvas.getContext("2d"); + context.drawImage(image, 0, 0); + const data = context.getImageData(x, 0, 1, image.height).data; + return Array.from({ length: image.height }, (_, i) => ({ r: data[i * 4], g: data[i * 4 + 1], b: data[i * 4 + 2], a: 1 })); + }, { bytes: [...png], x: Math.floor(box.width / 2) }); +} + +/** Two drawn colours that are the same, give or take a step of rounding. */ +const same = (x, y) => ["r", "g", "b"].every((c) => Math.abs(x[c] - y[c]) <= 2); + +for (const theme of ["light", "dark"]) { + test(`every scroller's bar is 6px, with no arrow buttons, its thumb 3:1 or more against the page: ${theme}`, async ({ demo }) => { + const main = await demo.mainWindow(); + const win = await demo.app.browserWindow(main); + await win.evaluate((w) => w.setContentSize(760, 600)); + await main.emulateMedia({ reducedMotion: "reduce" }); + await demo.useTheme(main, theme); + const seen = {}; + for (const [name, reach] of Object.entries(SCROLLERS)) { + const scroller = await reach(main); + await expect.poll(() => scroller.evaluate((el) => el.scrollHeight > el.clientHeight * 2), name).toBe(true); + + // What the page computes: the standard properties left alone (Chromium ignores ::-webkit-scrollbar + // wherever either is set), and the kit's bar, 6px each way, its track not drawn, its thumb the + // theme's text colour at half. + const bar = await computedBar(scroller); + expect(bar.standard, name).toEqual(["auto", "auto"]); + expect([bar.width, bar.height, bar.track, bar.thickness], name).toEqual(["6px", "6px", "rgba(0, 0, 0, 0)", 6]); + const text = parseColor(bar.text); + expect(parseColor(bar.thumb), name).toEqual({ r: text.r, g: text.g, b: text.b, a: 0.5 }); + const page = bar.layers.reverse().reduce((below, layer) => over(layer, below), { r: 255, g: 255, b: 255, a: 1 }); + const computed = contrastRatio(bar.thumb, page); + + // What the window draws, down the bar's middle: the thumb from its very top, where an arrow button + // would be, then the page to the very bottom, where the other would be. + const column = await drawnBar(main, scroller); + const track = column.at(-1); + expect(same(track, page), `${name}: the track isn't drawn`).toBe(true); + const marked = column.map((pixel) => !same(pixel, track)); + const start = marked.indexOf(true); + const end = marked.indexOf(false, start); + expect(start, `${name}: the thumb starts at the top`).toBeLessThanOrEqual(1); + expect(end, `${name}: the thumb is drawn`).toBeGreaterThanOrEqual(8); + expect(marked.slice(end).includes(true), `${name}: nothing is drawn under the thumb`).toBe(false); + const drawn = contrastRatio(column[Math.floor(end / 2)], track); + seen[name] = [Math.round(computed * 100) / 100, Math.round(drawn * 100) / 100]; + + if (name === "the bar's panel") { + await main.keyboard.press("Escape"); + await main.evaluate(() => window.demoShell.statusBar.show(false)); + } + } + expect(Object.keys(seen)).toEqual(Object.keys(SCROLLERS)); + const short = Object.entries(seen).filter(([, ratios]) => ratios.some((ratio) => ratio < 3)); + expect(short, JSON.stringify(seen)).toEqual([]); + }); +} + +test("an element that sets the standard scrollbar-width gets Chromium's own bar, as the docs say", async ({ demo }) => { + const main = await demo.mainWindow(); + const thickness = await main.evaluate(() => { + const box = Object.assign(document.createElement("div"), { style: "position: fixed; top: 0; left: 0; width: 100px; height: 100px; overflow: scroll; scrollbar-width: thin" }); + document.body.append(box); + const kit = Object.assign(document.createElement("div"), { style: "position: fixed; top: 0; left: 120px; width: 100px; height: 100px; overflow: scroll" }); + document.body.append(kit); + const sizes = [box.offsetWidth - box.clientWidth, kit.offsetWidth - kit.clientWidth]; + box.remove(); + kit.remove(); + return sizes; + }); + expect(thickness[1]).toBe(6); + expect(thickness[0]).toBeGreaterThan(6); +}); diff --git a/e2e/shell.spec.js b/e2e/shell.spec.js index 9efe403..ebecbee 100644 --- a/e2e/shell.spec.js +++ b/e2e/shell.spec.js @@ -337,6 +337,26 @@ test("the Settings tabs sit on a thin line, with an accent bar that slides to th expect(widths.size).toBe(3); }); +test("every Settings tab's content fills the pane, edge to edge, in a wide window: the app's own, Update and Credits", async ({ demo }) => { + const main = await demo.mainWindow(); + const win = await demo.app.browserWindow(main); + await win.evaluate((w) => w.setContentSize(1400, 700)); + await expect.poll(() => main.evaluate(() => innerWidth)).toBe(1400); + await main.getByRole("button", { name: "Settings", exact: true }).click(); + const seen = {}; + for (const name of ["General", "Update", "Credits"]) { + await main.getByRole("tab", { name, exact: name !== "Update" }).click(); + const pane = main.getByRole("tabpanel", { name: name === "Update" ? /^Update/ : name }); + seen[name] = await pane.evaluate((el) => { + const style = getComputedStyle(el); + const inside = el.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight); + return [...el.children].map((child) => Math.round(child.getBoundingClientRect().width - inside)); + }); + } + // Each child of each tab is as wide as the pane's inside: nothing holds a tab to a column. + expect(seen).toEqual({ General: [0], Update: [0], Credits: [0] }); +}); + test("a checkbox's tick is drawn when ticked and springs away when unticked, a switch's knob slides across, in the accent, and both stop moving under reduced motion", async ({ demo }) => { const main = await demo.mainWindow(); await main.getByRole("button", { name: "Controls", exact: true }).click(); diff --git a/e2e/update-tab.spec.js b/e2e/update-tab.spec.js index 827b74b..dc65290 100644 --- a/e2e/update-tab.spec.js +++ b/e2e/update-tab.spec.js @@ -214,9 +214,29 @@ test("the tab is two cards in a column: the version, its status and the buttons, expect(c.y).toBeGreaterThan(s.y); expect(Math.abs(d.y - c.y)).toBeLessThan(2); expect(d.x).toBeGreaterThan(c.x); - // A readable column, not the pane's width. - const width = await pane.locator(".update-tab").evaluate((el) => el.getBoundingClientRect().width); - expect(width).toBeLessThanOrEqual(36 * 16 + 1); +}); + +test("the Update tab's cards fill the pane, however wide the window, and the channel's select keeps its own width", async ({ demo }) => { + const main = await demo.mainWindow(); + const win = await demo.app.browserWindow(main); + const pane = await openUpdate(main); + for (const width of [760, 1400]) { + await win.evaluate((w, width) => w.setContentSize(width, 700), width); + await expect.poll(() => main.evaluate(() => innerWidth)).toBe(width); + const seen = await pane.evaluate((el) => { + const style = getComputedStyle(el); + const inside = el.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight); + return { + inside, + cards: [...el.querySelectorAll(".update-card")].map((card) => card.getBoundingClientRect().width), + channel: el.querySelector(".update-channel").getBoundingClientRect().width, + }; + }); + // At 1400px the pane is far wider than the old 36rem cap. + if (width === 1400) expect(seen.inside).toBeGreaterThan(36 * 16 * 1.5); + expect(seen.cards).toEqual([seen.inside, seen.inside]); + expect(seen.channel).toBeLessThanOrEqual(16 * 16 + 0.5); + } }); test("the header's toolbar is hidden on Settings, out of the Tab order, and back on the app's sections", async ({ demo }) => { diff --git a/e2e/visual.spec.js-snapshots/demo-dark-cards-selected-win32.png b/e2e/visual.spec.js-snapshots/demo-dark-cards-selected-win32.png index 2026d43..3435d4e 100644 Binary files a/e2e/visual.spec.js-snapshots/demo-dark-cards-selected-win32.png and b/e2e/visual.spec.js-snapshots/demo-dark-cards-selected-win32.png differ diff --git a/e2e/visual.spec.js-snapshots/demo-dark-cards-win32.png b/e2e/visual.spec.js-snapshots/demo-dark-cards-win32.png index 0ab709b..82628da 100644 Binary files a/e2e/visual.spec.js-snapshots/demo-dark-cards-win32.png and b/e2e/visual.spec.js-snapshots/demo-dark-cards-win32.png differ diff --git a/e2e/visual.spec.js-snapshots/demo-dark-form-dialog-win32.png b/e2e/visual.spec.js-snapshots/demo-dark-form-dialog-win32.png index 2952c54..e25523f 100644 Binary files a/e2e/visual.spec.js-snapshots/demo-dark-form-dialog-win32.png and b/e2e/visual.spec.js-snapshots/demo-dark-form-dialog-win32.png differ diff --git a/e2e/visual.spec.js-snapshots/demo-dark-update-dot-collapsed-win32.png b/e2e/visual.spec.js-snapshots/demo-dark-update-dot-collapsed-win32.png index d9bdc03..10a45f1 100644 Binary files a/e2e/visual.spec.js-snapshots/demo-dark-update-dot-collapsed-win32.png and b/e2e/visual.spec.js-snapshots/demo-dark-update-dot-collapsed-win32.png differ diff --git a/e2e/visual.spec.js-snapshots/demo-light-cards-selected-win32.png b/e2e/visual.spec.js-snapshots/demo-light-cards-selected-win32.png index df50b71..4da0134 100644 Binary files a/e2e/visual.spec.js-snapshots/demo-light-cards-selected-win32.png and b/e2e/visual.spec.js-snapshots/demo-light-cards-selected-win32.png differ diff --git a/e2e/visual.spec.js-snapshots/demo-light-cards-win32.png b/e2e/visual.spec.js-snapshots/demo-light-cards-win32.png index ae6a8e1..9670d74 100644 Binary files a/e2e/visual.spec.js-snapshots/demo-light-cards-win32.png and b/e2e/visual.spec.js-snapshots/demo-light-cards-win32.png differ diff --git a/e2e/visual.spec.js-snapshots/demo-light-form-dialog-win32.png b/e2e/visual.spec.js-snapshots/demo-light-form-dialog-win32.png index 1c0826c..2fc993a 100644 Binary files a/e2e/visual.spec.js-snapshots/demo-light-form-dialog-win32.png and b/e2e/visual.spec.js-snapshots/demo-light-form-dialog-win32.png differ diff --git a/e2e/visual.spec.js-snapshots/demo-light-update-dot-collapsed-win32.png b/e2e/visual.spec.js-snapshots/demo-light-update-dot-collapsed-win32.png index be73680..4d6e900 100644 Binary files a/e2e/visual.spec.js-snapshots/demo-light-update-dot-collapsed-win32.png and b/e2e/visual.spec.js-snapshots/demo-light-update-dot-collapsed-win32.png differ diff --git a/e2e/visual.spec.js-snapshots/dropgate-dark-cards-selected-win32.png b/e2e/visual.spec.js-snapshots/dropgate-dark-cards-selected-win32.png index 99d27df..3435d4e 100644 Binary files a/e2e/visual.spec.js-snapshots/dropgate-dark-cards-selected-win32.png and b/e2e/visual.spec.js-snapshots/dropgate-dark-cards-selected-win32.png differ diff --git a/e2e/visual.spec.js-snapshots/dropgate-dark-cards-win32.png b/e2e/visual.spec.js-snapshots/dropgate-dark-cards-win32.png index 1cbc9f8..427fee0 100644 Binary files a/e2e/visual.spec.js-snapshots/dropgate-dark-cards-win32.png and b/e2e/visual.spec.js-snapshots/dropgate-dark-cards-win32.png differ diff --git a/e2e/visual.spec.js-snapshots/dropgate-dark-form-dialog-win32.png b/e2e/visual.spec.js-snapshots/dropgate-dark-form-dialog-win32.png index 7c676c1..b150d49 100644 Binary files a/e2e/visual.spec.js-snapshots/dropgate-dark-form-dialog-win32.png and b/e2e/visual.spec.js-snapshots/dropgate-dark-form-dialog-win32.png differ diff --git a/e2e/visual.spec.js-snapshots/dropgate-light-cards-selected-win32.png b/e2e/visual.spec.js-snapshots/dropgate-light-cards-selected-win32.png index df50b71..64caadb 100644 Binary files a/e2e/visual.spec.js-snapshots/dropgate-light-cards-selected-win32.png and b/e2e/visual.spec.js-snapshots/dropgate-light-cards-selected-win32.png differ diff --git a/e2e/visual.spec.js-snapshots/dropgate-light-cards-win32.png b/e2e/visual.spec.js-snapshots/dropgate-light-cards-win32.png index 1edc1a2..dcf1a5c 100644 Binary files a/e2e/visual.spec.js-snapshots/dropgate-light-cards-win32.png and b/e2e/visual.spec.js-snapshots/dropgate-light-cards-win32.png differ diff --git a/e2e/visual.spec.js-snapshots/dropgate-light-form-dialog-win32.png b/e2e/visual.spec.js-snapshots/dropgate-light-form-dialog-win32.png index 99bdd65..f165236 100644 Binary files a/e2e/visual.spec.js-snapshots/dropgate-light-form-dialog-win32.png and b/e2e/visual.spec.js-snapshots/dropgate-light-form-dialog-win32.png differ diff --git a/e2e/visual.spec.js-snapshots/file-converter-dark-cards-selected-win32.png b/e2e/visual.spec.js-snapshots/file-converter-dark-cards-selected-win32.png index 3c7632e..8af39c6 100644 Binary files a/e2e/visual.spec.js-snapshots/file-converter-dark-cards-selected-win32.png and b/e2e/visual.spec.js-snapshots/file-converter-dark-cards-selected-win32.png differ diff --git a/e2e/visual.spec.js-snapshots/file-converter-dark-cards-win32.png b/e2e/visual.spec.js-snapshots/file-converter-dark-cards-win32.png index 0481cdd..3e36ea5 100644 Binary files a/e2e/visual.spec.js-snapshots/file-converter-dark-cards-win32.png and b/e2e/visual.spec.js-snapshots/file-converter-dark-cards-win32.png differ diff --git a/e2e/visual.spec.js-snapshots/file-converter-dark-form-dialog-win32.png b/e2e/visual.spec.js-snapshots/file-converter-dark-form-dialog-win32.png index b007d4d..6bc35ca 100644 Binary files a/e2e/visual.spec.js-snapshots/file-converter-dark-form-dialog-win32.png and b/e2e/visual.spec.js-snapshots/file-converter-dark-form-dialog-win32.png differ diff --git a/e2e/visual.spec.js-snapshots/file-converter-light-cards-selected-win32.png b/e2e/visual.spec.js-snapshots/file-converter-light-cards-selected-win32.png index 35135dd..45075df 100644 Binary files a/e2e/visual.spec.js-snapshots/file-converter-light-cards-selected-win32.png and b/e2e/visual.spec.js-snapshots/file-converter-light-cards-selected-win32.png differ diff --git a/e2e/visual.spec.js-snapshots/file-converter-light-cards-win32.png b/e2e/visual.spec.js-snapshots/file-converter-light-cards-win32.png index 72264c5..4609764 100644 Binary files a/e2e/visual.spec.js-snapshots/file-converter-light-cards-win32.png and b/e2e/visual.spec.js-snapshots/file-converter-light-cards-win32.png differ diff --git a/e2e/visual.spec.js-snapshots/file-converter-light-form-dialog-win32.png b/e2e/visual.spec.js-snapshots/file-converter-light-form-dialog-win32.png index 782ae27..b6260dd 100644 Binary files a/e2e/visual.spec.js-snapshots/file-converter-light-form-dialog-win32.png and b/e2e/visual.spec.js-snapshots/file-converter-light-form-dialog-win32.png differ diff --git a/e2e/visual.spec.js-snapshots/media-player-dark-cards-selected-win32.png b/e2e/visual.spec.js-snapshots/media-player-dark-cards-selected-win32.png index c5e2f51..c986c79 100644 Binary files a/e2e/visual.spec.js-snapshots/media-player-dark-cards-selected-win32.png and b/e2e/visual.spec.js-snapshots/media-player-dark-cards-selected-win32.png differ diff --git a/e2e/visual.spec.js-snapshots/media-player-dark-cards-win32.png b/e2e/visual.spec.js-snapshots/media-player-dark-cards-win32.png index f1a702d..6186639 100644 Binary files a/e2e/visual.spec.js-snapshots/media-player-dark-cards-win32.png and b/e2e/visual.spec.js-snapshots/media-player-dark-cards-win32.png differ diff --git a/e2e/visual.spec.js-snapshots/media-player-dark-form-dialog-win32.png b/e2e/visual.spec.js-snapshots/media-player-dark-form-dialog-win32.png index bdd04c5..3d8cd5d 100644 Binary files a/e2e/visual.spec.js-snapshots/media-player-dark-form-dialog-win32.png and b/e2e/visual.spec.js-snapshots/media-player-dark-form-dialog-win32.png differ diff --git a/e2e/visual.spec.js-snapshots/media-player-light-cards-selected-win32.png b/e2e/visual.spec.js-snapshots/media-player-light-cards-selected-win32.png index b2c8a28..4399b87 100644 Binary files a/e2e/visual.spec.js-snapshots/media-player-light-cards-selected-win32.png and b/e2e/visual.spec.js-snapshots/media-player-light-cards-selected-win32.png differ diff --git a/e2e/visual.spec.js-snapshots/media-player-light-cards-win32.png b/e2e/visual.spec.js-snapshots/media-player-light-cards-win32.png index 8045bea..d1f162f 100644 Binary files a/e2e/visual.spec.js-snapshots/media-player-light-cards-win32.png and b/e2e/visual.spec.js-snapshots/media-player-light-cards-win32.png differ diff --git a/e2e/visual.spec.js-snapshots/media-player-light-form-dialog-win32.png b/e2e/visual.spec.js-snapshots/media-player-light-form-dialog-win32.png index 0a2cddf..5ff8b99 100644 Binary files a/e2e/visual.spec.js-snapshots/media-player-light-form-dialog-win32.png and b/e2e/visual.spec.js-snapshots/media-player-light-form-dialog-win32.png differ diff --git a/test/kit-css.test.js b/test/kit-css.test.js index 3f353dd..e0dabb4 100644 --- a/test/kit-css.test.js +++ b/test/kit-css.test.js @@ -42,8 +42,12 @@ const TOKENS = { "--focus-ring-width": "2px", "--focus-ring-offset": "2px", "--scrollbar-width": "6px", - "--scrollbar-thumb": "rgba(128, 128, 128, 0.3)", - "--scrollbar-thumb-hover": "rgba(128, 128, 128, 0.5)", +}; + +/** The tokens each theme sets on its own root, worked out from its own text colour. */ +const THEME_TOKENS = { + "--scrollbar-thumb": "rgba(var(--bs-body-color-rgb), 0.5)", + "--scrollbar-thumb-hover": "rgba(var(--bs-body-color-rgb), 0.7)", }; /** A block's text, from its opening brace to the matching closing one. */ @@ -91,7 +95,7 @@ test("kit.css has the house tokens, in :root, with their values", () => { }); test("each token is set once, outside reduced motion", () => { - for (const name of Object.keys(TOKENS)) { + for (const name of [...Object.keys(TOKENS), ...Object.keys(THEME_TOKENS)]) { const set = KIT_RULES.filter((r) => Object.hasOwn(r.declarations, name)); assert.equal(set.length, 1, name); } @@ -327,3 +331,27 @@ test("a form dialog is a prompt's dialog with its rows apart, and the house's he assert.deepEqual(rule(".kit-form-dialog .kit-dialog-body"), { display: "flex", "flex-direction": "column", gap: "0.75rem" }); assert.deepEqual(rule(".form-text.reserved"), { "min-height": "1.5em" }); }); + +test("the scrollbar's thumb is each theme's text colour, at half and at 0.7 hovered, set on every theme's root", () => { + assert.deepEqual(rule(":root, [data-bs-theme]"), THEME_TOKENS); +}); + +test("every scrollbar is thin, its thumb the token's on a track that isn't drawn, with no arrow buttons", () => { + assert.deepEqual(rule("::-webkit-scrollbar"), { width: "var(--scrollbar-width)", height: "var(--scrollbar-width)", "background-color": "transparent" }); + assert.deepEqual(rule("::-webkit-scrollbar-thumb"), { "border-radius": "var(--radius-pill)", "background-color": "var(--scrollbar-thumb)" }); + assert.deepEqual(rule("::-webkit-scrollbar-thumb:hover"), { "background-color": "var(--scrollbar-thumb-hover)" }); + assert.deepEqual(rule("::-webkit-scrollbar-corner"), { "background-color": "transparent" }); + // No rule draws an arrow button: Chromium draws none unless one is styled. + assert.deepEqual(KIT_RULES.filter((r) => r.selector.includes("scrollbar-button")).map((r) => r.selector), []); + // The standard properties are never set: Chromium ignores ::-webkit-scrollbar wherever either is, and its + // scrollbar-width: thin still draws an arrow button at each end. + for (const { selector, declarations } of KIT_RULES) { + for (const name of ["scrollbar-width", "scrollbar-color", "scrollbar-gutter"]) assert.ok(!Object.hasOwn(declarations, name), `"${selector}" sets ${name}`); + } +}); + +test("the Update tab's cards fill the pane: nothing on a Settings tab caps its width but the channel's select", () => { + assert.deepEqual(rule(".update-tab"), { display: "flex", "flex-direction": "column", gap: "1rem" }); + const capped = KIT_RULES.filter((r) => /settings|update|credits/.test(r.selector) && Object.hasOwn(r.declarations, "max-width")); + assert.deepEqual(capped.map((r) => [r.selector, r.declarations["max-width"]]), [[".update-channel", "16rem"]]); +});