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:
parent
89cfe7bdec
commit
0e6d352dca
2 changed files with 158 additions and 166 deletions
|
|
@ -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) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
272
public/theme.css
272
public/theme.css
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue