diff --git a/web/scripts/plate.test.mjs b/web/scripts/plate.test.mjs new file mode 100644 index 0000000..b9324f6 --- /dev/null +++ b/web/scripts/plate.test.mjs @@ -0,0 +1,64 @@ +/** + * The notched plate, checked structurally: there is no DOM under `npm test`, + * and every way this breaks looks fine in a build. + * + * A plate is cut with clip-path, which clips an element's outline and + * box-shadow along with its background. That has two consequences worth a + * test. A plate's focus ring has to be an inset shadow, because an outlined + * one is cut away without any rule failing. And a control that opts out of + * the shape has to be given the outlined ring back, because the rule that + * turns the outline off is written against every button. + * + * Run with `npm test`. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; + +const web = join(new URL(".", import.meta.url).pathname, ".."); +const css = await readFile(join(web, "src/styles.css"), "utf8"); + +/** The body of the first rule whose selector list matches, or undefined. */ +function rule(selector) { + const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return css.match(new RegExp(`(?:^|\\n)${escaped}\\s*{([^}]*)}`))?.[1]; +} + +test("a button is a plate unless it says otherwise", () => { + const shape = rule("button,\n.plate"); + assert.ok(shape, "no shared shape rule for buttons and plates"); + assert.match(shape, /clip-path: polygon\(/); + assert.match(shape, /--notch:/); + assert.match(shape, /border-radius: 0/); +}); + +test("a plate's focus ring is inset", () => { + const ring = rule("button:focus-visible,\n.plate:focus-visible"); + assert.ok(ring, "no shared focus-visible rule for plates"); + assert.match(ring, /outline: 0/); + assert.match(ring, /box-shadow: inset 0 0 0 2px var\(--plate-ring\)/); +}); + +test("every control that drops the clip gets its outlined ring back", () => { + const optOut = [ + ...css.matchAll( + /(?:^|\n)((?:\.[\w-]+,\n)+\.[\w-]+)\s*{\s*clip-path: none/g, + ), + ]; + assert.ok(optOut.length, "nothing opts out of the plate any more"); + const opted = optOut.flatMap((m) => m[1].split(",\n").map((s) => s.trim())); + const focus = [ + ...css.matchAll( + /((?:\.[\w-]+:focus-visible,\n)+\.[\w-]+:focus-visible)\s*{([^}]*)}/g, + ), + ].find((m) => m[2].includes("box-shadow: none")); + assert.ok(focus, "no focus rule for the controls that opted out"); + assert.match(focus[2], /outline: 2px solid var\(--accent\)/); + const restored = new Set( + focus[1].split(",\n").map((s) => s.trim().replace(":focus-visible", "")), + ); + for (const name of opted) { + assert.ok(restored.has(name), `${name} drops the clip and has no ring`); + } +}); diff --git a/web/scripts/waiting-screen.test.mjs b/web/scripts/waiting-screen.test.mjs index 3985809..a8dbcbf 100644 --- a/web/scripts/waiting-screen.test.mjs +++ b/web/scripts/waiting-screen.test.mjs @@ -4,9 +4,9 @@ * * The scanline is animation, so it needs a reduced-motion rule, and a rule * that is simply absent is invisible until somebody who set the preference - * loads the screen. The scanline and Cancel launch are both cut with - * clip-path, which clips an element's outline along with its background — so - * a focus ring on one of them has to be an inset shadow, and an outlined one + * loads the screen. The scanline and Cancel are both cut with clip-path, + * which clips an element's outline along with its background — so a focus + * ring on one of them has to be an inset shadow, and an outlined one * disappears without any rule failing. * * Run with `npm test`. @@ -44,8 +44,13 @@ test("the scanline stops when motion is not wanted", () => { }); test("the cancel plate's focus ring is inset", () => { - const rule = css.match(/\.waiting-cancel:focus-visible\s*{([^}]*)}/)?.[1]; - assert.ok(rule, "no focus-visible rule for .waiting-cancel"); + // The ring is set once, for every button and everything wearing .plate, and + // Cancel is a button. A per-control copy is what this used to check. + const rule = css.match( + /button:focus-visible,\s*\.plate:focus-visible\s*{([^}]*)}/, + )?.[1]; + assert.ok(rule, "no shared focus-visible rule for plates"); assert.match(rule, /outline: 0/); assert.match(rule, /box-shadow: inset/); + assert.match(css, /\.waiting-cancel\s*{[^}]*--notch:/); }); diff --git a/web/src/layouts/Base.astro b/web/src/layouts/Base.astro index f34ac7c..9365923 100644 --- a/web/src/layouts/Base.astro +++ b/web/src/layouts/Base.astro @@ -202,7 +202,7 @@ const cardSize = image === DEFAULT_CARD ? { width: 1200, height: 630 } : null; ) : ( { line.textContent = fact.text; diff --git a/web/src/styles.css b/web/src/styles.css index 2da70bf..6efe39e 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -698,11 +698,11 @@ body:has(.hero) { .nav a.prominent { align-self: center; margin: 0 0.2rem; - padding: 0.3rem 0.85rem; + padding: 0.3rem 0.85rem 0.3rem 1rem; border: 1px solid transparent; - border-radius: var(--radius); color: var(--on-accent); background: var(--accent); + --plate-ring: var(--on-accent); } .nav a.prominent:hover { @@ -715,7 +715,8 @@ body:has(.hero) { } .nav a.prominent:focus-visible { - outline-color: var(--on-accent); + outline: 0; + box-shadow: inset 0 0 0 2px var(--plate-ring); } /* The same link when the player has a match still going. account.ts adds the @@ -728,7 +729,7 @@ body:has(.hero) { } .nav a.prominent.resume:focus-visible { - outline-color: var(--on-resume); + --plate-ring: var(--on-resume); } /* Narrow: everything shrinks a step, but every label stays written out. Seven @@ -1794,23 +1795,23 @@ body:has(.hero) { diagonal running through it, and a 2px seam of card between them keeps the two halves legible as two targets. - --notch is on the group so both halves cut by the same amount. */ + Both halves carry the same --notch rather than inheriting one from the + group: each is a plate, and a plate sets its own. */ .daily-launch { - --notch: 0.6rem; display: inline-flex; align-items: stretch; gap: 2px; } -/* clip-path clips the element's own outline and box-shadow along with its - background, so neither half can wear the site's focus ring or the Play - half's old outer glow. Both come back below as an inset ring and a - drop-shadow filter, which is applied after the clip and so takes the - notched shape. */ +.daily-launch > .daily-play, +.daily-launch > .daily-download { + --notch: 0.6rem; +} + +/* Both halves are plates (see .plate): the lit one cut at its top left, the + grey one at its bottom right, so the pair carries one shape between them. */ .daily-launch > .daily-play { - border-radius: 0; margin: 0; - clip-path: polygon(var(--notch) 0, 100% 0, 100% 100%, 0 100%, 0 var(--notch)); } .daily-download { @@ -1836,13 +1837,9 @@ body:has(.hero) { color: color-mix(in srgb, var(--ink) 80%, var(--panel)); background: color-mix(in srgb, var(--ink) 22%, var(--panel)); border: 0; - clip-path: polygon( - 0 0, - 100% 0, - 100% calc(100% - var(--notch)), - calc(100% - var(--notch)) 100%, - 0 100% - ); + /* Grey rather than accent-filled, so the ring that shows on it is the + accent one a quiet plate takes by default. */ + --plate-ring: var(--accent); } .daily-download:hover, @@ -1851,12 +1848,6 @@ body:has(.hero) { background: color-mix(in srgb, var(--ink) 34%, var(--panel)); } -/* The clipped ring. Inset, because an outlined one is cut away. */ -.daily-download:focus-visible { - outline: 0; - box-shadow: inset 0 0 0 2px var(--accent); -} - /* The archive is a stack of the same card, so the only thing it needs of its own is the space between them. */ .daily-archive-entry + .daily-archive-entry { @@ -2187,27 +2178,151 @@ button { color: var(--on-accent); background: var(--accent); border: 1px solid transparent; - border-radius: var(--radius); cursor: pointer; } +/* The quiet plate. Outlined, it lost its line along both cut edges and read + as a frame someone had taken a bite out of; filled, it is the treatment the + daily card's grey half and the waiting screen's way out already wear. + Opaque rather than a wash - over a dark card a 12% white is invisible. */ button.secondary { - color: var(--ink); - background: transparent; - border-color: var(--line); + color: color-mix(in srgb, var(--ink) 85%, var(--panel)); + background: color-mix(in srgb, var(--ink) 20%, var(--panel)); + border-color: transparent; font-weight: 500; } +button.secondary:hover:not(:disabled) { + color: var(--ink); + background: color-mix(in srgb, var(--ink) 32%, var(--panel)); +} + button[disabled] { opacity: 0.6; cursor: progress; } /* A button scaled to the row it sits in rather than to the page. Used by the - camo editor's action rows and by the match list. */ + camo editor's action rows and by the match list. The notch comes down with + it: a 0.5rem cut on a 0.8rem button eats the label. */ .small-btn { font-size: 0.8rem; - padding: 0.35rem 0.6rem; + padding: 0.35rem 0.6rem 0.35rem 0.7rem; + --notch: 0.35rem; +} + +/* --- notched plates ------------------------------------------------------- + The site's stamped shape, and the default for anything that reads as a + button: the top-left and bottom-right corners taken off at 45 degrees, so + an action reads as a plate bolted to the page rather than a rounded chip. + .plate-tl and .plate-br cut one corner each, for the halves of a split + control that make the shape up between them, and .plate is for the things + that are not