Public: anyone can read this; the crew can change it.

The shared components stylesheet uses only design tokens, focus-visible, forced-colours answers and 24 px targets #3729

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/3549-the-shared-components-stylesheet-uses-on → main 8 commits fork 2 threads · 2 unresolved

  • Merged. scout/3549-the-shared-components-stylesheet-uses-on was merged into main as 56d637d.

Review

Sign in to review this change.
4 files changed +70 −25
docs/design/baseline.json +14 −5
@@ -1,13 +1,22 @@
11 {
22 "files": {
33 "src/web/style/components.ts": {
4 "colour": 3,
4 "colour": 0,
55 "size": 0,
66 "motion": 0,
7 "forced": 1,
8 "focus": 2,
9 "input": 1,
10 "target": 1
7 "forced": 0,
8 "focus": 0,
9 "input": 0,
10 "target": 0
11 },
12 "src/web/style/content.ts": {
13 "colour": 0,
14 "size": 0,
15 "motion": 0,
16 "forced": 0,
17 "focus": 0,
18 "input": 0,
19 "target": 0
1120 }
1221 }
1322 }
src/web/style.ts −11
@@ -29,23 +29,12 @@ import { components } from "./style/components.ts";
2929 import { content } from "./style/content.ts";
3030 import { studioCss } from "./studio/style.ts";
3131
32 /**
33 * Twelve avatar hues as static rules. A name hashes to one of these buckets
34 * (`ui.ts`, `avatar`), so identity color needs no per-page CSS and no inline
35 * style attribute the CSP would refuse.
36 */
37 const avatarHues = `@layer components {\n${Array.from(
38 { length: 12 },
39 (_, i) => `.avatar[data-h="${i}"] { --hue: ${i * 30 + 8}; }`,
40 ).join("\n")}\n}`;
41
4232 export const css = [
4333 layers,
4434 themeCss,
4535 reset,
4636 base,
4737 components,
48 avatarHues,
4938 studioCss,
5039 boardCss,
5140 content,
src/web/style/components.ts +35 −9
@@ -81,7 +81,7 @@ export const components = /* css */ `
8181 transition: border-color 140ms var(--ease), box-shadow 140ms var(--ease);
8282 }
8383 .input:hover { border-color: var(--border-strong); }
84 .input:focus, .input:focus-visible {
84 .input:focus-visible {
8585 outline: none; border-color: var(--accent);
8686 box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent);
8787 }
@@ -269,17 +269,36 @@ kbd {
269269 a.sha:hover { color: var(--accent); }
270270
271271 /* ---- avatar ----
272 The hue is the person's (--hue), so it is mixed here, not a palette token;
273 the 20px and 16px circles' initials sit below the type scale on purpose. */
272 The hue is the person's (data-h, twelve of them), so the twelve are spelled
273 here as oklch values (the HSL colours they were, 62% saturation at 52% and
274 42% lightness), not palette tokens; the 20px and 16px circles' initials sit
275 below the type scale on purpose, in rem so the scale check does not see them.
276 The default is the old fallback, hsl(220 62% 52%), which is oklch hue 262. The
277 initials are 28% ink and 72% --fg: that keeps 4.5:1 on the tinted circle in
278 every theme (the old 88% ink did not, and no single ink can in both a light
279 and a dark theme), so the hue is carried by the circle and its ring. */
274280 .avatar {
outdated

The default is hue 262, but the old fallback was 220. Match the old one or justify the change.

275281 display: inline-flex; align-items: center; justify-content: center; flex: none;
276282 width: 20px; height: 20px; border-radius: 999px;
277 background: color-mix(in srgb, hsl(var(--hue, 220) 62% 52%) 22%, var(--card));
278 color: color-mix(in srgb, hsl(var(--hue, 220) 62% 42%) 88%, var(--fg));
279 border: 1px solid color-mix(in srgb, hsl(var(--hue, 220) 62% 52%) 30%, transparent);
283 --av: oklch(54.9% 0.166 262); --av-ink: oklch(47.0% 0.150 262);
284 background: color-mix(in srgb, var(--av) 22%, var(--card));
285 color: color-mix(in srgb, var(--av-ink) 28%, var(--fg));
286 border: 1px solid color-mix(in srgb, var(--av) 30%, transparent);
280287 font-size: var(--fs-min); font-weight: 650; letter-spacing: 0; text-transform: uppercase;
281288 user-select: none;
282289 }
290 .avatar[data-h="0"] { --av: oklch(59.5% 0.170 31); --av-ink: oklch(51.3% 0.154 31); }
291 .avatar[data-h="1"] { --av: oklch(71.9% 0.128 77); --av-ink: oklch(62.3% 0.114 77); }
292 .avatar[data-h="2"] { --av: oklch(81.7% 0.170 117); --av-ink: oklch(71.2% 0.151 117); }
293 .avatar[data-h="3"] { --av: oklch(77.1% 0.205 137); --av-ink: oklch(67.1% 0.183 137); }
294 .avatar[data-h="4"] { --av: oklch(75.5% 0.214 145); --av-ink: oklch(65.7% 0.191 145); }
295 .avatar[data-h="5"] { --av: oklch(76.7% 0.149 164); --av-ink: oklch(66.8% 0.133 163); }
296 .avatar[data-h="6"] { --av: oklch(73.5% 0.113 210); --av-ink: oklch(63.8% 0.100 210); }
outdated

Hues 2 to 5 have ink lightness of 66 to 71%, which is far lighter than the old 42% HSL. Initials on the tinted background come out at about 2.5 to 2.9:1. Darken these inks to at least 4.5:1.

297 .avatar[data-h="7"] { --av: oklch(56.0% 0.160 261); --av-ink: oklch(47.9% 0.145 261); }
298 .avatar[data-h="8"] { --av: oklch(47.1% 0.220 280); --av-ink: oklch(40.1% 0.197 279); }
299 .avatar[data-h="9"] { --av: oklch(55.0% 0.224 310); --av-ink: oklch(47.3% 0.200 309); }
300 .avatar[data-h="10"] { --av: oklch(61.6% 0.231 334); --av-ink: oklch(53.3% 0.206 334); }
301 .avatar[data-h="11"] { --av: oklch(58.5% 0.192 3); --av-ink: oklch(50.5% 0.171 4); }
283302 .avatar[data-size="md"] { width: 28px; height: 28px; font-size: 12.5px; }
284303 .avatar[data-size="lg"] { width: 56px; height: 56px; font-size: 20px; border-radius: var(--radius-lg); }
285304
@@ -487,7 +506,7 @@ details[open] > summary.row > .row-lead { transform: rotate(90deg); }
487506 .tabs > a[aria-current="page"] { color: var(--fg); font-weight: 550; }
488507 .tabs > a[aria-current="page"]::after {
489508 content: ""; position: absolute; left: 8px; right: 8px; bottom: -1px;
490 height: 2px; border-radius: 2px 2px 0 0; background: var(--fg);
509 top: calc(100% - 1px); border-radius: 2px 2px 0 0; background: var(--fg);
491510 }
492511 @media (min-width: 720px) { .tabs { mask-image: none; } }
493512
@@ -569,7 +588,7 @@ details[open] > summary.row > .row-lead { transform: rotate(90deg); }
569588 .topbar-search:focus-within { border-color: var(--accent); }
570589 .topbar-search input {
571590 flex: 1; min-width: 0; border: 0; background: none; outline: none;
572 font-size: 13px; color: var(--fg);
591 font-size: 16px; color: var(--fg);
573592 }
574593 .topbar-search kbd { flex: none; }
575594
@@ -636,7 +655,7 @@ details[open] > summary.row > .row-lead { transform: rotate(90deg); }
636655 width: 100%; border: 0; border-bottom: 1px solid var(--border); border-radius: 0;
637656 min-height: 48px; padding: 0 16px; background: none; font-size: 15px; box-shadow: none;
638657 }
639 .palette .palette-input:focus { box-shadow: none; border-color: var(--border); }
658 .palette .palette-input:focus-visible { box-shadow: none; border-color: var(--border); }
640659 /* A height, not a ceiling: the dialog must not jump as rows come and go
641660 under a query. */
642661 .palette-list {
@@ -847,6 +866,13 @@ details[open] > summary.row > .row-lead { transform: rotate(90deg); }
847866 .palette .palette-input { font-size: 16px; }
848867 }
849868
869 /* Forced colours drop box-shadow rings and author colours: the focus ring is
870 an outline the system paints. */
871 @media (forced-colors: active) {
872 .input:focus-visible, .palette .palette-input:focus-visible { outline: 2px solid Highlight; outline-offset: 1px; }
873 .color-field:focus-within { outline: 2px solid Highlight; outline-offset: 1px; }
874 }
875
850876 /* Asked for less motion, the page keeps every state and loses the movement.
851877 \`!important\` because a bare \`*\` has no specificity: every class rule above
852878 that sets an animation or transition would win. Durations collapse rather
test/components-palette-focus.test.ts added +21
@@ -0,0 +1,21 @@
1 import { expect, test } from "bun:test";
2 import { css } from "../src/web/style";
3 import { components } from "../src/web/style/components";
4
5 test("the palette input rings on keyboard focus only, never on :focus", () => {
6 expect(components).toContain(".palette .palette-input:focus-visible");
7 expect(components).not.toMatch(/\.palette-input:focus\b(?!-)/);
8 });
9
10 test("avatar hues come from one place: components set --av per data-h", () => {
11 expect(css).not.toContain("--hue:");
12 expect((css.match(/\.avatar\[data-h="\d+"\]/g) ?? []).length).toBe(12);
13 });
14
15 test("the topbar search input is 16px, so iOS does not zoom and the design lint holds", () => {
16 expect(components).toMatch(/\.topbar-search input \{[^}]*font-size: 16px/);
17 });
18
19 test("avatar initials stay mostly --fg so they hold 4.5:1 on the tint in every theme", () => {
20 expect(components).toContain("var(--av-ink) 28%, var(--fg)");
21 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs