2026-10-21 14:52:54 +02:00
|
|
|
// 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;
|
|
|
|
|
|
|
|
|
|
// /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()); the svh ladder is
|
|
|
|
|
the first paint and the no-JS fallback. A floor and not a height,
|
2026-10-21 14:58:19 +02:00
|
|
|
so a copy that needs more than a screen (a phone) grows the header
|
|
|
|
|
rather than being clipped, and the cards start under it. */
|
2026-10-21 14:52:54 +02:00
|
|
|
position: sticky;
|
|
|
|
|
top: 0;
|
|
|
|
|
z-index: 0;
|
|
|
|
|
max-width: none;
|
|
|
|
|
width: 100%;
|
|
|
|
|
min-height: 100vh;
|
|
|
|
|
min-height: 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
|
2026-10-21 14:58:19 +02:00
|
|
|
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.) */
|
2026-10-21 14:52:54 +02:00
|
|
|
.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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.hero-module .hero-copy {
|
|
|
|
|
position: static;
|
|
|
|
|
padding: 0 1.5rem 3.5rem;
|
|
|
|
|
gap: 0.6rem;
|
|
|
|
|
text-shadow: 0 0.12em 1.4em var(--hero-glow);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* 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;
|
|
|
|
|
inset: 0;
|
|
|
|
|
z-index: -1;
|
|
|
|
|
width: 100%;
|
|
|
|
|
height: 100%;
|
|
|
|
|
display: block;
|
|
|
|
|
pointer-events: none;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* 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;
|
|
|
|
|
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;
|
|
|
|
|
this.copy = this.hero ? this.hero.querySelector('.hero-copy') : null;
|
|
|
|
|
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;
|
|
|
|
|
// The canvas covers the whole sticky header, under everything
|
|
|
|
|
// in it; the name sits in the piece, the space above the copy.
|
|
|
|
|
(this.hero || container).prepend(this.canvas);
|
|
|
|
|
container.append(this.name);
|
|
|
|
|
|
|
|
|
|
this.width = 0;
|
|
|
|
|
this.height = 1;
|
|
|
|
|
this.boxHeight = 1;
|
|
|
|
|
this.progress = 0;
|
|
|
|
|
this.t0 = performance.now();
|
|
|
|
|
this.last = this.t0;
|
|
|
|
|
this.raf = 0;
|
|
|
|
|
|
|
|
|
|
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.
|
|
|
|
|
if (this.copy) {
|
|
|
|
|
const visible = 1 - clamp01(this.progress * 2.5);
|
|
|
|
|
this.copy.style.opacity = visible.toFixed(3);
|
|
|
|
|
this.copy.style.visibility = visible <= 0.01 ? 'hidden' : '';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
if (this.hero) this.hero.style.minHeight = this.height + 'px';
|
|
|
|
|
this.fit();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The canvas covers the header's real box, which is the screen
|
2026-10-21 14:58:19 +02:00
|
|
|
// unless the copy needs more; measured, since the copy's height
|
|
|
|
|
// is the fonts'.
|
2026-10-21 14:52:54 +02:00
|
|
|
fit() {
|
|
|
|
|
this.boxHeight = Math.max(1, this.hero ? this.hero.offsetHeight : this.height);
|
|
|
|
|
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);
|
|
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
if (this.hero) this.hero.style.minHeight = '';
|
|
|
|
|
if (this.copy) {
|
|
|
|
|
this.copy.style.opacity = '';
|
|
|
|
|
this.copy.style.visibility = '';
|
|
|
|
|
}
|
|
|
|
|
this.canvas.remove();
|
|
|
|
|
this.name.remove();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function mount(container) {
|
|
|
|
|
return new WestraHero(container);
|
|
|
|
|
}
|