diff --git a/src/assets/css/_card.scss b/src/assets/css/_card.scss index f06f465..a009929 100644 --- a/src/assets/css/_card.scss +++ b/src/assets/css/_card.scss @@ -40,8 +40,8 @@ // ============================================================================= // Shared texture/asset references -$grain: url('/public/holofoils/grain.webp'); -$glitter: url('/public/holofoils/glitter.png'); +$grain: url('/holofoils/grain.webp'); +$glitter: url('/holofoils/glitter.png'); $glittersize: 25%; @@ -190,7 +190,7 @@ $glittersize: 25%; /// No-mask fallback for cards using the illusion foil pattern. @mixin no-mask-illusion { --mask: none; - --foil: url('/public/holofoils/illusion.png'); + --foil: url('/holofoils/illusion.png'); --imgsize: 33%; -webkit-mask-image: var(--mask); mask-image: var(--mask); @@ -919,7 +919,7 @@ $glittersize: 25%; --space: 4%; clip-path: var(--clip); background-image: - url('/public/holofoils/cosmos-bottom.png'), + url('/holofoils/cosmos-bottom.png'), $cosmos-stripe, radial-gradient( farthest-corner circle at var(--pointer-x) var(--pointer-y), @@ -939,7 +939,7 @@ $glittersize: 25%; &::before { content: ''; z-index: 2; - background-image: url('/public/holofoils/cosmos-middle-trans.png'), $cosmos-stripe; + background-image: url('/holofoils/cosmos-middle-trans.png'), $cosmos-stripe; background-blend-mode: lighten, multiply; background-position: var(--cosmosbg, center center), @@ -953,7 +953,7 @@ $glittersize: 25%; &::after { content: ''; z-index: 3; - background-image: url('/public/holofoils/cosmos-top-trans.png'), $cosmos-stripe; + background-image: url('/holofoils/cosmos-top-trans.png'), $cosmos-stripe; background-blend-mode: multiply, multiply; background-position: var(--cosmosbg, center center), @@ -1081,7 +1081,7 @@ $glittersize: 25%; .card__shine, .card__shine::after { --mask: none; - --foil: url('/public/holofoils/trainerbg.png'); + --foil: url('/holofoils/trainerbg.png'); --imgsize: 25% auto; } .card__shine::after { background-blend-mode: difference; } @@ -1205,7 +1205,7 @@ $glittersize: 25%; } &:not(.masked) .card__shine { - --foil: url('/public/holofoils/illusion-mask.png'); + --foil: url('/holofoils/illusion-mask.png'); --imgsize: 33%; } } @@ -1377,7 +1377,7 @@ $glittersize: 25%; .card__glare { @extend %secret-rare-glare; } &:not(.masked) .card__shine { - --foil: url('/public/holofoils/geometric.png'); + --foil: url('/holofoils/geometric.png'); --imgsize: 33%; filter: brightness(calc((var(--pointer-from-center) * 0.3) + 0.2)) contrast(2) saturate(0.75); } @@ -1648,7 +1648,7 @@ $glittersize: 25%; } &:not(.masked) .card__shine { - --foil: url('/public/holofoils/illusion-mask.png'); + --foil: url('/holofoils/illusion-mask.png'); --imgsize: 33%; } } @@ -1719,7 +1719,7 @@ $glittersize: 25%; &:not(.masked) { .card__shine, .card__shine::after { - --foil: url('/public/holofoils/trainerbg.png'); + --foil: url('/holofoils/trainerbg.png'); --imgsize: 20%; background-blend-mode: color-burn, hue, hard-light; filter: brightness(calc((var(--pointer-from-center) * 0.05) + .6)) contrast(1.5) saturate(1.2); @@ -1828,7 +1828,7 @@ $glittersize: 25%; &:not(.masked) { .card__shine { - --foil: url('/public/holofoils/geometric.png'); + --foil: url('/holofoils/geometric.png'); --imgsize: 33%; filter: brightness(calc((var(--pointer-from-center) * 0.3) + 0.2)) contrast(2) saturate(0.75); } @@ -2027,7 +2027,7 @@ $glittersize: 25%; .card__shine, .card__shine::after { --mask: none; - --foil: url('/public/holofoils/vmaxbg.jpg'); + --foil: url('/holofoils/vmaxbg.jpg'); --imgsize: 60% 30%; } } @@ -2102,7 +2102,7 @@ $glittersize: 25%; .card__shine, .card__shine::after { --mask: none; - --foil: url('/public/holofoils/ancient.png'); + --foil: url('/holofoils/ancient.png'); --imgsize: 18% 15%; background-blend-mode: exclusion, hue, hard-light; filter: brightness(calc((var(--pointer-from-center) * .25) + .35)) contrast(1.8) saturate(1.75); diff --git a/src/assets/css/_holofoil-integration.scss b/src/assets/css/_holofoil-integration.scss index 02aa74a..aaab866 100644 --- a/src/assets/css/_holofoil-integration.scss +++ b/src/assets/css/_holofoil-integration.scss @@ -1,19 +1,40 @@ // ============================================================================= // HOLOFOIL INTEGRATION -// _holofoil-integration.scss +// ============================================================================= +// +// Three effect zones, determined by rarity and variant: +// +// NONE — no effect at all +// variant = Normal (or no recognised rarity/variant) +// +// INVERSE — effect on borders only (everything except the art window) +// variant = Reverse Holofoil +// rarity = Prism Rare +// +// ART WINDOW — effect clipped to the artwork area only +// rarity = Rare | Amazing Rare | Classic Collection | Holo Rare +// variant = Holofoil | 1st Edition Holofoil +// +// FULL CARD — effect over the entire card +// rarity = Ultra Rare | Character Rare | Illustration Rare | +// Special Illustration Rare | Double Rare | Hyper Rare | +// Mega Rare | Mega Attack Rare | ACE Spec Rare | ACE Rare | +// Art Rare | Special Art Rare | Black White Rare | +// Character Super Rare | Mega Ultra Rare | Rare BREAK | +// Secret Rare | Shiny Holo Rare | Shiny Rare | +// Shiny Secret Rare | Shiny Ultra Rare +// // ============================================================================= -@import "card"; - // ----------------------------------------------------------------------------- -// 1. WRAPPER NORMALISATION +// 1. CSS CUSTOM PROPERTIES — set on every wrapper element // ----------------------------------------------------------------------------- -%holofoil-wrapper-base { - --card-aspect: 0.718; - --card-radius: 4.55% / 3.5%; +.image-grow, +.card-image-wrap { + // Pointer tracking — updated by holofoil-init.js on mousemove --pointer-x: 50%; --pointer-y: 50%; --background-x: 50%; @@ -21,29 +42,23 @@ --pointer-from-center: 0; --pointer-from-top: 0.5; --pointer-from-left: 0.5; - --card-scale: 1; --card-opacity: 0; + --card-scale: 1; - --grain: url('/public/holofoils/grain.webp'); - --glitter: url('/public/holofoils/glitter.png'); - --glittersize: 25%; - --space: 5%; - --angle: 133deg; - --imgsize: cover; + // Card geometry — matches Bootstrap's rounded-4 (--bs-border-radius-xl) + --card-radius: var(--bs-border-radius-xl, 0.375rem); - --red: #f80e35; - --yellow: #eedf10; - --green: #21e985; - --blue: #0dbde9; - --violet: #c929f1; - - --clip: inset(9.85% 8% 52.85% 8%); - --clip-invert: polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%, 0 47.15%, 91.5% 47.15%, 91.5% 9.85%, 8% 9.85%, 8% 47.15%, 0 50%); - --clip-stage: polygon(91.5% 9.85%, 57% 9.85%, 54% 12%, 17% 12%, 16% 14%, 12% 16%, 8% 16%, 8% 47.15%, 92% 47.15%); - --clip-stage-invert: polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%, 0 47.15%, 91.5% 47.15%, 91.5% 9.85%, 57% 9.85%, 54% 12%, 17% 12%, 16% 14%, 12% 16%, 8% 16%, 8% 47.15%, 0 50%); - --clip-trainer: inset(14.5% 8.5% 48.2% 8.5%); - --clip-borders: inset(2.8% 4% round 2.55% / 1.5%); + // Art window clip — original poke-holo values, correct for standard TCG card scans + // inset(top right bottom left): top=9.85%, sides=8%, bottom=52.85% (art bottom at 47.15%) + --clip-art: inset(9.85% 8% 52.85% 8%); + // Sunpillar palette + --sunpillar-1: hsl(2, 100%, 73%); + --sunpillar-2: hsl(53, 100%, 69%); + --sunpillar-3: hsl(93, 100%, 69%); + --sunpillar-4: hsl(176, 100%, 76%); + --sunpillar-5: hsl(228, 100%, 74%); + --sunpillar-6: hsl(283, 100%, 73%); --sunpillar-clr-1: var(--sunpillar-1); --sunpillar-clr-2: var(--sunpillar-2); --sunpillar-clr-3: var(--sunpillar-3); @@ -51,40 +66,76 @@ --sunpillar-clr-5: var(--sunpillar-5); --sunpillar-clr-6: var(--sunpillar-6); - // NOTE: no overflow:hidden here -- that would clip the lift/scale transform - // on .image-grow. Overflow is handled by the child .holo-shine/.holo-glare. - position: relative; - isolation: isolate; - border-radius: var(--card-radius); -} + // Colour tokens + --red: #f80e35; + --yellow: #eedf10; + --green: #21e985; + --blue: #0dbde9; + --violet: #c929f1; -%holofoil-energy-glows { + // Glow + --card-glow: hsl(175, 100%, 90%); + + // Texture assets + --grain: url('/holofoils/grain.webp'); + --glitter: url('/holofoils/glitter.png'); + --glittersize: 25%; + --foil: none; + + // Energy glow overrides &[data-energy="Water"] { --card-glow: hsl(192, 97%, 60%); } &[data-energy="Fire"] { --card-glow: hsl(9, 81%, 59%); } &[data-energy="Grass"] { --card-glow: hsl(96, 81%, 65%); } &[data-energy="Lightning"] { --card-glow: hsl(54, 87%, 63%); } &[data-energy="Psychic"] { --card-glow: hsl(281, 62%, 58%); } - &[data-energy="Fighting"] { --card-glow: rgb(145, 90, 39); } + &[data-energy="Fighting"] { --card-glow: rgb(145, 90, 39); } &[data-energy="Darkness"] { --card-glow: hsl(189, 77%, 27%); } &[data-energy="Metal"] { --card-glow: hsl(184, 20%, 70%); } &[data-energy="Dragon"] { --card-glow: hsl(51, 60%, 35%); } &[data-energy="Fairy"] { --card-glow: hsl(323, 100%, 89%); } + + // Hover activates opacity; JS updates pointer vars + &:hover, + &[data-holo-active] { --card-opacity: 0.2; } + + display: block; // ensure wrapper is a block-level containing block + position: relative; + isolation: isolate; + border-radius: var(--card-radius); } // ----------------------------------------------------------------------------- -// 2. SHINE + GLARE CHILD DIVS +// 2. HOLO-SHINE AND HOLO-GLARE BASE STRUCTURE // ----------------------------------------------------------------------------- -%shine-base { +.holo-shine, +.holo-glare { pointer-events: none; + display: block; position: absolute; inset: 0; border-radius: var(--card-radius); - overflow: hidden; // clipping lives here, not on the parent - z-index: 3; + // NO overflow:hidden — it interferes with clip-path on the element itself will-change: transform, opacity, background-image, background-size, - background-position, background-blend-mode, filter; + background-position, background-blend-mode, filter; +} + +// The img inside has mb-2 but the wrapper already has the right size from +// aspect-ratio on .card-image — zero the margin so img fills wrapper flush. +.image-grow > img, +.card-image-wrap > img { + display: block; + margin-bottom: 0 !important; + width: 100% !important; + height: 100% !important; + object-fit: cover; +} + +.holo-shine { + z-index: 3; + mix-blend-mode: color-dodge; + opacity: var(--card-opacity); &::before, &::after { @@ -92,97 +143,380 @@ position: absolute; inset: 0; border-radius: var(--card-radius); + // Sunpillar palette shift for ::before depth layer + --sunpillar-clr-1: var(--sunpillar-5); + --sunpillar-clr-2: var(--sunpillar-6); + --sunpillar-clr-3: var(--sunpillar-1); + --sunpillar-clr-4: var(--sunpillar-2); + --sunpillar-clr-5: var(--sunpillar-3); + --sunpillar-clr-6: var(--sunpillar-4); + } + + &::after { + // Second palette shift for uppermost pseudo layer + --sunpillar-clr-1: var(--sunpillar-6); + --sunpillar-clr-2: var(--sunpillar-1); + --sunpillar-clr-3: var(--sunpillar-2); + --sunpillar-clr-4: var(--sunpillar-3); + --sunpillar-clr-5: var(--sunpillar-4); + --sunpillar-clr-6: var(--sunpillar-5); } } -%glare-base { - pointer-events: none; - position: absolute; - inset: 0; - border-radius: var(--card-radius); +.holo-glare { z-index: 4; - transform: translateZ(0); - overflow: hidden; - will-change: transform, opacity, background-image, background-size, - background-position, background-blend-mode, filter; -} + mix-blend-mode: overlay; + opacity: var(--card-opacity); + background-image: radial-gradient( + farthest-corner circle at var(--pointer-x) var(--pointer-y), + hsla(0, 0%, 100%, 0.8) 10%, + hsla(0, 0%, 100%, 0.65) 20%, + hsla(0, 0%, 0%, 0.5) 90% + ); - -// ----------------------------------------------------------------------------- -// 3. MODES -// ----------------------------------------------------------------------------- - -// -- 3a. GRID ----------------------------------------------------------------- -// No idle animation. Effect is invisible until hover. - -.image-grow, -.card-image-wrap { - @extend %holofoil-wrapper-base; - @extend %holofoil-energy-glows; - - // No effect if the image fell back to default.jpg - &[data-default="true"] { - .holo-shine, - .holo-glare { display: none !important; } + // Grain texture on ::before — soft-light blend adds physical substrate feel + &::before { + content: ''; + position: absolute; + inset: 0; + background-image: var(--grain); + background-size: 33%; + background-repeat: repeat; + mix-blend-mode: soft-light; + opacity: 0.15; + } + + // Glitter texture on ::after — overlay blend adds sparkle points + &::after { + content: ''; + position: absolute; + inset: 0; + background-image: var(--glitter); + background-size: var(--glittersize) var(--glittersize); + background-repeat: repeat; + background-position: + calc(50% - ((5px * 2) * var(--pointer-from-left)) + 5px) + calc(50% - ((5px * 2) * var(--pointer-from-top)) + 5px); + mix-blend-mode: overlay; + opacity: calc(var(--card-opacity) * 0.6); } - .holo-shine { @extend %shine-base; } - .holo-glare { @extend %glare-base; } } -// -- 3b. GRID HOVER ----------------------------------------------------------- -// The existing main.scss .image-grow:hover handles lift + scale. -// We layer the holo effect on top without overriding transform or transition. +// ----------------------------------------------------------------------------- +// 3. ZONE HELPERS — reusable effect mixin +// The standard prismatic effect, applied at different clip regions below. +// ----------------------------------------------------------------------------- -.image-grow:hover, -.image-grow[data-holo-active] { - --card-opacity: 0.45; +// Standard shine background (used by ART WINDOW and FULL CARD zones) +@mixin prismatic-shine { + background-image: + var(--grain), + repeating-linear-gradient(110deg, + var(--violet), var(--blue), var(--green), var(--yellow), var(--red), + var(--violet), var(--blue), var(--green), var(--yellow), var(--red), + var(--violet), var(--blue), var(--green), var(--yellow), var(--red) + ); + background-position: + center center, + calc(((50% - var(--background-x)) * 2.6) + 50%) + calc(((50% - var(--background-y)) * 3.5) + 50%); + background-size: 33%, 400% 400%; + background-repeat: repeat, no-repeat; + background-blend-mode: soft-light, normal; + filter: brightness(.8) contrast(.85) saturate(.75); +} + +@mixin prismatic-glare { + opacity: calc(var(--card-opacity) * 0.4); + filter: brightness(0.8) contrast(1.5); } -// -- 3c. MODAL ---------------------------------------------------------------- -// Sweeps once per minute. Peaks at 0.35. -// Pointer tracking bumps opacity to 0.45 while hovering. +// ----------------------------------------------------------------------------- +// 4. ZONE 0 — NORMAL: no effect +// ----------------------------------------------------------------------------- -@keyframes holo-modal-pulse { - 0% { - --card-opacity: 0; +.image-grow[data-variant="Normal" i], +.card-image-wrap[data-variant="Normal" i] { + .holo-shine, + .holo-glare { display: none !important; } +} + + +// ----------------------------------------------------------------------------- +// 5. ZONE 1 — ART WINDOW EFFECT +// rarity: Rare, Amazing Rare, Classic Collection, Holo Rare +// variant: Holofoil, 1st Edition Holofoil +// ----------------------------------------------------------------------------- + +.image-grow[data-rarity="Rare" i]:not([data-variant="Reverse Holofoil" i]):not([data-variant="Normal" i]), +.card-image-wrap[data-rarity="Rare" i]:not([data-variant="Reverse Holofoil" i]):not([data-variant="Normal" i]), +.image-grow[data-rarity="Amazing Rare" i]:not([data-variant="Reverse Holofoil" i]), +.card-image-wrap[data-rarity="Amazing Rare" i]:not([data-variant="Reverse Holofoil" i]), +.image-grow[data-rarity="Classic Collection" i]:not([data-variant="Reverse Holofoil" i]), +.card-image-wrap[data-rarity="Classic Collection" i]:not([data-variant="Reverse Holofoil" i]), +.image-grow[data-rarity="Holo Rare" i]:not([data-variant="Reverse Holofoil" i]), +.card-image-wrap[data-rarity="Holo Rare" i]:not([data-variant="Reverse Holofoil" i]), +.image-grow[data-variant="Holofoil" i]:not([data-variant="Reverse Holofoil" i]), +.card-image-wrap[data-variant="Holofoil" i]:not([data-variant="Reverse Holofoil" i]), +.image-grow[data-variant="1st Edition Holofoil" i], +.card-image-wrap[data-variant="1st Edition Holofoil" i] { + + .holo-shine { + clip-path: var(--clip-art); + @include prismatic-shine; + } + + .holo-glare { + clip-path: var(--clip-art); + @include prismatic-glare; + } +} + + +// ----------------------------------------------------------------------------- +// 5b. ZONE 1 BORDER ADDITION — Holofoil + 1st Edition Holofoil +// +// Real holofoil cards have the foil stamp on both the art window AND the card +// border. The element carries the art window clip; ::before carries the border +// clip via the same zero-width tunnel polygon as Zone 3. +// ::before inherits background-image/size/position from the parent via inherit. +// ----------------------------------------------------------------------------- + +.image-grow[data-variant="Holofoil" i]:not([data-variant="Reverse Holofoil" i]), +.card-image-wrap[data-variant="Holofoil" i]:not([data-variant="Reverse Holofoil" i]), +.image-grow[data-variant="1st Edition Holofoil" i], +.card-image-wrap[data-variant="1st Edition Holofoil" i] { + + .holo-shine { + &::before { + background-image: inherit; + background-size: inherit; + background-position: inherit; + background-repeat: inherit; + background-blend-mode: inherit; + filter: inherit; + mix-blend-mode: color-dodge; + clip-path: polygon( + 0% 0%, 0% 100%, 100% 100%, 100% 0%, 0% 0%, + 8% 9.85%, 92% 9.85%, 92% 47.15%, 8% 47.15%, 8% 9.85% + ); + } + } + + .holo-glare { + &::before { + clip-path: polygon( + 0% 0%, 0% 100%, 100% 100%, 100% 0%, 0% 0%, + 8% 9.85%, 92% 9.85%, 92% 47.15%, 8% 47.15%, 8% 9.85% + ); + } + } +} + + +// ----------------------------------------------------------------------------- +// 6. ZONE 2 — FULL CARD EFFECT +// ----------------------------------------------------------------------------- + +.image-grow[data-rarity="Ultra Rare" i]:not([data-variant="Reverse Holofoil" i]), +.card-image-wrap[data-rarity="Ultra Rare" i]:not([data-variant="Reverse Holofoil" i]), +.image-grow[data-rarity="Character Rare" i]:not([data-variant="Reverse Holofoil" i]), +.card-image-wrap[data-rarity="Character Rare" i]:not([data-variant="Reverse Holofoil" i]), +.image-grow[data-rarity="Illustration Rare" i]:not([data-variant="Reverse Holofoil" i]), +.card-image-wrap[data-rarity="Illustration Rare" i]:not([data-variant="Reverse Holofoil" i]), +.image-grow[data-rarity="Special Illustration Rare" i]:not([data-variant="Reverse Holofoil" i]), +.card-image-wrap[data-rarity="Special Illustration Rare" i]:not([data-variant="Reverse Holofoil" i]), +.image-grow[data-rarity="Double Rare" i]:not([data-variant="Reverse Holofoil" i]), +.card-image-wrap[data-rarity="Double Rare" i]:not([data-variant="Reverse Holofoil" i]), +.image-grow[data-rarity="Hyper Rare" i]:not([data-variant="Reverse Holofoil" i]), +.card-image-wrap[data-rarity="Hyper Rare" i]:not([data-variant="Reverse Holofoil" i]), +.image-grow[data-rarity="Mega Rare" i]:not([data-variant="Reverse Holofoil" i]), +.card-image-wrap[data-rarity="Mega Rare" i]:not([data-variant="Reverse Holofoil" i]), +.image-grow[data-rarity="Mega Attack Rare" i]:not([data-variant="Reverse Holofoil" i]), +.card-image-wrap[data-rarity="Mega Attack Rare" i]:not([data-variant="Reverse Holofoil" i]), +.image-grow[data-rarity="ACE Spec Rare" i]:not([data-variant="Reverse Holofoil" i]), +.card-image-wrap[data-rarity="ACE Spec Rare" i]:not([data-variant="Reverse Holofoil" i]), +.image-grow[data-rarity="ACE Rare" i]:not([data-variant="Reverse Holofoil" i]), +.card-image-wrap[data-rarity="ACE Rare" i]:not([data-variant="Reverse Holofoil" i]), +.image-grow[data-rarity="Art Rare" i]:not([data-variant="Reverse Holofoil" i]), +.card-image-wrap[data-rarity="Art Rare" i]:not([data-variant="Reverse Holofoil" i]), +.image-grow[data-rarity="Special Art Rare" i]:not([data-variant="Reverse Holofoil" i]), +.card-image-wrap[data-rarity="Special Art Rare" i]:not([data-variant="Reverse Holofoil" i]), +.image-grow[data-rarity="Black White Rare" i]:not([data-variant="Reverse Holofoil" i]), +.card-image-wrap[data-rarity="Black White Rare" i]:not([data-variant="Reverse Holofoil" i]), +.image-grow[data-rarity="Character Super Rare" i]:not([data-variant="Reverse Holofoil" i]), +.card-image-wrap[data-rarity="Character Super Rare" i]:not([data-variant="Reverse Holofoil" i]), +.image-grow[data-rarity="Mega Ultra Rare" i]:not([data-variant="Reverse Holofoil" i]), +.card-image-wrap[data-rarity="Mega Ultra Rare" i]:not([data-variant="Reverse Holofoil" i]), +.image-grow[data-rarity="Rare BREAK" i]:not([data-variant="Reverse Holofoil" i]), +.card-image-wrap[data-rarity="Rare BREAK" i]:not([data-variant="Reverse Holofoil" i]), +.image-grow[data-rarity="Secret Rare" i]:not([data-variant="Reverse Holofoil" i]), +.card-image-wrap[data-rarity="Secret Rare" i]:not([data-variant="Reverse Holofoil" i]), +.image-grow[data-rarity="Shiny Holo Rare" i]:not([data-variant="Reverse Holofoil" i]), +.card-image-wrap[data-rarity="Shiny Holo Rare" i]:not([data-variant="Reverse Holofoil" i]), +.image-grow[data-rarity="Shiny Rare" i]:not([data-variant="Reverse Holofoil" i]), +.card-image-wrap[data-rarity="Shiny Rare" i]:not([data-variant="Reverse Holofoil" i]), +.image-grow[data-rarity="Shiny Secret Rare" i]:not([data-variant="Reverse Holofoil" i]), +.card-image-wrap[data-rarity="Shiny Secret Rare" i]:not([data-variant="Reverse Holofoil" i]), +.image-grow[data-rarity="Shiny Ultra Rare" i]:not([data-variant="Reverse Holofoil" i]), +.card-image-wrap[data-rarity="Shiny Ultra Rare" i]:not([data-variant="Reverse Holofoil" i]) { + + .holo-shine { + clip-path: none; + @include prismatic-shine; + } + + .holo-glare { + clip-path: none; + @include prismatic-glare; + } +} + + +// ----------------------------------------------------------------------------- +// 7. ZONE 3 — INVERSE (borders only): Reverse Holofoil + Prism Rare +// +// Applies the effect to everything EXCEPT the art window. +// Uses the "zero-width tunnel" technique from css-tricks.com/cutting-inner-part-element-using-clip-path/ +// Outer rectangle drawn anticlockwise, closes back to 0% 0%, then the inner +// art window rectangle is drawn clockwise — nonzero winding treats the inner +// shape as a hole, leaving the art window transparent. +// +// Outer (anticlockwise): 0 0 → 0 100% → 100% 100% → 100% 0 → 0 0 +// Inner art window (clockwise): 8% 9.85% → 92% 9.85% → 92% 47.15% → 8% 47.15% +// ----------------------------------------------------------------------------- + +.image-grow[data-variant="Reverse Holofoil" i], +.card-image-wrap[data-variant="Reverse Holofoil" i], +.image-grow[data-rarity="Prism Rare" i], +.card-image-wrap[data-rarity="Prism Rare" i] { + + // Energy colour tint — multiply blend darkens the card toward --card-glow. + // z-index 2 puts it above the card image (z-index 1) but below holo-shine (3). + // Opacity tied to --card-opacity so it appears/disappears with the hover effect. + &::after { + content: ''; + position: absolute; + inset: 0; + border-radius: var(--card-radius); + background: var(--card-glow); + mix-blend-mode: multiply; + opacity: calc(var(--card-opacity) * 0.5); + z-index: 2; + pointer-events: none; + clip-path: polygon( + 0% 0%, 0% 100%, 100% 100%, 100% 0%, 0% 0%, + 8% 9.85%, 92% 9.85%, 92% 47.15%, 8% 47.15%, 8% 9.85% + ); + } + + .holo-shine { + // Energy-aware gradient — weaves --card-glow (set per data-energy on the + // wrapper) into the prismatic colour sequence so each energy type gets a + // tinted shimmer: Grass = green, Fire = orange, Water = cyan, etc. + background-image: + var(--grain), + repeating-linear-gradient(110deg, + var(--card-glow), + var(--blue), + var(--card-glow), + var(--green), + var(--yellow), + var(--card-glow), + var(--red), + var(--violet), + var(--card-glow) + ); + background-position: + center center, + calc(((50% - var(--background-x)) * 2.6) + 50%) + calc(((50% - var(--background-y)) * 3.5) + 50%); + background-size: 33%, 400% 400%; + background-repeat: repeat, no-repeat; + background-blend-mode: soft-light, normal; + filter: brightness(1.0) contrast(1.0) saturate(1.4); + clip-path: polygon( + 0% 0%, 0% 100%, 100% 100%, 100% 0%, 0% 0%, + 8% 9.85%, 92% 9.85%, 92% 47.15%, 8% 47.15%, 8% 9.85% + ) !important; + } + + .holo-glare { + @include prismatic-glare; + // Hot-spot tinted with energy colour to match the shine treatment + background-image: radial-gradient( + farthest-corner circle at var(--pointer-x) var(--pointer-y), + var(--card-glow) 0%, + hsla(0, 0%, 100%, 0.3) 20%, + hsla(0, 0%, 0%, 0.5) 90% + ); + clip-path: polygon( + 0% 0%, 0% 100%, 100% 100%, 100% 0%, 0% 0%, + 8% 9.85%, 92% 9.85%, 92% 47.15%, 8% 47.15%, 8% 9.85% + ) !important; + } +} + + +// ----------------------------------------------------------------------------- +// 8. MODAL ANIMATION +// ----------------------------------------------------------------------------- + +// Note: --card-opacity is intentionally NOT registered via @property. +// Registering it as makes it interpolatable, causing the browser +// to smoothly transition it when JS sets it via inline style — creating the +// unwanted slow fade-in. Without registration it changes instantly. +@property --pointer-x { syntax: ''; inherits: true; initial-value: 50%; } +@property --pointer-y { syntax: ''; inherits: true; initial-value: 50%; } +@property --background-x { syntax: ''; inherits: true; initial-value: 50%; } +@property --background-y { syntax: ''; inherits: true; initial-value: 50%; } +@property --pointer-from-center { syntax: ''; inherits: true; initial-value: 0; } +@property --pointer-from-left { syntax: ''; inherits: true; initial-value: 0.5; } +@property --pointer-from-top { syntax: ''; inherits: true; initial-value: 0.5; } + +@keyframes holo-modal-opacity { + 0% { opacity: 0; } + 4% { opacity: 0; } + 8% { opacity: 0.35; } + 85% { opacity: 0.35; } + 90%, 100%{ opacity: 0; } +} + +@keyframes holo-modal-position { + 0% { --pointer-x: 50%; --pointer-y: 50%; --background-x: 50%; --background-y: 50%; --pointer-from-center: 0; --pointer-from-left: 0.5; --pointer-from-top: 0.5; } - 4% { --card-opacity: 0; } - 8% { - --card-opacity: 0.35; + 8% { --pointer-x: 25%; --pointer-y: 15%; --background-x: 38%; --background-y: 28%; --pointer-from-center: 0.85; --pointer-from-left: 0.25; --pointer-from-top: 0.15; } - 25% { + 25% { --pointer-x: 70%; --pointer-y: 30%; --background-x: 64%; --background-y: 34%; --pointer-from-center: 0.9; --pointer-from-left: 0.70; --pointer-from-top: 0.30; } - 45% { + 45% { --pointer-x: 80%; --pointer-y: 70%; --background-x: 74%; --background-y: 68%; --pointer-from-center: 0.88; --pointer-from-left: 0.80; --pointer-from-top: 0.70; } - 65% { + 65% { --pointer-x: 35%; --pointer-y: 80%; --background-x: 38%; --background-y: 76%; --pointer-from-center: 0.8; --pointer-from-left: 0.35; --pointer-from-top: 0.80; } - 85% { - --card-opacity: 0.35; + 85% { --pointer-x: 25%; --pointer-y: 15%; --background-x: 38%; --background-y: 28%; --pointer-from-center: 0.85; } - 90% { --card-opacity: 0; } 100% { - --card-opacity: 0; --pointer-x: 50%; --pointer-y: 50%; --background-x: 50%; --background-y: 50%; --pointer-from-center: 0; @@ -190,160 +524,162 @@ } .card-image-wrap.holo-modal-mode { - --card-opacity: 0; + // Animate pointer vars on the wrapper so CSS custom props interpolate + animation: holo-modal-position 60s ease-in-out infinite; + animation-delay: var(--shimmer-delay, -2s); .holo-shine, .holo-glare { - animation: holo-modal-pulse 60s ease-in-out infinite; + // Animate opacity directly — no @property needed, native interpolation + animation: holo-modal-opacity 60s ease-in-out infinite; animation-delay: var(--shimmer-delay, -2s); } &[data-holo-active] { - --card-opacity: 0.45; - .holo-shine, - .holo-glare { animation-play-state: paused; } + animation-play-state: paused; + .holo-shine { opacity: 0.20; } + .holo-glare { opacity: calc(0.20 * 0.4); } } } // ----------------------------------------------------------------------------- -// 4. RARITY -> CLIP-PATH BRIDGE +// 9. MOBILE / TOUCH — static holofoil overlay, no JS tracking +// +// @media (hover: none) targets touchscreens only. +// Technique from joshdance.com/100/Day50: two rainbow gradients at opposing +// fixed positions interact via blend modes to create a static holographic sheen. +// Where the two gradient bands cross, the additive blending creates bright +// rainbow intersections that read as a light-catch effect — no pointer needed. +// +// Implementation: +// - .holo-shine gets the two-gradient stack at fixed diagonal positions +// - ::before moves in the opposite direction (negative position) so the +// crossing point creates the characteristic holofoil bright intersection +// - opacity is always-on at a low value — no hover event needed +// - will-change reset to auto — no GPU layer reservation needed +// - Glitter hidden — parallax position is meaningless without tracking +// - No CSS animation on touch — pure static CSS, zero JS involvement // ----------------------------------------------------------------------------- -.image-grow, -.card-image-wrap { +@media (hover: none) { + + .holo-shine, + .holo-glare { + will-change: auto; + } + + // Disable any animation on modal cards on touch — static treatment handles it + .card-image-wrap.holo-modal-mode { + animation: none; - // No effect on common/uncommon or unrecognised wrapper - &[data-rarity="common"], - &[data-rarity="uncommon"], - &:not([data-rarity]) { .holo-shine, - .holo-glare { display: none; } + .holo-glare { animation: none; } } - // Standard holo — artwork area only - &[data-rarity="rare holo"] { - .holo-shine { clip-path: var(--clip); } - &[data-subtypes^="stage"] .holo-shine { clip-path: var(--clip-stage); } - &[data-subtypes^="supporter"] .holo-shine, - &[data-subtypes^="item"] .holo-shine { clip-path: var(--clip-trainer); } - } + // Suppress glitter — parallax position is meaningless without pointer tracking + .holo-glare::after { display: none; } - // Cosmos holo - &[data-rarity="rare holo cosmos"] { - .holo-shine { clip-path: var(--clip); } - &[data-subtypes^="stage"] .holo-shine { clip-path: var(--clip-stage); } - &[data-subtypes^="supporter"] .holo-shine { clip-path: var(--clip-trainer); } - } + // ── Static holofoil overlay for all effect zones on touch ───────────────── + // Override the JS-driven background-position values with fixed diagonals. + // The ::before pseudo moves in the opposite direction to create crossing bands. - &[data-rarity="radiant rare"] { .holo-shine { clip-path: var(--clip-borders); } } - &[data-rarity="amazing rare"] { .holo-shine { clip-path: var(--clip); } } + .image-grow, + .card-image-wrap { - &[data-rarity="trainer gallery rare holo"], - &[data-rarity="rare holo"][data-trainer-gallery="true"] { - .holo-shine { clip-path: var(--clip-borders); } - } + // Zone 1 — art window + &[data-rarity="Rare" i]:not([data-variant="Reverse Holofoil" i]):not([data-variant="Normal" i]), + &[data-rarity="Amazing Rare" i]:not([data-variant="Reverse Holofoil" i]), + &[data-rarity="Classic Collection" i]:not([data-variant="Reverse Holofoil" i]), + &[data-rarity="Holo Rare" i]:not([data-variant="Reverse Holofoil" i]), + &[data-variant="Holofoil" i]:not([data-variant="Reverse Holofoil" i]), + &[data-variant="1st Edition Holofoil" i], + // Zone 2 — full card + &[data-rarity="Ultra Rare" i]:not([data-variant="Reverse Holofoil" i]), + &[data-rarity="Character Rare" i]:not([data-variant="Reverse Holofoil" i]), + &[data-rarity="Illustration Rare" i]:not([data-variant="Reverse Holofoil" i]), + &[data-rarity="Special Illustration Rare" i]:not([data-variant="Reverse Holofoil" i]), + &[data-rarity="Double Rare" i]:not([data-variant="Reverse Holofoil" i]), + &[data-rarity="Hyper Rare" i]:not([data-variant="Reverse Holofoil" i]), + &[data-rarity="Mega Rare" i]:not([data-variant="Reverse Holofoil" i]), + &[data-rarity="Mega Attack Rare" i]:not([data-variant="Reverse Holofoil" i]), + &[data-rarity="ACE Spec Rare" i]:not([data-variant="Reverse Holofoil" i]), + &[data-rarity="ACE Rare" i]:not([data-variant="Reverse Holofoil" i]), + &[data-rarity="Art Rare" i]:not([data-variant="Reverse Holofoil" i]), + &[data-rarity="Special Art Rare" i]:not([data-variant="Reverse Holofoil" i]), + &[data-rarity="Black White Rare" i]:not([data-variant="Reverse Holofoil" i]), + &[data-rarity="Character Super Rare" i]:not([data-variant="Reverse Holofoil" i]), + &[data-rarity="Mega Ultra Rare" i]:not([data-variant="Reverse Holofoil" i]), + &[data-rarity="Rare BREAK" i]:not([data-variant="Reverse Holofoil" i]), + &[data-rarity="Secret Rare" i]:not([data-variant="Reverse Holofoil" i]), + &[data-rarity="Shiny Holo Rare" i]:not([data-variant="Reverse Holofoil" i]), + &[data-rarity="Shiny Rare" i]:not([data-variant="Reverse Holofoil" i]), + &[data-rarity="Shiny Secret Rare" i]:not([data-variant="Reverse Holofoil" i]), + &[data-rarity="Shiny Ultra Rare" i]:not([data-variant="Reverse Holofoil" i]), + // Zone 3 — inverse (Reverse Holofoil + Prism Rare) + // Energy colour woven in via --card-glow, same as desktop Zone 3 + &[data-variant="Reverse Holofoil" i], + &[data-rarity="Prism Rare" i] { - &[data-rarity="rare shiny"] { - .holo-shine { clip-path: var(--clip); } - &[data-subtypes^="stage"] .holo-shine { clip-path: var(--clip-stage); } - } + // Energy colour multiply tint — kept subtle on mobile + &::after { + opacity: 0.04; + } - // Reverse holo by rarity — borders only - &[data-rarity$="reverse holo"] { .holo-shine { clip-path: var(--clip-invert); } } - // Reverse Holofoil variant — borders only - &[data-variant="Reverse Holofoil"] { .holo-shine { clip-path: var(--clip-invert); } } + .holo-shine { + background-image: + var(--grain), + repeating-linear-gradient(110deg, + var(--card-glow), + var(--blue), + var(--card-glow), + var(--green), + var(--yellow), + var(--card-glow), + var(--red), + var(--violet), + var(--card-glow) + ); + background-size: 33%, 400% 400%; + background-repeat: repeat, no-repeat; + background-blend-mode: soft-light, normal; + background-position: center, 38% 25%; + filter: brightness(1.0) contrast(1.1) saturate(1.0); + opacity: 0.35; - // True holofoil variants + full-bleed rarities — no clip - &[data-variant="Holofoil"], - &[data-variant="1st Edition Holofoil"], - &[data-variant="Unlimited Holofoil"], - &[data-rarity="rare ultra"], - &[data-rarity="rare holo v"], - &[data-rarity="rare holo vmax"], - &[data-rarity="rare holo vstar"], - &[data-rarity="rare shiny v"], - &[data-rarity="rare shiny vmax"], - &[data-rarity="rare rainbow"], - &[data-rarity="rare rainbow alt"], - &[data-rarity="rare secret"] { - .holo-shine { clip-path: none; } - } + &::before { + background-image: + repeating-linear-gradient(110deg, + var(--card-glow), + var(--blue), + var(--card-glow), + var(--green), + var(--yellow), + var(--card-glow), + var(--red), + var(--violet), + var(--card-glow) + ); + background-size: 400% 400%; + background-position: 62% 75%; + mix-blend-mode: color-dodge; + opacity: 0.18; + filter: brightness(1.0) contrast(1.1) saturate(1.0); + } - // Foil variant shine/glare — clip handled above per variant type - &[data-variant="Holofoil"], - &[data-variant="Reverse Holofoil"], - &[data-variant="1st Edition Holofoil"], - &[data-variant="Unlimited Holofoil"] { - .holo-shine { - background-image: - radial-gradient( - circle at var(--pointer-x) var(--pointer-y), - #fff 5%, #000 50%, #fff 80% - ), - linear-gradient( - var(--foil-angle, -45deg), - #000 15%, #fff, #000 85% + &::after { display: none; } + } + + .holo-glare { + opacity: 0.15; + background-image: radial-gradient( + farthest-corner circle at 35% 25%, + var(--card-glow) 0%, + hsla(0, 0%, 100%, 0.2) 30%, + hsla(0, 0%, 0%, 0.3) 90% ); - background-blend-mode: soft-light, difference; - background-size: 120% 120%, 200% 200%; - background-position: - center center, - calc(100% * var(--pointer-from-left)) calc(100% * var(--pointer-from-top)); - filter: brightness(var(--foil-brightness, 0.4)) contrast(1.3) saturate(var(--foil-saturation, 0.5)); - mix-blend-mode: color-dodge; - opacity: calc((var(--card-opacity) * 0.9) - (var(--pointer-from-center) * 0.1)); - } - - .holo-glare { - opacity: calc(var(--card-opacity) * 0.5); - background-image: radial-gradient( - farthest-corner circle at var(--pointer-x) var(--pointer-y), - hsla(0, 0%, 100%, 0.5) 10%, - hsla(0, 0%, 100%, 0.25) 30%, - hsla(0, 0%, 0%, 0.4) 90% - ); - filter: brightness(0.7) contrast(1.2); - mix-blend-mode: overlay; - } - } -} - - -// ----------------------------------------------------------------------------- -// 5. DEFAULT HOLO SHINE / GLARE -// Fallback for rarities not explicitly handled above. -// ----------------------------------------------------------------------------- - -.image-grow, -.card-image-wrap { - &[data-rarity]:not([data-rarity="common"]):not([data-rarity="uncommon"]) { - - .holo-shine { - background-image: - repeating-linear-gradient(110deg, - var(--violet), var(--blue), var(--green), var(--yellow), var(--red), - var(--violet), var(--blue), var(--green), var(--yellow), var(--red) - ); - background-position: - calc(((50% - var(--background-x)) * 2.6) + 50%) - calc(((50% - var(--background-y)) * 3.5) + 50%); - background-size: 400% 400%; - filter: brightness(0.7) contrast(0.9) saturate(0.8); - mix-blend-mode: color-dodge; - opacity: calc(var(--card-opacity) * 0.6); - } - - .holo-glare { - background-image: radial-gradient( - farthest-corner circle at var(--pointer-x) var(--pointer-y), - hsla(0, 0%, 100%, 0.35) 10%, - hsla(0, 0%, 100%, 0.15) 30%, - hsla(0, 0%, 0%, 0.35) 90% - ); - opacity: calc(var(--card-opacity) * 0.4); - mix-blend-mode: overlay; - filter: brightness(0.7) contrast(1.1); + filter: brightness(0.8) contrast(1.5); + } } } } \ No newline at end of file diff --git a/src/assets/css/main.scss b/src/assets/css/main.scss index 4d01fd4..0d0565f 100644 --- a/src/assets/css/main.scss +++ b/src/assets/css/main.scss @@ -24,7 +24,7 @@ $container-max-widths: ( @import "_bootstrap"; // ── Holofoil ────────────────────────────────────────────────────────────── -//@import "_holofoil-integration"; // also pulls in _card.scss +@import "_holofoil-integration"; // also pulls in _card.scss /* -------------------------------------------------- Root Variables diff --git a/src/assets/js/holofoil-init.js b/src/assets/js/holofoil-init.js index b95a44b..a73ee4b 100644 --- a/src/assets/js/holofoil-init.js +++ b/src/assets/js/holofoil-init.js @@ -1,53 +1,57 @@ /** * holofoil-init.js - * ----------------------------------------------------------------------------- * Instruments .image-grow and .card-image-wrap with the holofoil effect system. - * - * GRID (.image-grow) - * Effect is invisible at rest. On hover, pointer tracking drives the shine - * and glare layers. The card lift/scale comes from main.scss as before. - * - * MODAL (.card-image-wrap) - * Effect sweeps autonomously once per minute via CSS animation. - * Pointer tracking takes over while the user hovers the image. - * - * DEFAULT FALLBACK - * If data-default="true" is set (onerror in the Astro markup), no effect - * is applied -- even if the attribute appears after stamp() has run. - * ----------------------------------------------------------------------------- */ (function HolofoilSystem() { 'use strict'; - // -- Constants -------------------------------------------------------------- + // Variants that receive NO effect + const NO_EFFECT_VARIANTS = new Set(['normal']); - const SHIMMER_SEL = [ - '.image-grow[data-rarity]', - '.image-grow[data-variant="Holofoil"]', - '.image-grow[data-variant="1st Edition Holofoil"]', - '.image-grow[data-variant="Unlimited Holofoil"]', - '.image-grow[data-variant="Reverse Holofoil"]', - '.card-image-wrap[data-rarity]', - '.card-image-wrap[data-variant="Holofoil"]', - '.card-image-wrap[data-variant="1st Edition Holofoil"]', - '.card-image-wrap[data-variant="Unlimited Holofoil"]', - '.card-image-wrap[data-variant="Reverse Holofoil"]', - ].join(','); + // Variants that always receive an effect regardless of rarity + const HOLO_VARIANTS = new Set([ + 'reverse holofoil', + 'holofoil', + '1st edition holofoil', + ]); + + // Rarities that receive an effect + const HOLO_RARITIES = new Set([ + // Art window zone + 'rare', + 'amazing rare', + 'classic collection', + 'holo rare', + // Full card zone + 'ultra rare', + 'character rare', + 'illustration rare', + 'special illustration rare', + 'double rare', + 'hyper rare', + 'mega rare', + 'mega attack rare', + 'ace spec rare', + 'ace rare', + 'art rare', + 'special art rare', + 'black white rare', + 'character super rare', + 'mega ultra rare', + 'rare break', + 'secret rare', + 'shiny holo rare', + 'shiny rare', + 'shiny secret rare', + 'shiny ultra rare', + // Inverse zone + 'prism rare', + ]); const ALL_WRAPPERS_SEL = '.image-grow, .card-image-wrap'; - // Foil variant visual randomisation - const FOIL_ANGLE_MIN = -65, FOIL_ANGLE_MAX = -25; - const FOIL_BRITE_MIN = 0.18, FOIL_BRITE_MAX = 0.32; - const FOIL_SAT_MIN = 0.40, FOIL_SAT_MAX = 0.75; - - const SKIP_RARITIES = new Set(['common', 'uncommon', '']); - - - // -- Helpers ---------------------------------------------------------------- - const rand = (min, max) => parseFloat((Math.random() * (max - min) + min).toFixed(2)); const clamp01 = n => Math.max(0, Math.min(1, n)); @@ -56,13 +60,13 @@ const fromTop = clamp01((y - rect.top) / rect.height); const fromCenter = clamp01(Math.sqrt((fromLeft - 0.5) ** 2 + (fromTop - 0.5) ** 2) * 2); return { - px: fromLeft * 100, - py: fromTop * 100, + px: fromLeft * 100, + py: fromTop * 100, fromLeft, fromTop, fromCenter, - bgX: 50 + (fromLeft - 0.5) * 30, - bgY: 50 + (fromTop - 0.5) * 30, + bgX: 50 + (fromLeft - 0.5) * -30, + bgY: 50 + (fromTop - 0.5) * -30, }; } @@ -76,12 +80,18 @@ el.style.setProperty('--background-y', v.bgY.toFixed(1) + '%'); } - const isHoloVariant = v => ['Holofoil', 'Reverse Holofoil', '1st Edition Holofoil', 'Unlimited Holofoil'].includes(v); - const isModalWrapper = el => el.classList.contains('card-image-wrap'); - const isDefault = el => el.dataset.default === 'true'; - - - // -- Child injection -------------------------------------------------------- + function shouldHaveEffect(el) { + if (el.dataset.default === 'true') return false; + // Also check if the card image itself is the default fallback + const img = el.querySelector('img'); + if (img && img.src && img.src.endsWith('/cards/default.jpg')) return false; + const variant = (el.dataset.variant || '').toLowerCase().trim(); + const rarity = (el.dataset.rarity || '').toLowerCase().trim(); + if (NO_EFFECT_VARIANTS.has(variant)) return false; + if (HOLO_VARIANTS.has(variant)) return true; + if (HOLO_RARITIES.has(rarity)) return true; + return false; + } function injectChildren(el) { if (el.querySelector('.holo-shine')) return; @@ -93,18 +103,10 @@ el.appendChild(glare); } - - // -- Default image guard ---------------------------------------------------- - - /** - * Watch for the onerror handler in the Astro markup setting data-default="true" - * after stamp() has already run. Hide the effect children immediately when seen. - */ function watchForDefault(el) { - if (isDefault(el)) return; - + if (el.dataset.default === 'true') return; var observer = new MutationObserver(function() { - if (isDefault(el)) { + if (el.dataset.default === 'true') { var shine = el.querySelector('.holo-shine'); var glare = el.querySelector('.holo-glare'); if (shine) shine.style.display = 'none'; @@ -112,81 +114,54 @@ observer.disconnect(); } }); - observer.observe(el, { attributes: true, attributeFilter: ['data-default'] }); } - - // -- Stamp ------------------------------------------------------------------ + const canHover = window.matchMedia('(hover: hover)').matches; function stamp(el) { if (el.dataset.holoInit) return; - - // Skip if already a default fallback image - if (isDefault(el)) { + if (!shouldHaveEffect(el)) { el.dataset.holoInit = 'skip'; return; } - - const rarity = (el.dataset.rarity || '').toLowerCase(); - const variant = el.dataset.variant || ''; - - const hasHoloRarity = rarity && !SKIP_RARITIES.has(rarity); - const hasHoloVariant = isHoloVariant(variant); - - if (!hasHoloRarity && !hasHoloVariant) { - el.dataset.holoInit = 'skip'; - return; - } - injectChildren(el); - - // Per-card foil visual randomisation (angle/brightness/saturation) - if (hasHoloVariant) { - el.style.setProperty('--foil-angle', Math.round(rand(FOIL_ANGLE_MIN, FOIL_ANGLE_MAX)) + 'deg'); - el.style.setProperty('--foil-brightness', rand(FOIL_BRITE_MIN, FOIL_BRITE_MAX).toFixed(2)); - el.style.setProperty('--foil-saturation', rand(FOIL_SAT_MIN, FOIL_SAT_MAX ).toFixed(2)); + if (el.classList.contains('card-image-wrap')) { + if (canHover) { + // Desktop: use hover + pointer tracking, same as grid cards. + // No animation — CSS :hover rule controls --card-opacity directly. + el.classList.remove('holo-modal-mode'); + } else { + // Touch: use the autonomous CSS animation sweep. + el.classList.add('holo-modal-mode'); + el.style.setProperty('--shimmer-delay', rand(-8, 0) + 's'); + } } - - // Modal-only: set a stable delay offset for the autonomous CSS animation - if (isModalWrapper(el)) { - el.classList.add('holo-modal-mode'); - el.style.setProperty('--shimmer-delay', rand(-8, 0) + 's'); - } - watchForDefault(el); - el.dataset.holoInit = '1'; } - function stampAll(root) { - (root || document).querySelectorAll(ALL_WRAPPERS_SEL).forEach(stamp); - } - - - // -- Pointer tracking ------------------------------------------------------- - const pointerState = new WeakMap(); function onPointerEnter(e) { const el = e.currentTarget; - if (el.dataset.holoInit !== '1' || isDefault(el)) return; - + if (el.dataset.holoInit !== '1') return; el.dataset.holoActive = '1'; + // Inline style wins over CSS immediately — @property not registered for + // --card-opacity so no interpolation. All calc() multipliers in child + // rules (glare * 0.4, glitter * 0.6) work correctly from this single var. + el.style.setProperty('--card-opacity', '0.2'); if (!pointerState.has(el)) pointerState.set(el, { rafId: null }); } function onPointerMove(e) { const el = e.currentTarget; if (el.dataset.holoInit !== '1') return; - const state = pointerState.get(el); if (!state) return; - if (state.rafId) cancelAnimationFrame(state.rafId); state.rafId = requestAnimationFrame(function() { - const rect = el.getBoundingClientRect(); - applyPointerVars(el, pointerVars(e.clientX, e.clientY, rect)); + applyPointerVars(el, pointerVars(e.clientX, e.clientY, el.getBoundingClientRect())); state.rafId = null; }); } @@ -194,46 +169,39 @@ function onPointerLeave(e) { const el = e.currentTarget; if (el.dataset.holoInit !== '1') return; - const state = pointerState.get(el); if (state && state.rafId) { cancelAnimationFrame(state.rafId); state.rafId = null; } - delete el.dataset.holoActive; - - if (isModalWrapper(el)) { - // Let the CSS animation resume driving --card-opacity - el.style.removeProperty('--card-opacity'); - } + // Remove inline style so CSS default (--card-opacity: 0) takes over instantly + el.style.removeProperty('--card-opacity'); } function attachListeners(el) { if (el.dataset.holoListeners) return; + // On touch-only devices the CSS static shimmer handles the effect. + // Skip JS pointer tracking — pointermove never fires on touchscreens + // and registering listeners wastes memory with no benefit. + if (!window.matchMedia('(hover: hover)').matches) return; el.addEventListener('pointerenter', onPointerEnter, { passive: true }); el.addEventListener('pointermove', onPointerMove, { passive: true }); el.addEventListener('pointerleave', onPointerLeave, { passive: true }); el.dataset.holoListeners = '1'; } - function attachAllListeners(root) { - (root || document).querySelectorAll(SHIMMER_SEL).forEach(function(el) { + function stampAll(root) { + (root || document).querySelectorAll(ALL_WRAPPERS_SEL).forEach(function(el) { stamp(el); if (el.dataset.holoInit === '1') attachListeners(el); }); } - - // -- MutationObserver: react to HTMX / infinite scroll ---------------------- - function observeGrid() { var grid = document.getElementById('cardGrid'); if (!grid) return; - new MutationObserver(function(mutations) { - for (var i = 0; i < mutations.length; i++) { - var nodes = mutations[i].addedNodes; - for (var j = 0; j < nodes.length; j++) { - var node = nodes[j]; - if (node.nodeType !== 1) continue; + mutations.forEach(function(m) { + m.addedNodes.forEach(function(node) { + if (node.nodeType !== 1) return; if (node.matches && node.matches(ALL_WRAPPERS_SEL)) { stamp(node); if (node.dataset.holoInit === '1') attachListeners(node); @@ -244,8 +212,8 @@ if (el.dataset.holoInit === '1') attachListeners(el); }); } - } - } + }); + }); }).observe(grid, { childList: true, subtree: true }); } @@ -253,20 +221,32 @@ var modal = document.getElementById('cardModal'); if (!modal) return; - new MutationObserver(function() { - modal.querySelectorAll(ALL_WRAPPERS_SEL).forEach(function(el) { - stamp(el); - if (el.dataset.holoInit === '1') attachListeners(el); + new MutationObserver(function(mutations) { + mutations.forEach(function(m) { + m.addedNodes.forEach(function(node) { + if (node.nodeType !== 1) return; + + var wrappers = []; + if (node.matches && node.matches(ALL_WRAPPERS_SEL)) wrappers.push(node); + if (node.querySelectorAll) { + node.querySelectorAll(ALL_WRAPPERS_SEL).forEach(function(el) { + wrappers.push(el); + }); + } + + wrappers.forEach(function(el) { + // Reset stamp so each new card is evaluated fresh + delete el.dataset.holoInit; + stamp(el); + if (el.dataset.holoInit === '1') attachListeners(el); + }); + }); }); }).observe(modal, { childList: true, subtree: true }); } - - // -- Bootstrap -------------------------------------------------------------- - function init() { stampAll(); - attachAllListeners(); observeGrid(); observeModal(); } diff --git a/src/components/CardGrid.astro b/src/components/CardGrid.astro index ac8f0f0..a3c2590 100644 --- a/src/components/CardGrid.astro +++ b/src/components/CardGrid.astro @@ -44,7 +44,7 @@ import BackToTop from "./BackToTop.astro" - +