also looking at this
Promote main to production: signed-out chrome and the subscriber tables
#17
54 files
+4252
-1353
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -211,6 +211,33 @@ body { | ||
| 211 | 211 | } |
| 212 | 212 | } |
| 213 | 213 | |
| 214 | /* The one element that uses it, as a named class rather than an arbitrary | |
| 215 | * `[animation:loghq-pulse_1.4s_ease-in-out_infinite]` utility. | |
| 216 | * | |
| 217 | * That utility worked. What never worked was the guard beside it, | |
| 218 | * `motion-reduce:[animation:none]`: a variant on an arbitrary property is not | |
| 219 | * recognised by crosswind's class extractor, so it produced no rule at all and | |
| 220 | * the skeleton kept pulsing for people who asked their system not to animate. | |
| 221 | * Silent, as that whole class of miss is -- no error, no warning, nothing in | |
| 222 | * the sheet. Filed as stacksjs/ts-css#37; the generator side of it is fixed in | |
| 223 | * 0.3.14 (#36), the extractor is not. | |
| 224 | * | |
| 225 | * Both halves live here, in source order, so the reduced-motion case does not | |
| 226 | * depend on the extractor, on crosswind's output ordering, or on an | |
| 227 | * !important. It also cannot be separated from the keyframes it guards. Note | |
| 228 | * this file is linked BEFORE the generated sheet, which is why the animation | |
| 229 | * is declared here rather than left as a utility for this rule to fight. | |
| 230 | */ | |
| 231 | .skeleton-pulse { | |
| 232 | animation: loghq-pulse 1.4s ease-in-out infinite; | |
| 233 | } | |
| 234 | ||
| 235 | @media (prefers-reduced-motion: reduce) { | |
| 236 | .skeleton-pulse { | |
| 237 | animation: none; | |
| 238 | } | |
| 239 | } | |
| 240 | ||
| 214 | 241 | /* Theme-toggle icon swap. Three identical copies of this lived in |
| 215 | 242 | * dashboard.stx, settings.stx and projects/index.stx. |
| 216 | 243 | * |
| @@ -143,7 +143,7 @@ onMount(() => { | ||
| 143 | 143 | {{-- Skeleton and value are separate elements rather than one element |
| 144 | 144 | swapping classes: :class works but logs on every bind, and the |
| 145 | 145 | skeleton needs a fixed box the real text must not inherit. --}} |
| 146 | <div class="h-5 w-[160px] bg-[color-mix(in_srgb,var(--text-3)_20%,transparent)] rounded-md [animation:loghq-pulse_1.4s_ease-in-out_infinite] motion-reduce:[animation:none]" :show="!loaded()"></div> | |
| 146 | <div class="h-5 w-[160px] bg-[color-mix(in_srgb,var(--text-3)_20%,transparent)] rounded-md skeleton-pulse" :show="!loaded()"></div> | |
| 147 | 147 | <div class="font-semibold text-lg" :show="loaded()" :text="displayName()"></div> |
| 148 | 148 | <div class="mt-1.5 text-muted text-sm" :text="email()"></div> |
| 149 | 149 | </div> |
| @@ -0,0 +1,78 @@ | ||
| 1 | /** | |
| 2 | * The reduced-motion guard on the account skeleton, and the spelling that | |
| 3 | * silently cannot provide one. | |
| 4 | * | |
| 5 | * account.stx used to carry `motion-reduce:[animation:none]` beside an | |
| 6 | * `[animation:loghq-pulse_…]` utility. The animation worked; the guard never | |
| 7 | * did. Crosswind's class EXTRACTOR does not recognise a variant applied to an | |
| 8 | * arbitrary property, so that class never reached the generator and produced | |
| 9 | * no rule at all -- no error, no warning, nothing in the sheet. Measured on | |
| 10 | * the served page: every other class on that element reached the stylesheet | |
| 11 | * and this one did not, while `active:` and `md:` rules from the same page | |
| 12 | * were present, so plain variants are fine. | |
| 13 | * | |
| 14 | * Filed as stacksjs/ts-css#37. The generator half was fixed in 0.3.14 | |
| 15 | * (ts-css#36); the extractor half is open, so the spelling is still a trap. | |
| 16 | * | |
| 17 | * Both halves now live in public/tokens.css instead, in source order, which | |
| 18 | * is what makes the guard independent of the extractor, of crosswind's output | |
| 19 | * ordering and of any !important. That file is also linked BEFORE the | |
| 20 | * generated sheet, which is why the animation itself moved there rather than | |
| 21 | * staying a utility for the guard to fight. | |
| 22 | */ | |
| 23 | import { describe, expect, test } from 'bun:test' | |
| 24 | import { readdirSync, readFileSync, statSync } from 'node:fs' | |
| 25 | import { join } from 'node:path' | |
| 26 | ||
| 27 | const ROOT = join(import.meta.dir, '../..') | |
| 28 | const tokens = readFileSync(join(ROOT, 'public/tokens.css'), 'utf8') | |
| 29 | ||
| 30 | function stxFiles(dir: string): string[] { | |
| 31 | const out: string[] = [] | |
| 32 | for (const entry of readdirSync(dir)) { | |
| 33 | const full = join(dir, entry) | |
| 34 | if (statSync(full).isDirectory()) | |
| 35 | out.push(...stxFiles(full)) | |
| 36 | else if (entry.endsWith('.stx')) | |
| 37 | out.push(full) | |
| 38 | } | |
| 39 | return out | |
| 40 | } | |
| 41 | ||
| 42 | describe('reduced motion', () => { | |
| 43 | test('the skeleton declares its animation and its guard, in that order', () => { | |
| 44 | const base = tokens.search(/\.skeleton-pulse\s*\{/) | |
| 45 | const guard = tokens.search(/@media \(prefers-reduced-motion: reduce\)\s*\{\s*\.skeleton-pulse\s*\{/) | |
| 46 | expect(`base declared: ${base >= 0}`).toBe('base declared: true') | |
| 47 | expect(`guard declared: ${guard >= 0}`).toBe('guard declared: true') | |
| 48 | // Same file, same specificity, so the later one is what decides. | |
| 49 | expect(`guard after base: ${guard > base}`).toBe('guard after base: true') | |
| 50 | expect(/@media \(prefers-reduced-motion: reduce\)\s*\{\s*\.skeleton-pulse\s*\{\s*animation:\s*none/.test(tokens)).toBe(true) | |
| 51 | }) | |
| 52 | ||
| 53 | test('the element that pulses is the one the guard names', () => { | |
| 54 | const account = readFileSync(join(ROOT, 'resources/views/account.stx'), 'utf8') | |
| 55 | expect(account).toContain('skeleton-pulse') | |
| 56 | // The animation must not also be applied as a utility: that lands in the | |
| 57 | // generated sheet, which is linked after tokens.css, and would outrank the | |
| 58 | // guard on source order while tying on specificity. | |
| 59 | expect(account).not.toMatch(/\[animation:loghq-pulse/) | |
| 60 | }) | |
| 61 | ||
| 62 | test('no view uses a variant on an arbitrary property', () => { | |
| 63 | /* | |
| 64 | * `motion-reduce:[animation:none]`, `hover:[border-color:red]` and friends. | |
| 65 | * They parse, they look right, and they emit nothing. Worth failing a test | |
| 66 | * over precisely because nothing else reports it -- and the cases people | |
| 67 | * reach for this spelling are the accessibility and edge-state ones, which | |
| 68 | * are the least likely to be noticed by eye. | |
| 69 | * | |
| 70 | * Drop this when ts-css#37 closes and the extractor can see them. | |
| 71 | */ | |
| 72 | for (const file of stxFiles(join(ROOT, 'resources'))) { | |
| 73 | const source = readFileSync(file, 'utf8').replace(/\{\{--[\s\S]*?--\}\}/g, '') | |
| 74 | const hit = /[a-z-]+:\[[a-z-]+:[^\]]*\]/.exec(source) | |
| 75 | expect(`${file.slice(ROOT.length + 1)}: ${hit ? hit[0] : 'none'}`).toBe(`${file.slice(ROOT.length + 1)}: none`) | |
| 76 | } | |
| 77 | }) | |
| 78 | }) | |