Compare commits

...
Sign in to create a new pull request.

1 commit

Author SHA1 Message Date
0e6d352dca
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>
2026-08-07 17:39:05 -07:00
2 changed files with 158 additions and 166 deletions

View file

@ -2,6 +2,20 @@
@import "reset.css" layer(reset); @import "reset.css" layer(reset);
@import "theme.css" layer(theme); @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: "<number>";
inherits: true;
initial-value: 0;
}
@font-face { @font-face {
font-family: "unscii16"; font-family: "unscii16";
src: src:
@ -45,13 +59,37 @@
:root { :root {
/* Color */ /* Color */
--color-fg: oklch(1 0 0); --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-bg: oklch(0 0 0);
--color-accent: oklch(0.6 0.18 142.5); --color-accent: oklch(0.6 0.18 142.5);
--color-accent-bright: oklch(0.8 0.18 142.5); --color-accent-bright: oklch(0.8 0.18 142.5);
--color-glow: oklch(0.8328 0 0); --color-glow: oklch(0.8328 0 0);
--color-accent-transparent: oklch(0.6 0.18 142.5 / 50%); --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 */ /* Spacing */
--gutter-large: clamp(3ch, 2.5vmax, 3.75ch); --gutter-large: clamp(3ch, 2.5vmax, 3.75ch);
--stack-large: clamp(1.25ex, 2.5vmax, 1.75ex); --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) { @media (prefers-color-scheme: light) {
} }

View file

@ -39,12 +39,21 @@ html {
width: 100%; width: 100%;
height: 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 { 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-color: var(--color-bg);
background-image: radial-gradient( background-image: radial-gradient(
var(--color-accent-transparent), var(--crt-vignette-core),
black 120% var(--crt-vignette-edge) 120%
); );
color: var(--color-fg); color: var(--color-fg);
column-gap: var(--gutter-large); column-gap: var(--gutter-large);
@ -58,46 +67,56 @@ body {
position: absolute; position: absolute;
right: 0; right: 0;
row-gap: var(--stack-large); 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; 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 { body::after {
content: " "; content: "";
display: block; inset: 0;
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;
pointer-events: none; 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 { body::before {
content: " "; background-image:
display: block; repeating-linear-gradient(
position: absolute; transparent 0 calc(var(--crt-scanline-pitch) / 2),
top: 0; var(--crt-scanline-color) 0 var(--crt-scanline-pitch)
left: 0; ),
bottom: 0; repeating-linear-gradient(
right: 0;
background:
linear-gradient(rgba(18, 16, 16, 0) 50%, rgba(0, 0, 0, 0.25) 50%),
linear-gradient(
90deg, 90deg,
rgba(255, 0, 0, 0.06), var(--crt-grille-r) 0 calc(var(--crt-grille-pitch) / 3),
rgba(0, 255, 0, 0.02), var(--crt-grille-g) 0 calc(var(--crt-grille-pitch) / 3 * 2),
rgba(0, 0, 255, 0.06) var(--crt-grille-b) 0 var(--crt-grille-pitch)
); );
z-index: 2; }
background-size: /* Phosphor flicker. The old version tinted with rgba(18, 16, 16, 0.1) at
100% 2px, 0.08-0.96 opacity: an effective alpha of 0.008-0.096 of a colour ~7%
3px 100%; lighter than the background, i.e. a peak lift of under 2/255. It was
background-attachment: fixed; invisible. Tinting with the accent at ~3% is small enough to cost no
pointer-events: none; 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,
:root[data-reduce-motion="on"] body::after { :root[data-reduce-motion="on"] body::after {
@ -132,197 +151,122 @@ body::before {
animation: none; animation: none;
} }
} }
@keyframes flicker { @keyframes crt-flicker {
0% { 0% {
opacity: 0.27861; opacity: 0.03;
} }
5% { 7% {
opacity: 0.34769; opacity: 0.024;
} }
10% { 13% {
opacity: 0.23604; opacity: 0.041;
} }
15% { 19% {
opacity: 0.90626; opacity: 0.028;
} }
20% { 26% {
opacity: 0.18128; opacity: 0.019;
} }
25% { 31% {
opacity: 0.83891; opacity: 0.036;
} }
30% { 38% {
opacity: 0.65583; opacity: 0.026;
} }
35% { 44% {
opacity: 0.67807; opacity: 0.048;
} }
40% { 51% {
opacity: 0.26559; opacity: 0.022;
} }
45% { 58% {
opacity: 0.84693; opacity: 0.033;
} }
50% { 63% {
opacity: 0.96019; opacity: 0.017;
}
55% {
opacity: 0.08594;
}
60% {
opacity: 0.20313;
}
65% {
opacity: 0.71988;
} }
70% { 70% {
opacity: 0.53455; opacity: 0.039;
} }
75% { 77% {
opacity: 0.37288; opacity: 0.027;
} }
80% { 83% {
opacity: 0.71428; opacity: 0.044;
} }
85% { 91% {
opacity: 0.70419; opacity: 0.021;
}
90% {
opacity: 0.7003;
}
95% {
opacity: 0.36108;
} }
100% { 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% { 0% {
text-shadow: --crt-shift: 0.113;
0.4389924193300864px 0 1px rgba(0, 30, 255, 0.5),
-0.4389924193300864px 0 1px rgba(255, 0, 80, 0.3),
0 0 3px;
} }
5% { 5% {
text-shadow: --crt-shift: 0.717;
2.7928974010788217px 0 1px rgba(0, 30, 255, 0.5),
-2.7928974010788217px 0 1px rgba(255, 0, 80, 0.3),
0 0 3px;
} }
10% { 10% {
text-shadow: --crt-shift: 0.008;
0.02956275843481219px 0 1px rgba(0, 30, 255, 0.5),
-0.02956275843481219px 0 1px rgba(255, 0, 80, 0.3),
0 0 3px;
} }
15% { 15% {
text-shadow: --crt-shift: 0.103;
0.40218538552878136px 0 1px rgba(0, 30, 255, 0.5),
-0.40218538552878136px 0 1px rgba(255, 0, 80, 0.3),
0 0 3px;
} }
20% { 20% {
text-shadow: --crt-shift: 0.893;
3.4794037899852017px 0 1px rgba(0, 30, 255, 0.5),
-3.4794037899852017px 0 1px rgba(255, 0, 80, 0.3),
0 0 3px;
} }
25% { 25% {
text-shadow: --crt-shift: 0.414;
1.6125630401149584px 0 1px rgba(0, 30, 255, 0.5),
-1.6125630401149584px 0 1px rgba(255, 0, 80, 0.3),
0 0 3px;
} }
30% { 30% {
text-shadow: --crt-shift: 0.18;
0.7015590085143956px 0 1px rgba(0, 30, 255, 0.5),
-0.7015590085143956px 0 1px rgba(255, 0, 80, 0.3),
0 0 3px;
} }
35% { 35% {
text-shadow: --crt-shift: 1;
3.896914047650351px 0 1px rgba(0, 30, 255, 0.5),
-3.896914047650351px 0 1px rgba(255, 0, 80, 0.3),
0 0 3px;
} }
40% { 40% {
text-shadow: --crt-shift: 0.993;
3.870905614848819px 0 1px rgba(0, 30, 255, 0.5),
-3.870905614848819px 0 1px rgba(255, 0, 80, 0.3),
0 0 3px;
} }
45% { 45% {
text-shadow: --crt-shift: 0.573;
2.231056963361899px 0 1px rgba(0, 30, 255, 0.5),
-2.231056963361899px 0 1px rgba(255, 0, 80, 0.3),
0 0 3px;
} }
50% { 50% {
text-shadow: --crt-shift: 0.021;
0.08084290417898504px 0 1px rgba(0, 30, 255, 0.5),
-0.08084290417898504px 0 1px rgba(255, 0, 80, 0.3),
0 0 3px;
} }
55% { 55% {
text-shadow: --crt-shift: 0.61;
2.3758461067427543px 0 1px rgba(0, 30, 255, 0.5),
-2.3758461067427543px 0 1px rgba(255, 0, 80, 0.3),
0 0 3px;
} }
60% { 60% {
text-shadow: --crt-shift: 0.565;
2.202193051050636px 0 1px rgba(0, 30, 255, 0.5),
-2.202193051050636px 0 1px rgba(255, 0, 80, 0.3),
0 0 3px;
} }
65% { 65% {
text-shadow: --crt-shift: 0.735;
2.8638780614874975px 0 1px rgba(0, 30, 255, 0.5),
-2.8638780614874975px 0 1px rgba(255, 0, 80, 0.3),
0 0 3px;
} }
70% { 70% {
text-shadow: --crt-shift: 0.125;
0.48874025155497314px 0 1px rgba(0, 30, 255, 0.5),
-0.48874025155497314px 0 1px rgba(255, 0, 80, 0.3),
0 0 3px;
} }
75% { 75% {
text-shadow: --crt-shift: 0.486;
1.8948491305757957px 0 1px rgba(0, 30, 255, 0.5),
-1.8948491305757957px 0 1px rgba(255, 0, 80, 0.3),
0 0 3px;
} }
80% { 80% {
text-shadow: --crt-shift: 0.021;
0.0833037308038857px 0 1px rgba(0, 30, 255, 0.5),
-0.0833037308038857px 0 1px rgba(255, 0, 80, 0.3),
0 0 3px;
} }
85% { 85% {
text-shadow: --crt-shift: 0.025;
0.09769827255241735px 0 1px rgba(0, 30, 255, 0.5),
-0.09769827255241735px 0 1px rgba(255, 0, 80, 0.3),
0 0 3px;
} }
90% { 90% {
text-shadow: --crt-shift: 0.884;
3.443339761481782px 0 1px rgba(0, 30, 255, 0.5),
-3.443339761481782px 0 1px rgba(255, 0, 80, 0.3),
0 0 3px;
} }
95% { 95% {
text-shadow: --crt-shift: 0.56;
2.1841838852799786px 0 1px rgba(0, 30, 255, 0.5),
-2.1841838852799786px 0 1px rgba(255, 0, 80, 0.3),
0 0 3px;
} }
100% { 100% {
text-shadow: --crt-shift: 0.673;
2.6208764473832513px 0 1px rgba(0, 30, 255, 0.5),
-2.6208764473832513px 0 1px rgba(255, 0, 80, 0.3),
0 0 3px;
} }
} }
header, header,