From f9820c7ee065b11d2d980fa98d2a66f951efaa6f Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Thu, 20 Aug 2026 09:14:30 -0400 Subject: [PATCH] feat: cut every button as a notched plate The site's stamped shape - top-left and bottom-right corners taken off - was written out once per control on the daily card and the waiting screen. It is now the default for every button and for anything wearing .plate, with tiles, round icons, pills, menu rows and mode cards opting out. A clipped control cannot wear an outlined focus ring, so plates take an inset one and the opt-outs keep the outline; a quiet button is filled rather than outlined, because a cut edge left its border broken. --- web/scripts/plate.test.mjs | 64 ++++++++ web/scripts/waiting-screen.test.mjs | 15 +- web/src/layouts/Base.astro | 2 +- web/src/screens/daily.ts | 4 +- web/src/screens/waiting.ts | 8 +- web/src/styles.css | 243 +++++++++++++++++++--------- 6 files changed, 252 insertions(+), 84 deletions(-) create mode 100644 web/scripts/plate.test.mjs 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