@@ -6,71 +6,195 @@ import { illustrationUrl } from '../lib/art';
66
77const base = import .meta .env .BASE_URL ;
88
9- // Auto-detect a finished illustration for a character: drop a file named
10- // <id>.png (or .jpg/.jpeg/.webp) into public/art/illustrations/ and it appears
11- // here automatically — with a toggle back to Lili's original sketch.
129const cards = characters .map ((c ) => ({
1310 ... c ,
1411 sketchUrl: artUrl (c .sketch , ' character' , base ),
1512 illoUrl: illustrationUrl (c .id , base ),
1613}));
1714const doneCount = cards .filter ((c ) => c .illoUrl ).length ;
1815
19- // Group the cast by the book they belong to, keeping first-seen order.
2016const stories = [... new Set (cards .map ((c ) => c .story ))];
2117const byStory = stories .map ((story ) => ({ story , cast: cards .filter ((c ) => c .story === story ) }));
18+
19+ // cast cut-outs (illustrations) to float across the hero
20+ const heroCast = cards .filter ((c ) => c .illoUrl ).slice (0 , 7 );
2221---
2322
2423<Base title =" Meet the Characters · Lili's Story World" active =" characters" >
25- <section class =" wrap hero" style =" padding-bottom:0" >
26- <h1 >🎭 Meet the Characters</h1 >
27- <p >Everyone from Lili's stories — drawn by Lili herself.
28- { doneCount > 0
29- ? <span >Tap <b >✏️ Sketch</b > / <b >🎨 Colour</b > to see how a drawing came to life!</span >
30- : <span >Her sketches are here now — the colourful illustrations are coming next!</span >}
31- </p >
24+ <!-- ===================== HERO ===================== -->
25+ <section class =" cx-hero" >
26+ <div class =" cx-wash" ></div >
27+ <span class =" cx-blob b1" data-parallax =" 0.06" aria-hidden =" true" ></span >
28+ <span class =" cx-blob b2" data-parallax =" 0.1" aria-hidden =" true" ></span >
29+ <div class =" starfield" aria-hidden =" true" >
30+ <span class =" star" data-parallax =" 0.12" style =" top:14%;left:8%;font-size:1.3rem" >⭐</span >
31+ <span class =" star" data-parallax =" 0.24" style =" top:8%;left:30%;font-size:0.9rem" >✨</span >
32+ <span class =" star" data-parallax =" 0.18" style =" top:18%;left:62%;font-size:1.1rem" >🌟</span >
33+ <span class =" star" data-parallax =" 0.28" style =" top:10%;left:84%;font-size:1.4rem" >⭐</span >
34+ <span class =" star" data-parallax =" 0.15" style =" top:26%;left:46%;font-size:0.8rem" >✨</span >
35+ </div >
36+
37+ <div class =" wrap cx-hero__inner" data-reveal =" up" >
38+ <span class =" kicker" >The cast</span >
39+ <h1 class =" cx-title" >Meet the Characters</h1 >
40+ <p class =" cx-lede" >Everyone from Lili's stories — <b >drawn by Lili herself</b >.
41+ { doneCount > 0
42+ ? <span >Tap <b >✏️ Sketch</b > / <b >🎨 Colour</b > to see a drawing come to life.</span >
43+ : <span >Her sketches are here now — the colour versions are coming next!</span >}
44+ </p >
45+ </div >
46+
47+ { heroCast .length > 0 && (
48+ <div class = " cx-parade" aria-hidden = " true" >
49+ { heroCast .map ((c , i ) => (
50+ <img src = { c .illoUrl } alt = " " class = " cx-parade__item" data-tilt = { (i % 2 === 0 ? 14 : - 12 ).toString ()} style = { ` --d:${i * 0.35 }s ` } />
51+ ))}
52+ </div >
53+ )}
3254 </section >
3355
56+ <!-- ===================== CAST BY STORY ===================== -->
3457 { byStory .map (({ story , cast }) => (
35- <section class = " wrap" style = " margin-top:1.5rem" >
36- <h2 class = " cast-heading" >{ cast [0 ].emoji } { story } </h2 >
37- <div class = " char-grid" >
38- { cast .map ((c ) => (
39- <div class = " char" style = { ` --tint:${c .color } ` } >
40- { c .illoUrl && c .sketchUrl ? (
41- <div class = " frame flip" data-show = " art" >
42- <img class = " layer art" src = { c .illoUrl } alt = { ` Illustration of ${c .name } ` } loading = " lazy" />
43- <img class = " layer sketch" src = { c .sketchUrl } alt = { ` Lili's pencil sketch of ${c .name } ` } loading = " lazy" />
44- <button class = " flip-btn" type = " button" data-flip >✏️ Sketch</button >
45- </div >
46- ) : c .illoUrl ? (
47- <div class = " frame" ><img class = " illo" src = { c .illoUrl } alt = { ` Illustration of ${c .name } ` } loading = " lazy" /></div >
48- ) : c .sketchUrl ? (
49- <div class = " frame" ><img src = { c .sketchUrl } alt = { ` Lili's drawing of ${c .name } ` } loading = " lazy" /></div >
50- ) : (
51- <div class = " frame placeholder" >
52- <div >
53- <div style = " font-size:2.2rem" >✏️</div >
54- <b >Draw me, Lili!</b >
58+ <section class = " wrap v-section" >
59+ <header class = " v-head" data-reveal = " up" >
60+ <span class = " kicker" >{ cast [0 ].emoji } Story</span >
61+ <h2 >{ story } </h2 >
62+ </header >
63+ <div class = " char-grid" >
64+ { cast .map ((c , i ) => (
65+ <div class = " char cx-card" style = { ` --tint:${c .color } ` } data-reveal = " zoom" data-reveal-delay = { (i % 4 ) * 80 } >
66+ { c .illoUrl && c .sketchUrl ? (
67+ <div class = " frame flip" data-show = " art" >
68+ <img class = " layer art" src = { c .illoUrl } alt = { ` Illustration of ${c .name } ` } loading = " lazy" />
69+ <img class = " layer sketch" src = { c .sketchUrl } alt = { ` Lili's pencil sketch of ${c .name } ` } loading = " lazy" />
70+ <button class = " flip-btn" type = " button" data-flip >✏️ Sketch</button >
5571 </div >
56- </div >
57- )}
58- <h3 >{ c .emoji } { c .name } </h3 >
59- <span class = { ` power ${c .powerClass } ` } >{ c .power } </span >
60- <p class = " desc" ><span class = " swatch" style = { ` background:${c .color } ` } ></span >{ c .desc } </p >
61- </div >
62- ))}
63- </div >
64- </section >
72+ ) : c .illoUrl ? (
73+ <div class = " frame" ><img class = " illo" src = { c .illoUrl } alt = { ` Illustration of ${c .name } ` } loading = " lazy" /></div >
74+ ) : c .sketchUrl ? (
75+ <div class = " frame" ><img src = { c .sketchUrl } alt = { ` Lili's drawing of ${c .name } ` } loading = " lazy" /></div >
76+ ) : (
77+ <div class = " frame placeholder" ><div ><div style = " font-size:2.2rem" >✏️</div ><b >Draw me, Lili!</b ></div ></div >
78+ )}
79+ <h3 >{ c .emoji } { c .name } </h3 >
80+ <span class = { ` power ${c .powerClass } ` } >{ c .power } </span >
81+ <p class = " desc" ><span class = " swatch" style = { ` background:${c .color } ` } ></span >{ c .desc } </p >
82+ </div >
83+ ))}
84+ </div >
85+ </section >
6586 ))}
6687
6788 <section class =" wrap center" style =" margin:2.5rem 0 1rem" >
6889 <a class =" btn btn-teal btn-big" href ={ base } >🏠 Back to the bookshelf</a >
6990 </section >
7091</Base >
7192
93+ <style >
94+ /* ===== Characters hero ===== */
95+ .cx-hero { position: relative; overflow: hidden; isolation: isolate; padding: clamp(1.5rem, 5vw, 3rem) 0 0; }
96+ .cx-wash {
97+ position: absolute; inset: 0; z-index: -3;
98+ background: linear-gradient(180deg, #fff7ea 0%, var(--page-bg) 80%); /* fallback (no color-mix) */
99+ background:
100+ radial-gradient(80% 60% at 78% 6%, color-mix(in srgb, var(--purple) 14%, transparent) 0%, transparent 60%),
101+ radial-gradient(70% 60% at 12% 30%, color-mix(in srgb, var(--teal) 12%, transparent) 0%, transparent 60%),
102+ linear-gradient(180deg, #fff7ea 0%, var(--page-bg) 80%);
103+ }
104+ .cx-blob { position: absolute; z-index: -2; border-radius: 50%; filter: blur(42px); opacity: 0.5; will-change: transform; }
105+ .cx-blob.b1 { width: 38vw; max-width: 460px; aspect-ratio: 1; top: -10%; right: 4%; background: radial-gradient(circle, color-mix(in srgb, var(--sunny) 55%, transparent), transparent 65%); }
106+ .cx-blob.b2 { width: 32vw; max-width: 400px; aspect-ratio: 1; top: 20%; left: -6%; background: radial-gradient(circle, color-mix(in srgb, var(--coral) 30%, transparent), transparent 65%); }
107+
108+ .cx-hero__inner { text-align: center; max-width: 46ch; margin-inline: auto; }
109+ .kicker { display: inline-block; font-family: var(--font-display); font-weight: 800; font-size: 0.85rem; letter-spacing: 1.5px; text-transform: uppercase; color: var(--coral); margin-bottom: 0.4rem; }
110+ .cx-title {
111+ font-family: var(--font-serif); font-weight: 700; line-height: 1.04; padding-bottom: 0.06em;
112+ font-size: clamp(2.2rem, 6.5vw, 4.4rem); letter-spacing: -1px; margin: 0 0 0.8rem;
113+ background: linear-gradient(120deg, var(--teal-deep) 0%, var(--ink) 58%, #4b2f9e 104%);
114+ -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; color: transparent;
115+ }
116+ .cx-lede { font-size: clamp(1.05rem, 2.3vw, 1.3rem); line-height: 1.5; color: var(--ink); margin: 0 auto; }
117+
118+ /* floating cast parade */
119+ .cx-parade { display: flex; justify-content: center; align-items: flex-end; gap: clamp(0.4rem, 2vw, 1.4rem); margin-top: clamp(1rem, 3vw, 2rem); flex-wrap: wrap; }
120+ .cx-parade__item { width: clamp(74px, 11vw, 128px); height: auto; object-fit: contain; object-position: bottom center; filter: drop-shadow(0 12px 18px rgba(43,37,51,0.22)); will-change: transform; animation: cx-bob 4s ease-in-out infinite; animation-delay: var(--d); }
121+ @keyframes cx-bob { 0%,100% { transform: translateY(0) rotate(-1.5deg); } 50% { transform: translateY(-10px) rotate(1.5deg); } }
122+
123+ .starfield { position: absolute; inset: 0 0 auto 0; height: 40%; z-index: -1; pointer-events: none; }
124+ .star { position: absolute; opacity: 0.85; will-change: transform; animation: cx-twinkle 4s ease-in-out infinite; }
125+ .star:nth-child(even) { animation-delay: 1.3s; }
126+ @keyframes cx-twinkle { 0%,100% { opacity: 0.45; } 50% { opacity: 1; } }
127+
128+ /* ===== sections + reveals ===== */
129+ .v-section { padding: clamp(2rem, 6vw, 4rem) 0 0; }
130+ .v-head { text-align: center; margin: 0 auto 1.6rem; }
131+ .v-head .kicker { color: var(--teal-deep); }
132+ .v-head h2 { font-family: var(--font-serif); font-size: clamp(1.6rem, 4.5vw, 2.6rem); margin: 0; }
133+
134+ [data-reveal] { opacity: 0; transition: opacity 0.7s cubic-bezier(0.2,0.7,0.2,1), transform 0.7s cubic-bezier(0.2,0.7,0.2,1); }
135+ [data-reveal="up"] { transform: translateY(30px); }
136+ [data-reveal="zoom"] { transform: scale(0.92); }
137+ [data-reveal].is-in { opacity: 1; transform: none; }
138+
139+ /* ===== premium character cards ===== */
140+ .cx-card { transition: transform 0.3s cubic-bezier(0.2,0.7,0.2,1), box-shadow 0.3s ease; }
141+ .cx-card:hover { transform: translateY(-6px); box-shadow: 0 22px 44px color-mix(in srgb, var(--tint) 30%, rgba(43,37,51,0.18)); }
142+ .cx-card .frame { transition: transform 0.4s cubic-bezier(0.2,0.7,0.2,1); }
143+ .cx-card:hover .frame { transform: scale(1.03); }
144+
145+ @media (prefers-reduced-motion: reduce) {
146+ .cx-parade__item, .star { animation: none; }
147+ }
148+ </style >
149+
72150<script >
73- // Flip a card between the finished illustration and Lili's original sketch.
151+ const rm = matchMedia('(prefers-reduced-motion: reduce)').matches;
152+
153+ const reveals = Array.from(document.querySelectorAll('[data-reveal]')) as HTMLElement[];
154+ if (rm || !('IntersectionObserver' in window)) {
155+ reveals.forEach((el) => el.classList.add('is-in'));
156+ } else {
157+ const io = new IntersectionObserver((entries) => {
158+ entries.forEach((e) => {
159+ if (!e.isIntersecting) return;
160+ const el = e.target as HTMLElement;
161+ window.setTimeout(() => el.classList.add('is-in'), parseInt(el.dataset.revealDelay || '0', 10));
162+ io.unobserve(el);
163+ });
164+ }, { threshold: 0.12, rootMargin: '0px 0px -8% 0px' });
165+ reveals.forEach((el) => io.observe(el));
166+ }
167+
168+ if (!rm) {
169+ const pLayers = Array.from(document.querySelectorAll('[data-parallax]')) as HTMLElement[];
170+ let ticking = false;
171+ const onScroll = () => {
172+ if (ticking) return; ticking = true;
173+ requestAnimationFrame(() => {
174+ const y = window.scrollY;
175+ for (const l of pLayers) l.style.transform = `translate3d(0, ${(y * parseFloat(l.dataset.parallax || '0')).toFixed(1)}px, 0)`;
176+ ticking = false;
177+ });
178+ };
179+ window.addEventListener('scroll', onScroll, { passive: true });
180+ onScroll();
181+
182+ const tilts = Array.from(document.querySelectorAll('[data-tilt]')) as HTMLElement[];
183+ const hero = document.querySelector('.cx-hero');
184+ if (hero && tilts.length && matchMedia('(pointer: fine)').matches) {
185+ hero.addEventListener('pointermove', (e) => {
186+ const r = (hero as HTMLElement).getBoundingClientRect();
187+ const cx = (e.clientX - r.left) / r.width - 0.5;
188+ const cy = (e.clientY - r.top) / r.height - 0.5;
189+ for (const t of tilts) {
190+ const d = parseFloat(t.dataset.tilt || '0');
191+ t.style.transform = `translate3d(${(cx * d).toFixed(1)}px, ${(cy * d).toFixed(1)}px, 0)`;
192+ }
193+ });
194+ }
195+ }
196+
197+ // sketch ↔ colour flip
74198 document.querySelectorAll<HTMLButtonElement>('[data-flip]').forEach((btn) => {
75199 btn.addEventListener('click', () => {
76200 const frame = btn.closest('.frame') as HTMLElement;
0 commit comments