Make the chrome title and logo readable

Letter faces reflected a studio that was black overhead and dark below the
horizon, so half of every letter sank into the dark page. The sky now runs from
mid grey to white, the horizon is a thin dark band, the floor brightens quickly,
and each line's face spans only the bright middle of the studio.

Small text clogged up under its side wall: the wall is half as deep and gone
below 20 px, and the shadow is set on its own, so small text and the logo keep
it as a dark outline. Text gets a third of the colour split at its edges; the
waveform keeps all of it. The title is a weight lighter and spaced a little
wider, and the logo spaced wider too.
This commit is contained in:
nvrl committed 2026-11-01 22:10:04 +01:00
1 parent 2586beece3
commit 3de51b2517
4 files changed
+40 -29

No files matched your search

+11 -7
View File
@@ -15,7 +15,10 @@ export interface BakeJob {
w: number;
h: number;
bevel: number;
/// How far the side wall reaches down; 0 for none.
depth: number;
/// How far the shadow falls and spreads; 0 for none.
shadow: number;
lines: Line[];
}
@@ -23,7 +26,7 @@ const INF = 1e20;
/// The mask's alpha to the texture the shader lights: RG the surface normal, B
/// the coverage of the metal (face and side wall), A the shadow under it.
export function bake({ alpha, w, h, bevel, depth, lines }: BakeJob): Uint8Array<ArrayBuffer> {
export function bake({ alpha, w, h, bevel, depth, shadow: fall, lines }: BakeJob): Uint8Array<ArrayBuffer> {
const n = w * h;
const cover = new Float32Array(n);
// Squared distance from inside the shape to its edge, with sub-pixel edges.
@@ -41,7 +44,8 @@ export function bake({ alpha, w, h, bevel, depth, lines }: BakeJob): Uint8Array<
height[i] = 0.6 * t + 0.4 * (1 - (1 - t) * (1 - t));
}
// How far down its line each pixel is; each line is a plate curved top to
// bottom, so its face runs from the sky through the horizon to the floor.
// bottom, so its face runs from the bright sky through the horizon to the upper
// floor, never out to the dark ends of the studio.
const run = new Float32Array(n).fill(0.5);
for (const l of lines) {
const x0 = Math.max(0, Math.floor(l.left)), x1 = Math.min(w, Math.ceil(l.left + l.width));
@@ -53,14 +57,14 @@ export function bake({ alpha, w, h, bevel, depth, lines }: BakeJob): Uint8Array<
}
// Slope per px times the bevel width: a bevel at about 55 degrees.
const k = 0.5 * bevel * 1.4;
const shadow = depth ? shadowOf(cover, w, h, depth) : null;
const shadow = fall ? shadowOf(cover, w, h, depth, fall) : null;
const out = new Uint8Array(n * 4);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const i = y * w + x;
const hx = (x + 1 < w ? height[i + 1] : 0) - (x > 0 ? height[i - 1] : 0);
const hy = (y + 1 < h ? height[i + w] : 0) - (y > 0 ? height[i - w] : 0);
let nx = -hx * k, ny = hy * k + 0.38 - run[i] * 0.8, nz = 1;
let nx = -hx * k, ny = hy * k + 0.25 - run[i] * 0.5, nz = 1;
// The side wall: the nearest face above, up to `depth` px; the further
// down from it, the more it faces the floor.
let m = cover[i];
@@ -89,9 +93,9 @@ export function bake({ alpha, w, h, bevel, depth, lines }: BakeJob): Uint8Array<
}
/// The coverage moved down past the side wall and blurred: the shadow it casts.
function shadowOf(cover: Float32Array, w: number, h: number, depth: number): Float32Array {
const drop = depth + Math.max(1, Math.round(depth * 0.5));
const r = Math.max(1, Math.round(depth * 0.8));
function shadowOf(cover: Float32Array, w: number, h: number, depth: number, fall: number): Float32Array {
const drop = depth + Math.max(1, Math.round(fall * 0.5));
const r = Math.max(1, Math.round(fall * 0.8));
let a: Float32Array<ArrayBuffer> = new Float32Array(cover.length);
for (let y = drop; y < h; y++) a.set(cover.subarray((y - drop) * w, (y - drop + 1) * w), y * w);
// Two box blurs each way are close enough to a gaussian.
+19 -15
View File
@@ -115,7 +115,8 @@ void main() {
vec3 n = vec3(s.rg * 2.0 - 1.0, 0.0);
n.z = sqrt(max(0.0, 1.0 - dot(n.xy, n.xy)));
// Split the colours at the steep edges, like a cheap lens; a kick pushes it.
float split = 0.015 + (1.0 - n.z) * (0.05 + 0.3 * uKick);
// Text gets a third of it, so small glyphs keep clean outlines.
float split = (0.015 + (1.0 - n.z) * (0.05 + 0.3 * uKick)) * (wave ? 1.0 : 0.35);
vec3 c;
c.r = env(normalize(vec3(n.xy * (1.0 + split), n.z))).r;
c.g = env(n).g;
@@ -231,12 +232,13 @@ export class Chrome {
c.height = ENV_H;
const ctx = c.getContext("2d")!;
const half = ENV_H / 2;
// The sky: black overhead, rising to white at the horizon.
// The sky: mid grey overhead, rising to white at the horizon, so no part of a
// face sinks into the dark page.
const sky = ctx.createLinearGradient(0, 0, 0, half);
sky.addColorStop(0, "#000000");
sky.addColorStop(0.3, "#16181d");
sky.addColorStop(0.62, "#626876");
sky.addColorStop(0.9, "#eef1f6");
sky.addColorStop(0, "#5c626e");
sky.addColorStop(0.35, "#7c8290");
sky.addColorStop(0.75, "#d6dae2");
sky.addColorStop(0.95, "#ffffff");
sky.addColorStop(1, "#ffffff");
ctx.fillStyle = sky;
ctx.fillRect(0, 0, ENV_W, half);
@@ -245,7 +247,7 @@ export class Chrome {
ctx.fillRect(0, half * 0.38, ENV_W, 3);
ctx.fillStyle = "rgba(255,255,255,0.35)";
ctx.fillRect(ENV_W * 0.74, 0, 2, half * 0.9);
// The floor: black at the horizon, the card's colour and art further down.
// The floor: a thin dark horizon, then the card's colour and art.
ctx.fillStyle = color;
ctx.fillRect(0, half, ENV_W, half);
if (img) {
@@ -258,9 +260,10 @@ export class Chrome {
ctx.globalAlpha = 1;
}
const dark = ctx.createLinearGradient(0, half, 0, ENV_H);
dark.addColorStop(0, "rgba(0,0,0,1)");
dark.addColorStop(0.1, "rgba(0,0,0,0.7)");
dark.addColorStop(1, "rgba(0,0,0,0.1)");
dark.addColorStop(0, "rgba(0,0,0,0.9)");
dark.addColorStop(0.06, "rgba(0,0,0,0.6)");
dark.addColorStop(0.25, "rgba(0,0,0,0.1)");
dark.addColorStop(1, "rgba(0,0,0,0)");
ctx.fillStyle = dark;
ctx.fillRect(0, half, ENV_W, half);
tex ??= gl.createTexture();
@@ -304,10 +307,11 @@ export class Chrome {
/// Give `p` its shape: `draw` paints the mask in CSS px onto a canvas of
/// `w` x `h` CSS px and returns, per painted region, the line it belongs to
/// (for the face's sky-to-floor run). `bevel` and `depth` (of the side wall
/// and the shadow; 0 for none) are in CSS px. Resolves once the shape is on
/// the GPU, or false if it was dropped or asked for again in the meantime.
async shape(p: Plate, w: number, h: number, bevel: number, depth: number, draw: (ctx: CanvasRenderingContext2D) => Line[]): Promise<boolean> {
/// (for the face's sky-to-floor run). `bevel`, `depth` (of the side wall) and
/// `shadow` (how far it falls; 0 for none) are in CSS px. Resolves once the
/// shape is on the GPU, or false if it was dropped or asked for again in the
/// meantime.
async shape(p: Plate, w: number, h: number, bevel: number, depth: number, shadow: number, draw: (ctx: CanvasRenderingContext2D) => Line[]): Promise<boolean> {
const dpr = this.dpr;
const W = Math.max(1, Math.round(w * dpr)), H = Math.max(1, Math.round(h * dpr));
const mask = document.createElement("canvas");
@@ -320,7 +324,7 @@ export class Chrome {
const alpha = ctx.getImageData(0, 0, W, H).data;
const scaled = lines.map((l) => ({ top: l.top * dpr, height: l.height * dpr, left: l.left * dpr, width: l.width * dpr }));
const job = ++p.job;
const pixels = await this.baker.bake({ alpha, w: W, h: H, bevel: bevel * dpr, depth: Math.round(depth * dpr), lines: scaled });
const pixels = await this.baker.bake({ alpha, w: W, h: H, bevel: bevel * dpr, depth: Math.round(depth * dpr), shadow: Math.round(shadow * dpr), lines: scaled });
if (job !== p.job) return false;
p.w = W;
p.h = H;
+3 -3
View File
@@ -75,7 +75,7 @@ button {
}
body.zen #bar { display: none; }
.logo { position: relative; font: 800 0.95rem var(--font-display); display: inline-flex; align-items: center; gap: 0.4em; white-space: nowrap; justify-self: start; }
.logo { position: relative; font: 800 0.95rem var(--font-display); letter-spacing: 0.03em; display: inline-flex; align-items: center; gap: 0.4em; white-space: nowrap; justify-self: start; }
.logo b { position: relative; color: var(--accent); }
.logo svg { width: 1.3em; height: 1.3em; color: var(--accent); flex: none; }
.bar-end { justify-self: end; display: flex; align-items: center; gap: 0.4rem; }
@@ -533,8 +533,8 @@ button.danger:not(.link) { background: var(--accent); border: none; color: #fff;
.title {
position: relative;
z-index: 1; /* over the drop's spill */
font: 800 clamp(1.1rem, 3vw, 1.5rem)/1.2 var(--font-display);
letter-spacing: -0.01em;
font: 700 clamp(1.1rem, 3vw, 1.5rem)/1.2 var(--font-display);
letter-spacing: 0.02em;
margin: 0;
overflow-wrap: anywhere;
text-wrap: balance;
+7 -4
View File
@@ -26,7 +26,9 @@ const CHROME_FPS = 30; // at most, 20 on phones
const TOUCH_CHROME_FPS = 20;
const PAD = 16; // CSS px the chrome reaches past the text, for its side wall and shadow
const BEVEL = 0.05; // of the font size
const DEPTH = 0.09; // of the font size: how far the side wall reaches down
const DEPTH = 0.05; // of the font size: how far the side wall reaches down …
const WALL_MIN = 20; // … on text from this many CSS px; smaller text would clog up
const SHADOW = 0.09; // of the font size: how far the shadow falls
const SWEEP = 0.9; // s for the highlight to sweep across a new title
const AIM = 4; // 1/s: how fast the light follows the pointer
const KICK_STEP = 0.02; // a change in the kick envelope worth a frame
@@ -290,7 +292,8 @@ export class Visualizer {
if (tkey !== d.titleKey) {
d.titleKey = tkey;
const size = parseFloat(getComputedStyle(el).fontSize);
chrome.shape(d.title, el.clientWidth + 2 * PAD, el.clientHeight + 2 * PAD, size * BEVEL, size * DEPTH, (ctx) => textMask(el, PAD, ctx)).then((ok) => {
const wall = size < WALL_MIN ? 0 : size * DEPTH;
chrome.shape(d.title, el.clientWidth + 2 * PAD, el.clientHeight + 2 * PAD, size * BEVEL, wall, size * SHADOW, (ctx) => textMask(el, PAD, ctx)).then((ok) => {
if (!ok) return;
// The sweep runs over the title once it is metal.
if (!el.classList.contains("chromed")) d.sweepFrom = performance.now();
@@ -302,7 +305,7 @@ export class Visualizer {
if (wkey !== d.waveKey) {
d.waveKey = wkey;
const [w, h] = [c.clientWidth, c.clientHeight];
chrome.shape(d.wave, w, h, CHROME_BAR * 0.25, 0, (ctx) => {
chrome.shape(d.wave, w, h, CHROME_BAR * 0.25, 0, 0, (ctx) => {
const { base, tops } = d.card.waveMask(ctx, w, h, CHROME_BAR);
d.waveW = w;
d.bars = tops.length;
@@ -333,7 +336,7 @@ export class Visualizer {
const key = `${el.clientWidth}x${el.clientHeight}`;
if (key === logo.key || !el.clientWidth) return;
logo.key = key;
chrome.shape(logo.plate, el.clientWidth + 2 * LOGO_PAD, el.clientHeight + 2 * LOGO_PAD, 1, 1, (ctx) => textMask(el, LOGO_PAD, ctx)).then((ok) => {
chrome.shape(logo.plate, el.clientWidth + 2 * LOGO_PAD, el.clientHeight + 2 * LOGO_PAD, 1, 0, 1.5, (ctx) => textMask(el, LOGO_PAD, ctx)).then((ok) => {
if (!ok || this.logo !== logo) return;
chrome.still(logo.plate, logo.canvas, { tint: LOGO_TINT, tilt: REST_TILT, kick: 0, sweep: -1 });
el.classList.add("chromed");