Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 47 additions & 9 deletions css/kit.css
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
8 changes: 7 additions & 1 deletion docs/shell.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 14 additions & 1 deletion docs/theming.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
169 changes: 169 additions & 0 deletions e2e/scrollbars.spec.js
Original file line number Diff line number Diff line change
@@ -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);
});
20 changes: 20 additions & 0 deletions e2e/shell.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading
Loading