Add the 'eyes' banner design; seed banners only on select sub pages
Banner designs can now ship arbitrary markup as banner.html (canvas + style + script), taking precedence over banner.svg; the artwork is inlined in a div[data-design] wrapper either way, which the editor's live preview preserves (and never re-runs its scripts). The bundled 'eyes' design (themes/eyes/banner.html + banner.css sizing the stage) replaces the eyes canvas script that was embedded in the notes-on-urls page's own banner — the page now just picks the design. Seeds set banners only on select sub pages (the-long-read gradient, canvas-nights stars): the front page no longer gets a banner image and shows the theme's default design. Seed entries carry a banner_design field.
This commit is contained in:
@@ -57,10 +57,11 @@ not for the public pages. See `docs/design-principles.md` for the design.
|
||||
AFTER the banner design's artwork, so author code (e.g. a `<style>`
|
||||
override) always wins over the design's own styles.
|
||||
`Node.banner_design` picks a banner design: a theme folder name whose
|
||||
`banner.css`/`banner.svg` supply the design's styles and inline SVG
|
||||
artwork (marked `svg[data-design]`); "" = explicitly no design, None =
|
||||
`banner.css` styles it and whose `banner.html` (arbitrary markup:
|
||||
canvas + style + script) or `banner.svg` supplies the inline artwork
|
||||
(wrapped in `div[data-design]`); "" = explicitly no design, None =
|
||||
inherit (nearest ancestor, front page last, then the active theme's
|
||||
own design if it ships banner.css/banner.svg). The design's banner.css
|
||||
own design if it ships banner.css/banner.svg/banner.html). The design's banner.css
|
||||
is linked in `<head>` (id `pagerite-banner`) between the theme and the
|
||||
custom CSS.
|
||||
`Data.version` is bumped on every write
|
||||
|
||||
@@ -89,11 +89,13 @@ evolves.
|
||||
navigation bar overlaid on it — no separate chrome header. The banner
|
||||
combines two layers, stacked in `#page-banner` (a grid, so they overlay):
|
||||
first the **banner design** — a named design living in a theme folder
|
||||
(`pagerite/themes/{name}/banner.css` + `banner.svg`), chosen per page via
|
||||
(`pagerite/themes/{name}/banner.css` plus artwork as `banner.html` —
|
||||
arbitrary markup like canvas + style + script — or `banner.svg`),
|
||||
chosen per page via
|
||||
`Node.banner_design` (a design name, "" for none, None to inherit from
|
||||
the nearest ancestor, then the front page, then the active theme's own
|
||||
design). The artwork is an **inline SVG** (marked `svg[data-design]`): as
|
||||
markup it can be recolored from the theme stylesheet (corporate's single
|
||||
design). The artwork is inlined into a `div[data-design]` wrapper: SVG
|
||||
artwork can be recolored from the theme stylesheet (corporate's single
|
||||
SVG serves both light and dark mode via `var()`-driven stops). Second,
|
||||
**per-page author code**: `Node.banner` holds an arbitrary trusted HTML
|
||||
snippet (an image, a styled div, canvas + script — anything), resolved by
|
||||
|
||||
@@ -775,12 +775,14 @@ function previewBanner() {
|
||||
if (!el) return
|
||||
if (banner.value.trim()) {
|
||||
// Own banner code supplements the design: the inlined design artwork
|
||||
// (marked svg[data-design]) stays in place, the author code goes after
|
||||
// it so its styles win.
|
||||
const artwork = [...el.querySelectorAll('svg[data-design]')]
|
||||
// (marked with [data-design]) is detached while the author code is
|
||||
// swapped in (so runScripts never re-runs the design's own scripts),
|
||||
// then put back first — author code stays last so its styles win.
|
||||
const artwork = [...el.querySelectorAll('[data-design]')]
|
||||
for (const a of artwork) a.remove()
|
||||
el.innerHTML = banner.value
|
||||
el.prepend(...artwork)
|
||||
runScripts(el)
|
||||
el.prepend(...artwork)
|
||||
} else {
|
||||
// No banner code of its own: the region must show the inherited/design
|
||||
// banner — re-render from the server (an empty write here would wipe it).
|
||||
|
||||
@@ -160,6 +160,15 @@ body {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* The design artwork wrapper (div[data-design]) takes the sizing and
|
||||
parallax above; an svg inside it fills it (it is what used to be the
|
||||
direct child before designs got a wrapper). */
|
||||
#page-banner [data-design]>svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#brand,
|
||||
#nav {
|
||||
position: relative;
|
||||
|
||||
+2
-1
@@ -130,13 +130,14 @@ async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
|
||||
if missing:
|
||||
with kanta.transaction("seed missing pages"):
|
||||
for path in missing:
|
||||
title, markdown, files, banner, order = seed.PAGES[path]
|
||||
title, markdown, files, banner, order, design = seed.PAGES[path]
|
||||
for orig, body in files.items():
|
||||
markdown, banner = _store_seed_file(markdown, banner, orig, body)
|
||||
node = _ensure(data.menu, path)
|
||||
node.title = title
|
||||
node.content = markdown
|
||||
node.banner = banner
|
||||
node.banner_design = design
|
||||
node.order = order
|
||||
await frontend.load()
|
||||
yield
|
||||
|
||||
+15
-138
@@ -10,10 +10,8 @@ Welcome to your new **Pagerite** site. Pages are written in Markdown — includi
|
||||
|
||||
Have a look around:
|
||||
|
||||
- The [docs](/docs) section explains [how to write content](/docs/editing),
|
||||
including images and positioning.
|
||||
- [The Long Read](/blog/the-long-read) demonstrates a longer article with
|
||||
scroll effects.
|
||||
- The [docs](/docs) section explains [how to write content](/docs/editing), including images and positioning.
|
||||
- [The Long Read](/blog/the-long-read) demonstrates a longer article with scroll effects.
|
||||
- The [about](/about) page shows off assorted formatting.
|
||||
|
||||

|
||||
@@ -188,135 +186,8 @@ CANVAS_BANNER = """\
|
||||
</script>
|
||||
"""
|
||||
|
||||
EYES_BANNER = """\
|
||||
<canvas id="eyes"></canvas>
|
||||
<script>
|
||||
(() => {
|
||||
const c = document.getElementById("eyes");
|
||||
const ctx = c.getContext("2d");
|
||||
const BG = "#f3e9d7";
|
||||
const fit = () => { c.width = c.clientWidth; c.height = c.clientHeight; };
|
||||
fit();
|
||||
addEventListener("resize", fit);
|
||||
|
||||
// Mouse in canvas coordinates; pupils wander idly when it goes stale.
|
||||
let mx = 0, my = 0, lastMove = 0;
|
||||
addEventListener("mousemove", (e) => {
|
||||
const r = c.getBoundingClientRect();
|
||||
mx = e.clientX - r.left;
|
||||
my = e.clientY - r.top;
|
||||
lastMove = performance.now();
|
||||
});
|
||||
|
||||
// The pair of eyes is one critter: it wanders around the banner, and
|
||||
// every so often ducks below the bottom edge, then pops back up.
|
||||
let gx = 0.5, gy = 0.5; // group position (fractions of the canvas)
|
||||
let tx = 0.5, ty = 0.5; // wander target
|
||||
let yoff = 0, vy = 0; // vertical hide/pop spring (px)
|
||||
let hidePhase = 0; // 0 = up, 1 = ducking, 2 = down, waiting
|
||||
let nextMove = 0, nextHide = 4000 + Math.random() * 5000, resurfaceAt = 0;
|
||||
|
||||
// Per-eye pupil state: spring physics for goofy lag and overshoot.
|
||||
const eyes = [{ x: 0, y: 0, vx: 0, vy: 0, pr: 0.3 }, { x: 0, y: 0, vx: 0, vy: 0, pr: 0.3 }];
|
||||
|
||||
let prev = performance.now();
|
||||
(function frame(now) {
|
||||
if (!c.isConnected) return;
|
||||
const dt = Math.min(now - prev, 100) / 16.7; prev = now;
|
||||
ctx.fillStyle = BG;
|
||||
ctx.fillRect(0, 0, c.width, c.height);
|
||||
const R = Math.min(c.height * 0.32, 70);
|
||||
|
||||
// Wander: ease toward a spot, pick a new one every few seconds.
|
||||
if (now > nextMove && !hidePhase) {
|
||||
tx = 0.15 + Math.random() * 0.7;
|
||||
ty = 0.3 + Math.random() * 0.4;
|
||||
nextMove = now + 2500 + Math.random() * 3500;
|
||||
}
|
||||
gx += (tx - gx) * 0.02 * dt;
|
||||
gy += (ty - gy) * 0.02 * dt;
|
||||
|
||||
// Duck down, wait hidden, then spring back (underdamped = pops past
|
||||
// the resting point and wobbles). Resurfaces at a new spot.
|
||||
if (hidePhase === 0 && now > nextHide) hidePhase = 1;
|
||||
if (hidePhase === 1 && yoff > c.height * 0.9) {
|
||||
hidePhase = 2;
|
||||
resurfaceAt = now + 500 + Math.random() * 900;
|
||||
}
|
||||
if (hidePhase === 2 && now > resurfaceAt) {
|
||||
hidePhase = 0;
|
||||
nextHide = now + 5000 + Math.random() * 7000;
|
||||
tx = 0.15 + Math.random() * 0.7;
|
||||
gx = tx;
|
||||
nextMove = now + 3000 + Math.random() * 3000;
|
||||
}
|
||||
const yTarget = hidePhase ? c.height : 0;
|
||||
vy += (yTarget - yoff) * 0.06 * dt;
|
||||
vy *= 0.85;
|
||||
yoff += vy * dt;
|
||||
|
||||
const cy0 = gy * c.height + yoff;
|
||||
const cx0 = gx * c.width;
|
||||
const watching = now - lastMove < 4000;
|
||||
eyes.forEach((e, i) => {
|
||||
const cx = cx0 + (i ? 1.3 : -1.3) * R;
|
||||
// Pupil target: toward the cursor, or a slow idle drift.
|
||||
let ptx, pty;
|
||||
if (watching) {
|
||||
const dx = mx - cx, dy = my - cy0;
|
||||
const d = Math.hypot(dx, dy) || 1;
|
||||
const reach = R * 0.45 * Math.min(1, d / 200);
|
||||
ptx = (dx / d) * reach; pty = (dy / d) * reach;
|
||||
} else {
|
||||
ptx = Math.sin(now / 900 + i * 2) * R * 0.3;
|
||||
pty = Math.cos(now / 1300 + i * 3) * R * 0.2;
|
||||
}
|
||||
// Spring toward the target (underdamped: overshoots, wobbles).
|
||||
e.vx += (ptx - e.x) * 0.08 * dt; e.vy += (pty - e.y) * 0.08 * dt;
|
||||
e.vx *= 0.82; e.vy *= 0.82;
|
||||
e.x += e.vx * dt; e.y += e.vy * dt;
|
||||
// Pupils dilate when the cursor comes close to the eye.
|
||||
const near = Math.hypot(mx - cx, my - cy0) < R * 2.5;
|
||||
e.pr += ((near ? 0.42 : 0.3) - e.pr) * 0.1 * dt;
|
||||
// Sclera.
|
||||
ctx.fillStyle = "#fff";
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(cx, cy0, R, R * 1.15, 0, 0, 7);
|
||||
ctx.fill();
|
||||
// Iris + pupil + glint, clipped to the sclera.
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(cx, cy0, R, R * 1.15, 0, 0, 7);
|
||||
ctx.clip();
|
||||
ctx.fillStyle = "#7c5cff";
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx + e.x, cy0 + e.y, R * 0.55, 0, 7);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = "#1d1730";
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx + e.x, cy0 + e.y, R * e.pr, 0, 7);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = "#fff";
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx + e.x - R * 0.15, cy0 + e.y - R * 0.18, R * 0.09, 0, 7);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
ctx.strokeStyle = "#2b2440";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(cx, cy0, R, R * 1.15, 0, 0, 7);
|
||||
ctx.stroke();
|
||||
});
|
||||
requestAnimationFrame(frame);
|
||||
})(prev);
|
||||
})();
|
||||
</script>
|
||||
"""
|
||||
|
||||
BLOG_BANNER = '<div style="background: linear-gradient(100deg, #14243d, #3d2b6b 45%, #7c5cff 75%, #ff5c8a)"></div>'
|
||||
|
||||
FRONT_BANNER = '<img src="/waves.svg" alt="">'
|
||||
|
||||
WAVES_SVG = """\
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 400">
|
||||
<defs>
|
||||
@@ -359,19 +230,22 @@ DUNES_SVG = """\
|
||||
</svg>
|
||||
"""
|
||||
|
||||
#: path -> (title, markdown, {filename: bytes}, banner HTML, menu order).
|
||||
#: path -> (title, markdown, {filename: bytes}, banner HTML, menu order,
|
||||
#: banner design). Banners are deliberately set only on select sub pages
|
||||
#: (not the front page), so the theme's default design shows elsewhere.
|
||||
#: Note there are deliberately no "docs" or "blog" landing pages: those
|
||||
#: labels are created without content, so they render a placeholder page
|
||||
#: and their nav links point at the first child (see views.first_leaf).
|
||||
PAGES: dict[str, tuple[str, str, dict[str, bytes], str, float]] = {
|
||||
"": ("Welcome", WELCOME, {"waves.svg": WAVES_SVG.encode()}, FRONT_BANNER, 1),
|
||||
"about": ("About", ABOUT, {}, "", 2),
|
||||
PAGES: dict[str, tuple[str, str, dict[str, bytes], str, float, str | None]] = {
|
||||
"": ("Welcome", WELCOME, {"waves.svg": WAVES_SVG.encode()}, "", 1, None),
|
||||
"about": ("About", ABOUT, {}, "", 2, None),
|
||||
"docs/editing": (
|
||||
"Writing Content",
|
||||
EDITING,
|
||||
{"shapes.svg": SHAPES_SVG.encode()},
|
||||
"",
|
||||
1,
|
||||
None,
|
||||
),
|
||||
"blog/the-long-read": (
|
||||
"The Long Read",
|
||||
@@ -379,8 +253,11 @@ PAGES: dict[str, tuple[str, str, dict[str, bytes], str, float]] = {
|
||||
{"dunes.svg": DUNES_SVG.encode()},
|
||||
BLOG_BANNER,
|
||||
1,
|
||||
None,
|
||||
),
|
||||
"blog/notes-on-urls": ("Notes on URLs", NOTES_ON_URLS, {}, EYES_BANNER, 2),
|
||||
"blog/canvas-nights": ("Canvas Nights", CANVAS_NIGHTS, {}, CANVAS_BANNER, 3),
|
||||
"blog/small-releases": ("Small Releases", SMALL_RELEASES, {}, "", 4),
|
||||
# The eyes critter is a named banner design (pagerite/themes/eyes/),
|
||||
# not code embedded in the page.
|
||||
"blog/notes-on-urls": ("Notes on URLs", NOTES_ON_URLS, {}, "", 2, "eyes"),
|
||||
"blog/canvas-nights": ("Canvas Nights", CANVAS_NIGHTS, {}, CANVAS_BANNER, 3, None),
|
||||
"blog/small-releases": ("Small Releases", SMALL_RELEASES, {}, "", 4, None),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
/* Eyes banner design: a canvas critter watching the cursor from the
|
||||
grass (banner.html — markup + styles + script inlined by the backend
|
||||
into #page-banner). Fixed-height stage matching the canvas. */
|
||||
|
||||
#banner {
|
||||
height: 240px;
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
<canvas id="eyes"></canvas>
|
||||
<style>
|
||||
#eyes {
|
||||
width: 100%;
|
||||
height: 240px;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
<script><!--
|
||||
(() => {
|
||||
const c = document.getElementById('eyes')
|
||||
const ctx = c.getContext('2d')
|
||||
const DPR = devicePixelRatio || 1
|
||||
|
||||
const fit = () => {
|
||||
const w = Math.max(1, c.clientWidth)
|
||||
const h = Math.max(1, c.clientHeight)
|
||||
c.width = Math.round(w * DPR)
|
||||
c.height = Math.round(h * DPR)
|
||||
ctx.setTransform(DPR, 0, 0, DPR, 0, 0)
|
||||
}
|
||||
|
||||
fit()
|
||||
addEventListener('resize', fit)
|
||||
|
||||
let mx = 0
|
||||
let my = 0
|
||||
let lastMove = 0
|
||||
|
||||
addEventListener('mousemove', e => {
|
||||
const r = c.getBoundingClientRect()
|
||||
|
||||
// Convert viewport coordinates into the canvas' CSS-pixel coordinate
|
||||
// system. This remains correct with browser zoom, CSS transforms, etc.
|
||||
mx = (e.clientX - r.left) * c.clientWidth / r.width
|
||||
my = (e.clientY - r.top) * c.clientHeight / r.height
|
||||
lastMove = performance.now()
|
||||
})
|
||||
|
||||
let gx = 0.5
|
||||
let gy = 0.5
|
||||
let tx = 0.5
|
||||
let ty = 0.5
|
||||
let yoff = 0
|
||||
let vy = 0
|
||||
let hidePhase = 0
|
||||
let nextMove = 0
|
||||
let nextHide = 4000 + Math.random() * 5000
|
||||
let resurfaceAt = 0
|
||||
|
||||
const eyes = [
|
||||
{ x: 0, y: 0, vx: 0, vy: 0, pr: 0.3 },
|
||||
{ x: 0, y: 0, vx: 0, vy: 0, pr: 0.3 }
|
||||
]
|
||||
|
||||
const ridgeY = (x, w, h) =>
|
||||
h * 0.72 +
|
||||
Math.sin(x * 0.012) * 10 +
|
||||
Math.sin(x * 0.003 + 1.4) * 16 +
|
||||
Math.sin(x * 0.02 + 0.7) * 3
|
||||
|
||||
const drawCloud = (x, y, s) => {
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.85)'
|
||||
ctx.beginPath()
|
||||
ctx.arc(x - s * 0.55, y + s * 0.05, s * 0.38, 0, 7)
|
||||
ctx.arc(x - s * 0.12, y - s * 0.08, s * 0.48, 0, 7)
|
||||
ctx.arc(x + s * 0.32, y, s * 0.42, 0, 7)
|
||||
ctx.arc(x + s * 0.64, y + s * 0.1, s * 0.28, 0, 7)
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
const drawHills = (w, h) => {
|
||||
ctx.fillStyle = '#b7d7a8'
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, h)
|
||||
ctx.lineTo(0, h * 0.63)
|
||||
ctx.quadraticCurveTo(w * 0.18, h * 0.48, w * 0.35, h * 0.62)
|
||||
ctx.quadraticCurveTo(w * 0.52, h * 0.78, w * 0.68, h * 0.58)
|
||||
ctx.quadraticCurveTo(w * 0.82, h * 0.43, w, h * 0.57)
|
||||
ctx.lineTo(w, h)
|
||||
ctx.closePath()
|
||||
ctx.fill()
|
||||
|
||||
ctx.fillStyle = '#99c685'
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, h)
|
||||
ctx.lineTo(0, h * 0.72)
|
||||
ctx.quadraticCurveTo(w * 0.14, h * 0.6, w * 0.28, h * 0.7)
|
||||
ctx.quadraticCurveTo(w * 0.46, h * 0.82, w * 0.62, h * 0.66)
|
||||
ctx.quadraticCurveTo(w * 0.82, h * 0.5, w, h * 0.68)
|
||||
ctx.lineTo(w, h)
|
||||
ctx.closePath()
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
const drawBackground = (w, h) => {
|
||||
const sky = ctx.createLinearGradient(0, 0, 0, h)
|
||||
sky.addColorStop(0, '#8ed0ff')
|
||||
sky.addColorStop(0.62, '#d9f1ff')
|
||||
sky.addColorStop(1, '#eef9ff')
|
||||
ctx.fillStyle = sky
|
||||
ctx.fillRect(0, 0, w, h)
|
||||
|
||||
ctx.fillStyle = 'rgba(255,240,170,0.5)'
|
||||
ctx.beginPath()
|
||||
ctx.arc(w * 0.83, h * 0.2, h * 0.16, 0, 7)
|
||||
ctx.fill()
|
||||
|
||||
drawCloud(w * 0.18, h * 0.2, h * 0.16)
|
||||
drawCloud(w * 0.43, h * 0.14, h * 0.12)
|
||||
drawCloud(w * 0.68, h * 0.24, h * 0.15)
|
||||
|
||||
drawHills(w, h)
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const x = (i + 0.5) * w / 5
|
||||
const y = h * 0.58 + Math.sin(i * 1.7) * 8
|
||||
|
||||
ctx.fillStyle = '#5f8d4e'
|
||||
ctx.beginPath()
|
||||
ctx.arc(x, y, 18, Math.PI, 0)
|
||||
ctx.arc(x - 14, y + 2, 14, Math.PI, 0)
|
||||
ctx.arc(x + 14, y + 3, 12, Math.PI, 0)
|
||||
ctx.fill()
|
||||
}
|
||||
}
|
||||
|
||||
const drawCritter = (cx0, eyeY, R, now, dt) => {
|
||||
const headR = R * 2
|
||||
const headCx = cx0
|
||||
const headCy = eyeY + R * 0.52
|
||||
|
||||
ctx.fillStyle = '#5fbe61'
|
||||
ctx.strokeStyle = '#285838'
|
||||
ctx.lineWidth = 3
|
||||
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(headCx - headR * 0.45, headCy - headR * 0.84)
|
||||
ctx.quadraticCurveTo(
|
||||
headCx - headR * 0.62,
|
||||
headCy - headR * 1.18,
|
||||
headCx - headR * 0.2,
|
||||
headCy - headR * 0.94
|
||||
)
|
||||
ctx.fill()
|
||||
ctx.stroke()
|
||||
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(headCx + headR * 0.45, headCy - headR * 0.84)
|
||||
ctx.quadraticCurveTo(
|
||||
headCx + headR * 0.62,
|
||||
headCy - headR * 1.18,
|
||||
headCx + headR * 0.2,
|
||||
headCy - headR * 0.94
|
||||
)
|
||||
ctx.fill()
|
||||
ctx.stroke()
|
||||
|
||||
ctx.beginPath()
|
||||
ctx.arc(headCx, headCy, headR, 0, 7)
|
||||
ctx.fill()
|
||||
ctx.stroke()
|
||||
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.12)'
|
||||
ctx.beginPath()
|
||||
ctx.arc(
|
||||
headCx - headR * 0.28,
|
||||
headCy - headR * 0.22,
|
||||
headR * 0.4,
|
||||
0,
|
||||
7
|
||||
)
|
||||
ctx.fill()
|
||||
|
||||
ctx.fillStyle = '#4caa50'
|
||||
|
||||
for (let i = -1; i <= 1; i++) {
|
||||
ctx.beginPath()
|
||||
ctx.arc(
|
||||
headCx + i * headR * 0.42,
|
||||
headCy - headR * 0.16,
|
||||
headR * 0.13,
|
||||
0,
|
||||
7
|
||||
)
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
ctx.strokeStyle = '#285838'
|
||||
ctx.lineCap = 'round'
|
||||
|
||||
for (let i = -1; i <= 1; i++) {
|
||||
ctx.lineWidth = 4
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(headCx + i * 10, headCy - headR * 0.94)
|
||||
ctx.lineTo(
|
||||
headCx + i * 16,
|
||||
headCy - headR * 1.1 - Math.sin(now / 180 + i) * 3
|
||||
)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
eyes.forEach((e, i) => {
|
||||
const cx = cx0 + (i ? 1.3 : -1.3) * R
|
||||
const watching = now - lastMove < 4000
|
||||
|
||||
let ptx
|
||||
let pty
|
||||
|
||||
if (watching) {
|
||||
const dx = mx - cx
|
||||
const dy = my - eyeY
|
||||
const d = Math.hypot(dx, dy) || 1
|
||||
|
||||
const maxReach = R * 0.43
|
||||
const responseDistance = R * 4
|
||||
|
||||
// Direction points exactly at the cursor, while reach increases
|
||||
// smoothly with cursor distance.
|
||||
const reach = maxReach * Math.min(1, d / responseDistance)
|
||||
|
||||
ptx = dx / d * reach
|
||||
pty = dy / d * reach
|
||||
} else {
|
||||
ptx = Math.sin(now / 900 + i * 2) * R * 0.3
|
||||
pty = Math.cos(now / 1300 + i * 3) * R * 0.2
|
||||
}
|
||||
|
||||
e.vx += (ptx - e.x) * 0.08 * dt
|
||||
e.vy += (pty - e.y) * 0.08 * dt
|
||||
e.vx *= Math.pow(0.82, dt)
|
||||
e.vy *= Math.pow(0.82, dt)
|
||||
e.x += e.vx * dt
|
||||
e.y += e.vy * dt
|
||||
|
||||
const near = Math.hypot(mx - cx, my - eyeY) < R * 2.5
|
||||
e.pr += ((near ? 0.42 : 0.3) - e.pr) * 0.1 * dt
|
||||
|
||||
ctx.fillStyle = '#fff'
|
||||
ctx.strokeStyle = '#1f2d22'
|
||||
ctx.lineWidth = 2.5
|
||||
ctx.beginPath()
|
||||
ctx.ellipse(cx, eyeY, R, R * 1.12, 0, 0, 7)
|
||||
ctx.fill()
|
||||
ctx.stroke()
|
||||
|
||||
ctx.save()
|
||||
ctx.beginPath()
|
||||
ctx.ellipse(cx, eyeY, R, R * 1.12, 0, 0, 7)
|
||||
ctx.clip()
|
||||
|
||||
ctx.fillStyle = '#f2b84b'
|
||||
ctx.beginPath()
|
||||
ctx.arc(cx + e.x, eyeY + e.y, R * 0.56, 0, 7)
|
||||
ctx.fill()
|
||||
|
||||
ctx.strokeStyle = 'rgba(140,84,8,0.45)'
|
||||
ctx.lineWidth = 1
|
||||
|
||||
for (let a = 0; a < 12; a++) {
|
||||
const ang = a / 12 * Math.PI * 2
|
||||
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(cx + e.x, eyeY + e.y)
|
||||
ctx.lineTo(
|
||||
cx + e.x + Math.cos(ang) * R * 0.5,
|
||||
eyeY + e.y + Math.sin(ang) * R * 0.5
|
||||
)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
ctx.fillStyle = '#191919'
|
||||
ctx.beginPath()
|
||||
ctx.arc(cx + e.x, eyeY + e.y, R * e.pr, 0, 7)
|
||||
ctx.fill()
|
||||
|
||||
ctx.fillStyle = '#fff'
|
||||
ctx.beginPath()
|
||||
ctx.arc(
|
||||
cx + e.x - R * 0.14,
|
||||
eyeY + e.y - R * 0.17,
|
||||
R * 0.09,
|
||||
0,
|
||||
7
|
||||
)
|
||||
ctx.fill()
|
||||
|
||||
ctx.restore()
|
||||
|
||||
ctx.strokeStyle = '#1f2d22'
|
||||
ctx.lineWidth = 3
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(cx - R * 0.7, eyeY - R * 1.2)
|
||||
ctx.quadraticCurveTo(
|
||||
cx,
|
||||
eyeY - R * 1.48 - (i ? -1 : 1) * 2,
|
||||
cx + R * 0.72,
|
||||
eyeY - R * 1.12
|
||||
)
|
||||
ctx.stroke()
|
||||
})
|
||||
}
|
||||
|
||||
const drawForeground = (w, h) => {
|
||||
ctx.fillStyle = '#69ae4b'
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, h)
|
||||
ctx.lineTo(0, ridgeY(0, w, h))
|
||||
|
||||
for (let x = 0; x <= w; x += 8)
|
||||
ctx.lineTo(x, ridgeY(x, w, h))
|
||||
|
||||
ctx.lineTo(w, h)
|
||||
ctx.closePath()
|
||||
ctx.fill()
|
||||
|
||||
ctx.fillStyle = 'rgba(48,102,34,0.18)'
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, h)
|
||||
ctx.lineTo(0, ridgeY(0, w, h) + 10)
|
||||
|
||||
for (let x = 0; x <= w; x += 8)
|
||||
ctx.lineTo(x, ridgeY(x, w, h) + 10)
|
||||
|
||||
ctx.lineTo(w, h)
|
||||
ctx.closePath()
|
||||
ctx.fill()
|
||||
|
||||
ctx.strokeStyle = '#4d8d37'
|
||||
ctx.lineWidth = 2
|
||||
ctx.lineCap = 'round'
|
||||
|
||||
for (let x = 0; x <= w; x += 16) {
|
||||
const y = ridgeY(x, w, h)
|
||||
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x, y + 6)
|
||||
ctx.quadraticCurveTo(x - 4, y - 10, x + 1, y - 2)
|
||||
ctx.moveTo(x + 1, y + 6)
|
||||
ctx.quadraticCurveTo(x + 4, y - 12, x + 3, y - 1)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const x = (i + 0.4) * w / 8 + Math.sin(i * 2.4) * 10
|
||||
const y = ridgeY(x, w, h) + 2
|
||||
|
||||
ctx.fillStyle = i % 2 ? '#ffdc6b' : '#ff8aa7'
|
||||
ctx.beginPath()
|
||||
ctx.arc(x, y, 3, 0, 7)
|
||||
ctx.arc(x - 4, y + 2, 3, 0, 7)
|
||||
ctx.arc(x + 4, y + 2, 3, 0, 7)
|
||||
ctx.fill()
|
||||
}
|
||||
}
|
||||
|
||||
let prev = performance.now()
|
||||
|
||||
const frame = now => {
|
||||
if (!c.isConnected) return
|
||||
|
||||
const dt = Math.min(now - prev, 100) / 16.7
|
||||
prev = now
|
||||
|
||||
const w = c.clientWidth
|
||||
const h = c.clientHeight
|
||||
const R = Math.min(h * 0.11, 38)
|
||||
|
||||
if (now > nextMove && !hidePhase) {
|
||||
tx = 0.15 + Math.random() * 0.7
|
||||
ty = 0.3 + Math.random() * 0.4
|
||||
nextMove = now + 2500 + Math.random() * 3500
|
||||
}
|
||||
|
||||
gx += (tx - gx) * 0.02 * dt
|
||||
gy += (ty - gy) * 0.02 * dt
|
||||
|
||||
if (hidePhase === 0 && now > nextHide)
|
||||
hidePhase = 1
|
||||
|
||||
if (hidePhase === 1 && yoff > h * 0.9) {
|
||||
hidePhase = 2
|
||||
resurfaceAt = now + 500 + Math.random() * 900
|
||||
}
|
||||
|
||||
if (hidePhase === 2 && now > resurfaceAt) {
|
||||
hidePhase = 0
|
||||
nextHide = now + 5000 + Math.random() * 7000
|
||||
tx = 0.15 + Math.random() * 0.7
|
||||
ty = 0.3 + Math.random() * 0.4
|
||||
gx = tx
|
||||
gy = ty
|
||||
nextMove = now + 3000 + Math.random() * 3000
|
||||
}
|
||||
|
||||
const yTarget = hidePhase ? h : 0
|
||||
|
||||
vy += (yTarget - yoff) * 0.06 * dt
|
||||
vy *= Math.pow(0.85, dt)
|
||||
yoff += vy * dt
|
||||
|
||||
drawBackground(w, h)
|
||||
|
||||
const cx0 = gx * w
|
||||
const ridge = ridgeY(cx0, w, h)
|
||||
|
||||
// Normally the full pair of eyes sits above the grass. gy gives it
|
||||
// a small amount of bobbing/wandering without burying it again.
|
||||
const eyeY =
|
||||
ridge -
|
||||
R * 1.25 +
|
||||
(gy - 0.5) * R * 0.8 +
|
||||
yoff
|
||||
|
||||
drawCritter(cx0, eyeY, R, now, dt)
|
||||
drawForeground(w, h)
|
||||
|
||||
requestAnimationFrame(frame)
|
||||
}
|
||||
|
||||
requestAnimationFrame(frame)
|
||||
})()
|
||||
</script>
|
||||
+23
-12
@@ -52,11 +52,12 @@ def _theme_names() -> list[str]:
|
||||
|
||||
|
||||
def _banner_design_names() -> list[str]:
|
||||
"""Available banner designs: theme folders with banner.css or banner.svg."""
|
||||
"""Available banner designs: theme folders with artwork and/or styles."""
|
||||
return sorted(
|
||||
d.name
|
||||
for d in THEMES.iterdir()
|
||||
if d.is_dir() and ((d / "banner.css").exists() or (d / "banner.svg").exists())
|
||||
if d.is_dir()
|
||||
and any((d / f).exists() for f in ("banner.css", "banner.svg", "banner.html"))
|
||||
)
|
||||
|
||||
|
||||
@@ -284,18 +285,26 @@ def banner_html(menu: dict[str, Node], path: str, theme: str = "") -> HTML:
|
||||
|
||||
|
||||
def _design_banner(design: str) -> HTML:
|
||||
"""The design's inline banner SVG (empty for none/unknown designs).
|
||||
"""The design's inline banner artwork (empty for none/unknown designs).
|
||||
|
||||
Read from disk on every request: design files are never built/cached,
|
||||
so editing them on disk shows on the next page load, even in prod."""
|
||||
banner.html (arbitrary markup: canvas + style + script...) takes
|
||||
precedence over banner.svg. Read from disk on every request: design
|
||||
files are never built/cached, so editing them on disk shows on the
|
||||
next page load, even in prod. The data-design wrapper marks the
|
||||
artwork as the design's (not author code), so the site editor's live
|
||||
banner preview keeps it in place.
|
||||
"""
|
||||
if not _valid_name(design):
|
||||
return HTML("")
|
||||
path = THEMES / design / "banner.svg"
|
||||
if not path.exists():
|
||||
html = THEMES / design / "banner.html"
|
||||
svg = THEMES / design / "banner.svg"
|
||||
if html.exists():
|
||||
body = html.read_text()
|
||||
elif svg.exists():
|
||||
body = svg.read_text()
|
||||
else:
|
||||
return HTML("")
|
||||
# data-design marks the artwork as the design's (not author code), so
|
||||
# the site editor's live banner preview keeps it in place.
|
||||
return HTML(path.read_text().replace("<svg", "<svg data-design", 1))
|
||||
return HTML(f'<div data-design="{design}">{body}</div>')
|
||||
|
||||
|
||||
def banner_design(menu: dict[str, Node], path: str, theme: str = "") -> str:
|
||||
@@ -316,8 +325,10 @@ def banner_design(menu: dict[str, Node], path: str, theme: str = "") -> str:
|
||||
return front.banner_design
|
||||
if (
|
||||
_valid_name(theme)
|
||||
and ((THEMES / theme / "banner.css").exists()
|
||||
or (THEMES / theme / "banner.svg").exists())
|
||||
and any(
|
||||
(THEMES / theme / f).exists()
|
||||
for f in ("banner.css", "banner.svg", "banner.html")
|
||||
)
|
||||
):
|
||||
return theme
|
||||
return ""
|
||||
|
||||
Reference in New Issue
Block a user