Skip to content

Commit 9ea683c

Browse files
committed
Merge cinematic redesign (home + characters) into main
2 parents bfa6adf + 41b6504 commit 9ea683c

7 files changed

Lines changed: 585 additions & 102 deletions

File tree

public/art/hero/world.jpg

481 KB
Loading

public/art/illustrations/blaze.png

-312 KB
Loading
193 KB
Loading

scripts/gen-asset.mjs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
#!/usr/bin/env node
2+
/* -------------------------------------------------------------------------
3+
gen-asset.mjs — generate a single on-brand asset with Gemini.
4+
5+
node scripts/gen-asset.mjs --out lili-hero --ref lily.png \
6+
--prompt "a cheerful girl welcoming you into a story"
7+
8+
Options:
9+
--out <name> output file name (no extension) [required]
10+
--prompt "<...>" the description [required]
11+
--ref <file> a reference image in public/art/illustrations [optional]
12+
--dir <folder> subfolder of public/art (default: illustrations)
13+
--style append the shared SCENE_STYLE (on by default; --no-style off)
14+
Then (for transparent cut-outs):
15+
npm run remove-bg public/art/illustrations/<name>.png
16+
------------------------------------------------------------------------- */
17+
import fs from 'node:fs';
18+
import path from 'node:path';
19+
import url from 'node:url';
20+
import { SCENE_STYLE } from '../art-style.mjs';
21+
22+
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
23+
const ROOT = path.resolve(__dirname, '..');
24+
const MODEL = process.env.GEMINI_IMAGE_MODEL || 'gemini-2.5-flash-image';
25+
26+
(function loadEnv() {
27+
const f = path.join(ROOT, '.env');
28+
if (!fs.existsSync(f)) return;
29+
for (const line of fs.readFileSync(f, 'utf8').split('\n')) {
30+
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/i);
31+
if (m && !process.env[m[1]]) process.env[m[1]] = m[2].replace(/^["']|["']$/g, '');
32+
}
33+
})();
34+
const KEY = process.env.GEMINI_API_KEY;
35+
36+
const args = process.argv.slice(2);
37+
const flag = (n) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : null; };
38+
const out = flag('--out');
39+
let prompt = flag('--prompt');
40+
const ref = flag('--ref');
41+
const dir = flag('--dir') || 'illustrations';
42+
const useStyle = !args.includes('--no-style');
43+
if (!out || !prompt) { console.error('need --out and --prompt'); process.exit(1); }
44+
if (useStyle) prompt += '\n\n' + SCENE_STYLE;
45+
46+
async function callGemini(p, imagePaths = []) {
47+
const parts = [{ text: p }];
48+
for (const ip of imagePaths.filter(Boolean)) parts.push({ inline_data: { mime_type: 'image/png', data: fs.readFileSync(ip).toString('base64') } });
49+
const endpoint = `https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent?key=${KEY}`;
50+
const res = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ contents: [{ parts }], generationConfig: { responseModalities: ['IMAGE'] } }) });
51+
const json = await res.json();
52+
if (!res.ok) throw new Error(`Gemini ${res.status}: ${json?.error?.message || JSON.stringify(json).slice(0, 200)}`);
53+
const img = (json?.candidates?.[0]?.content?.parts || []).find((q) => q.inlineData?.data || q.inline_data?.data);
54+
if (!img) throw new Error('No image returned');
55+
return Buffer.from(img.inlineData?.data || img.inline_data.data, 'base64');
56+
}
57+
58+
const outDir = path.join(ROOT, 'public', 'art', dir);
59+
fs.mkdirSync(outDir, { recursive: true });
60+
const refPath = ref ? path.join(ROOT, 'public', 'art', 'illustrations', ref) : null;
61+
process.stdout.write(`🎨 ${dir}/${out} … `);
62+
const png = await callGemini(prompt, refPath ? [refPath] : []);
63+
fs.writeFileSync(path.join(outDir, `${out}.png`), png);
64+
console.log(`✅ ${(png.length / 1024).toFixed(0)} KB → public/art/${dir}/${out}.png`);

src/pages/characters.astro

Lines changed: 165 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -6,71 +6,195 @@ import { illustrationUrl } from '../lib/art';
66
77
const 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.
129
const cards = characters.map((c) => ({
1310
...c,
1411
sketchUrl: artUrl(c.sketch, 'character', base),
1512
illoUrl: illustrationUrl(c.id, base),
1613
}));
1714
const doneCount = cards.filter((c) => c.illoUrl).length;
1815
19-
// Group the cast by the book they belong to, keeping first-seen order.
2016
const stories = [...new Set(cards.map((c) => c.story))];
2117
const 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&nbsp;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

Comments
 (0)