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 <noreply@anthropic.com>
This commit is contained in:
badblocks 2026-08-07 17:39:05 -07:00
parent 89cfe7bdec
commit 0e6d352dca
Signed by: badblocks
SSH key fingerprint: SHA256:hEcM6BP4hKm9F7WsomNuXSBpPn2BSDnFzPSl3y1NerQ
2 changed files with 158 additions and 166 deletions

View file

@ -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,