diff --git a/public/style.css b/public/style.css index 4650b4d..3b72735 100644 --- a/public/style.css +++ b/public/style.css @@ -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: ""; - 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) { } diff --git a/public/theme.css b/public/theme.css index 7643dd3..ea6dcb7 100644 --- a/public/theme.css +++ b/public/theme.css @@ -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; + 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; + 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,