Compare commits

..

No commits in common. "worktree-crt-effects" and "main" have entirely different histories.

2 changed files with 186 additions and 178 deletions

View file

@ -2,20 +2,6 @@
@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: "<number>";
inherits: true;
initial-value: 0;
}
@font-face {
font-family: "unscii16";
src:
@ -59,37 +45,13 @@
:root {
/* Color */
--color-fg: oklch(1 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-mg: oklch(0.5 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);
@ -150,16 +112,6 @@
}
}
/* 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) {
}

View file

@ -39,21 +39,12 @@ 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: 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;
animation: textShadow 1.6s infinite;
background-color: var(--color-bg);
background-image: radial-gradient(
var(--crt-vignette-core),
var(--crt-vignette-edge) 120%
var(--color-accent-transparent),
black 120%
);
color: var(--color-fg);
column-gap: var(--gutter-large);
@ -67,56 +58,46 @@ body {
position: absolute;
right: 0;
row-gap: var(--stack-large);
/* 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);
text-shadow: 0 0 5px 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: " ";
inset: 0;
pointer-events: none;
position: fixed;
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;
pointer-events: none;
animation: flicker 15s infinite;
}
/* 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 {
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(
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(
90deg,
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)
rgba(255, 0, 0, 0.06),
rgba(0, 255, 0, 0.02),
rgba(0, 0, 255, 0.06)
);
}
/* 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);
z-index: 2;
background-size:
100% 2px,
3px 100%;
background-attachment: fixed;
pointer-events: none;
}
:root[data-reduce-motion="on"] body,
:root[data-reduce-motion="on"] body::after {
@ -151,122 +132,197 @@ body::after {
animation: none;
}
}
@keyframes crt-flicker {
@keyframes flicker {
0% {
opacity: 0.03;
}
7% {
opacity: 0.024;
}
13% {
opacity: 0.041;
}
19% {
opacity: 0.028;
}
26% {
opacity: 0.019;
}
31% {
opacity: 0.036;
}
38% {
opacity: 0.026;
}
44% {
opacity: 0.048;
}
51% {
opacity: 0.022;
}
58% {
opacity: 0.033;
}
63% {
opacity: 0.017;
}
70% {
opacity: 0.039;
}
77% {
opacity: 0.027;
}
83% {
opacity: 0.044;
}
91% {
opacity: 0.021;
}
100% {
opacity: 0.03;
}
}
/* 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% {
--crt-shift: 0.113;
opacity: 0.27861;
}
5% {
--crt-shift: 0.717;
opacity: 0.34769;
}
10% {
--crt-shift: 0.008;
opacity: 0.23604;
}
15% {
--crt-shift: 0.103;
opacity: 0.90626;
}
20% {
--crt-shift: 0.893;
opacity: 0.18128;
}
25% {
--crt-shift: 0.414;
opacity: 0.83891;
}
30% {
--crt-shift: 0.18;
opacity: 0.65583;
}
35% {
--crt-shift: 1;
opacity: 0.67807;
}
40% {
--crt-shift: 0.993;
opacity: 0.26559;
}
45% {
--crt-shift: 0.573;
opacity: 0.84693;
}
50% {
--crt-shift: 0.021;
opacity: 0.96019;
}
55% {
--crt-shift: 0.61;
opacity: 0.08594;
}
60% {
--crt-shift: 0.565;
opacity: 0.20313;
}
65% {
--crt-shift: 0.735;
opacity: 0.71988;
}
70% {
--crt-shift: 0.125;
opacity: 0.53455;
}
75% {
--crt-shift: 0.486;
opacity: 0.37288;
}
80% {
--crt-shift: 0.021;
opacity: 0.71428;
}
85% {
--crt-shift: 0.025;
opacity: 0.70419;
}
90% {
--crt-shift: 0.884;
opacity: 0.7003;
}
95% {
--crt-shift: 0.56;
opacity: 0.36108;
}
100% {
--crt-shift: 0.673;
opacity: 0.24387;
}
}
@keyframes textShadow {
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;
}
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;
}
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;
}
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;
}
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;
}
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;
}
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;
}
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;
}
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;
}
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;
}
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;
}
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;
}
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;
}
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;
}
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;
}
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;
}
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;
}
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;
}
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;
}
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;
}
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;
}
}
header,