ReviewOS

also looking at this

loghqorg/loghq

Promote main to production: signed-out chrome and the subscriber tables

#17
Merged glennmichael123 wants to merge main into production
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.

public/tokens.cssmodified+27-0
Changes to public/tokens.css
@@ -211,6 +211,33 @@ body {
211211 }
212212}
213213
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
214241/* Theme-toggle icon swap. Three identical copies of this lived in
215242 * dashboard.stx, settings.stx and projects/index.stx.
216243 *
resources/views/account.stxmodified+1-1
Changes to resources/views/account.stx
@@ -143,7 +143,7 @@ onMount(() => {
143143 {{-- Skeleton and value are separate elements rather than one element
144144 swapping classes: :class works but logs on every bind, and the
145145 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>
147147 <div class="font-semibold text-lg" :show="loaded()" :text="displayName()"></div>
148148 <div class="mt-1.5 text-muted text-sm" :text="email()"></div>
149149 </div>
tests/unit/reduced-motion.test.tsadded+78-0
Changes to tests/unit/reduced-motion.test.ts
@@ -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 */
23import { describe, expect, test } from 'bun:test'
24import { readdirSync, readFileSync, statSync } from 'node:fs'
25import { join } from 'node:path'
26
27const ROOT = join(import.meta.dir, '../..')
28const tokens = readFileSync(join(ROOT, 'public/tokens.css'), 'utf8')
29
30function 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
42describe('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})