fix(badges): perceptually uniform rainbow via OKLCH + @property

Replaces filter:hue-rotate() with CSS @property --ph-hue/--ph-lit animated
as numbers, applied as oklch() colors on SVG rect/line children. OKLCH keeps
perceptual lightness constant across hue rotation — no more brown/dark-blue
variance between slots. Pulse mode animates --ph-lit 0.42→0.78 for breathing.
Adds slow_pulse cfg_json flag + form checkbox. @property inherits:true lets
the animated value cascade from the div to its SVG children without per-child
animation declarations.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-06-04 20:47:43 -04:00
parent 70fda25c95
commit 29a24812f4

View File

@@ -1419,24 +1419,58 @@ const code_to_icon: {
* active they form a proper tri-phase RGB cycle. Two active slots are 120° apart. * active they form a proper tri-phase RGB cycle. Two active slots are 120° apart.
* Negative animation-delay starts each slot mid-cycle at its designated phase. * Negative animation-delay starts each slot mid-cycle at its designated phase.
*/ */
/* Cycle mode (default): fast linear hue rotation, 120° phase apart per slot */ /*
@keyframes ae_punch_hole_rainbow { * Perceptually uniform punch-hole rainbow via OKLCH + @property.
from { filter: hue-rotate(0deg); } *
to { filter: hue-rotate(360deg); } * WHY NOT hue-rotate(): CSS filter hue-rotate() works in RGB space — perceptual
* brightness varies across hues (yellow ≫ blue), so slots at different phases look
* noticeably lighter or darker than each other.
*
* FIX: @property lets the browser interpolate --ph-hue as a number. Applying colors
* as oklch(L C H) rotates hue at CONSTANT perceptual lightness — all three slots
* stay visually equally bright regardless of phase position.
*
* inherits: true — the animated value cascades to SVG children (rect, line) so
* each child reads the parent div's current --ph-hue without its own animation.
*/
@property --ph-hue {
syntax: '<number>';
inherits: true;
initial-value: 0;
}
@property --ph-lit {
syntax: '<number>';
inherits: true;
initial-value: 0.65;
} }
.punch_hole_rainbow_left { animation: ae_punch_hole_rainbow 2.5s 0.000s linear infinite; }
.punch_hole_rainbow_right { animation: ae_punch_hole_rainbow 2.5s -0.833s linear infinite; }
.punch_hole_rainbow_center { animation: ae_punch_hole_rainbow 2.5s -1.667s linear infinite; }
/* Pulse mode: slow breathing — dim→bright while hue shifts 180° and back. /* Cycle: hue rotates, lightness stays at 0.65 */
Same 120° phase offsets (6s ÷ 3 = 2s per slot) so they breathe in turn. */ @keyframes ae_ph_cycle { from { --ph-hue: 0; } to { --ph-hue: 360; } }
@keyframes ae_punch_hole_pulse {
0%, 100% { filter: hue-rotate(0deg) brightness(0.55); } /* Pulse: hue shifts 180° while lightness breathes 0.42 → 0.78 → 0.42 */
50% { filter: hue-rotate(180deg) brightness(1.30); } @keyframes ae_ph_pulse {
0%, 100% { --ph-hue: 0; --ph-lit: 0.42; }
50% { --ph-hue: 180; --ph-lit: 0.78; }
}
/* 120° phase offsets: 2.5s ÷ 3 ≈ 0.833s | 6s ÷ 3 = 2s */
.punch_hole_rainbow_left { animation: ae_ph_cycle 2.5s 0.000s linear infinite; }
.punch_hole_rainbow_right { animation: ae_ph_cycle 2.5s -0.833s linear infinite; }
.punch_hole_rainbow_center { animation: ae_ph_cycle 2.5s -1.667s linear infinite; }
.punch_hole_pulse_left { animation: ae_ph_pulse 6s 0.000s ease-in-out infinite; }
.punch_hole_pulse_right { animation: ae_ph_pulse 6s -2.000s ease-in-out infinite; }
.punch_hole_pulse_center { animation: ae_ph_pulse 6s -4.000s ease-in-out infinite; }
/* CSS overrides the static inline SVG fill/stroke attributes for animated markers. */
.punch_hole_rainbow_left rect, .punch_hole_rainbow_right rect, .punch_hole_rainbow_center rect,
.punch_hole_pulse_left rect, .punch_hole_pulse_right rect, .punch_hole_pulse_center rect {
fill: oklch(var(--ph-lit) 0.20 var(--ph-hue) / 0.40);
stroke: oklch(var(--ph-lit) 0.25 var(--ph-hue));
}
.punch_hole_rainbow_left line, .punch_hole_rainbow_right line, .punch_hole_rainbow_center line,
.punch_hole_pulse_left line, .punch_hole_pulse_right line, .punch_hole_pulse_center line {
stroke: oklch(var(--ph-lit) 0.25 var(--ph-hue));
} }
.punch_hole_pulse_left { animation: ae_punch_hole_pulse 6s 0.000s ease-in-out infinite; }
.punch_hole_pulse_right { animation: ae_punch_hole_pulse 6s -2.000s ease-in-out infinite; }
.punch_hole_pulse_center { animation: ae_punch_hole_pulse 6s -4.000s ease-in-out infinite; }
.punch_hole_print_rgb { display: none; } .punch_hole_print_rgb { display: none; }
@media print { @media print {