personal-site/public/theme.css
badbl0cks 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

665 lines
14 KiB
CSS

h1.giga {
font-size: var(--pt-double-canon);
}
h1.mega {
font-size: var(--pt-canon);
}
h1,
header > p {
font-size: var(--pt-double-great-primer);
}
h2 {
font-size: var(--pt-double-english);
}
h3 {
font-size: var(--pt-double-pica);
}
h4 {
font-size: var(--pt-paragon);
}
h5 {
font-size: var(--pt-great-primer);
}
h6 {
font-size: var(--pt-english);
}
small {
font-size: var(--pt-small-pica);
}
small.micro {
font-size: var(--pt-long-primer);
}
small.nano {
font-size: var(--pt-bourgeois);
}
small.pico {
font-size: var(--pt-minion);
}
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;
background-color: var(--color-bg);
background-image: radial-gradient(
var(--crt-vignette-core),
var(--crt-vignette-edge) 120%
);
color: var(--color-fg);
column-gap: var(--gutter-large);
display: grid;
font-size: var(--pt-pica);
grid-template-rows: auto 1fr auto;
left: 0;
overflow-x: hidden;
padding-inline: var(--page-padding-inline);
place-items: center;
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);
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;
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 {
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,
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)
);
}
/* 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 {
animation: none;
}
@media (prefers-reduced-motion: reduce) {
:root:not([data-reduce-motion="off"]) body,
:root:not([data-reduce-motion="off"]) body::after {
animation: none;
}
}
:root[data-more-contrast="on"] body {
background-image: none;
text-shadow: none;
animation: none;
}
:root[data-more-contrast="on"] body::before,
:root[data-more-contrast="on"] body::after {
content: none;
}
@media (prefers-contrast: more) {
:root:not([data-more-contrast="off"]) body {
background-image: none;
text-shadow: none;
animation: none;
}
:root:not([data-more-contrast="off"]) body::before,
:root:not([data-more-contrast="off"]) body::after {
content: none;
display: none;
background: none;
animation: none;
}
}
@keyframes crt-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;
}
5% {
--crt-shift: 0.717;
}
10% {
--crt-shift: 0.008;
}
15% {
--crt-shift: 0.103;
}
20% {
--crt-shift: 0.893;
}
25% {
--crt-shift: 0.414;
}
30% {
--crt-shift: 0.18;
}
35% {
--crt-shift: 1;
}
40% {
--crt-shift: 0.993;
}
45% {
--crt-shift: 0.573;
}
50% {
--crt-shift: 0.021;
}
55% {
--crt-shift: 0.61;
}
60% {
--crt-shift: 0.565;
}
65% {
--crt-shift: 0.735;
}
70% {
--crt-shift: 0.125;
}
75% {
--crt-shift: 0.486;
}
80% {
--crt-shift: 0.021;
}
85% {
--crt-shift: 0.025;
}
90% {
--crt-shift: 0.884;
}
95% {
--crt-shift: 0.56;
}
100% {
--crt-shift: 0.673;
}
}
header,
main,
footer {
width: 100%;
}
header,
footer {
padding-block: var(--stack-large);
text-align: center;
a {
text-decoration: none;
}
}
nav {
padding-block: var(--stack-small);
padding-inline: var(--gutter-small);
width: 100%;
}
.display-prefs-nav {
position: absolute;
top: 0;
right: 0;
z-index: 3;
}
.display-prefs-nav summary {
align-items: center;
cursor: pointer;
display: flex;
justify-content: flex-end;
list-style: none;
padding-block: var(--stack-small);
padding-inline: 0;
}
.display-prefs-nav summary::-webkit-details-marker {
display: none;
}
.display-prefs-nav summary::before {
color: transparent;
text-shadow: none;
content: "[" / "";
}
.display-prefs-nav summary::after {
color: transparent;
text-shadow: none;
content: "]" / "";
}
.display-prefs-nav summary:hover::before,
.display-prefs-nav summary:hover::after {
color: var(--color-glow);
text-shadow: inherit;
}
.display-prefs-nav summary > span {
translate: 0 -0.225em;
}
.display-prefs-panel {
background-color: var(--color-bg);
border: 1px solid var(--color-accent);
min-inline-size: 10em;
padding-block: var(--stack-small);
padding-inline: var(--gutter-small);
position: absolute;
right: 0;
inline-size: max-content;
}
nav ul {
column-gap: var(--gutter-large);
display: flex;
flex-wrap: wrap;
justify-content: center;
list-style: none;
margin-block-start: 0;
row-gap: var(--stack-large);
}
nav li {
align-content: center;
white-space: nowrap;
}
.iconBox {
display: inline-block;
height: 19px;
width: 19px;
}
p,
ul {
margin-block-end: var(--stack-large);
max-inline-size: var(--line-length);
}
form p {
margin-block-end: none;
}
a {
color: var(--color-fg);
margin: 0 -1ch;
text-decoration: underline;
text-decoration-color: var(--color-glow);
/* Brackets always occupy their space and hover only reveals them. Swapping
ch-based padding for a rendered glyph shifts the text by a subpixel,
because the fluid root font-size makes 1ch fractional.
The / "" keeps them out of the accessible name. */
&::before {
color: transparent;
text-shadow: none;
content: "[" / "";
}
&::after {
color: transparent;
text-shadow: none;
content: "]" / "";
}
&:hover::before,
&:hover::after {
color: var(--color-glow);
text-shadow: inherit;
}
&:hover {
color: var(--color-fg);
text-decoration: none;
}
}
footer a {
text-decoration: none;
width: fit-content;
display: inline-block;
margin: 0 auto;
padding: 0;
&::before,
&::after {
content: none;
}
}
ul {
list-style-type: square;
}
fieldset {
border: 2px solid var(--color-fg);
padding-block: var(--stack-small);
padding-inline: var(--gutter-small);
}
label {
display: block;
}
label:has(> input[type="checkbox"]),
label:has(> input[type="radio"]) {
align-items: center;
column-gap: var(--gutter-large);
display: flex;
margin-block: var(--stack-large);
}
/* TODO: use textarea-wrapper strategy to implement invisible inputs idea,
but have the text-glow effect done on the ::after pseudo-element and hide
the input when it isnt focused, and swap when focused */
input[type="text"],
input[type="email"],
input[type="password"],
input[type="search"],
input[type="url"],
input[type="tel"],
input[type="number"],
input[type="date"],
input[type="time"],
input[type="datetime-local"],
textarea,
.textarea-wrapper::after,
select {
background-color: var(--color-bg);
border: 2px solid var(--color-accent);
padding-block: var(--stack-small);
padding-inline: var(--gutter-small);
transition:
border-color 0.5s,
box-shadow 0.5s;
width: 100%;
}
textarea,
.textarea-wrapper::after {
grid-area: 1 / 1 / 2 / 2; /* Place on top of each other */
min-height: calc(2rlh + (var(--stack-small) * 2) - 2px);
}
.textarea-wrapper {
display: grid;
}
.textarea-wrapper::after {
content: attr(data-replicated-value) " "; /* Needed to prevent jumpy behavior */
visibility: hidden;
white-space: pre-wrap;
}
.textarea-wrapper > textarea {
overflow: hidden;
resize: none;
}
input:focus,
textarea:focus,
select:focus {
box-shadow: 0 0 5px var(--color-glow);
outline: none;
}
input:disabled,
textarea:disabled,
select:disabled,
button:disabled,
fieldset:disabled {
cursor: not-allowed;
opacity: 0.5;
}
button,
input[type="submit"],
input[type="reset"] {
background-color: var(--color-accent);
box-shadow: 0 0 2px var(--color-glow);
color: var(--color-bg);
margin-block: var(--stack-small);
padding-block: var(--stack-small);
padding-inline: var(--gutter-small);
transition: background-color 0.5s;
text-align: center;
}
button:hover:enabled,
input[type="submit"]:hover:enabled,
input[type="reset"]:hover:enabled {
background-color: var(--color-accent-bright);
}
button:active:enabled,
input[type="submit"]:active:enabled,
input[type="reset"]:active:enabled {
background-color: var(--color-accent-bright);
}
svg {
fill: currentColor;
}
/*input[type="checkbox"],
input[type="radio"] {
appearance: none;
background-color: var(--color-bg);
border: 2px solid var(--color-fg);
cursor: pointer;
height: var(--pt-english);
position: relative;
vertical-align: middle;
width: var(--pt-english);
}
input[type="checkbox"] {
border-radius: 3px;
}
input[type="radio"] {
border-radius: 50%;
}
input[type="checkbox"]:checked,
input[type="radio"]:checked {
background-color: var(--color-accent);
}
input[type="checkbox"]:checked::after {
color: var(--color-accent);
content: "✓";
font-size: var(--pt-long-primer);
left: 2px;
position: absolute;
top: -1px;
}
input[type="radio"]:checked::after {
background-color: var(--color-accent);
border-radius: 50%;
content: "";
height: var(--pt-minion);
left: 3px;
position: absolute;
top: 3px;
width: var(--pt-minion);
}
select {
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%23333' viewBox='0 0 16 16'%3E%3Cpath d='M7.247 11.14L2.451 5.658C1.885 5.013 2.345 4 3.204 4h9.592a1 1 0 0 1 .753 1.659l-4.796 5.48a1 1 0 0 1-1.506 0z'/%3E%3C/svg%3E");
background-position: calc(100% - 8px) center;
background-repeat: no-repeat;
padding-block-end: var(--stack-small);
}
input[type="file"] {
background-color: var(--color-bg);
border: 2px dashed var(--color-fg);
cursor: pointer;
padding: 20px;
text-align: center;
}
input[type="file"]::file-selector-button {
background-color: var(--color-accent);
border: none;
border-radius: 4px;
color: var(--bg-color);
cursor: pointer;
font-family: inherit;
margin-right: 10px;
padding: 8px 16px;
}
input[type="range"] {
appearance: none;
background-color: var(--color-fg);
border-radius: 3px;
height: 6px;
margin: 10px 0;
width: 100%;
}
input[type="range"]::-webkit-slider-thumb {
appearance: none;
background-color: var(--color-accent);
border: 2px solid var(--bg-color);
border-radius: 50%;
cursor: pointer;
height: 18px;
width: 18px;
}
input[type="color"] {
border: 2px solid var(--color-fg);
border-radius: 4px;
cursor: pointer;
height: 40px;
padding: 2px;
width: 60px;
}
input:invalid,
textarea:invalid,
select:invalid {
border-color: var(--color-accent);
}
label.required::after {
color: var(--color-accent);
content: "*";
margin-left: 4px;
}
button,
input[type="submit"],
input[type="reset"] {
background-color: var(--color-accent);
border: 2px solid var(--color-accent);
border-radius: 4px;
color: var(--bg-color);
cursor: pointer;
font-family: inherit;
font-size: 1rem;
padding: 10px 20px;
transition: all 0.2s;
}
button:hover,
input[type="submit"]:hover,
input[type="reset"]:hover {
background-color: var(--color-fg);
border-color: var(--color-fg);
transform: translateY(-1px);
}
button:active,
input[type="submit"]:active,
input[type="reset"]:active {
transform: translateY(1px);
}
progress {
background-color: var(--color-bg);
border: 2px solid var(--color-fg);
border-radius: 4px;
height: 20px;
width: 100%;
}
progress::-webkit-progress-bar {
background-color: var(--color-bg);
border-radius: 2px;
}
progress::-webkit-progress-value {
background-color: var(--color-accent);
border-radius: 2px;
}
*/