diff --git a/css/style.css b/css/style.css index 6a0bf70..14db4f7 100644 --- a/css/style.css +++ b/css/style.css @@ -186,3 +186,86 @@ button { padding-left: 1rem; color: var(--muted); } + +/* ── sky ──────────────────────────────────────────────────────────────── + Generated, not photographed. tools/background.py renders every style into + img/bg/; inc/header.php enumerates that directory and emits one custom + property per style, so --bg-img follows html[data-bg]. Only the matching + rule's url() is fetched. Each is Bayer ordered-dithered to a few grey + levels and upscaled with pixelated rendering so the cells stay crunchy. + Regenerate everything: python tools/background.py all */ +:root { + --bg: #060606; + /* opacity of the text-column scrim; raise for busier background styles */ + --scrim: .82; +} + +body { position: relative; } + +/* background layer */ +body::before { + content: ""; + position: fixed; + inset: 0; + z-index: -2; + background: #060606 var(--bg-img) center center / cover no-repeat; + image-rendering: pixelated; +} + +/* Readability scrim + diagonal hatch. + The first layer is a feathered vertical band, ~880px wide and centred on + the 680px text column, sitting above the artwork (z-index -2) but below + the content. It gives every background style a consistent dark bed to + read against instead of relying on each one being quiet in the middle, + and because the edges fade out there is no visible panel or card. */ +body::after { + content: ""; + position: fixed; + inset: 0; + z-index: -1; + pointer-events: none; + background: + linear-gradient(90deg, + transparent 0%, + rgba(4, 4, 4, var(--scrim)) 14%, + rgba(4, 4, 4, var(--scrim)) 86%, + transparent 100%), + radial-gradient(ellipse 70% 60% at 50% 38%, rgba(4, 4, 4, .32), transparent 82%), + repeating-linear-gradient(-45deg, rgba(240, 240, 240, .045) 0 1px, transparent 1px 8px); + background-size: 880px 100%, auto, auto; + background-position: center top, center, center; + background-repeat: no-repeat, no-repeat, repeat; +} + +/* cards sat on flat #161616 before; let the sky read through them */ +.status-card, +.spotify-card, +.post-body pre { background-color: rgba(6, 6, 6, .66); } +input, textarea { background: rgba(6, 6, 6, .66); } + +/* guestbook sign button */ +.btn-sign { + display: inline-flex; + align-items: center; + gap: .5rem; + background: var(--fg); + color: var(--bg); + border: 1px solid var(--fg); + padding: .5rem 1.4rem; + border-radius: 4px; + font: inherit; + cursor: pointer; + transition: background .12s ease, color .12s ease, transform .08s ease; +} +.btn-sign .arrow { transition: transform .15s ease; } +.btn-sign:hover:not(:disabled) { background: transparent; color: var(--fg); } +.btn-sign:hover:not(:disabled) .arrow { transform: translateX(3px); } +.btn-sign:active:not(:disabled) { transform: translateY(1px); } +.btn-sign:focus-visible { outline: 2px solid var(--muted); outline-offset: 2px; } +.btn-sign:disabled { opacity: .55; cursor: progress; } + +/* htmx swaps the label while the request is in flight */ +.btn-sign .pending { display: none; } +.btn-sign.htmx-request .idle { display: none; } +.btn-sign.htmx-request .pending { display: inline; } +.btn-sign.htmx-request .arrow { display: none; } diff --git a/guestbook.php b/guestbook.php index f8ea795..5cb77c7 100644 --- a/guestbook.php +++ b/guestbook.php @@ -98,12 +98,18 @@ require __DIR__ . '/inc/header.php';
- +
diff --git a/img/bg/forest-21.png b/img/bg/forest-21.png new file mode 100644 index 0000000..7da5c76 Binary files /dev/null and b/img/bg/forest-21.png differ diff --git a/img/bg/forest-4.png b/img/bg/forest-4.png new file mode 100644 index 0000000..cbaca06 Binary files /dev/null and b/img/bg/forest-4.png differ diff --git a/img/bg/forest-57.png b/img/bg/forest-57.png new file mode 100644 index 0000000..a4f080b Binary files /dev/null and b/img/bg/forest-57.png differ diff --git a/img/bg/forest-93.png b/img/bg/forest-93.png new file mode 100644 index 0000000..6928e41 Binary files /dev/null and b/img/bg/forest-93.png differ diff --git a/inc/backgrounds.php b/inc/backgrounds.php new file mode 100644 index 0000000..f467ace --- /dev/null +++ b/inc/backgrounds.php @@ -0,0 +1,31 @@ + pathinfo($f, PATHINFO_FILENAME), $files); + sort($names); + return $names; +} + +function bg_default(): string +{ + $all = bg_styles(); + return in_array('flow', $all, true) ? 'flow' : ($all[0] ?? ''); +} + +/** A different style on every page load. */ +function bg_pick(): string +{ + $all = bg_styles(); + return $all ? $all[random_int(0, count($all) - 1)] : ''; +} diff --git a/inc/footer.php b/inc/footer.php index f820bcb..d6d7f11 100644 --- a/inc/footer.php +++ b/inc/footer.php @@ -10,5 +10,21 @@ $build = preg_match('/[0-9a-f]{7,40}/i', $build, $m) ? $m[0] : ($build ?: 'dev')
© · build: + + 1): ?> + + diff --git a/inc/header.php b/inc/header.php index 28db7db..f5e36a6 100644 --- a/inc/header.php +++ b/inc/header.php @@ -1,12 +1,30 @@ - + - +> <?= htmlspecialchars($page_title ?? SITE_TITLE) ?> + + + diff --git a/tools/__pycache__/background.cpython-313.pyc b/tools/__pycache__/background.cpython-313.pyc new file mode 100644 index 0000000..00fcbe0 Binary files /dev/null and b/tools/__pycache__/background.cpython-313.pyc differ diff --git a/tools/background.py b/tools/background.py new file mode 100644 index 0000000..792eb93 --- /dev/null +++ b/tools/background.py @@ -0,0 +1,299 @@ +#!/usr/bin/env python3 +"""Generate the dithered backgrounds for 0xa0.dev. + + python tools/background.py all # every style -> img/bg/ + python tools/background.py attractor # just one + python tools/background.py flow out.png # explicit destination + python tools/background.py forest img/bg/forest-2.png 21 # pick a seed + +All procedural, no source photos. Each is Bayer ordered-dithered to a few +grey levels so it matches the terminal aesthetic and stays small. Drop a new +PNG into img/bg/ and the site picks it up automatically -- inc/header.php +enumerates that directory, so nothing else needs editing. +""" +import numpy as np +from PIL import Image +import sys, os + +W, H = 1400, 875 +OUT = 'img/bg' + + +# -- helpers ---------------------------------------------------------- +def vnoise(res, seed, w=W, h=H): + r = np.random.default_rng(seed).random((res + 1, res + 1)) + im = Image.fromarray((r * 255).astype(np.uint8), 'L').resize((w, h), Image.BICUBIC) + return np.asarray(im, dtype=np.float64) / 255.0 + + +def fbm(octaves=5, base=3, seed=1, w=W, h=H): + o = np.zeros((h, w)); a = 1.0; t = 0.0 + for i in range(octaves): + o += a * vnoise(base * 2 ** i, seed + i * 101, w, h); t += a; a *= 0.5 + return o / t + + +def bayer(n): + m = np.array([[0.0]]); s = 1 + while s < n: + m = np.block([[4 * m, 4 * m + 2], [4 * m + 3, 4 * m + 1]]); s *= 2 + return (m + 0.5) / (s * s) + + +def calm(strength=0.55, width=0.32, shape=None): + """Damp the middle of the frame, where the text column sits.""" + h, w = shape or (H, W) + _, xx = np.mgrid[0:h, 0:w] + return (1 - strength) + strength * np.clip(np.abs(xx / w - 0.5) / width, 0, 1) ** 1.3 + + +def seg(c, x0, y0, x1, y1, val=1.0): + h, w = c.shape + n = int(max(abs(x1 - x0), abs(y1 - y0))) + 1 + xs = np.linspace(x0, x1, n).astype(int); ys = np.linspace(y0, y1, n).astype(int) + ok = (xs >= 0) & (xs < w) & (ys >= 0) & (ys < h) + np.add.at(c, (ys[ok], xs[ok]), val) + + +def dither(v, levels=4, mn=4, lo=6, hi=196, mode='bayer'): + """Quantise to `levels` greys. + + mode 'bayer': ordered dither against an n x n Bayer matrix. The matrix + size caps how many densities are distinguishable -- 4x4 gives 17 steps, + 8x8 gives 65 -- so bigger matrices band far less on smooth gradients at + the cost of a finer, less chunky texture. + + mode 'fs': Floyd-Steinberg error diffusion. No repeating pattern at all, + so gradients are smooth and organic, but it loses the woven/print look. + """ + v = np.clip(v, 0, 1) + h, w = v.shape + if mode == 'fs': + a = v.copy() * (levels - 1) + for yy in range(h): + for xx in range(w): + old = a[yy, xx] + new = round(min(max(old, 0), levels - 1)) + err = old - new + a[yy, xx] = new + if xx + 1 < w: a[yy, xx + 1] += err * 7 / 16 + if yy + 1 < h: + if xx: a[yy + 1, xx - 1] += err * 3 / 16 + a[yy + 1, xx] += err * 5 / 16 + if xx + 1 < w: a[yy + 1, xx + 1] += err * 1 / 16 + out = np.clip(a, 0, levels - 1) / (levels - 1) + else: + t = bayer(mn); th = np.tile(t, (h // mn + 1, w // mn + 1))[:h, :w] + s = v * (levels - 1); low = np.floor(s) + out = np.clip(low + (s - low > th), 0, levels - 1) / (levels - 1) + g = (lo + out * (hi - lo)).astype(np.uint8) + return Image.fromarray(g, 'L').convert('P', palette=Image.ADAPTIVE, colors=levels) + + +# -- styles ----------------------------------------------------------- +def flow(seed=29): + """Particles advected through a noise vector field; the bright filaments + are where the field converges and thousands of paths pile up.""" + rng = np.random.default_rng(seed) + ang = fbm(4, 3, seed) * np.pi * 3.0 + c = np.zeros((H, W)); n = 22000 + px = rng.random(n) * W; py = rng.random(n) * H + for _ in range(240): + a = ang[np.clip(py.astype(int), 0, H - 1), np.clip(px.astype(int), 0, W - 1)] + px += np.cos(a) * 1.3; py += np.sin(a) * 1.3 + ok = (px >= 0) & (px < W) & (py >= 0) & (py < H) + np.add.at(c, (py[ok].astype(int), px[ok].astype(int)), 1.0) + return np.clip(c / np.percentile(c, 99.5), 0, 1) ** 0.9 * calm() * 0.92, {} + + +def attractor(a=-1.7, b=1.8, cc=-1.9, d=-0.4, seed=3): + """Clifford attractor: x' = sin(a*y) + c*cos(a*x). The bright edges are + density caustics where orbits crowd together.""" + rng = np.random.default_rng(seed) + n = 260000; x = rng.uniform(-2, 2, n); y = rng.uniform(-2, 2, n) + c = np.zeros((H, W)) + for i in range(70): + x, y = np.sin(a * y) + cc * np.cos(a * x), np.sin(b * x) + d * np.cos(b * y) + if i > 8: + px = ((x + 2.6) / 5.2 * W).astype(int) + py = ((y + 2.6) / 5.2 * H).astype(int) + ok = (px >= 0) & (px < W) & (py >= 0) & (py < H) + np.add.at(c, (py[ok], px[ok]), 1.0) + return np.clip(c / np.percentile(c, 99.6), 0, 1) ** 0.75 * 0.95, {} + + +def truchet(cell=46, seed=5): + """Quarter-arc maze tiles. Regular and quiet; genuinely tileable.""" + c = np.zeros((H, W)); rng = np.random.default_rng(seed) + yy, xx = np.mgrid[0:H, 0:W] + for gy in range(0, H, cell): + for gx in range(0, W, cell): + sl = (slice(gy, min(gy + cell, H)), slice(gx, min(gx + cell, W))) + ly = yy[sl] - gy; lx = xx[sl] - gx + if rng.random() < 0.5: + d1 = np.hypot(lx, ly); d2 = np.hypot(lx - cell, ly - cell) + else: + d1 = np.hypot(lx - cell, ly); d2 = np.hypot(lx, ly - cell) + r = cell / 2.0 + c[sl] = np.maximum(np.clip(1 - np.abs(d1 - r) / 2.0, 0, 1), + np.clip(1 - np.abs(d2 - r) / 2.0, 0, 1)) + return c * 0.78, {'levels': 3} + + +def circuit(seed=13, g=16, traces=150): + """PCB-style routed traces at 45/90 degrees, with vias.""" + c = np.zeros((H, W)); rng = np.random.default_rng(seed) + dirs = [(g, 0), (-g, 0), (0, g), (0, -g), (g, g), (-g, g), (g, -g), (-g, -g)] + yy, xx = np.mgrid[0:H, 0:W] + for _ in range(traces): + x, y = int(rng.integers(0, W // g)) * g, int(rng.integers(0, H // g)) * g + dx, dy = dirs[int(rng.integers(len(dirs)))] + for _ in range(int(rng.integers(8, 30))): + nx, ny = x + dx, y + dy + if not (0 <= nx < W and 0 <= ny < H): + break + seg(c, x, y, nx, ny, 1.0); x, y = nx, ny + if rng.random() < 0.28: + dx, dy = dirs[int(rng.integers(len(dirs)))] + if rng.random() < 0.45: + c += np.clip(1 - np.abs(np.hypot(xx - x, yy - y) - 3.5) / 1.6, 0, 1) * 0.9 + return np.clip(c, 0, 1) * calm(0.5) * 0.72, {'levels': 3} + + +def reaction(seed=9, steps=3800): + """Gray-Scott reaction-diffusion. Striking but dense, so it is knocked + well back in tone to stay readable behind text.""" + gw, gh = 450, 282 + U = np.ones((gh, gw)); V = np.zeros((gh, gw)) + rng = np.random.default_rng(seed) + for _ in range(26): + sx, sy = int(rng.integers(12, gw - 12)), int(rng.integers(12, gh - 12)) + U[sy - 5:sy + 5, sx - 5:sx + 5] = 0.5 + V[sy - 5:sy + 5, sx - 5:sx + 5] = 0.25 + Du, Dv, F, k = 0.16, 0.08, 0.0545, 0.062 + + def lap(Z): + return (np.roll(Z, 1, 0) + np.roll(Z, -1, 0) + + np.roll(Z, 1, 1) + np.roll(Z, -1, 1) - 4 * Z) + + for _ in range(steps): + uvv = U * V * V + U += Du * lap(U) - uvv + F * (1 - U) + V += Dv * lap(V) + uvv - (F + k) * V + v = np.clip(V / max(V.max(), 1e-6), 0, 1) + v = np.asarray(Image.fromarray((v * 255).astype(np.uint8), 'L') + .resize((W, H), Image.BICUBIC)) / 255.0 + return v ** 1.1 * calm(0.45) * 0.42, {} + + +def ridge(seed=900, lines=40): + """Stacked ridgelines, Joy Division style.""" + c = np.zeros((H, W)) + for i in range(lines): + base = H * 0.10 + i * (H * 0.84 / lines) + prof = fbm(5, 4, seed + i * 7, w=W, h=8)[0] + amp = (H * 0.84 / lines) * 11.0 * (0.25 + 0.75 * np.exp(-((i / lines - 0.5) ** 2) / 0.055)) + ys = base - (prof - prof.mean()) * amp + for x in range(W - 1): + seg(c, x, ys[x], x + 1, ys[x + 1], 1.0) + return np.clip(c, 0, 1) * 0.85, {'levels': 3} + + +def topo(seed=11, bands=16): + """Iso-contour map of a fractal-noise height field.""" + h = fbm(6, 3, seed) + d = np.abs((h * bands) % 1.0 - 0.5) + return (np.clip(1 - d * 7.0, 0, 1) ** 2.2 * 0.7 + h * 0.12) * calm(0.4), {} + + +def _pine(c, cx, base, h, w, val): + """One tiered conifer, drawn as rows of spans so the edges stay blocky.""" + fh, fw = c.shape + top = int(base - h) + rows = np.arange(max(top, 0), min(int(base), fh)) + if rows.size == 0: + return + t = (rows - top) / max(h, 1.0) + tier = (t * 3.0) % 1.0 # three stacked skirts + hw = w * (0.10 + 0.90 * t) * (0.55 + 0.45 * tier) + for r, half in zip(rows, hw): + x0 = max(int(cx - half), 0); x1 = min(int(cx + half) + 1, fw) + if x1 > x0: + c[r, x0:x1] = val + + +def forest(seed=4, layers=4): + """1-bit pixel-art conifer forest. + + Composition follows the knitwear reference: dark starry sky across the + top (where the text column sits), a dithered mist band at the horizon, + then receding layers of pines. Trees are drawn bright against the dark + sky -- each gets a dark outline pass first, so overlapping trees stay + separate instead of merging into one white mass. Rendered small and + upscaled by CSS with image-rendering: pixelated, so it reads as sprite + art rather than a photo. + """ + fw, fh = 760, 475 + rng = np.random.default_rng(seed) + v = np.zeros((fh, fw)) + y = np.arange(fh)[:, None] / fh + ones = np.ones((1, fw)) + horizon = 0.66 + + # stars, denser toward the top of the sky + n = 440 + sx = rng.integers(0, fw, n) + sy = (rng.random(n) ** 2.3 * fh * horizon).astype(int) + v[sy, sx] = 1.0 + + # mist: a narrow glow hugging the horizon, not a wash over the whole + # middle. The ordered dither turns this band into the scattered + # transition that the knitwear reference has. + v = np.maximum(v, np.exp(-((y - horizon) ** 2) / 0.0040) * 0.26 * ones) + + # receding tree layers, far to near. Each tree gets a slightly larger + # dark pass first so overlapping trees stay separate; the outline hugs + # the shape rather than overshooting it. + for d in range(layers): + f = d / max(layers - 1, 1) + base = fh * (horizon + 0.02 + 0.34 * f ** 1.2) + count = int(120 * (1 - 0.80 * f)) + 8 + val = 0.38 + 0.36 * f + for _ in range(count): + cx = rng.random() * (fw + 140) - 70 + hgt = (20 + 74 * f ** 1.3) * (0.72 + 0.56 * rng.random()) + wid = hgt * (0.27 + 0.11 * rng.random()) + jitter = (rng.random() - 0.5) * 12 * (0.3 + f) + _pine(v, cx, base + jitter, hgt * 1.04 + 2, wid * 1.16 + 1.2, 0.0) + _pine(v, cx, base + jitter, hgt, wid, val) + + # a little ground haze at the very bottom for the nearest trees to sit on + v = np.maximum(v, np.clip((y - 0.96) / 0.04, 0, 1) * 0.30 * ones) + return v, {'levels': 2, 'mn': 8, 'hi': 205} + + +STYLES = {'flow': flow, 'attractor': attractor, 'truchet': truchet, + 'circuit': circuit, 'reaction': reaction, 'ridge': ridge, + 'topo': topo, 'forest': forest} + + +def render(name, dest=None, seed=None): + v, opts = STYLES[name](seed=seed) if seed is not None else STYLES[name]() + img = dither(v, **{**{'levels': 4, 'mn': 4}, **opts}) + dest = dest or os.path.join(OUT, name + '.png') + os.makedirs(os.path.dirname(dest) or '.', exist_ok=True) + img.save(dest, optimize=True) + a = np.asarray(img.convert('L')) + print(" %-10s -> %-22s %3dKB mean=%.0f" + % (name, dest, os.path.getsize(dest) // 1024, a.mean())) + + +if __name__ == '__main__': + what = sys.argv[1] if len(sys.argv) > 1 else 'all' + if what == 'all': + for n in STYLES: + render(n) + else: + dest = sys.argv[2] if len(sys.argv) > 2 else None + seed = int(sys.argv[3]) if len(sys.argv) > 3 else None + render(what, dest, seed)