From 0e6d352dca7c51a2ffdd3a697995a9325143014f Mon Sep 17 00:00:00 2001 From: badbl0cks <4161747+badbl0cks@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:39:05 -0700 Subject: [PATCH] Rework the CRT raster, glow and flicker effects The three effects had drifted apart from each other and from the token system. Consolidate them behind --crt-* tokens and fix what was broken. - The animated text-shadow silently overrode the static one, so reduce-motion users saw a 5px white glow and everyone else a 3px currentColor glow. Both now come from one declaration, with the aberration offset factored out into --crt-shift. - Step the aberration instead of tweening it. text-shadow is not compositable and this one inherits to every glyph, so the smooth version repainted the whole document at refresh rate. - The flicker was invisible: a near-black tint at an effective alpha of 0.008-0.096 over a black background, a peak lift of under 2/255. Retint with the accent at ~3%. - Anchor the vignette to the viewport. It was sized to body's box, which is document-height, so on long pages the bloom sat off-screen. - Pin the overlays with position: fixed rather than painting document-sized layers, and give the grille hard stops so it is three stripes rather than a smooth R->G->B ramp. - Scale the raster pitch down at >=2dppx, where a 2px scanline is 4 device px and resamples into moire at fractional DPR. - Raise --color-mg to oklch(0.62): at 0.5 it was 3.5:1 on the background and 1.7:1 over the vignette centre. Halve the vignette alpha via its own token, keeping --color-accent-transparent at 50% for ProjectCard's glow. Collapses the two keyframe blocks from 193 lines to 40. Co-Authored-By: Claude Opus 5 --- public/style.css | 50 ++++++++- public/theme.css | 274 +++++++++++++++++++---------------------------- 2 files changed, 158 insertions(+), 166 deletions(-) diff --git a/public/style.css b/public/style.css index 3b72735..4650b4d 100644 --- a/public/style.css +++ b/public/style.css @@ -2,6 +2,20 @@ @import "reset.css" layer(reset); @import "theme.css" layer(theme); +/* The chromatic-aberration offset, as a 0..1 multiplier of + --crt-aberration-max. Registering it gives it a typed initial value, so the + calc() in body's text-shadow stays valid when the animation is not running. + Note the var(--crt-shift, 0) fallbacks at the use sites: without one, an + unresolved --crt-shift makes the calc() invalid at computed-value time, + which drops the *whole* text-shadow — glow included — not just the smear. + The animation itself does not depend on this rule, because step-end never + interpolates; it only ever substitutes a literal keyframe value. */ +@property --crt-shift { + syntax: ""; + inherits: true; + initial-value: 0; +} + @font-face { font-family: "unscii16"; src: @@ -45,13 +59,37 @@ :root { /* Color */ --color-fg: oklch(1 0 0); - --color-mg: oklch(0.5 0 0); + /* 0.62, not 0.5: at 0.5 this was 3.5:1 on the background, under AA, and + 1.7:1 against the centre of the CRT vignette. 0.62 gives 5.8:1 / 4.4:1. */ + --color-mg: oklch(0.62 0 0); --color-bg: oklch(0 0 0); --color-accent: oklch(0.6 0.18 142.5); --color-accent-bright: oklch(0.8 0.18 142.5); --color-glow: oklch(0.8328 0 0); --color-accent-transparent: oklch(0.6 0.18 142.5 / 50%); + /* CRT + The raster (scanline + aperture grille) and the phosphor (vignette, + glow, chromatic aberration). Every value the effect uses lives here so + the whole look can be retuned without touching the keyframes. */ + --crt-scanline-pitch: 2px; + --crt-grille-pitch: 3px; + --crt-scanline-color: rgb(0 0 0 / 0.25); + --crt-grille-r: rgb(255 0 0 / 0.06); + --crt-grille-g: rgb(0 255 0 / 0.02); + --crt-grille-b: rgb(0 0 255 / 0.06); + /* Deliberately not --color-accent-transparent, which ProjectCard uses for + its box-shadow glow and wants at 50%. As a full-screen wash behind text, + 50% costs too much contrast; 25% keeps --color-mg above 4.5:1. */ + --crt-vignette-core: oklch(0.6 0.18 142.5 / 25%); + --crt-vignette-edge: var(--color-bg); + --crt-shadow-cool: rgb(0 30 255 / 0.5); + --crt-shadow-warm: rgb(255 0 80 / 0.3); + --crt-glow-blur: 3px; + /* In em so the smear scales with the fluid root font-size. */ + --crt-aberration-max: 0.15em; + --crt-flicker-base: 0.03; + /* Spacing */ --gutter-large: clamp(3ch, 2.5vmax, 3.75ch); --stack-large: clamp(1.25ex, 2.5vmax, 1.75ex); @@ -112,6 +150,16 @@ } } + /* The raster is specified in CSS px, so on a 2dppx panel a 2px scanline is + 4 device px — coarse, and on fractional DPR it resamples into moire. + Tightening the pitch keeps the apparent scale roughly constant. */ + @media (min-resolution: 2dppx) { + :root { + --crt-scanline-pitch: 1.5px; + --crt-grille-pitch: 2.25px; + } + } + @media (prefers-color-scheme: light) { } diff --git a/public/theme.css b/public/theme.css index ea6dcb7..7643dd3 100644 --- a/public/theme.css +++ b/public/theme.css @@ -39,12 +39,21 @@ html { width: 100%; height: 100%; } +/* The aberration is a discrete jitter, so it is stepped rather than + interpolated: step-end repaints 21 times per cycle instead of once per + frame. text-shadow is not compositable, and this one inherits to every + glyph on the page, so a smooth tween would repaint the whole document at + refresh rate. Stepping also reads more like chroma noise than a ramp does. */ body { - animation: textShadow 1.6s infinite; + animation: crt-aberration 1.6s step-end infinite; + /* Anchors the vignette to the viewport. Without it the gradient is sized to + body's box, which is document-height, so on a long page its centre sits + at the middle of the document and the bloom is off-screen at scroll-top. */ + background-attachment: fixed; background-color: var(--color-bg); background-image: radial-gradient( - var(--color-accent-transparent), - black 120% + var(--crt-vignette-core), + var(--crt-vignette-edge) 120% ); color: var(--color-fg); column-gap: var(--gutter-large); @@ -58,46 +67,56 @@ body { position: absolute; right: 0; row-gap: var(--stack-large); - text-shadow: 0 0 5px var(--color-glow); + /* Single source of truth for the glow: when the animation is off, + --crt-shift is 0 and this is exactly the static glow. Previously the + animation overrode a separate `0 0 5px var(--color-glow)` rule, so + reduce-motion users saw a 5px white glow and everyone else a 3px + currentColor one — two looks nobody had chosen. */ + text-shadow: + calc(var(--crt-shift, 0) * var(--crt-aberration-max)) 0 1px + var(--crt-shadow-cool), + calc(var(--crt-shift, 0) * var(--crt-aberration-max) * -1) 0 1px + var(--crt-shadow-warm), + 0 0 var(--crt-glow-blur) var(--color-glow); top: 0; } +/* Fixed rather than absolute: these used to be document-sized layers that + relied on background-attachment to keep the pattern still. Pinning them to + the viewport gets the same effect while bounding the painted layer, and it + covers the screen regardless of how tall the content is. */ +body::before, body::after { - content: " "; - display: block; - position: absolute; - top: 0; - left: 0; - bottom: 0; - right: 0; - background: rgba(18, 16, 16, 0.1); - background-attachment: fixed; - opacity: 0; - z-index: 2; + content: ""; + inset: 0; pointer-events: none; - animation: flicker 15s infinite; + position: fixed; + z-index: 2; } +/* Scanlines and aperture grille. repeating-linear-gradient with hard stops + replaces background-size tiling: the grille used to be a smooth R->G->B + ramp across 3px, where a real grille is three discrete stripes. */ body::before { - content: " "; - display: block; - position: absolute; - top: 0; - left: 0; - bottom: 0; - right: 0; - background: - linear-gradient(rgba(18, 16, 16, 0) 50%, rgba(0, 0, 0, 0.25) 50%), - linear-gradient( + background-image: + repeating-linear-gradient( + transparent 0 calc(var(--crt-scanline-pitch) / 2), + var(--crt-scanline-color) 0 var(--crt-scanline-pitch) + ), + repeating-linear-gradient( 90deg, - rgba(255, 0, 0, 0.06), - rgba(0, 255, 0, 0.02), - rgba(0, 0, 255, 0.06) + var(--crt-grille-r) 0 calc(var(--crt-grille-pitch) / 3), + var(--crt-grille-g) 0 calc(var(--crt-grille-pitch) / 3 * 2), + var(--crt-grille-b) 0 var(--crt-grille-pitch) ); - z-index: 2; - background-size: - 100% 2px, - 3px 100%; - background-attachment: fixed; - pointer-events: none; +} +/* Phosphor flicker. The old version tinted with rgba(18, 16, 16, 0.1) at + 0.08-0.96 opacity: an effective alpha of 0.008-0.096 of a colour ~7% + lighter than the background, i.e. a peak lift of under 2/255. It was + invisible. Tinting with the accent at ~3% is small enough to cost no + measurable contrast and large enough to actually see. */ +body::after { + animation: crt-flicker 3s step-end infinite; + background: var(--color-accent); + opacity: var(--crt-flicker-base); } :root[data-reduce-motion="on"] body, :root[data-reduce-motion="on"] body::after { @@ -132,197 +151,122 @@ body::before { animation: none; } } -@keyframes flicker { +@keyframes crt-flicker { 0% { - opacity: 0.27861; + opacity: 0.03; } - 5% { - opacity: 0.34769; + 7% { + opacity: 0.024; } - 10% { - opacity: 0.23604; + 13% { + opacity: 0.041; } - 15% { - opacity: 0.90626; + 19% { + opacity: 0.028; } - 20% { - opacity: 0.18128; + 26% { + opacity: 0.019; } - 25% { - opacity: 0.83891; + 31% { + opacity: 0.036; } - 30% { - opacity: 0.65583; + 38% { + opacity: 0.026; } - 35% { - opacity: 0.67807; + 44% { + opacity: 0.048; } - 40% { - opacity: 0.26559; + 51% { + opacity: 0.022; } - 45% { - opacity: 0.84693; + 58% { + opacity: 0.033; } - 50% { - opacity: 0.96019; - } - 55% { - opacity: 0.08594; - } - 60% { - opacity: 0.20313; - } - 65% { - opacity: 0.71988; + 63% { + opacity: 0.017; } 70% { - opacity: 0.53455; + opacity: 0.039; } - 75% { - opacity: 0.37288; + 77% { + opacity: 0.027; } - 80% { - opacity: 0.71428; + 83% { + opacity: 0.044; } - 85% { - opacity: 0.70419; - } - 90% { - opacity: 0.7003; - } - 95% { - opacity: 0.36108; + 91% { + opacity: 0.021; } 100% { - opacity: 0.24387; + opacity: 0.03; } } -@keyframes textShadow { +/* Normalised from the original per-keyframe pixel offsets, divided by their + max (3.8969px), so the rhythm is unchanged but the amplitude is now set in + one place by --crt-aberration-max. */ +@keyframes crt-aberration { 0% { - text-shadow: - 0.4389924193300864px 0 1px rgba(0, 30, 255, 0.5), - -0.4389924193300864px 0 1px rgba(255, 0, 80, 0.3), - 0 0 3px; + --crt-shift: 0.113; } 5% { - text-shadow: - 2.7928974010788217px 0 1px rgba(0, 30, 255, 0.5), - -2.7928974010788217px 0 1px rgba(255, 0, 80, 0.3), - 0 0 3px; + --crt-shift: 0.717; } 10% { - text-shadow: - 0.02956275843481219px 0 1px rgba(0, 30, 255, 0.5), - -0.02956275843481219px 0 1px rgba(255, 0, 80, 0.3), - 0 0 3px; + --crt-shift: 0.008; } 15% { - text-shadow: - 0.40218538552878136px 0 1px rgba(0, 30, 255, 0.5), - -0.40218538552878136px 0 1px rgba(255, 0, 80, 0.3), - 0 0 3px; + --crt-shift: 0.103; } 20% { - text-shadow: - 3.4794037899852017px 0 1px rgba(0, 30, 255, 0.5), - -3.4794037899852017px 0 1px rgba(255, 0, 80, 0.3), - 0 0 3px; + --crt-shift: 0.893; } 25% { - text-shadow: - 1.6125630401149584px 0 1px rgba(0, 30, 255, 0.5), - -1.6125630401149584px 0 1px rgba(255, 0, 80, 0.3), - 0 0 3px; + --crt-shift: 0.414; } 30% { - text-shadow: - 0.7015590085143956px 0 1px rgba(0, 30, 255, 0.5), - -0.7015590085143956px 0 1px rgba(255, 0, 80, 0.3), - 0 0 3px; + --crt-shift: 0.18; } 35% { - text-shadow: - 3.896914047650351px 0 1px rgba(0, 30, 255, 0.5), - -3.896914047650351px 0 1px rgba(255, 0, 80, 0.3), - 0 0 3px; + --crt-shift: 1; } 40% { - text-shadow: - 3.870905614848819px 0 1px rgba(0, 30, 255, 0.5), - -3.870905614848819px 0 1px rgba(255, 0, 80, 0.3), - 0 0 3px; + --crt-shift: 0.993; } 45% { - text-shadow: - 2.231056963361899px 0 1px rgba(0, 30, 255, 0.5), - -2.231056963361899px 0 1px rgba(255, 0, 80, 0.3), - 0 0 3px; + --crt-shift: 0.573; } 50% { - text-shadow: - 0.08084290417898504px 0 1px rgba(0, 30, 255, 0.5), - -0.08084290417898504px 0 1px rgba(255, 0, 80, 0.3), - 0 0 3px; + --crt-shift: 0.021; } 55% { - text-shadow: - 2.3758461067427543px 0 1px rgba(0, 30, 255, 0.5), - -2.3758461067427543px 0 1px rgba(255, 0, 80, 0.3), - 0 0 3px; + --crt-shift: 0.61; } 60% { - text-shadow: - 2.202193051050636px 0 1px rgba(0, 30, 255, 0.5), - -2.202193051050636px 0 1px rgba(255, 0, 80, 0.3), - 0 0 3px; + --crt-shift: 0.565; } 65% { - text-shadow: - 2.8638780614874975px 0 1px rgba(0, 30, 255, 0.5), - -2.8638780614874975px 0 1px rgba(255, 0, 80, 0.3), - 0 0 3px; + --crt-shift: 0.735; } 70% { - text-shadow: - 0.48874025155497314px 0 1px rgba(0, 30, 255, 0.5), - -0.48874025155497314px 0 1px rgba(255, 0, 80, 0.3), - 0 0 3px; + --crt-shift: 0.125; } 75% { - text-shadow: - 1.8948491305757957px 0 1px rgba(0, 30, 255, 0.5), - -1.8948491305757957px 0 1px rgba(255, 0, 80, 0.3), - 0 0 3px; + --crt-shift: 0.486; } 80% { - text-shadow: - 0.0833037308038857px 0 1px rgba(0, 30, 255, 0.5), - -0.0833037308038857px 0 1px rgba(255, 0, 80, 0.3), - 0 0 3px; + --crt-shift: 0.021; } 85% { - text-shadow: - 0.09769827255241735px 0 1px rgba(0, 30, 255, 0.5), - -0.09769827255241735px 0 1px rgba(255, 0, 80, 0.3), - 0 0 3px; + --crt-shift: 0.025; } 90% { - text-shadow: - 3.443339761481782px 0 1px rgba(0, 30, 255, 0.5), - -3.443339761481782px 0 1px rgba(255, 0, 80, 0.3), - 0 0 3px; + --crt-shift: 0.884; } 95% { - text-shadow: - 2.1841838852799786px 0 1px rgba(0, 30, 255, 0.5), - -2.1841838852799786px 0 1px rgba(255, 0, 80, 0.3), - 0 0 3px; + --crt-shift: 0.56; } 100% { - text-shadow: - 2.6208764473832513px 0 1px rgba(0, 30, 255, 0.5), - -2.6208764473832513px 0 1px rgba(255, 0, 80, 0.3), - 0 0 3px; + --crt-shift: 0.673; } } header,