From 47203974507ccfc25c0a49c7c97cc3f7fbe3aa09 Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Tue, 25 Aug 2026 17:24:14 +0200 Subject: [PATCH] Content-owned hero module (WIP branch for local verification) --- hero.js | 688 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ site.yaml | 6 + 2 files changed, 694 insertions(+) create mode 100644 hero.js create mode 100644 site.yaml diff --git a/hero.js b/hero.js new file mode 100644 index 0000000..cc964c3 --- /dev/null +++ b/hero.js @@ -0,0 +1,688 @@ +// uhhm.no's hero: "YES - Rasterized Lines", the piece that has been +// live at uhhm.no since the static site (ported from +// ~/repos/webpage/content/visualize/ah.html). Content-owned: portal +// knows only site.yaml's `hero: {kind: module, module: hero.js}` and +// the contract below - it serves this file same-origin at +// /site/hero.js, starts it at HTML parse time, adopts it on hydration, +// and calls stop() when the page is left. +// +// Contract: `export function mount(container) -> handle`, handle has +// `stop()`. Everything visual - the canvases, the sticky full-viewport +// header, the palette - is this module's, including the stylesheet it +// injects once below (selectors target portal's .hero-module header +// and .hero-piece box). +// +// Canvas paint can't read CSS custom properties, so THEMES mirrors +// the portal stylesheet's palette for both color schemes - a palette +// change lands there AND here. + +const STYLE_ID = 'uhhm-hero-style'; +const CSS = ` +.hero-module { + /* Sticky at the viewport top for the whole scroll (its containing + block is the page itself), so the piece stays animating behind + everything that follows - the translucent cards scroll over it + and it shows through them and in the gaps around them. z-index 0 + so positioned content below can stack above with z-index 1. */ + position: sticky; + top: 0; + z-index: 0; + max-width: none; + width: 100%; + /* svh here is only the pre-JS/no-JS fallback (and first paint before + yes.js's constructor runs). Once yes.js mounts, it overwrites this + with an inline \`height: \` frozen from a single measurement - + see setupCanvas()'s comment in yes.js for why: on real mobile + Safari, content bottom-aligned inside this box kept sliding down + as the address bar collapsed even with a spec'd-stable viewport + unit here, so the box's actual height can't be trusted to stay + put on that unit alone. An inline style set from JS always wins + the cascade over this rule, so that frozen number is what + actually governs once the page is interactive. + Plain-vh fallback declared first - an engine without svh support + ignores the invalid second line rather than falling through to + auto height, which would collapse this to the height of its + in-flow content and clip the canvas via overflow:hidden below. */ + height: 100vh; + height: 100svh; + padding: 0; + justify-content: flex-end; + overflow: hidden; +} + +.hero-module .hero-copy { + position: relative; + z-index: 1; + padding: 0 1.5rem 3.5rem; + gap: 0.6rem; + text-shadow: 0 0.12em 1.4em var(--hero-glow); +} + +.hero-canvas { + position: absolute; + inset: 0; +} + +.hero-canvas canvas { + position: absolute; + top: 0; + left: 0; + cursor: pointer; +} + +.hero-canvas #rasterCanvas { + mix-blend-mode: overlay; + opacity: 0.5; +} + +/* The piece wants the whole viewport, not portal's 55svh reservation: + the canvases position against the sticky header itself. */ +.hero-module .hero-piece { + position: static; + min-height: 0; +} + +@media (prefers-color-scheme: light) { + /* overlay against near-white paper resolves to ~white and the + raster ghost vanishes; multiply lets the light theme's gray YES + show as ink. */ + .hero-canvas #rasterCanvas { + mix-blend-mode: multiply; + } +} +`; + +function ensureStyle() { + if (document.getElementById(STYLE_ID)) return; + const style = document.createElement('style'); + style.id = STYLE_ID; + style.textContent = CSS; + document.head.appendChild(style); +} + +class RasterizedYES { + constructor(container) { + ensureStyle(); + // The piece builds its own DOM inside portal's box - two + // stacked canvases, raster ghost under the drifting lines. + const wrap = document.createElement('div'); + wrap.className = 'hero-canvas'; + this.lineCanvas = document.createElement('canvas'); + this.lineCanvas.id = 'lineCanvas'; + this.rasterCanvas = document.createElement('canvas'); + this.rasterCanvas.id = 'rasterCanvas'; + wrap.append(this.lineCanvas, this.rasterCanvas); + container.appendChild(wrap); + this.wrap = wrap; + + // The Rust side only constructs this once it's confirmed (via a + // NodeRef) that the canvas is mounted, but that guard has shown + // a real gap on fast client-side re-navigation back to `/` - + // this is the actual failure point, so it gets its own defense + // rather than depending on getting that timing exactly right + // from the other side of the wasm boundary. Leaving the + // instance otherwise-inert (no crash, no animation) rather than + // throwing mid-render - `stop()` already tolerates a partially + // (non-)initialized instance. + if (!this.rasterCanvas || !this.lineCanvas) { + console.warn('RasterizedYES: canvas not in DOM yet, skipping'); + this.destroyed = true; + return; + } + + this.rasterCtx = this.rasterCanvas.getContext('2d'); + this.lineCtx = this.lineCanvas.getContext('2d'); + + this.lines = []; + this.rasterData = null; + this.isActive = true; + this.destroyed = false; + this.time = 0; + + this.containmentStrength = 0.5; + this.wiggleAmount = 0.5; + + // .hero-canvas is inset:0 inside this - freezing an inline + // height here (below) is what actually locks the box, not + // just reading its rect. + this.heroEl = container.closest('.hero'); + + this.setupCanvas(); + this.setupResizeHandler(); + this.setupDrift(); + this.setupTheme(); + this.setupScrollFade(); + this.setupClickHandler(); + this.rasterizeText(); + this.initializeLines(); + this.animate(); + } + + stop() { + this.destroyed = true; + if (this.wrap) this.wrap.remove(); + if (this.heroEl) this.heroEl.style.height = ''; + if (this.heroCopy) { this.heroCopy.style.opacity = ''; this.heroCopy.style.visibility = ''; } + if (this._resizeHandler) { + window.removeEventListener('resize', this._resizeHandler); + } + if (this._themeQuery) { + this._themeQuery.removeEventListener('change', this._themeHandler); + } + if (this._scrollHandler) { + window.removeEventListener('scroll', this._scrollHandler); + } + } + + // The hero is position: sticky (main.css), so without this the + // title/wordmark stay pinned at the viewport bottom for the whole + // page and ghost through the translucent cards scrolling over + // them. Fade the copy out across the first half-screen of scroll; + // visibility: hidden at the end so the wordmark link can't be + // clicked while invisible. + setupScrollFade() { + this.heroCopy = this.heroEl ? this.heroEl.querySelector('.hero-copy') : null; + if (!this.heroCopy) return; + this._scrollHandler = () => { + const opacity = Math.max(0, 1 - window.scrollY / (this.displayHeight * 0.5)); + this.heroCopy.style.opacity = opacity; + this.heroCopy.style.visibility = opacity <= 0.01 ? 'hidden' : ''; + }; + window.addEventListener('scroll', this._scrollHandler, { passive: true }); + this._scrollHandler(); + } + + // Canvas paint can't read CSS custom properties, so the piece + // carries its own copy of both palettes and follows + // prefers-color-scheme itself - values must track main.css's :root + // (--paper especially: the fade fill IS the page background where + // the canvas shows through translucent cards). Dark keeps the + // original neon-on-black inks; light restates them as CMYK process + // inks dark enough to carry on paper, since 80%-lightness pastels + // vanish on white. + setupTheme() { + this._themeQuery = window.matchMedia('(prefers-color-scheme: light)'); + this._themeHandler = () => { + this.applyTheme(); + // Repaint the raster ghost in the new theme's ink and + // hard-clear the trails - a slow 3%-alpha fade from the + // old paper color would smear across the flip otherwise. + this.rasterizeText(); + this.lineCtx.fillStyle = this.theme.paper; + this.lineCtx.fillRect(0, 0, this.displayWidth, this.displayHeight); + }; + this._themeQuery.addEventListener('change', this._themeHandler); + this.applyTheme(); + } + + applyTheme() { + this.theme = this._themeQuery.matches + ? { + paper: '#f6f5f1', + fade: 'rgba(246, 245, 241, 0.03)', + // White ground so multiply (the light theme's CSS + // blend mode for #rasterCanvas) leaves the paper + // untouched; gray ink becomes the faint YES ghost. + rasterBg: '#ffffff', + rasterInk: '#6b6b6b', + strokes: [ + 'hsla(185, 70%, 32%, 0.85)', + 'hsla(315, 60%, 38%, 0.85)', + 'hsla(50, 90%, 40%, 0.85)' + ] + } + : { + paper: '#0a0a0a', + fade: 'rgba(10, 10, 10, 0.03)', + rasterBg: '#111111', + rasterInk: '#ffffff', + strokes: [ + 'hsla(180, 90%, 80%, 0.8)', + 'hsla(300, 90%, 80%, 0.8)', + 'hsla(60, 90%, 80%, 0.8)' + ] + }; + } + + // Reading .hero-canvas's rendered rect (a prior attempt at this) + // only helps if that rect actually stays put - and on real mobile + // Safari it didn't: .hero-copy (bottom-aligned via flexbox inside + // .hero-yes) kept sliding down as the address bar collapsed, even + // with a spec'd-stable viewport unit (svh, then lvh) driving the + // box's height. Either the browser isn't holding up its end, or + // something in the cascade is still landing on a live value - not + // provable from here without the device. Rather than keep + // guessing at which CSS viewport unit actually holds still, yes.js + // becomes the source of truth instead: it freezes .hero-yes's + // rendered height to a literal inline px value once, up front. An + // inline style always wins the cascade over the stylesheet's + // `height: 100svh`, so once this runs, nothing the browser does + // with that unit afterward can move the box - the number is fixed + // in the DOM, not recomputed from a unit at all. + setupCanvas() { + const pixelRatio = window.devicePixelRatio || 1; + const width = window.innerWidth; + const height = window.innerHeight; + + if (this.heroEl) { + this.heroEl.style.height = height + 'px'; + } + + this.rasterCanvas.style.width = width + 'px'; + this.rasterCanvas.style.height = height + 'px'; + this.lineCanvas.style.width = width + 'px'; + this.lineCanvas.style.height = height + 'px'; + + this.rasterCanvas.width = width * pixelRatio; + this.rasterCanvas.height = height * pixelRatio; + this.lineCanvas.width = width * pixelRatio; + this.lineCanvas.height = height * pixelRatio; + + this.rasterCtx.scale(pixelRatio, pixelRatio); + this.lineCtx.scale(pixelRatio, pixelRatio); + + this.displayWidth = width; + this.displayHeight = height; + this.pixelRatio = pixelRatio; + } + + setupResizeHandler() { + // Gate on width, not height: mobile Safari's address-bar + // animation changes window.innerHeight continuously with no + // real layout change to react to (that's the live value this + // whole method exists to stop trusting). A genuine resize - + // orientation change, desktop window drag - always changes + // width too, so that's the real signal to re-freeze on. + this._resizeHandler = () => { + if (window.innerWidth === this.displayWidth) return; + this.setupCanvas(); + this.rasterizeText(); + }; + window.addEventListener('resize', this._resizeHandler); + } + + // The two behavior dials (containmentStrength from x, wiggleAmount + // from y) used to follow the pointer. Now a smooth noise field + // wanders them instead - the same 0..1 inputs a mouse would give, + // but drifting at cloud pace, so the piece breathes on its own and + // behaves identically with nobody touching it (which on a landing + // hero is most of the time, and on touch devices was always the + // case between taps). Value noise with two octaves: smooth + // (C1-continuous via smoothstep), never repeats visibly, no jumps. + setupDrift() { + const channel = (seed) => { + const rand = (i) => { + let h = Math.imul(i ^ seed, 2654435761) >>> 0; + h ^= h >>> 13; + h = Math.imul(h, 0x5bd1e995) >>> 0; + // The >>> 0 here is load-bearing: ^ yields a SIGNED + // 32-bit value, and without the reinterpret a set top + // bit made this "0..1" noise go as low as -0.5, + // pushing both dials below their intended floors. + h = (h ^ (h >>> 15)) >>> 0; + return h / 4294967296; + }; + const noise = (t) => { + const i = Math.floor(t); + const f = t - i; + const s = f * f * (3 - 2 * f); + return rand(i) * (1 - s) + rand(i + 1) * s; + }; + // Two octaves, renormalized to 0..1: the slow octave sets + // the overall weather, the faster one keeps it from + // feeling like a pendulum. + return (t) => (noise(t) * 2 / 3 + noise(t * 2.7 + 913) * 1 / 3); + }; + + // One full "weather change" roughly every DRIFT_PERIOD + // seconds per octave - the pace of watching clouds, not of a + // hand on a mouse. + this.driftPeriod = 25; + this.driftX = channel(0x9e3779b9); + this.driftY = channel(0x85ebca6b); + + this.containmentStrength = 0.55; + this.wiggleAmount = 1.05; + } + + updateDrift() { + const t = this.time / this.driftPeriod; + const x = this.driftX(t); + const y = this.driftY(t); + // Same mapping the mouse position used to feed. + this.containmentStrength = 0.1 + (x * 0.9); + this.wiggleAmount = 0.1 + (y * 1.9); + } + + setupClickHandler() { + this.lineCanvas.addEventListener('click', () => { + this.restartAnimation(); + }); + } + + restartAnimation() { + this.time = 0; + this.lineCtx.fillStyle = this.theme.paper; + this.lineCtx.fillRect(0, 0, this.displayWidth, this.displayHeight); + this.initializeLines(); + this.isActive = true; + } + + rasterizeText() { + const width = this.displayWidth; + const height = this.displayHeight; + + const aspectRatio = width / height; + let fontSize; + if (aspectRatio < 1) { + fontSize = width * 0.4; + } else { + fontSize = height * 0.5; + } + + this.fontSize = fontSize; + + this.rasterCtx.font = `bold ${fontSize}px Arial, sans-serif`; + this.rasterCtx.textAlign = 'left'; + this.rasterCtx.textBaseline = 'middle'; + + const fullTextMetrics = this.rasterCtx.measureText('YES'); + const textWidth = fullTextMetrics.width; + const textStartX = (width - textWidth) / 2; + const textY = height / 2; + + const letters = ['Y', 'E', 'S']; + this.letterPositions = []; + let currentX = textStartX; + + for (let i = 0; i < letters.length; i++) { + const letterMetrics = this.rasterCtx.measureText(letters[i]); + this.letterPositions[i] = { + x: currentX, + y: textY, + width: letterMetrics.width, + centerX: currentX + letterMetrics.width / 2 + }; + currentX += letterMetrics.width; + } + + this.letterRasters = []; + + for (let i = 0; i < 3; i++) { + this.rasterCtx.fillStyle = '#111'; + this.rasterCtx.fillRect(0, 0, width, height); + + this.rasterCtx.fillStyle = '#ffffff'; + this.rasterCtx.fillText(letters[i], this.letterPositions[i].x, this.letterPositions[i].y); + + this.letterRasters[i] = this.rasterCtx.getImageData(0, 0, this.rasterCanvas.width, this.rasterCanvas.height); + } + + // The visible layer, distinct from the letterRasters sampled + // above (those stay #111/#fff - isInSpecificLetter's red>128 + // test depends on it): painted in theme ink so the ghost works + // under the theme's blend mode (overlay on dark, multiply on + // light - see #rasterCanvas in main.css). + this.rasterCtx.fillStyle = this.theme.rasterBg; + this.rasterCtx.fillRect(0, 0, width, height); + this.rasterCtx.fillStyle = this.theme.rasterInk; + this.rasterCtx.fillText('YES', textStartX, textY); + + this.rasterData = this.rasterCtx.getImageData(0, 0, this.rasterCanvas.width, this.rasterCanvas.height); + } + + isInSpecificLetter(x, y, letterIndex) { + const canvasX = x * this.pixelRatio; + const canvasY = y * this.pixelRatio; + + if (!this.letterRasters || !this.letterRasters[letterIndex] || + canvasX < 0 || canvasY < 0 || + canvasX >= this.rasterCanvas.width || canvasY >= this.rasterCanvas.height) { + return false; + } + + const index = (Math.floor(canvasY) * this.rasterCanvas.width + Math.floor(canvasX)) * 4; + const red = this.letterRasters[letterIndex].data[index]; + return red > 128; + } + + initializeLines() { + this.lines = []; + const numLines = 60; + + const letterCentroids = this.calculateLetterCentroids(); + + for (let i = 0; i < numLines; i++) { + const letterIndex = Math.floor(i / (numLines / 3)); + let startX, startY, centerX, centerY; + + if (letterCentroids[letterIndex]) { + centerX = letterCentroids[letterIndex].x; + centerY = letterCentroids[letterIndex].y; + + const startVariation = this.fontSize * 0.1; + startX = centerX + (Math.random() - 0.5) * startVariation; + startY = centerY + (Math.random() - 0.5) * startVariation; + + if (!this.isInSpecificLetter(startX, startY, letterIndex)) { + const nearestPoint = this.findNearestSpecificLetterPixel(startX, startY, letterIndex); + if (nearestPoint) { + startX = nearestPoint.x; + startY = nearestPoint.y; + } else { + startX = centerX; + startY = centerY; + } + } + } else { + const letterPos = this.letterPositions[letterIndex]; + startX = letterPos.centerX; + startY = letterPos.y; + centerX = startX; + centerY = startY; + } + + // Stroke color lives on the theme, looked up per frame by + // letterIndex (see draw()) - not frozen per line - so a + // theme flip recolors live lines instead of leaving + // dark-theme neon smearing across light paper. + this.lines.push({ + relativeX: (startX - centerX) / this.fontSize, + relativeY: (startY - centerY) / this.fontSize, + prevRelativeX: (startX - centerX) / this.fontSize, + prevRelativeY: (startY - centerY) / this.fontSize, + angle: Math.random() * Math.PI * 2, + letterIndex: letterIndex, + lastSeenInside: { x: (startX - centerX) / this.fontSize, y: (startY - centerY) / this.fontSize }, + outsideDuration: 0 + }); + } + } + + calculateLetterCentroids() { + const centroids = []; + + for (let letterIndex = 0; letterIndex < 3; letterIndex++) { + let sumX = 0, sumY = 0, count = 0; + + const letterPos = this.letterPositions[letterIndex]; + const searchStartX = Math.max(0, letterPos.x - this.fontSize * 0.1); + const searchEndX = Math.min(this.displayWidth, letterPos.x + letterPos.width + this.fontSize * 0.1); + const searchStartY = Math.max(0, letterPos.y - this.fontSize * 0.6); + const searchEndY = Math.min(this.displayHeight, letterPos.y + this.fontSize * 0.6); + + const step = Math.max(1, Math.floor(this.fontSize * 0.02)); + for (let y = searchStartY; y <= searchEndY; y += step) { + for (let x = searchStartX; x <= searchEndX; x += step) { + if (this.isInSpecificLetter(x, y, letterIndex)) { + sumX += x; + sumY += y; + count++; + } + } + } + + if (count > 0) { + centroids[letterIndex] = { + x: sumX / count, + y: sumY / count + }; + } else { + centroids[letterIndex] = { + x: letterPos.centerX, + y: letterPos.y + }; + } + } + + return centroids; + } + + updateLines() { + this.time += 0.016; + this.updateDrift(); + if (!this.isActive) return; + + const letterCentroids = this.calculateLetterCentroids(); + + this.lines.forEach(line => { + line.prevRelativeX = line.relativeX; + line.prevRelativeY = line.relativeY; + + const centroid = letterCentroids[line.letterIndex]; + if (!centroid) return; + + const currentX = centroid.x + line.relativeX * this.fontSize; + const currentY = centroid.y + line.relativeY * this.fontSize; + + const currentlyInside = this.isInSpecificLetter(currentX, currentY, line.letterIndex); + + if (currentlyInside) { + line.outsideDuration = 0; + line.lastSeenInside = { x: line.relativeX, y: line.relativeY }; + } else { + line.outsideDuration++; + } + + const visionDistance = 0.08 * this.fontSize; + const centerX = currentX + Math.cos(line.angle) * visionDistance; + const centerY = currentY + Math.sin(line.angle) * visionDistance; + const leftX = currentX + Math.cos(line.angle - 0.4) * visionDistance; + const leftY = currentY + Math.sin(line.angle - 0.4) * visionDistance; + const rightX = currentX + Math.cos(line.angle + 0.4) * visionDistance; + const rightY = currentY + Math.sin(line.angle + 0.4) * visionDistance; + + const centerSees = this.isInSpecificLetter(centerX, centerY, line.letterIndex); + const leftSees = this.isInSpecificLetter(leftX, leftY, line.letterIndex); + const rightSees = this.isInSpecificLetter(rightX, rightY, line.letterIndex); + + let speed = 0.02; + + const attractionThreshold = Math.floor(15 + (1 - this.containmentStrength) * 45); + + if (line.outsideDuration > attractionThreshold) { + const targetX = line.lastSeenInside.x; + const targetY = line.lastSeenInside.y; + const deltaX = targetX - line.relativeX; + const deltaY = targetY - line.relativeY; + const angleToTarget = Math.atan2(deltaY, deltaX); + + let angleDiff = angleToTarget - line.angle; + while (angleDiff > Math.PI) angleDiff -= 2 * Math.PI; + while (angleDiff < -Math.PI) angleDiff += 2 * Math.PI; + + const baseAttraction = Math.min(0.4, line.outsideDuration / 80); + const attractionStrength = baseAttraction * this.containmentStrength; + line.angle += angleDiff * attractionStrength; + } + + if (centerSees) { + const baseWiggle = 0.15; + line.angle += (Math.random() - 0.5) * baseWiggle * this.wiggleAmount; + } else { + speed *= (0.3 + this.containmentStrength * 0.4); + + const baseTurnStrength = 0.3 + (this.containmentStrength * 0.4); + const randomTurnAmount = 0.2 * this.wiggleAmount; + + if (leftSees && !rightSees) { + line.angle -= baseTurnStrength + Math.random() * randomTurnAmount; + } else if (rightSees && !leftSees) { + line.angle += baseTurnStrength + Math.random() * randomTurnAmount; + } else { + const randomTurn = (Math.random() - 0.5) * (0.8 + this.wiggleAmount * 0.7); + line.angle += randomTurn; + } + } + + line.relativeX += Math.cos(line.angle) * speed; + line.relativeY += Math.sin(line.angle) * speed; + + line.relativeX = Math.max(-1.7, Math.min(1.7, line.relativeX)); + line.relativeY = Math.max(-1.7, Math.min(1.7, line.relativeY)); + }); + } + + findNearestSpecificLetterPixel(x, y, letterIndex) { + const searchRadius = this.fontSize * 0.08; + const step = Math.max(1, Math.floor(this.fontSize * 0.01)); + let nearestPoint = null; + let nearestDistance = Infinity; + + for (let dy = -searchRadius; dy <= searchRadius; dy += step) { + for (let dx = -searchRadius; dx <= searchRadius; dx += step) { + const testX = x + dx; + const testY = y + dy; + + if (this.isInSpecificLetter(testX, testY, letterIndex)) { + const distance = Math.sqrt(dx * dx + dy * dy); + if (distance < nearestDistance) { + nearestDistance = distance; + nearestPoint = { x: testX, y: testY }; + } + } + } + } + + return nearestPoint; + } + + draw() { + this.lineCtx.fillStyle = this.theme.fade; + this.lineCtx.fillRect(0, 0, this.displayWidth, this.displayHeight); + + const letterCentroids = this.calculateLetterCentroids(); + + this.lines.forEach(line => { + const centroid = letterCentroids[line.letterIndex]; + if (!centroid) return; + + const currentX = centroid.x + line.relativeX * this.fontSize; + const currentY = centroid.y + line.relativeY * this.fontSize; + const prevX = centroid.x + line.prevRelativeX * this.fontSize; + const prevY = centroid.y + line.prevRelativeY * this.fontSize; + + if (prevX === currentX && prevY === currentY) return; + + this.lineCtx.strokeStyle = this.theme.strokes[line.letterIndex]; + this.lineCtx.lineWidth = this.fontSize * 0.003; + this.lineCtx.lineCap = 'round'; + + this.lineCtx.beginPath(); + this.lineCtx.moveTo(prevX, prevY); + this.lineCtx.lineTo(currentX, currentY); + this.lineCtx.stroke(); + }); + } + + animate() { + if (this.destroyed) return; + this.updateLines(); + this.draw(); + requestAnimationFrame(() => this.animate()); + } +} + +export function mount(container) { + return new RasterizedYES(container); +} diff --git a/site.yaml b/site.yaml new file mode 100644 index 0000000..cfd7f56 --- /dev/null +++ b/site.yaml @@ -0,0 +1,6 @@ +# Instance branding - title and wordmark left at portal's defaults +# (SITE_NAME, /wordmark.svg); the landing hero is this repo's own +# "YES - Rasterized Lines" module, served at /site/hero.js. +hero: + kind: module + module: hero.js