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>`
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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.
|
||||||
|
|
||||||

|

|
||||||
@@ -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),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]:
|
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 ""
|
||||||
|
|||||||
Reference in New Issue
Block a user