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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3549-the-shared-components-stylesheet-uses-on → main 8 commits fork 2 threads · 2 unresolved
4 files
changed +70
−25
docs/design/baseline.json +14 −5
| @@ -1,13 +1,22 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "files": { |
| 3 | 3 | "src/web/style/components.ts": { |
| 4 | "colour": 3, | |
| 4 | "colour": 0, | |
| 5 | 5 | "size": 0, |
| 6 | 6 | "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 | |
| 11 | 20 | } |
| 12 | 21 | } |
| 13 | 22 | } |
src/web/style.ts −11
| @@ -29,23 +29,12 @@ import { components } from "./style/components.ts"; | ||
| 29 | 29 | import { content } from "./style/content.ts"; |
| 30 | 30 | import { studioCss } from "./studio/style.ts"; |
| 31 | 31 | |
| 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 | ||
| 42 | 32 | export const css = [ |
| 43 | 33 | layers, |
| 44 | 34 | themeCss, |
| 45 | 35 | reset, |
| 46 | 36 | base, |
| 47 | 37 | components, |
| 48 | avatarHues, | |
| 49 | 38 | studioCss, |
| 50 | 39 | boardCss, |
| 51 | 40 | content, |
src/web/style/components.ts +35 −9
| @@ -81,7 +81,7 @@ export const components = /* css */ ` | ||
| 81 | 81 | transition: border-color 140ms var(--ease), box-shadow 140ms var(--ease); |
| 82 | 82 | } |
| 83 | 83 | .input:hover { border-color: var(--border-strong); } |
| 84 | .input:focus, .input:focus-visible { | |
| 84 | .input:focus-visible { | |
| 85 | 85 | outline: none; border-color: var(--accent); |
| 86 | 86 | box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent); |
| 87 | 87 | } |
| @@ -269,17 +269,36 @@ kbd { | ||
| 269 | 269 | a.sha:hover { color: var(--accent); } |
| 270 | 270 | |
| 271 | 271 | /* ---- 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. */ | |
| 274 | 280 | .avatar { |
| 275 | 281 | display: inline-flex; align-items: center; justify-content: center; flex: none; |
| 276 | 282 | 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); | |
| 280 | 287 | font-size: var(--fs-min); font-weight: 650; letter-spacing: 0; text-transform: uppercase; |
| 281 | 288 | user-select: none; |
| 282 | 289 | } |
| 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); } | |
| 283 | 302 | .avatar[data-size="md"] { width: 28px; height: 28px; font-size: 12.5px; } |
| 284 | 303 | .avatar[data-size="lg"] { width: 56px; height: 56px; font-size: 20px; border-radius: var(--radius-lg); } |
| 285 | 304 | |
| @@ -487,7 +506,7 @@ details[open] > summary.row > .row-lead { transform: rotate(90deg); } | ||
| 487 | 506 | .tabs > a[aria-current="page"] { color: var(--fg); font-weight: 550; } |
| 488 | 507 | .tabs > a[aria-current="page"]::after { |
| 489 | 508 | 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); | |
| 491 | 510 | } |
| 492 | 511 | @media (min-width: 720px) { .tabs { mask-image: none; } } |
| 493 | 512 | |
| @@ -569,7 +588,7 @@ details[open] > summary.row > .row-lead { transform: rotate(90deg); } | ||
| 569 | 588 | .topbar-search:focus-within { border-color: var(--accent); } |
| 570 | 589 | .topbar-search input { |
| 571 | 590 | 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); | |
| 573 | 592 | } |
| 574 | 593 | .topbar-search kbd { flex: none; } |
| 575 | 594 | |
| @@ -636,7 +655,7 @@ details[open] > summary.row > .row-lead { transform: rotate(90deg); } | ||
| 636 | 655 | width: 100%; border: 0; border-bottom: 1px solid var(--border); border-radius: 0; |
| 637 | 656 | min-height: 48px; padding: 0 16px; background: none; font-size: 15px; box-shadow: none; |
| 638 | 657 | } |
| 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); } | |
| 640 | 659 | /* A height, not a ceiling: the dialog must not jump as rows come and go |
| 641 | 660 | under a query. */ |
| 642 | 661 | .palette-list { |
| @@ -847,6 +866,13 @@ details[open] > summary.row > .row-lead { transform: rotate(90deg); } | ||
| 847 | 866 | .palette .palette-input { font-size: 16px; } |
| 848 | 867 | } |
| 849 | 868 | |
| 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 | ||
| 850 | 876 | /* Asked for less motion, the page keeps every state and loses the movement. |
| 851 | 877 | \`!important\` because a bare \`*\` has no specificity: every class rule above |
| 852 | 878 | 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 | }); |
new above
offline
The default is hue 262, but the old fallback was 220. Match the old one or justify the change.