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:
2026-08-18 06:36:05 +00:00
parent c5a91edac7
commit 0ec9be330e
9 changed files with 494 additions and 161 deletions
+4 -3
View File
@@ -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>` AFTER the banner design's artwork, so author code (e.g. a `<style>`
override) always wins over the design's own styles. override) always wins over the design's own styles.
`Node.banner_design` picks a banner design: a theme folder name whose `Node.banner_design` picks a banner design: a theme folder name whose
`banner.css`/`banner.svg` supply the design's styles and inline SVG `banner.css` styles it and whose `banner.html` (arbitrary markup:
artwork (marked `svg[data-design]`); "" = explicitly no design, None = 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 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 is linked in `<head>` (id `pagerite-banner`) between the theme and the
custom CSS. custom CSS.
`Data.version` is bumped on every write `Data.version` is bumped on every write
+5 -3
View File
@@ -89,11 +89,13 @@ evolves.
navigation bar overlaid on it — no separate chrome header. The banner navigation bar overlaid on it — no separate chrome header. The banner
combines two layers, stacked in `#page-banner` (a grid, so they overlay): combines two layers, stacked in `#page-banner` (a grid, so they overlay):
first the **banner design** — a named design living in a theme folder 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 `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 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 design). The artwork is inlined into a `div[data-design]` wrapper: SVG
markup it can be recolored from the theme stylesheet (corporate's single artwork can be recolored from the theme stylesheet (corporate's single
SVG serves both light and dark mode via `var()`-driven stops). Second, SVG serves both light and dark mode via `var()`-driven stops). Second,
**per-page author code**: `Node.banner` holds an arbitrary trusted HTML **per-page author code**: `Node.banner` holds an arbitrary trusted HTML
snippet (an image, a styled div, canvas + script — anything), resolved by snippet (an image, a styled div, canvas + script — anything), resolved by
+6 -4
View File
@@ -775,12 +775,14 @@ function previewBanner() {
if (!el) return if (!el) return
if (banner.value.trim()) { if (banner.value.trim()) {
// Own banner code supplements the design: the inlined design artwork // Own banner code supplements the design: the inlined design artwork
// (marked svg[data-design]) stays in place, the author code goes after // (marked with [data-design]) is detached while the author code is
// it so its styles win. // swapped in (so runScripts never re-runs the design's own scripts),
const artwork = [...el.querySelectorAll('svg[data-design]')] // 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.innerHTML = banner.value
el.prepend(...artwork)
runScripts(el) runScripts(el)
el.prepend(...artwork)
} else { } else {
// No banner code of its own: the region must show the inherited/design // 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). // banner — re-render from the server (an empty write here would wipe it).
+9
View File
@@ -160,6 +160,15 @@ body {
will-change: transform; 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, #brand,
#nav { #nav {
position: relative; position: relative;
+2 -1
View File
@@ -130,13 +130,14 @@ async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
if missing: if missing:
with kanta.transaction("seed missing pages"): with kanta.transaction("seed missing pages"):
for path in missing: 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(): for orig, body in files.items():
markdown, banner = _store_seed_file(markdown, banner, orig, body) markdown, banner = _store_seed_file(markdown, banner, orig, body)
node = _ensure(data.menu, path) node = _ensure(data.menu, path)
node.title = title node.title = title
node.content = markdown node.content = markdown
node.banner = banner node.banner = banner
node.banner_design = design
node.order = order node.order = order
await frontend.load() await frontend.load()
yield yield
+15 -138
View File
@@ -10,10 +10,8 @@ Welcome to your new **Pagerite** site. Pages are written in Markdown — includi
Have a look around: Have a look around:
- The [docs](/docs) section explains [how to write content](/docs/editing), - The [docs](/docs) section explains [how to write content](/docs/editing), including images and positioning.
including images and positioning. - [The Long Read](/blog/the-long-read) demonstrates a longer article with scroll effects.
- [The Long Read](/blog/the-long-read) demonstrates a longer article with
scroll effects.
- The [about](/about) page shows off assorted formatting. - The [about](/about) page shows off assorted formatting.
![Abstract waves](waves.svg "Generated SVG artwork, attached to this page") ![Abstract waves](waves.svg "Generated SVG artwork, attached to this page")
@@ -188,135 +186,8 @@ CANVAS_BANNER = """\
</script> </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>' BLOG_BANNER = '<div style="background: linear-gradient(100deg, #14243d, #3d2b6b 45%, #7c5cff 75%, #ff5c8a)"></div>'
FRONT_BANNER = '<img src="/waves.svg" alt="">'
WAVES_SVG = """\ WAVES_SVG = """\
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 400"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 400">
<defs> <defs>
@@ -359,19 +230,22 @@ DUNES_SVG = """\
</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 #: Note there are deliberately no "docs" or "blog" landing pages: those
#: labels are created without content, so they render a placeholder page #: labels are created without content, so they render a placeholder page
#: and their nav links point at the first child (see views.first_leaf). #: and their nav links point at the first child (see views.first_leaf).
PAGES: dict[str, tuple[str, str, dict[str, bytes], str, float]] = { PAGES: dict[str, tuple[str, str, dict[str, bytes], str, float, str | None]] = {
"": ("Welcome", WELCOME, {"waves.svg": WAVES_SVG.encode()}, FRONT_BANNER, 1), "": ("Welcome", WELCOME, {"waves.svg": WAVES_SVG.encode()}, "", 1, None),
"about": ("About", ABOUT, {}, "", 2), "about": ("About", ABOUT, {}, "", 2, None),
"docs/editing": ( "docs/editing": (
"Writing Content", "Writing Content",
EDITING, EDITING,
{"shapes.svg": SHAPES_SVG.encode()}, {"shapes.svg": SHAPES_SVG.encode()},
"", "",
1, 1,
None,
), ),
"blog/the-long-read": ( "blog/the-long-read": (
"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()}, {"dunes.svg": DUNES_SVG.encode()},
BLOG_BANNER, BLOG_BANNER,
1, 1,
None,
), ),
"blog/notes-on-urls": ("Notes on URLs", NOTES_ON_URLS, {}, EYES_BANNER, 2), # The eyes critter is a named banner design (pagerite/themes/eyes/),
"blog/canvas-nights": ("Canvas Nights", CANVAS_NIGHTS, {}, CANVAS_BANNER, 3), # not code embedded in the page.
"blog/small-releases": ("Small Releases", SMALL_RELEASES, {}, "", 4), "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),
} }
+7
View File
@@ -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;
}
+423
View File
@@ -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
View File
@@ -52,11 +52,12 @@ def _theme_names() -> list[str]:
def _banner_design_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( return sorted(
d.name d.name
for d in THEMES.iterdir() 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: 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, banner.html (arbitrary markup: canvas + style + script...) takes
so editing them on disk shows on the next page load, even in prod.""" 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): if not _valid_name(design):
return HTML("") return HTML("")
path = THEMES / design / "banner.svg" html = THEMES / design / "banner.html"
if not path.exists(): svg = THEMES / design / "banner.svg"
if html.exists():
body = html.read_text()
elif svg.exists():
body = svg.read_text()
else:
return HTML("") return HTML("")
# data-design marks the artwork as the design's (not author code), so return HTML(f'<div data-design="{design}">{body}</div>')
# the site editor's live banner preview keeps it in place.
return HTML(path.read_text().replace("<svg", "<svg data-design", 1))
def banner_design(menu: dict[str, Node], path: str, theme: str = "") -> str: 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 return front.banner_design
if ( if (
_valid_name(theme) _valid_name(theme)
and ((THEMES / theme / "banner.css").exists() and any(
or (THEMES / theme / "banner.svg").exists()) (THEMES / theme / f).exists()
for f in ("banner.css", "banner.svg", "banner.html")
)
): ):
return theme return theme
return "" return ""