diff --git a/package-lock.json b/package-lock.json index 947eef2b..1915ec32 100644 --- a/package-lock.json +++ b/package-lock.json @@ -86,6 +86,7 @@ "@types/ua-parser-js": "0.7.36", "@typescript-eslint/eslint-plugin": "5.46.1", "@typescript-eslint/parser": "5.46.1", + "@vanilla-extract/integration": "6.5.0", "@vitejs/plugin-react": "4.2.0", "buffer": "6.0.3", "cz-conventional-changelog": "3.3.0", @@ -5095,6 +5096,7 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/@vanilla-extract/integration/-/integration-6.5.0.tgz", "integrity": "sha512-E2YcfO8vA+vs+ua+gpvy1HRqvgWbI+MTlUpxA8FvatOvybuNcWAY0CKwQ/Gpj7rswYKtC6C7+xw33emM6/ImdQ==", + "license": "MIT", "dependencies": { "@babel/core": "^7.20.7", "@babel/plugin-syntax-typescript": "^7.20.0", diff --git a/package.json b/package.json index 1aebfd94..69f589c6 100644 --- a/package.json +++ b/package.json @@ -114,6 +114,7 @@ "@types/ua-parser-js": "0.7.36", "@typescript-eslint/eslint-plugin": "5.46.1", "@typescript-eslint/parser": "5.46.1", + "@vanilla-extract/integration": "6.5.0", "@vitejs/plugin-react": "4.2.0", "buffer": "6.0.3", "cz-conventional-changelog": "3.3.0", diff --git a/src/app/features/scene/skyPlate.ts b/src/app/features/scene/skyPlate.ts index 86429ba5..353134ab 100644 --- a/src/app/features/scene/skyPlate.ts +++ b/src/app/features/scene/skyPlate.ts @@ -11,8 +11,10 @@ export const SKY_PLATE_FALLBACK = { /** * The sign-in footer's tint: how much of the plate's fill (--sky-bubble) is mixed over the blurred scene behind the * footer line. The footer is a blurred, tinted band with soft edges, not a plate: the scene shows through it. The - * footer ink is the plate's, so the tint has to bring the scene behind it to where that ink clears 4.5:1 - * (skyTheme.test.ts). The browser mixes in sRGB, so the test does too (skyTheme.test.ts): over pure black 0.80 gives 4.36 and 0.82 gives 4.55, and - * the darkest terrain drawn is 0.0045, not black; measured on the drawn pixels the footer clears 6.2. + * footer ink is the plate's, so the tint has to bring the scene behind it to where that ink clears 4.5:1. The browser + * mixes in sRGB, so the test does too (skyTheme.test.ts): over pure black 0.80 gives 4.36 and 0.82 gives 4.55. The + * terrain is not black: in the shots at 390x844 (terrain scene, tier low) the darkest pixel in the footer's region is + * 0.0203 at Dusk, 0.0936 at Day (dark ink) and 0.0052 at Night (light ink). Measured on the drawn pixels with the band + * on, the worst link contrast is 5.0 at Dusk, 5.7 at Day and 11.2 at Night. */ export const FOOTER_TINT = 0.82; diff --git a/src/app/pages/auth/footerBand.test.ts b/src/app/pages/auth/footerBand.test.ts new file mode 100644 index 00000000..c3293c4d --- /dev/null +++ b/src/app/pages/auth/footerBand.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; +import zlib from 'zlib'; +import { compile, processVanillaFile } from '@vanilla-extract/integration'; +import { AuthFooter } from './styles.css'; + +// The CSS styles.css.ts emits for the sign-in footer, read back from vanilla-extract's own compile. +const emitted = async () => { + const filePath = `${process.cwd()}/src/app/pages/auth/styles.css.ts`; + const { source } = await compile({ filePath, cwd: process.cwd(), identOption: 'debug' }); + const code = await processVanillaFile({ source, filePath, identOption: 'debug' }); + const encoded = (code.match(/source=#([A-Za-z0-9+/=]+)/) as RegExpMatchArray)[1]; + return zlib.gunzipSync(Buffer.from(encoded, 'base64')).toString(); +}; +const declarations = (css: string, selector: string) => + [...css.matchAll(/^([^{}\n]+) \{\n([^}]*)\}/gm)] + .filter((m) => m[1] === selector) + .map((m) => + m[2] + .split('\n') + .map((l) => l.trim().replace(/;$/, '')) + .filter(Boolean) + ); + +describe('the sign-in footer band, as emitted', () => { + const over = `:root[data-scene] .${AuthFooter}`; + + it('is one rule over the scene, so no later rule can override its mask', async () => { + expect(declarations(await emitted(), over)).toHaveLength(1); + }); + + it('puts the plate fill before the color-mix tint, so a browser without color-mix keeps the fill', async () => { + const [rule] = declarations(await emitted(), over); + const fills = rule.filter((d) => d.startsWith('background-color:')); + expect(fills).toHaveLength(2); + expect(fills[0]).toBe('background-color: var(--sky-bubble, #0d1a26)'); + expect(fills[1]).toContain('color-mix('); + }); + + it('blurs what is behind it by a real radius', async () => { + const [rule] = declarations(await emitted(), over); + const blur = rule.find((d) => d.startsWith('backdrop-filter:')) ?? ''; + const radius = Number(blur.match(/^backdrop-filter: blur\((\d+(?:\.\d+)?)px\)$/)?.[1]); + expect(radius).toBeGreaterThan(0); + }); + + it('fades along both axes over a soft ramp, not a hard edge', async () => { + const [rule] = declarations(await emitted(), over); + const mask = rule.find((d) => d.startsWith('mask-image:')) ?? ''; + expect(rule).toContain('mask-composite: intersect'); + ['bottom', 'right'].forEach((axis) => { + const ramp = mask.match( + new RegExp( + `linear-gradient\\(to ${axis}, transparent, #000 (\\d+)px, #000 calc\\(100% - (\\d+)px\\), transparent\\)` + ) + ); + expect(ramp).not.toBeNull(); + // Each end ramps from clear to opaque over at least 8px. + expect(Number(ramp?.[1])).toBeGreaterThanOrEqual(8); + expect(Number(ramp?.[2])).toBeGreaterThanOrEqual(8); + }); + }); + + it('has no border or radius, in the scene rule or in the base footer rule', async () => { + const css = await emitted(); + const [rule] = declarations(css, over); + const base = declarations(css, `.${AuthFooter}`); + expect(base).toHaveLength(1); + [rule, base[0]].forEach((r) => { + expect(r.filter((d) => /^(border|outline)/.test(d))).toEqual([]); + }); + }); +}); diff --git a/src/app/pages/auth/styles.css.ts b/src/app/pages/auth/styles.css.ts index 772f02a2..fc47dd79 100644 --- a/src/app/pages/auth/styles.css.ts +++ b/src/app/pages/auth/styles.css.ts @@ -90,10 +90,11 @@ globalStyle(`${scene} ${AuthCardContent}`, { gap: config.space.S500, }); -// The footer line sits on the terrain, which is dark at its foot in every sky (luminance 0.02 to 0.04 at dusk), where -// the sky's dark ink is unreadable. Over the scene it gets a blurred band tinted with the plate's fill and the plate's +// The footer line sits on the terrain, which is dark at its foot in every sky (luminance 0.02 at dusk), where the +// sky's dark ink is unreadable. Over the scene it gets a blurred band tinted with the plate's fill and the plate's // ink, faded out at its edges so it reads as haze and not as a bar: the scene shows through and round it. A browser -// without color-mix gets the plate's opaque fill, as the chat bubbles do (message/layout/layout.css.ts). The tint (FOOTER_TINT) is checked against the ink in skyTheme.test.ts. +// without color-mix gets the plate's opaque fill, as the chat bubbles do (message/layout/layout.css.ts). The tint +// (FOOTER_TINT) is checked against the ink in skyTheme.test.ts, the emitted rule in footerBand.test.ts. const FADE_Y = 'linear-gradient(to bottom, transparent, #000 16px, #000 calc(100% - 16px), transparent)'; const FADE_X =