// Westra Engineering's hero: the name, parted by the scroll, over a // lightning line drawn full-screen on a sticky canvas. // // Content-owned: portal knows only site.yaml's `hero: {kind: module, // module: hero.js}` and the contract - `mount(container) -> handle` // with `stop()` - and serves this file same-origin at /site/hero.js. // The module owns its look. The stylesheet it injects below restyles // portal's .hero-module header the way uhhm's hero does: sticky at // the viewport top for the whole page, so the line keeps running // behind the translucent cards that scroll over it. On the landing // page the name drawn here is the wordmark, so the image one is // hidden there. // // Scroll is the timeline. Over the first SPLIT_RANGE of a viewport // "Westra" slides left and "Engineering" right, each until it has // left the screen, the way the logo already sets them: Westra to the // left, Engineering to the right. The logo's ribbon and tagline are // not drawn: the line takes the ribbon's place under the two words. // The transform follows the scroll // position through a short exponential lag (TAU_MS), so a wheel notch // reads as a movement, not a jump; with reduced motion asked for it // follows exactly. The copy under the name fades over the same range // so it does not ghost through the cards. // // The line is /usr/share/gpupaper/shaders/light_line.frag, Brandon // Fogerty's "Lightning" in glslsandbox conventions (time, mouse, // resolution), the one gpupaper draws on the desktop. It is embedded // verbatim; the two edits made to it at compile time are listed and // explained at FRAG_EDITS. WebGL 1 is enough for it and is everywhere // (a CSS paint worklet is not: no shaders, no per-pixel access, and // Chromium only). Without WebGL the name and the scroll still work // over plain paper. const STYLE_ID = 'westra-hero-style'; // Viewport heights of scroll over which the name parts completely. const SPLIT_RANGE = 0.6; // How far the transform lags the scroll, as the time constant of an // exponential approach: after one TAU it has closed 63% of the gap. const TAU_MS = 140; // The shader is cheap per pixel but runs for every pixel, three times // over (its loop), and a 4K screen at a device pixel ratio of 2 is 33 // million pixels a frame: choppy on an integrated GPU. The line is a // glow, so it is drawn at no more than this many pixels and the // browser scales the canvas up to the screen; at 1080p that is about // 0.7 of the screen's pixels, at 4K about a third. const MAX_PIXELS = 1.2e6; // How long to wait for the app to adopt the piece before drawing it // anyway (no wasm, a blocked script): longer than any hydration. const ATTACH_FALLBACK_MS = 4000; // /usr/share/gpupaper/shaders/light_line.frag, as is. const LIGHT_LINE_FRAG = `// Lightning // By: Brandon Fogerty // bfogerty at gmail dot com // xdpixel.com #ifdef GL_ES precision mediump float; #endif uniform float time; uniform vec2 mouse; uniform vec2 resolution; float Hash( vec2 p) { vec3 p2 = vec3(p.xy,2.0); return fract(sin(dot(p2,vec3(27.1,20.7, 2.4)))*0.5453123); } float noise(in vec2 p) { vec2 i = floor(p); vec2 f = fract(p); f *= f * (3.0-2.0*f); return mix(mix(Hash(i + vec2(0.,0.)), Hash(i + vec2(1.,0.)),f.x), mix(Hash(i + vec2(0.,1.)), Hash(i + vec2(1.,1.)),f.x), f.y); } float fbm(vec2 p) { float v = 0.0; v += noise(p*1.0) * .5; v += noise(p*2.) * .25; v += noise(p*4.) * .15; return v; } void main( void ) { vec2 uv = ( gl_FragCoord.xy / resolution.xy ) * 2.0 - 1.0; vec2 center = vec2(0.0, 0.0); float angle = time * 0.4; //uv.x = cos(angle) * (uv.x - center.x) - sin(angle) * (uv.y - center.y) + center.x; //uv.y = sin(angle) * (uv.x - center.y) + cos(angle) * (uv.y - center.y) + center.y; vec3 finalColor = vec3( 0.0 ); for( int i=0; i < 3; ++i ) { float t = abs( 1.4 / (sin(uv.y + fbm( uv + time * 0.04 )) * 100.0) ); finalColor += t * vec3( 2.9, 1.5, .25 ); finalColor = vec3(t); } gl_FragColor = vec4( finalColor, 1.0 ); } `; // The edits, as exact replacements, so the source above stays a copy // of the file. Each must match or the line is not drawn at all. // // 1. mediump → highp where the GPU has it: the hash takes sin() of a // large dot product, which at 16-bit precision bands into stripes // on phones. The two uniforms the second edit uses go in here too, // after the precision statement, where GLSL ES wants them. // 2. One line after uv is set: uv.y is shifted by `shift`, so the line // (which the shader draws where uv.y + fbm crosses zero, about 45% // below the middle of its own frame) lies under the two words, at // the height of the logo's ribbon, wherever the layout has put // them (see size()). The shader's own wander stays. // 3. The last line. The file paints the line white on black, hot in // the middle. Here it is one blue, the words' own (--accent), with // the line's brightness as alpha, so the paper shows through // everywhere else and the middle is that same blue, fully opaque. const FRAG_EDITS = [ [ '#ifdef GL_ES\nprecision mediump float;\n#endif\n', '#ifdef GL_ES\nprecision PRECISION float;\n#endif\nuniform vec3 tint;\nuniform float shift;\n', ], [ '\tvec2 uv = ( gl_FragCoord.xy / resolution.xy ) * 2.0 - 1.0;\n', '\tvec2 uv = ( gl_FragCoord.xy / resolution.xy ) * 2.0 - 1.0;\n\tuv.y -= shift;\n', ], [ '\tgl_FragColor = vec4( finalColor, 1.0 );\n', '\tfloat a = clamp( finalColor.r, 0.0, 1.0 );\n' + '\tgl_FragColor = vec4( tint * a, a );\n', ], ]; const VERT = 'attribute vec2 position; void main() { gl_Position = vec4(position, 0.0, 1.0); }'; const CSS = ` .hero-module { /* Sticky at the viewport top for the whole scroll (its containing block is the page itself), z-index 0 so the cards (z-index 1) stack over it. The height is a floor, frozen to a measured pixel value by the module once it runs (see size()) - as a custom property on , never as an inline style on the header: the module runs before the app hydrates, and an attribute written onto an element the app is about to hydrate throws the hydration off, and with it client-side navigation (uhhm's hero shows the same). The svh ladder is the first paint and the no-JS fallback. A floor and not a height, so a copy that needs more than a screen (a phone) grows the header rather than being clipped, and the cards start under it. */ position: sticky; top: 0; z-index: 0; max-width: none; width: 100%; min-height: 100vh; min-height: 100svh; min-height: var(--westra-hero-h, 100svh); padding: 0; justify-content: flex-end; overflow: hidden; } /* The piece is whatever the header has left above the copy, not portal's 55svh reservation; the name centres in it. It keeps a quarter of the screen at least, so a long copy cannot squeeze the name out. (A picture strip does not belong in this header: it would be pinned under the name for the whole page. It lives on the pages without the module, /prosjekter.) */ .hero-module .hero-piece { position: relative; flex: 1 1 auto; width: 100%; min-height: 26svh; } /* The name drawn below is the wordmark here. Who is here (portal 0.6: on the wordmark's own line, .hero-top, which would sit level with the mark) goes to the top right corner of the header instead, the sticky header being its containing block now that the copy is not positioned. */ .hero-module .wordmark { display: none; } .hero-module .hero-top { position: absolute; top: 0.9rem; right: 1.5rem; width: auto; justify-content: flex-end; } .hero-module .hero-top .session { position: static; transform: none; } /* The copy's fade is a custom property too, for the same reason. */ .hero-module .hero-copy { position: static; opacity: var(--westra-copy, 1); visibility: var(--westra-copy-vis, visible); padding: 0 1.5rem 3.5rem; gap: 0.6rem; text-shadow: 0 0.12em 1.4em var(--hero-glow); } /* The canvas lives in the piece (everything the module adds to the page goes inside the piece: a node added anywhere else before the app hydrates throws the hydration off, and with it client-side navigation) but covers the whole header: fit() sets its height to the header's box and its top to the piece's offset in it. Behind everything in the header: the header is a stacking context of its own, so -1 is still above the page. */ .westra-line { position: absolute; top: 0; left: 0; z-index: -1; width: 100%; height: 100%; display: block; pointer-events: none; animation: westra-in 700ms ease-out; } /* They arrive after the app has hydrated (see attachWhenHydrated), so they arrive as a fade rather than a pop. */ @keyframes westra-in { from { opacity: 0; } to { opacity: 1; } } /* The name, laid out as the logo lays it out: a box with the logo's proportions (its 430x128 viewBox), Westra large at the top left, Engineering under it and to the right. Positions and sizes are the logo's own numbers as hundredths of the box width (cqw), so the layout is the logo's at every size; the ribbon's place under both words is where the line is aimed (see aim()). --p is the parting: Westra leaves by half the viewport plus its own width, Engineering the other way. */ .westra-name { position: absolute; inset: 0; animation: westra-in 700ms ease-out; display: flex; align-items: center; justify-content: center; padding: 1rem 1.5rem; box-sizing: border-box; container-type: size; pointer-events: none; --p: 0; } /* As wide as the piece allows, and no taller than most of it. */ .westra-mark { position: relative; width: min(100%, 40rem, 260cqh); aspect-ratio: 430 / 128; container-type: inline-size; font-family: Georgia, 'Times New Roman', 'Liberation Serif', serif; font-variant: small-caps; font-feature-settings: 'smcp' 1; font-weight: 600; letter-spacing: 0.028em; line-height: 1; color: var(--accent); } .westra-mark > * { position: absolute; white-space: nowrap; } /* A halo of the paper's own colour around the glyphs, dense close in and fading out, so the line's glow does not blur the words where it runs under or through them. */ .westra-mark .westra-a, .westra-mark .westra-b { will-change: transform; text-shadow: 0 0 0.04em var(--paper), 0 0 0.1em var(--paper), 0 0 0.2em var(--paper), 0 0 0.4em var(--paper), 0 0.06em 0.9em var(--hero-glow); } /* x 6, baseline 54, size 54 */ .westra-mark .westra-a { left: 1.4cqw; top: 2.8cqw; font-size: 12.56cqw; transform: translateX(calc(var(--p) * (-50vw - 100%))); } /* x 150, baseline 96, size 41 */ .westra-mark .westra-b { left: 34.9cqw; top: 14.8cqw; font-size: 9.53cqw; transform: translateX(calc(var(--p) * (50vw + 100%))); } `; function ensureStyle() { if (document.getElementById(STYLE_ID)) return; const style = document.createElement('style'); style.id = STYLE_ID; style.textContent = CSS; document.head.appendChild(style); } const clamp01 = (x) => Math.min(1, Math.max(0, x)); // Gentle in, gentle out: the halves start moving as the scroll does, // without a kick. const ease = (p) => (p < 0.5 ? 2 * p * p : 1 - 2 * (1 - p) * (1 - p)); // "#rgb" / "#rrggbb" from a custom property, as a 0..1 triple. function rgb(value, fallback) { const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec((value || '').trim()); if (!m) return fallback; let h = m[1]; if (h.length === 3) h = h.replace(/./g, (c) => c + c); return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16) / 255); } class WestraHero { constructor(container) { ensureStyle(); this.stopped = false; this.hero = container.closest('.hero') || container.parentElement; // Where the module's custom properties go: an element the app // never hydrates, so writing to it cannot disturb hydration. this.host = document.body; this.reduced = window.matchMedia('(prefers-reduced-motion: reduce)'); this.dark = window.matchMedia('(prefers-color-scheme: dark)'); this.canvas = document.createElement('canvas'); this.canvas.className = 'westra-line'; this.name = document.createElement('div'); this.name.className = 'westra-name'; const mark = document.createElement('div'); mark.className = 'westra-mark'; const a = document.createElement('span'); a.className = 'westra-a'; a.textContent = 'Westra'; const b = document.createElement('span'); b.className = 'westra-b'; b.textContent = 'Engineering'; mark.append(a, b); this.name.append(mark); this.mark = mark; // Both go inside the piece, and nothing outside it (see // .westra-line) - but not yet: see attachWhenHydrated(). this.container = container; this.width = 0; this.height = 1; this.boxHeight = 1; this.progress = 0; this.t0 = performance.now(); this.last = this.t0; this.raf = 0; this.attached = false; this.attachWhenHydrated(); } // Portal starts this module at HTML parse time, before its app // has hydrated the page, and hydration walks the DOM as served: // nodes added to the piece before it runs throw it off (verified // on the live site: with the DOM added at once the router never // came up and every click was a full page load; with nothing // added it hydrated every time). So the stylesheet goes in at // once (the sticky header and the hidden wordmark are CSS) and // the canvas and the name wait for the app to adopt this // instance (portal sets __heroAdopted when it does), or for // ATTACH_FALLBACK_MS if no app comes - then they fade in. On a // client-side navigation back here the app mounts the module // itself, after hydration, so they go in at once. attachWhenHydrated() { const ready = () => window.__heroAdopted === true || performance.now() - this.t0 > ATTACH_FALLBACK_MS; const poll = () => { if (this.stopped) return; if (!ready()) { this.raf = requestAnimationFrame(poll); return; } // One more frame after the flag, so the app's own work // for this frame is done before the piece changes. this.raf = requestAnimationFrame(() => { this.raf = 0; if (!this.stopped) this.attach(); }); }; poll(); } attach() { this.attached = true; this.container.append(this.canvas, this.name); this.gl = this.initGl(); this.size(); this._onResize = () => this.onResize(); this._onScroll = () => this.wake(); this._onTheme = () => { this.recolor(); this.wake(); }; this._onLost = (e) => { e.preventDefault(); this.gl = null; }; this._onRestored = () => { this.gl = this.initGl(); this.size(); this.wake(); }; window.addEventListener('resize', this._onResize); window.addEventListener('scroll', this._onScroll, { passive: true }); if (this.hero && typeof ResizeObserver === 'function') { this.observer = new ResizeObserver(() => { if (this.stopped) return; if (this.hero.offsetHeight !== this.boxHeight) { this.fit(); this.wake(); } }); this.observer.observe(this.hero); } this.dark.addEventListener('change', this._onTheme); this.reduced.addEventListener('change', this._onTheme); this.canvas.addEventListener('webglcontextlost', this._onLost); this.canvas.addEventListener('webglcontextrestored', this._onRestored); this.wake(); } // The frame loop runs while the line animates; with reduced motion // the line stands still and frames are drawn only when the scroll, // size or theme changes, until the transform has settled. wake() { if (this.stopped || this.raf) return; this.raf = requestAnimationFrame((now) => this.frame(now)); } frame(now) { this.raf = 0; if (this.stopped) return; const dt = Math.min(100, now - this.last); this.last = now; const target = clamp01(window.scrollY / (this.height * SPLIT_RANGE)); if (this.reduced.matches) { this.progress = target; } else { this.progress += (target - this.progress) * (1 - Math.exp(-dt / TAU_MS)); if (Math.abs(target - this.progress) < 0.0005) this.progress = target; } const p = ease(this.progress); this.name.style.setProperty('--p', p.toFixed(4)); // The copy goes sooner than the name: it has to be gone before // the first card reaches it, on a short screen too. Written // on , which the app does not hydrate (see the CSS). const visible = 1 - clamp01(this.progress * 2.5); this.host.style.setProperty('--westra-copy', visible.toFixed(3)); this.host.style.setProperty('--westra-copy-vis', visible <= 0.01 ? 'hidden' : 'visible'); this.draw(this.reduced.matches ? 0 : (now - this.t0) / 1000); const settled = this.progress === target; if (!this.reduced.matches || !settled) this.wake(); } // The header's height is frozen from one measurement of the // viewport rather than left to svh: on phones the box would // otherwise follow the address bar in and out and the name with // it. Re-measured when the width changes (a rotation, a window // resize), not when only the height moves a little (that bar). onResize() { const dw = window.innerWidth !== this.width; const dh = Math.abs(window.innerHeight - this.height) > this.height * 0.25; if (dw || dh) this.size(); this.wake(); } size() { this.width = window.innerWidth; this.height = Math.max(1, window.innerHeight); this.host.style.setProperty('--westra-hero-h', this.height + 'px'); this.fit(); } // The canvas covers the header's real box, which is the screen // unless the copy needs more; measured, since the copy's height // is the fonts'. fit() { this.boxHeight = Math.max(1, this.hero ? this.hero.offsetHeight : this.height); if (this.hero) { const offset = this.container.getBoundingClientRect().top - this.hero.getBoundingClientRect().top; this.canvas.style.top = -offset + 'px'; this.canvas.style.height = this.boxHeight + 'px'; } const dpr = window.devicePixelRatio || 1; const scale = Math.min(dpr, Math.sqrt(MAX_PIXELS / (this.width * this.boxHeight))); this.canvas.width = Math.max(1, Math.round(this.width * scale)); this.canvas.height = Math.max(1, Math.round(this.boxHeight * scale)); if (this.gl) { this.gl.viewport(0, 0, this.canvas.width, this.canvas.height); this.aim(); } } // Where the logo's ribbon would be on screen (its lower edge, y // 116 of the box's 128), as the shader's uv.y (+1 at the top, -1 // at the foot): the line's own zero crossing sits about 0.45 // below its frame's middle, so the frame is shifted up by that // much less. aim() { const hero = this.hero ? this.hero.getBoundingClientRect() : { top: 0 }; const mark = this.mark.getBoundingClientRect(); const ribbonY = mark.top - hero.top + mark.height * (116 / 128); const uvY = 1 - 2 * (ribbonY / this.boxHeight); this.gl.uniform1f(this.u.shift, uvY + 0.45); } initGl() { const gl = this.canvas.getContext('webgl', { alpha: true, antialias: false, depth: false, stencil: false, premultipliedAlpha: true, powerPreference: 'low-power', }); if (!gl) return null; const highp = gl.getShaderPrecisionFormat(gl.FRAGMENT_SHADER, gl.HIGH_FLOAT); const precision = highp && highp.precision > 0 ? 'highp' : 'mediump'; let frag = LIGHT_LINE_FRAG; for (const [from, to] of FRAG_EDITS) { if (!frag.includes(from)) { console.warn('westra hero: light_line.frag no longer matches its edits; line not drawn'); return null; } frag = frag.replace(from, to.replace('PRECISION', precision)); } const compile = (type, src) => { const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s); if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) { console.warn('westra hero: shader', gl.getShaderInfoLog(s)); return null; } return s; }; const vs = compile(gl.VERTEX_SHADER, VERT); const fs = compile(gl.FRAGMENT_SHADER, frag); if (!vs || !fs) return null; const program = gl.createProgram(); gl.attachShader(program, vs); gl.attachShader(program, fs); gl.linkProgram(program); if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { console.warn('westra hero: link', gl.getProgramInfoLog(program)); return null; } gl.useProgram(program); // One triangle that covers the clip space; the shader only // reads gl_FragCoord. const buf = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, buf); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW); const position = gl.getAttribLocation(program, 'position'); gl.enableVertexAttribArray(position); gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0); this.u = { time: gl.getUniformLocation(program, 'time'), resolution: gl.getUniformLocation(program, 'resolution'), tint: gl.getUniformLocation(program, 'tint'), shift: gl.getUniformLocation(program, 'shift'), }; gl.clearColor(0, 0, 0, 0); this.gl = gl; this.recolor(); this.aim(); return gl; } // The line is the words' blue: --accent from the page's own // palette (custom.css sets it for both schemes), read when the // scheme changes rather than every frame. recolor() { if (!this.gl) return; const cs = getComputedStyle(this.hero || document.documentElement); const tint = rgb(cs.getPropertyValue('--accent'), [0.13, 0.19, 0.31]); this.gl.uniform3f(this.u.tint, tint[0], tint[1], tint[2]); } draw(time) { const gl = this.gl; if (!gl) return; gl.uniform1f(this.u.time, time); gl.uniform2f(this.u.resolution, this.canvas.width, this.canvas.height); gl.clear(gl.COLOR_BUFFER_BIT); gl.drawArrays(gl.TRIANGLES, 0, 3); } stop() { this.stopped = true; if (this.raf) cancelAnimationFrame(this.raf); if (!this.attached) return; window.removeEventListener('resize', this._onResize); window.removeEventListener('scroll', this._onScroll); if (this.observer) this.observer.disconnect(); this.dark.removeEventListener('change', this._onTheme); this.reduced.removeEventListener('change', this._onTheme); this.canvas.removeEventListener('webglcontextlost', this._onLost); this.canvas.removeEventListener('webglcontextrestored', this._onRestored); if (this.gl) { const lose = this.gl.getExtension('WEBGL_lose_context'); if (lose) lose.loseContext(); } for (const prop of ['--westra-hero-h', '--westra-copy', '--westra-copy-vis']) { this.host.style.removeProperty(prop); } this.canvas.remove(); this.name.remove(); } } export function mount(container) { return new WestraHero(container); }