Compare commits

..
13 Commits
Author SHA1 Message Date
0xa0 618d8254f1 koi: swap the sakura panels for a shoal of letter-drawn koi
Deploy / deploy (push) Successful in 3s
Five varieties (kohaku, ogon, sanke, showa, tancho) swim in the side
margins, drawn the way the reference does it: every cell is a filled tile
in the koi's colour with a letter punched into it in near-black, picked
from a thin-to-thick ramp by how dense that part of the body is. The tile
carries the colour, the letter carries the texture.

The grid is square and the geometry is in pixels, snapping to cells only
when one is written. Laying the body out in grid cells instead makes its
proportions depend on heading -- thickness is perpendicular to travel, so
on the natural text grid a fish swimming sideways came out short and fat
while one swimming up came out slim.

The body is a follow-the-leader chain with a bend limit per joint. A spine
projected from the head rotates rigidly the moment the fish turns, and a
raw position trail coils into a ball; the chain lags and the bend cap
keeps it from curling. Swimming comes from the head yawing with the tail
beat, which the chain propagates as a travelling wave.

Tails dissolve into loose tiles toward the tip, and the panels carry a 1em
cell grid so the motion reads against something.

Drops the sakura wiring: no art.php parse, no sideart.php stylesheet, no
<pre> layers, and the inlined sideart @font-face goes with them (-6.9 KB
of base64 from style.css). art.php, sideart.php, sakura.ans and the woff2
are left on disk, now unreferenced.
2026-10-24 19:32:58 +02:00
0xA0 ec2ad2c618 ship the side art as a cached stylesheet so cached loads paint in one frame
Deploy / deploy (push) Successful in 3s
2026-10-13 15:05:21 +02:00
0xA0 ecb899a866 parse art once per request; inline the art font and set a dark color-scheme to stop the load flicker
Deploy / deploy (push) Successful in 3s
2026-10-13 14:31:03 +02:00
0xA0 aecd362e68 art.php: bitwise byte-string fast path and memoized color codes (105ms -> 21ms uncached)
Deploy / deploy (push) Successful in 37s
2026-10-13 14:25:19 +02:00
0xa0 598246411a removed twitter (idk why)
Deploy / deploy (push) Successful in 3s
2026-10-07 02:58:07 +02:00
0xA0 bb1ee4e3dd render side art as stacked per-color layers (fixes torn right panel, much lighter layout)
Deploy / deploy (push) Successful in 3s
2026-10-03 16:41:17 +02:00
0xA0 f33c753b23 add a 404 page
Deploy / deploy (push) Successful in 1m0s
2026-10-03 15:37:03 +02:00
0xA0 e37ba4727e build link opens the source tree at that commit
Deploy / deploy (push) Successful in 3s
2026-10-03 15:31:58 +02:00
0xA0 acf1f2c601 link the footer build sha to its commit
Deploy / deploy (push) Successful in 3s
2026-10-03 15:29:37 +02:00
0xA0 2201006b24 serve 88x31 buttons from cache URLs instead of base64, preload the art font
Deploy / deploy (push) Successful in 3s
2026-10-03 15:27:54 +02:00
0xA0 2e80e535b3 store side art as one ANSI file, render it with PHP (cached)
Deploy / deploy (push) Successful in 3s
2026-10-03 15:22:16 +02:00
0xA0 8e3b3be401 cache-bust style.css with its mtime
Deploy / deploy (push) Successful in 3s
2026-10-03 15:12:40 +02:00
0xA0 fcb1cdcacb replace background with ascii sakura side art
Deploy / deploy (push) Successful in 3s
2026-10-03 15:08:34 +02:00
20 changed files with 836 additions and 427 deletions
+12
View File
@@ -0,0 +1,12 @@
<?php
// Caddy's handle_errors 404 rewrites here, so set the status ourselves.
require_once __DIR__ . '/config.php';
http_response_code(404);
$page_title = '404 · ' . SITE_TITLE;
require __DIR__ . '/inc/header.php';
?>
<p class="back-link"><a href="/">&larr; go home</a></p>
<h1>404</h1>
<p>nothing here.</p>
<?php require __DIR__ . '/inc/footer.php'; ?>
+22
View File
@@ -0,0 +1,22 @@
<?php
// Serves an 88x31 button from the local cache (see inc/buttons.php).
require_once __DIR__ . '/config.php';
$h = $_GET['h'] ?? '';
$file = DATA_DIR . '/buttons/' . $h;
if (!preg_match('/^[0-9a-f]{32}$/', $h) || !is_file($file)) {
http_response_code(404);
exit;
}
$raw = (string) file_get_contents($file);
$nl = strpos($raw, "\n");
$type = $nl !== false ? substr($raw, 0, $nl) : '';
if (!preg_match('#^image/[\w.+-]+$#', $type)) {
http_response_code(404);
exit;
}
header('Content-Type: ' . $type);
header('Cache-Control: public, max-age=31536000, immutable'); // URL carries ?v=<mtime>
echo substr($raw, $nl + 1);
+1 -1
View File
@@ -1,6 +1,6 @@
<?php
const DISCORD_ID = '298221448642953217';
const SITE_TITLE = '0xA0.dev';
const REPO_URL = 'https://git.0xa0.dev';
const REPO_URL = 'https://git.0xa0.dev/0xA0/0xa0.dev';
define('DATA_DIR', getenv('DATA_DIR') ?: '/var/www/data');
BIN
View File
Binary file not shown.
+42 -44
View File
@@ -187,61 +187,59 @@ button {
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: "";
/* The koi own the margins now. Cell size comes straight from this font-size,
and the body is 16 cells long, so this is the dial for how big they are.
The column shadow (body::before, z-index -1) fades them toward the text. */
.koi {
position: fixed;
inset: 0;
top: 0;
z-index: -2;
background: #060606 var(--bg-img) center center / cover no-repeat;
image-rendering: pixelated;
width: calc(50% - 360px);
height: 100vh;
pointer-events: none;
user-select: none;
/* font-size IS the square cell size in px -- the one size dial */
font: clamp(7px, 0.95vh, 10px) / 1 ui-monospace, "JetBrains Mono", Menlo, monospace;
/* the pond's cell grid. 1em == one cell, so it stays locked to the tiles
the canvas draws however the font-size scales. */
background-image:
linear-gradient(to right, rgba(228, 230, 238, .05) 1px, transparent 1px),
linear-gradient(to bottom, rgba(228, 230, 238, .05) 1px, transparent 1px);
background-size: 1em 1em;
}
.koi-left { left: 0; }
.koi-right { right: 0; }
/* 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 {
/* the column's shadow: the art fades out under it instead of stopping dead
beside the text. Eased falloff, as wide as about a third of the margin. */
body::before {
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;
--l: calc(50% - 340px);
--r: calc(50% + 340px);
--fade: clamp(60px, (100vw - 680px) * .16, 180px);
background: linear-gradient(90deg,
transparent calc(var(--l) - var(--fade)),
color-mix(in srgb, var(--bg) 22%, transparent) calc(var(--l) - var(--fade) * .75),
color-mix(in srgb, var(--bg) 55%, transparent) calc(var(--l) - var(--fade) * .5),
color-mix(in srgb, var(--bg) 85%, transparent) calc(var(--l) - var(--fade) * .25),
var(--bg) var(--l) var(--r),
color-mix(in srgb, var(--bg) 85%, transparent) calc(var(--r) + var(--fade) * .25),
color-mix(in srgb, var(--bg) 55%, transparent) calc(var(--r) + var(--fade) * .5),
color-mix(in srgb, var(--bg) 22%, transparent) calc(var(--r) + var(--fade) * .75),
transparent calc(var(--r) + var(--fade)));
}
/* 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); }
@media (max-width: 1280px) {
.koi, body::before { display: none; }
}
@media print, (forced-colors: active) {
.koi, body::before { display: none; }
}
/* guestbook sign button */
.btn-sign {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

+231
View File
@@ -0,0 +1,231 @@
<?php
/**
* ANSI art (a .ans file you can `cat` in a terminal) for the side panels.
* Understands foreground colors: truecolor (38;2;r;g;b), 256-color (38;5;n),
* the 16 basic ones, and resets; everything else is ignored. The right panel
* is the left mirrored, swapping /\ () <> [] {} so shapes still read.
*
* The art ships as a stylesheet (sideart.php), not HTML: one full-size text
* layer per color, each the `content` of a ::before on an empty <b>, stacked
* by style.css. It's render-blocking and cached, so the first paint already
* has it; inline, it made the page too big for one network round trip and
* Firefox painted the page half-arrived (a dark empty flash) on cached loads.
*/
function ansi_color(array $p, ?string $cur): ?string
{
static $basic = ['000000', 'aa0000', '00aa00', 'aa5500', '0000aa', 'aa00aa', '00aaaa', 'aaaaaa',
'555555', 'ff5555', '55ff55', 'ffff55', '5555ff', 'ff55ff', '55ffff', 'ffffff'];
for ($i = 0; $i < count($p); $i++) {
$n = (int) $p[$i];
if ($n === 0 || $n === 39) {
$cur = null;
} elseif ($n >= 30 && $n <= 37) {
$cur = $basic[$n - 30];
} elseif ($n >= 90 && $n <= 97) {
$cur = $basic[$n - 82];
} elseif ($n === 38 && ($p[$i + 1] ?? '') === '2') {
$cur = sprintf('%02x%02x%02x', $p[$i + 2] ?? 0, $p[$i + 3] ?? 0, $p[$i + 4] ?? 0);
$i += 4;
} elseif ($n === 38 && ($p[$i + 1] ?? '') === '5') {
$c = (int) ($p[$i + 2] ?? 0);
$i += 2;
if ($c < 16) {
$cur = $basic[$c];
} elseif ($c < 232) {
$c -= 16;
$lvl = static fn(int $v): int => $v ? 55 + 40 * $v : 0;
$cur = sprintf('%02x%02x%02x', $lvl(intdiv($c, 36)), $lvl(intdiv($c, 6) % 6), $lvl($c % 6));
} else {
$cur = str_repeat(sprintf('%02x', 8 + 10 * ($c - 232)), 3);
}
}
}
return $cur;
}
/**
* The stylesheet for both panels, cached on disk until the .ans file (or this
* file) changes. Returns [path to the css, layers on the left, on the right].
*/
function ansi_css_cached(string $file): array
{
$dir = (defined('DATA_DIR') ? DATA_DIR : sys_get_temp_dir()) . '/art-cache';
$base = $dir . '/' . md5($file);
$key = $base . '-' . filemtime($file) . '-' . filemtime(__FILE__);
if (!is_file("$key.css") || !is_file("$key.json")) {
@mkdir($dir, 0755, true);
array_map('unlink', glob($base . '-*') ?: []); // every deploy bumps the mtimes
$css = '';
$counts = [];
foreach (['left' => false, 'right' => true] as $side => $mirror) {
$layers = ansi_layers($file, $mirror);
$counts[$side] = count($layers);
foreach ($layers as $i => [$color, $text]) {
$css .= ".side-$side > b:nth-child(" . ($i + 1) . ')::before{content:' . ansi_css_string($text)
. ($color !== '' ? ";color:#$color" : '') . "}\n";
}
}
foreach (["$key.css" => $css, "$key.json" => json_encode($counts)] as $path => $data) {
$tmp = $path . '.' . getmypid();
if (@file_put_contents($tmp, $data) !== false) {
rename($tmp, $path);
}
}
}
return ["$key.css", ...array_values((array) json_decode((string) @file_get_contents("$key.json"), true))];
}
/** A CSS string literal; newlines become \A (the space after it ends the escape). */
function ansi_css_string(string $text): string
{
return '"' . str_replace(['\\', '"', "\n"], ['\\\\', '\\"', '\\A '], $text) . '"';
}
/** The art as [[hex color or '', text], ...]: one full-size layer per color. */
function ansi_layers(string $file, bool $mirror = false): array
{
$raw = (string) file_get_contents($file);
if (!preg_match('/[\x80-\xff]/', $raw)) {
$layers = ansi_layers_bytes($raw, $mirror);
if ($layers !== null) {
return $layers;
}
}
return ansi_layers_cells($raw, $mirror);
}
/**
* Plain-ASCII art as [lines of chars, lines of color-index bytes, color => index],
* or null with too many colors for a byte. One regex pass over the whole file;
* kept per request so the mirrored side doesn't parse it a second time.
*/
function ansi_parse_bytes(string $raw): ?array
{
static $memo = [], $done = [];
$key = md5($raw);
if (array_key_exists($key, $done)) {
return $done[$key];
}
$index = ['' => 0]; // color hex => byte (0 = uncolored)
$chars = [];
$masks = [];
$c = $m = '';
$cur = null;
foreach (preg_split('/(\e\[[0-9;]*m|\n)/', rtrim($raw, "\n"), -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY) as $part) {
if ($part === "\n") { // colors don't carry across lines
$chars[] = $c;
$masks[] = $m;
$c = $m = '';
$cur = null;
} elseif ($part[0] === "\e") {
// a handful of distinct codes repeated thousands of times: decode each once
$cur = $memo[$part . '|' . $cur] ??= ansi_color(explode(';', substr($part, 2, -1)), $cur);
} else {
$k = $index[$cur ?? ''] ??= count($index);
$c .= $part;
$m .= str_repeat(chr($k), strlen($part));
}
}
$chars[] = $c;
$masks[] = $m;
return $done[$key] = count($index) > 250 ? null : [$chars, $masks, $index];
}
/**
* Fast path for plain-ASCII art (one byte per cell): each line becomes a
* string of characters plus a parallel string of color indices, and every
* color layer is cut out of the whole art with bitwise string ops, which run
* in C. Returns null if there are too many colors to index in a byte.
*/
function ansi_layers_bytes(string $raw, bool $mirror): ?array
{
static $from = null, $to = null; // byte 0 -> 0xff, anything else -> 0
$from ??= implode(array_map('chr', range(0, 255)));
$to ??= "\xff" . str_repeat("\0", 255);
$parsed = ansi_parse_bytes($raw);
if ($parsed === null) {
return null;
}
[$chars, $masks, $index] = $parsed;
$width = max(array_map('strlen', $chars) ?: [0]);
foreach ($chars as $i => $c) {
$chars[$i] = str_pad($c, $width);
$masks[$i] = str_pad($masks[$i], $width, "\0");
if ($mirror) {
$chars[$i] = strtr(strrev($chars[$i]), '/\\()<>[]{}', '\\/)(><][}{');
$masks[$i] = strrev($masks[$i]);
}
}
// the whole art as one string; 0xfe in the mask marks the line breaks
$all = implode("\n", $chars);
$mask = implode("\xfe", $masks);
$blank = implode("\n", array_fill(0, count($chars), str_repeat(' ', $width)));
$n = strlen($all);
$layers = [];
foreach ($index as $color => $k) {
$sel = strtr($mask ^ str_repeat(chr($k), $n), $from, $to); // 0xff where this color, else 0
$layer = ($all & $sel) | ($blank & ~$sel);
if (trim($layer) !== '') { // skip colors that only touch blanks
$layers[] = [(string) $color, $layer];
}
}
return $layers;
}
/** General path: a [char, color] cell at a time, so multibyte characters work. */
function ansi_layers_cells(string $raw, bool $mirror): array
{
static $flip = ['/' => '\\', '\\' => '/', '(' => ')', ')' => '(', '<' => '>', '>' => '<',
'[' => ']', ']' => '[', '{' => '}', '}' => '{'];
$lines = [];
foreach (explode("\n", rtrim($raw, "\n")) as $line) {
// split into [char, color] cells
$cells = [];
$cur = null;
foreach (preg_split('/(\e\[[0-9;]*m)/', $line, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY) as $part) {
if ($part[0] === "\e") {
$cur = ansi_color(explode(';', substr($part, 2, -1)), $cur);
continue;
}
foreach (mb_str_split($part) as $ch) {
$cells[] = [$ch, $cur];
}
}
$lines[] = $cells;
}
$width = max(array_map('count', $lines) ?: [0]);
foreach ($lines as &$cells) {
$cells = array_pad($cells, $width, [' ', null]);
if ($mirror) {
$cells = array_map(static fn($c) => [$flip[$c[0]] ?? $c[0], $c[1]], array_reverse($cells));
}
}
unset($cells);
$colors = [];
foreach ($lines as $cells) {
foreach ($cells as [$ch, $color]) {
if ($ch !== ' ') {
$colors[$color ?? ''] = true;
}
}
}
$layers = [];
foreach (array_keys($colors) as $color) {
$rows = [];
foreach ($lines as $cells) {
$row = '';
foreach ($cells as [$ch, $c]) {
$row .= ($ch !== ' ' && ($c ?? '') === $color) ? $ch : ' ';
}
$rows[] = $row;
}
$layers[] = [(string) $color, implode("\n", $rows)];
}
return $layers;
}
+88
View File
@@ -0,0 +1,88 @@
-+.:===+=: : ,#@@@@@@@@@%@@@@@@@@@@@@@@@@@@%@@@@@@@@@@@@@@@@@@@@@@@@@@%@@@%@@@@@@@@%%o
,+-===-, .**. .X@@@@@@@@o*o*@@@@@@%@@@@@@#@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
=**=. , o@@@@@@@%###@@@@%#%@@@%xx=x@@@@@@X@#@X#@@@@@@@@@@@@@@@@@@@@%%%@@@@@@@@@@
,,:+*+, -%: -%#@@@@@@@@@@@@@@%X+=@x@@-#+o%@@@X-: .-. ,@%#%*@@@@%@@@@@@@@@@@@@@@@@@@%#@
==+***=----;;, :#@@@@@@@@@@@@@@@#,:xX*@@;==Xo@%@%= . ,*;x@@@%@%x@@@@@@@@@@@@@x+x=
--=***+++******=-:. ;+xX+@@@@@@@@@@@@%xo@@@@@@@@@%@@@@%x: ;=,*#@@@@xx@%@@%@@#@@@+,
.,,::;;-=++++++=;:xX;,,*x*.:=*o@@@@@@@@@@@#@@@@@@@@@@@@@@@%--. :*+@@@@@@@@@@X@@@@@@X:#-=:
.,;+**==+***++**+++=*Xx@@@@@@@@@@@@@@@@@@@@@@@@@@@X@+::;;;;-o@@@@@@@@@@@@@@@%+: -*
,;===+=: ...:=o#@@@@@@@@@@@@@@@@@@@@@@oX%@xX;,.. =*o@@@@@#o@@@@@@@@@@@,,,
.*, .:+*+*=;. ;=.,*x@x@@@@@@@@@@@@@@@@#Xx. .;=-o*;:;. ;@@@@@X+,:=o@@@@@@@@#,
,. . ;+***+-,:- ,*o+@@@@@@@@@@* :--#o ,;x@@@@@x=%@@@@@@@%%@+--o@@#@o*.
,; *= :++-+**=;.:x*=@@@@@@@@@@#, :; :#@@@@@@@:o@@@@@@@@@@@@##@x*:
:- :- ,++: :=++o=#@@@@@@@@@@%-,.;. ,;;+@@@#%*.@@@@@@@oo+@@@@@@@@@@@@@@@o;
.o*, ,==. ;*%@@@@@@Xx#*:o=*@%@@@@@@@@@@@@:.;:*@@*o+ ;#x@%@@@@@@@+*#-
:++- o@@@@@@@@#@@@%-#%@@@@@@@@@@@#%##@*o#;;: .-,X@@@@@@@@##-
:; ,o++#x, x#@@@@@@@@@@@@#@@@@@@@@@@@@X%%%@@@@#%%. ;@@@@@@@@@##Xo;.
, .+. -- -++, , ,@@@@@@@@@@@@@@@@@@@@@%@#@@#@@@@@@@@%= *@@@@@@@%= . ,
, -x: .++; :@#xX@@%@@@@@@@@@@@@@@@@@@@@%@%@@@@@@@@%%x,,,=@@@@@x=: ;:. .+=x#,
,,. .oX- -*= ;X%@@@@@@@@@@@@@@@@@@@@@@@@@@@@%#+;X=*@X::, -x@@@@@@%* +X. ,*+:
,::;;;:, +-; -*- x+:X@@@@@@@@@@@@@@@@@@@@@@%o@@+*@@Xxo. .o@@@@@@@X- .
. .,:---;+=;.,-=: ,o+@@@@@@@@@@@@@@@@@@@##*.+@@@@@@@%; ,+o@@@@#+; ;#+.
, .+o===;. +@@@@@@@@@@@@@@@x%#x+; +@%@@@@@@@o :X*X*, ;
#%: :=+;. ,#@@@@@@@@@@@@@@+,.::++;:.X#x@@%#X. .
=. :+=: =o+=-@@@@@@@@@o+o=..:-=; :X; .@@@%*
.-=, ;+==. .:%@@@@@@@@@x-;-..-=:+*X@x==x+,=- . %@@@@@%
:=; . ,;==+- ;@@@@@@@@@@@@X@+, .;%@@@@@@@@@@o ,,%@-,.:;;,x@@@#-
.. ,--. ;%+. :=++=++. ,o@@@@@@@@@#@@%#@+#%@@@@@@@@@@@@- .-@@@@@@o=#*#%#:*@@
,@%:=x#;+: : .::::-++#x#o *++%*@@@@@@@@@@@@@@@@@@@@@@@@@@X: .:::::,,-@@@@o-#@@@@@@@@@
#@@@@@@@@%- ,;-;,,;-==o@@@%o+@@@@@@x%@@@@@@@@@@@@@@@@@@@@@;;-ox#*:=-::;-;;--;:=:.=; :;@xxX@XX=
@@@@@@%@#o++x: .,;:,.:;;;=-=*X;%x=@@@@@@@@@@@@@@@@@@@@@@@@@@@%%-,,:*@@@@%@#=-:, :+
@@@@@@@@#%@@@xX*:::,:;;-;;=++==#o. o@@@@@@@@@@@@@@@@@@@@@@X;-:::*X:.=#@@@@%#=, ;:,.xx,;+:,, x
*%@@@%#@@@@@@@x;::;-;::,.;=,o@@@#; ;-=X%@@@@@@@@@@@@@@@@@@X=-:=o%@#%* ;@@=*+,:-;.,,,,,;-@@@%%@@@@%@Xx#
x%@@%@@@@@@@#@o;:,. .=;.:@@@@Xx;-====o#@@@@@@@@@#X@@@@@@@@%o:=x@@@@=;-=+-====--==:::+%@@@@@@@@@@@#%oo
=+XX%@@@@@@@@@o...,;#@==*oXx@X#%;;;,,.,-@@@@@@@@@@@@@@@@@@@@@@x: x@@@@X:,,,,::;-;-==-,,;#@@@@@@@@%ox+,:+
o*=-@@@@@@@@@@@@@@@@@@%@#@@@@@@%- -, x%@@@@@@@@%#X@@@#@@@@@@@@@=;=@@-=. ::. .,=-x@@@@@@@@%@@X-;=X
@@@@@@@@@@@@@@#o%%@@@@@@@@@@@@@@@=-@o *@##@@x%-,@@@@@@@@@@@@@@@@@@@%@@%o: ,;,:,.x@@@@@@@X%@%@@X#%@
@@@@@@@@@@x%@@@@x-@@#@@xX@@@@@@@@@@@%X+x@@@o.;%@#@@@@@@@@@@@@@@@@@@@@@@@@@@X :-.,*%@@@@@@%@@@@@%%@@@
@@@@@@@@X=,=@@@@@@@@@@@:=#X%@@#@@@%@X**x%@##*#@@@@@@X@@@@@@@@@@@@@@@@@@@@@@*:xo=%@@%%@@X@@@@@@@@@@@@@@@@
@@@@@@@@@#@@@@@@@%@@@@+,.,+;+. :=.*%X@X,*@X*@@#@@@@@%@@@@@@@@@@@%#@@@@@@**-;-%@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@Xo#@@@@@=*%@+++: ,*. =%@@%@%#@@@@@@@@@@@@@@@@@@@@@@@@@@%X: :@@@@@@@%@@@@@@@@@@@@@@@@@@@@#%
@@@@@@#o*x@@@@@@@@@@%x;=:,=, =@@@@x#X%@@@@@@@@@@@@@@@@@@=o%@@@@@@#:,+#@@%%XXx%@@@@@@@@@@@@@@@@@@@@@
@%@@@@@@@@%%@@@@@@X%# ,+,Xo: *@@@%*:=*%@#@@@%Xo:X@@@@@@@==#@@@@@@@@@%=:%x ;oX@%@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@+ + ;+-,. ,x%*+X* *#%@@@@@@o ;*@@@@@@@X: :,%@@@@@%x*+;. ,-.*o@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@#*, -X+@@, ,x@%@%xo; ;x@@@@@@@@=.=#@@@@@@%o- o@%X@@@+x%o, ;@@@@@@@@@@@@@@@@
@@@@@@@@Xo%@@@@@@# :#@@@@X,-%@@@@@%*x@@@@@@@@@@%@*X@@@@@@Xo= .;=@@@@@@@@@@=. ;%@#@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@%#****@@@@x+X@@@@@@@@@@@@@@@@%=%@%@X#%@@@@%= +@@@@@@@@+--:,:-+%@@@@@@@@@@@@@@@
@@@@%@@%-%@@@@%#@%%@@@%@@@@@@@@%o:@@@@@@@@@@@@@.;: #@o;; .*-+@@@@@%X= :=x@x%@X%@@@@@@@@@@@@@
@@@@==; ,@@@@@@#@@@@@@@@@#@%.--.,,,o%Xx@@@@@@@x-%+ ,o##@#X-.X#; ;X@@@#+xo@@@@@@@@@@@@
@@@X:,,,*@@@@@@@@@@@@%@@#xo@; :;x- ;#x%@@@#%@@%@+ .*@@@@@* -*; ,-+@#o=*@@@@@@@@@@@@@
@@@@@@:;@@@@@@@@@@@@@@@@@@@@#+ .:. ::=;=o%:#@@@@o:.X%: X@@@@@@* ,, -*++-. ::+-=%@@@@@@@@@@@@
@@@@@@X%@@@@@@@@@@@@@@@@@@%@X; :+,:: X%%@@@#@@%@@*+, :@@@@@@@@@%**: -o%%= .;**-@@@@@@%X@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@x..;*@%oo::;++*%@@@@@@@@@@#X; ,o@@@@@@@@@++* :=; ;+;:+o@@@@@@@@
@@@@@@@@@@@@@@@@@@@@%#@@@@@@@@X@@@@@@%x#@@@@@@@##@@@@@@@@;,.,. .oX;#@@@@@@@@o ,=*@o@+ ;=.+X@@@@@@@@
@@@@@@@@@@@@@@@@@#@oX#@@@@@@@@@@@@@@@XX@@@@@@X%%%%@@@@@@@@@@@@@%. ..:o;=@%X#, .%%@@%; xXo@@@@@@@@@
*%@@@@@@@@@@@@@@ :@@@@@@@@@@@@@@@@@#-o@@@@@@@:=@@x@@@@@@@@@@@@o:. ,, -, #x+X*X@@@o; %#@@@@@@@@@@@@
:x%@@@@@@@@@@@@x. ;X@@@@@@@@@@@@@@@@@@@@@@@@@%%X@@@@@@@@@@@@@@@%#+ .: .X#%@@@@%%@@@#.*@@@@@@@@@@@#@@
#@#@@@@@@@#@%*oX;.**@@@@@@@@@@@@@@@@@@@@@@@@@%:#@@@@@@@@@@@@%@@*%x ; .:;@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@%@%%@%X#@%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@Xo@@@@o*@@@@@@@o +@o#* .*@@@@@@@@@@@@@@#+%@@@@@@@
@@@@@@@@@@@%xx@@@@@@@@@@@@@@@@@@@@@@@@@@ox@@%%@@@@@o,.;x#@@@@o x@@@@@@;:.-;,,,;,+-o+-x%@@@@@@@%@@@@@@@@@
#@@@@@@@@@%#x+*::X@X#@@@@@@@@@@@@@%#@@@%; ;xx%@@@@@X ,#%+o,,:@#%@@@@x@%*= ;@@@@@@@#XX#%@@@@@@@
-;:--@@@#x@X::-;;Xo;=%@@@@@@@@@@@@@@#= ,@%:;- ,:=#+ :, :@@@@@@@@@* =X@@@@@@* +#Xo@%X*:
++=--=*%@@@*-;=*;;::x@@@@@@@@@@@@@@x. -X=,::;:-o@@%@#;,x@@@@@@@@+. -=X+@@@@%X@* ,==+::-
@oo, ,@@@@@@@x,-=; ;*%@@@@@@@@@@@@@@o#o X@%@@X=:, :%@@@@@X##@@@@@@@@%X= ;: .*@@@@@@xx. ;=- .
x@@@+;*@@@@@@#x%@@@@@@%@@@@@@%X@@@@@@%=;,:;;:::+%@@@@@@# -@@@@@*X%@@%@%@@%#;=#*,,- x%@@@@@@@@XX ,+-.
@@@@@@@@@@@@@@@@@@@@@@@@@@%X x%@@%%@o.,;-:,%xx@@@@@@%#o:.xX%@#%oXx+@%*x*##=x#@@@@: +@o@@@@@@@@= ;=-
@@@@@@@@@@@@@@@@@@@@@@@@@@@@+ .:,;*.-+--::,X@@@@@@@@@@%X*,o* *x, . .:*@@@@* .,o+=*X%;-; .+:
++@@@@@@@@@@@@@@@@@@@@@@@@@*. *oox :-;,::=*@@@@@@@@@@#*x#@@@@=:%o+; ,X#o=- ,+
%=..@X#@%@@@@@@@*o%o@@@@%-+: =#@@@%Xx:=@@X@@@@@@@@@@@+:@@@@@@+#%@%, ,:%#@%+-@X;. :x=. .
X, -%@@@@@@@@;. ,:-,+- , o@@@@%x==%@@@@@@@@@@@@@@@%@@@@@@@X:=. -*@@@@@@%@@@#+ .*:
.Xo+#*%@@@@@=: =o+@X., ,@@%@%@@X%@@@@@@@@@@@@@@@@@@@@@@@%; .=#@@@@@@@@@@%; ,,
:X@@@@*. ,o%@@@@%. ,x@@@%+ -@@@@@@@@@@#@%;@@@@@@@@@+ o#o@@@@@@@@@@= -%x +*
;; .@@@@@@@@=x@%@@@@%@x XX;#@o,#@%%@@@@@@%*#o*%%@@%@#=#o ;o@@@@@@@@@@@X=- . ;*@@
**. ,::@@@@@@@@@XX%X+:o=+-o#%@@@@@@@@%#@@@@@x+*x#@o+*,::, =o#@@@@@@@@@x=+ ;o@@
x@@@%@@@@@o*@@#%@X%@X@@@@@@@@@x,=@@@@@@@%@%@@%= ,;;=X%@@@@@@@@@@@%:@xx* +%- ,o=
:- ,X@@@@@%@@@@@@@@@@@@@@o,+@@@@@@%@@x@@@@@@%@%%@= ;#@@@@@@@@@@@@X%%@@* ::.
:; :x*X@@@@@@@@@@@@@@@@-*X=*@x*-x%@@@@@@%@%@@@%@X= +@@@@@@@@@@@@X- #*-- .;.
.x%@@@@@@@@@@@@@%;. +@@@@@:oX@@@@@@@@@@@@@@#= -@o#@#@#+X@@@@@@%=;@x-@Xo- **. *@@
,#@@%o.-%@@@* . +@%@%o-#@@@@@@@@@@@@@@@@=,,#@@@@@%@x;x@@@@#==@@@@@@@@;..;=+@@x%%;o%%
.=;*+ .*=+= x@%@@@@@@@@@%@@@@@@@@%%+ +@@@@@@@;:@@@@@X@X@@x@o;=. x@@@@@@=,++*
:%@@@@@@@@@@@@@@@@@@@@@;** o%+@@@@@@@@@@@@@@@@%X= ,,,;#@@@@x@X
,+o@@@@@@X%@@@@@%@@@@X;#@@@@@@@@x%@@@@@@@@@@@@@@%;#+.. ,;*%@@X, o
,#@@@@@@@@==%@@@@@@@@::ox@X@@@@@*-#@@@@@@@@@@@@@@%@@@o ,;@@@@@- ;x
,#@@@@=@@@@%- .x@@%@%o ;#%X@@@@@@@@@@@@#xX;X@%@@%@%%@, :%@@@@#- -*
##+X:=@X@+ :..+- .. ,=@@@@@@@@@@@@@@@*@@%@x%@; x@X%@- -.
.. . ;X@@@@@@@@@@@@@@@@@@##%@@@%%- ,; :--%=
., .:X@@@@@@@@@@@@@@%@%@xx#@@%@x: ++.
-@%. ,: o@@@@@@@@@@@@@@@%x--x; .-,:x= .
, ;##: X@@@#x@@@@@@@@@@#*; :%x,
-31
View File
@@ -1,31 +0,0 @@
<?php
/**
* Backgrounds are just PNGs in img/bg/, generated by tools/background.py.
* Drop a new one in and it joins the cycle automatically -- this list drives
* both the CSS custom properties and the footer cycler.
*/
function bg_styles(): array
{
static $names = null;
if ($names !== null) {
return $names;
}
$files = glob(__DIR__ . '/../img/bg/*.png') ?: [];
$names = array_map(static fn($f) => 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)] : '';
}
+15 -15
View File
@@ -20,10 +20,10 @@ const BUTTONS_88x31 = [
const BUTTON_CACHE_TTL = 7 * 86400; // re-fetch each button at most once a week
/**
* Fetch a remote image, cache it locally, and return it as a base64 data: URI.
* Returns null on failure so the caller can fall back to hotlinking.
* Fetch a remote image into the local cache (at most once a week) and return
* the cache file's path, or null if it couldn't be fetched.
*/
function button_data_uri(string $url): ?string
function button_cache(string $url): ?string
{
$dir = DATA_DIR . '/buttons';
@mkdir($dir, 0775, true);
@@ -47,19 +47,19 @@ function button_data_uri(string $url): ?string
}
}
if (!is_file($file)) {
return null;
return is_file($file) ? $file : null;
}
$raw = (string) file_get_contents($file);
$nl = strpos($raw, "\n");
if ($nl === false) {
return null;
}
$type = substr($raw, 0, $nl);
$data = substr($raw, $nl + 1);
return 'data:' . $type . ';base64,' . base64_encode($data);
/**
* Where the page should load a button from: our own copy (button.php serves
* it, versioned by the cache file's mtime so browsers can keep it), or the
* original if fetching failed. Was inlined as base64 before, which made every
* page ~260 KB heavier.
*/
function button_src(string $url): string
{
$file = button_cache($url);
return $file ? '/button.php?h=' . basename($file) . '&v=' . filemtime($file) : $url;
}
/** Render the whole 88x31 button row as HTML. */
@@ -67,7 +67,7 @@ function render_buttons_88x31(): string
{
$html = '';
foreach (BUTTONS_88x31 as $b) {
$uri = button_data_uri($b['src']) ?? $b['src']; // fall back to hotlink if fetch failed
$uri = button_src($b['src']);
$img = '<img src="' . htmlspecialchars($uri) . '" alt="' . htmlspecialchars($b['alt']) . '" width="88" height="31">';
$href = $b['href'] ?? '';
if ($href !== '') {
+4 -17
View File
@@ -8,23 +8,10 @@ $build = preg_match('/[0-9a-f]{7,40}/i', $build, $m) ? $m[0] : ($build ?: 'dev')
<div class="buttons88x31">
<?= render_buttons_88x31() ?>
</div>
<small>&copy; <?= date('Y') ?> · build: <a href="<?= htmlspecialchars(REPO_URL) ?>"><?= htmlspecialchars($build) ?></a></small>
<small>&copy; <?= date('Y') ?> · build: <a href="<?= htmlspecialchars(REPO_URL . ($build !== 'dev' ? '/src/commit/' . $build : '')) ?>"><?= htmlspecialchars($build) ?></a></small>
</footer>
<?php require_once __DIR__ . '/backgrounds.php'; $bgs = bg_styles(); ?>
<?php if (count($bgs) > 1): ?>
<script>
/* Unlabelled: press b to flick through the backgrounds without reloading.
Not persisted -- the server picks a fresh one on every load anyway. */
(function () {
var all = <?= json_encode($bgs) ?>, root = document.documentElement;
document.addEventListener('keydown', function (e) {
if (e.key !== 'b' || e.metaKey || e.ctrlKey || e.altKey) return;
var t = e.target;
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
root.dataset.bg = all[(all.indexOf(root.dataset.bg) + 1) % all.length];
});
})();
</script>
<?php endif; ?>
<canvas class="koi koi-left" aria-hidden="true"></canvas>
<canvas class="koi koi-right" aria-hidden="true"></canvas>
<script src="/js/koi.js?v=<?= filemtime(__DIR__ . '/../js/koi.js') ?>" defer></script>
</body>
</html>
+3 -18
View File
@@ -1,30 +1,15 @@
<?php
require_once __DIR__ . '/../config.php';
require_once __DIR__ . '/backgrounds.php';
$bgs = bg_styles();
$bgPick = bg_pick();
// the page picks a style per request, so don't let a cached copy pin one
header('Cache-Control: no-store');
?>
<!doctype html>
<html lang="en"<?= $bgPick !== '' ? ' data-bg="' . htmlspecialchars($bgPick) . '"' : '' ?>>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<title><?= htmlspecialchars($page_title ?? SITE_TITLE) ?></title>
<link rel="icon" href="/img/favicon.ico" sizes="any">
<link rel="stylesheet" href="/css/style.css">
<?php if ($bgs): ?>
<style>
/* One custom property per style; data-bg on <html> selects which.
Only the matching rule's url() is ever fetched, so the styles that
are not showing cost nothing but a line of CSS. */
:root { --bg-img: url("/img/bg/<?= rawurlencode(bg_default()) ?>.png"); }
<?php foreach ($bgs as $b): ?>
html[data-bg="<?= htmlspecialchars($b) ?>"] { --bg-img: url("/img/bg/<?= rawurlencode($b) ?>.png"); }
<?php endforeach; ?>
</style>
<?php endif; ?>
<link rel="stylesheet" href="/css/style.css?v=<?= filemtime(__DIR__ . '/../css/style.css') ?>">
<script src="/js/htmx.min.js" defer></script>
<script src="/js/idiomorph-ext.min.js" defer></script>
</head>
+1 -1
View File
@@ -13,7 +13,7 @@
<?php $age = (new DateTime('2002-11-07'))->diff(new DateTime('today'))->y; ?>
<p>I'm a <?= $age ?>-year-old developer (I know I'm unc), I hold a bachelor's degree in computer science (BUT Informatique) and I'm also a 42 school student.</p>
<p>if you wanna see what I'm building, feel free to check out my <a href="https://github.com/0xa00">GitHub</a> or follow me on <a href="https://x.com/0xa0_">Twitter</a> !</p>
<p>if you wanna see what I'm building, feel free to check out my <a href="https://github.com/0xa00">GitHub</a> !</p>
<section class="posts-widget">
<h2>recent posts</h2>
+406
View File
@@ -0,0 +1,406 @@
/**
* Koi for the side panels -- letters on a square cell grid, swimming.
*
* Rendering follows the reference: every cell is a filled tile in the koi's
* colour with a letter punched into it in near-black ink. The tile carries
* the colour and its brightness, the letter carries the texture, picked from
* a thin-to-thick ramp by how dense that part of the body is. Drawing bright
* letters on a dark page instead looks nothing like it.
*
* The grid is square. Using the natural text grid (cells ~.6 as wide as they
* are tall) makes a fish's proportions depend on its heading: thickness is
* perpendicular to travel, so swimming sideways comes out short and fat.
*
* The body is a follow-the-leader chain with a bend limit per joint, not a
* spine projected from the head. A projected spine rotates rigidly the
* instant the fish turns; a chain lags, and the bend limit stops it coiling
* into a ball the way a raw position trail does.
*/
(function () {
'use strict';
var RAMP = ':;+*xzXZ%#&@'; /* thin -> thick */
var EYE = 'o';
var INK = '#0b0b0d'; /* the letter punched into each tile */
var GAP = 1; /* gutter between tiles, px */
var CREAM = [233, 229, 220], RED = [206, 68, 42],
GOLD = [224, 169, 46], SUMI = [82, 83, 90];
var VARIETIES = [
{ name: 'kohaku', base: CREAM, patches: [[0.12, 0.36, RED], [0.52, 0.72, RED]] },
{ name: 'ogon', base: GOLD, patches: [[0.58, 0.82, [186, 136, 36]]] },
{ name: 'sanke', base: CREAM, patches: [[0.14, 0.36, RED], [0.38, 0.47, SUMI],
[0.56, 0.72, RED], [0.76, 0.88, SUMI]] },
{ name: 'showa', base: SUMI, patches: [[0.12, 0.32, RED], [0.44, 0.60, CREAM],
[0.68, 0.84, RED]] },
{ name: 'tancho', base: CREAM, patches: [[0.03, 0.13, RED]] }
];
var LINKS = 24; /* joints in the body -- its length */
var SLIM = 0.115; /* max half-width as a fraction of length */
var BEND = 0.17; /* max radians between consecutive links */
var LEVELS = 5; /* quantised tile brightnesses */
/* Half-width as a fraction of the maximum, nose (0) to tail tip (1). */
function profile(u) {
if (u < 0.05) return 0.44 + u * 3.4;
if (u < 0.26) return 0.61 + (u - 0.05) * 1.9;
if (u < 0.64) return 1.00 - (u - 0.26) * 1.95;
if (u < 0.80) return 0.26 - (u - 0.64) * 1.1;
return 0.08 + (u - 0.80) * 3.2;
}
/* stable per-cell jitter in body coordinates, so the texture travels with
the fish instead of shimmering against the screen */
function hash(a, b) {
var x = Math.sin(a * 12.9898 + b * 78.233) * 43758.5453;
return x - Math.floor(x);
}
function colourAt(v, t) {
for (var i = 0; i < v.patches.length; i++) {
var p = v.patches[i];
if (t >= p[0] && t <= p[1]) return p[2];
}
return v.base;
}
/* rgb() strings for every colour at every brightness, built once --
rebuilding them per cell would churn thousands of strings a frame */
var SHADES = {};
function shadesFor(c) {
var k = c[0] + '_' + c[1] + '_' + c[2];
if (SHADES[k]) return SHADES[k];
var out = [];
for (var i = 0; i < LEVELS; i++) {
var f = 0.34 + 0.66 * (i / (LEVELS - 1));
out.push('rgb(' + Math.round(c[0] * f) + ',' +
Math.round(c[1] * f) + ',' +
Math.round(c[2] * f) + ')');
}
SHADES[k] = out;
return out;
}
function Koi(panel, i, n, offset) {
this.v = VARIETIES[(i + (offset || 0)) % VARIETIES.length];
this.panel = panel;
this.a = Math.random() * Math.PI * 2;
this.speed = 16 + Math.random() * 12; /* px per second -- koi drift */
this.phase = Math.random() * Math.PI * 2;
this.beat = 1.7 + Math.random() * 0.9;
this.sway = 0.20 + Math.random() * 0.16;
this.girth = 0.9 + Math.random() * 0.26;
this.w1 = 0.09 + Math.random() * 0.07;
this.w2 = 0.19 + Math.random() * 0.11;
this.p1 = Math.random() * 99;
this.p2 = Math.random() * 99;
this.x = panel.w * (0.15 + 0.7 * Math.random());
this.y = panel.h * ((i + 0.5) / n);
/* born straight, laid out behind the head */
this.j = new Float64Array(LINKS * 2);
var link = panel.cell;
for (var k = 0; k < LINKS; k++) {
this.j[k * 2] = this.x - Math.cos(this.a) * k * link;
this.j[k * 2 + 1] = this.y - Math.sin(this.a) * k * link;
}
}
Koi.prototype.step = function (dt, t, pointer) {
var p = this.panel;
/* wander: two slow sines, so the heading meanders without repeating */
this.a += (Math.sin(t * this.w1 + this.p1) + Math.sin(t * this.w2 + this.p2)) * dt * 0.22;
/* Turn well before the edge: the margin has to clear the whole body. */
var body = LINKS * p.cell;
var mx = Math.min(p.w * 0.32, body * 0.45);
var my = Math.min(p.h * 0.20, body * 0.32);
if (this.x < mx) this.a += turnTo(this.a, 0, dt * 0.8);
if (this.x > p.w - mx) this.a += turnTo(this.a, Math.PI, dt * 0.8);
if (this.y < my) this.a += turnTo(this.a, Math.PI / 2, dt * 0.8);
if (this.y > p.h - my) this.a += turnTo(this.a, -Math.PI / 2, dt * 0.8);
var speed = this.speed;
/* drift away from the pointer -- a spook, not a panic */
if (pointer.in) {
var dx = this.x - pointer.x, dy = this.y - pointer.y;
var d = Math.hypot(dx, dy);
if (d < 170 && d > 0.01) {
this.a += turnTo(this.a, Math.atan2(dy, dx), dt * (1.1 * (1 - d / 170)));
speed += 34 * (1 - d / 170);
}
}
if (pointer.typing) speed += 14;
this.phase += dt * this.beat;
/* the head yaws with the tail beat; the chain turns that into a
travelling wave down the body on its own */
var head = this.a + Math.sin(this.phase) * this.sway;
this.x += Math.cos(head) * speed * dt;
this.y += Math.sin(head) * speed * dt;
this.x = Math.max(2, Math.min(p.w - 2, this.x));
this.y = Math.max(2, Math.min(p.h - 2, this.y));
/* follow the leader: each joint is pulled to a fixed distance behind the
one ahead, and the turn between links is capped so it cannot coil */
var j = this.j, link = p.cell;
j[0] = this.x; j[1] = this.y;
var prevAng = head + Math.PI;
for (var i = 1; i < LINKS; i++) {
var ax = j[i * 2] - j[(i - 1) * 2];
var ay = j[i * 2 + 1] - j[(i - 1) * 2 + 1];
var ang = Math.atan2(ay, ax);
var diff = Math.atan2(Math.sin(ang - prevAng), Math.cos(ang - prevAng));
if (diff > BEND) diff = BEND; else if (diff < -BEND) diff = -BEND;
ang = prevAng + diff;
j[i * 2] = j[(i - 1) * 2] + Math.cos(ang) * link;
j[i * 2 + 1] = j[(i - 1) * 2 + 1] + Math.sin(ang) * link;
prevAng = ang;
}
};
function turnTo(a, target, k) {
var d = Math.atan2(Math.sin(target - a), Math.cos(target - a));
return d * Math.min(1, k * 2.6);
}
Koi.prototype.draw = function (ctx, p) {
var cells = p.cells;
cells.clear();
var cell = p.cell;
var L = (LINKS - 1) * cell;
var maxHW = L * SLIM * this.girth;
var j = this.j;
var steps = 2; /* sub-steps per link, so no cell is missed */
for (var i = 0; i < LINKS - 1; i++) {
var ax = j[i * 2], ay = j[i * 2 + 1];
var bx = j[(i + 1) * 2], by = j[(i + 1) * 2 + 1];
var ang = Math.atan2(by - ay, bx - ax);
var sin = Math.sin(ang), cos = Math.cos(ang);
for (var s = 0; s < steps; s++) {
var f = s / steps;
var u = (i + f) / (LINKS - 1);
var sx = ax + (bx - ax) * f;
var sy = ay + (by - ay) * f;
var hw = profile(u) * maxHW;
var shades = shadesFor(colourAt(this.v, u));
for (var v = -hw - cell; v <= hw + cell; v += cell * 0.5) {
var av = Math.abs(v);
var cov = (hw - av) / cell + 0.55;
/* fork the caudal fin: a notch opening toward the tip */
if (u > 0.86) {
var notch = (u - 0.86) * 5.6 * maxHW;
if (av < notch) cov -= (notch - av) / cell * 1.5;
}
if (cov <= 0.06) continue;
var edge = hw > 0 ? 1 - av / hw : 0;
if (edge < 0) edge = 0;
var d = 0.26 + 0.6 * edge * Math.min(1, cov)
+ (hash(i * 2 + s, Math.round(v / cell * 2)) - 0.5) * 0.26;
/* The tail dissolves instead of ending in a solid fan: drop an
increasing share of its cells toward the tip and dim what is
left, so it breaks into loose tiles the way the reference does. */
if (u > 0.74) {
var sc = (u - 0.74) / 0.26;
if (hash(i * 5 + s, Math.round(v / cell * 2) + 91) < sc * 0.66) continue;
d *= 1 - sc * 0.4;
}
if (d < 0.06) continue;
if (d > 0.99) d = 0.99;
put(cells, p, sx - sin * v, sy + cos * v, d, shades, false);
}
/* pectoral fins, trailing back off the shoulder */
if (s === 0 && i === Math.round((LINKS - 1) * 0.27)) {
for (var fin = 1; fin <= 4; fin++) {
var reach = hw + fin * cell * 0.85, back = fin * cell * 0.72;
if (hash(i, fin + 7) > 0.22) {
put(cells, p, sx - sin * reach - cos * back,
sy + cos * reach - sin * back, 0.34 - fin * 0.06, shades, false);
}
if (hash(i, fin + 31) > 0.22) {
put(cells, p, sx + sin * reach - cos * back,
sy - cos * reach - sin * back, 0.34 - fin * 0.06, shades, false);
}
}
}
/* eyes, just behind the nose */
if (s === 0 && i === Math.round((LINKS - 1) * 0.07)) {
var ew = hw * 0.62;
put(cells, p, sx - sin * ew, sy + cos * ew, 0.95, shades, true);
put(cells, p, sx + sin * ew, sy - cos * ew, 0.95, shades, true);
}
}
}
var tile = cell - GAP, half = tile / 2;
cells.forEach(function (c) {
ctx.fillStyle = c.sh[Math.min(LEVELS - 1, Math.floor(c.d * LEVELS))];
ctx.fillRect(c.x, c.y, tile, tile);
ctx.fillStyle = INK;
ctx.fillText(c.eye ? EYE : RAMP.charAt(Math.min(RAMP.length - 1,
Math.floor(c.d * RAMP.length))), c.x + half, c.y + half);
});
};
/* takes pixels; the grid is the rasterisation target, not the coordinates */
function put(cells, p, x, y, d, sh, eye) {
var gx = Math.round(x / p.cell), gy = Math.round(y / p.cell);
var k = gx + ',' + gy;
var prev = cells.get(k);
if (prev && (prev.eye || (!eye && prev.d >= d))) return;
cells.set(k, { x: gx * p.cell, y: gy * p.cell, d: d, sh: sh, eye: !!eye });
}
function Panel(canvas, count, offset) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.count = count;
this.offset = offset || 0;
this.koi = [];
this.cells = new Map();
this.w = 0;
this.h = 0;
this.cell = 9;
}
Panel.prototype.resize = function () {
var r = this.canvas.getBoundingClientRect();
var dpr = window.devicePixelRatio || 1;
this.w = r.width;
this.h = r.height;
this.canvas.width = Math.max(1, Math.round(r.width * dpr));
this.canvas.height = Math.max(1, Math.round(r.height * dpr));
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
var cs = getComputedStyle(this.canvas);
this.cell = Math.max(5, parseFloat(cs.fontSize) || 9); /* square cells */
this.font = Math.round(this.cell * 0.92) + 'px ' + cs.fontFamily;
this.applyFont();
if (!this.koi.length) {
for (var i = 0; i < this.count; i++) this.koi.push(new Koi(this, i, this.count, this.offset));
} else {
for (var k = 0; k < this.koi.length; k++) {
this.koi[k].x = Math.min(this.koi[k].x, this.w - 4);
this.koi[k].y = Math.min(this.koi[k].y, this.h - 4);
}
}
};
Panel.prototype.applyFont = function () {
this.ctx.font = this.font;
this.ctx.textBaseline = 'middle';
this.ctx.textAlign = 'center';
};
Panel.prototype.frame = function (dt, t, pointer) {
this.ctx.clearRect(0, 0, this.w, this.h);
this.applyFont();
for (var i = 0; i < this.koi.length; i++) {
this.koi[i].step(dt, t, pointer);
this.koi[i].draw(this.ctx, this);
}
};
function start() {
var left = document.querySelector('.koi-left');
var right = document.querySelector('.koi-right');
if (!left || !right) return;
var wide = window.matchMedia('(min-width: 1281px)');
var still = window.matchMedia('(prefers-reduced-motion: reduce)');
var panels = [new Panel(left, 3, 0), new Panel(right, 2, 3)];
var pointer = { x: 0, y: 0, in: false, typing: false, until: 0 };
var running = false, last = 0, t = 0, raf = 0;
function resize() { for (var i = 0; i < panels.length; i++) panels[i].resize(); }
function once() { for (var i = 0; i < panels.length; i++) panels[i].frame(0, 0, pointer); }
function loop(now) {
if (!running) return;
var dt = Math.min(0.05, (now - last) / 1000) || 0;
last = now;
t += dt;
pointer.typing = now < pointer.until;
for (var i = 0; i < panels.length; i++) panels[i].frame(dt, t, pointer);
raf = requestAnimationFrame(loop);
}
function play() {
if (running || !wide.matches || document.hidden) return;
running = true;
last = performance.now();
raf = requestAnimationFrame(loop);
}
function stop() {
running = false;
if (raf) cancelAnimationFrame(raf);
}
resize();
if (still.matches) {
once();
} else {
play();
document.addEventListener('visibilitychange', function () {
if (document.hidden) stop(); else play();
});
wide.addEventListener('change', function () {
if (wide.matches) { resize(); play(); } else stop();
});
}
var rt;
window.addEventListener('resize', function () {
clearTimeout(rt);
rt = setTimeout(function () { resize(); if (still.matches) once(); }, 150);
});
window.addEventListener('pointermove', function (e) {
for (var i = 0; i < panels.length; i++) {
var r = panels[i].canvas.getBoundingClientRect();
if (e.clientX >= r.left && e.clientX <= r.right) {
pointer.in = true;
pointer.x = e.clientX - r.left;
pointer.y = e.clientY - r.top;
return;
}
}
pointer.in = false;
}, { passive: true });
document.addEventListener('keydown', function (e) {
var el = e.target;
if (el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable)) {
pointer.until = performance.now() + 700;
}
}, { passive: true });
}
if (document.fonts && document.fonts.ready) {
document.fonts.ready.then(start);
} else if (document.readyState !== 'loading') {
start();
} else {
document.addEventListener('DOMContentLoaded', start);
}
})();
+10
View File
@@ -0,0 +1,10 @@
<?php
// The side art as a stylesheet (see inc/art.php). The URL carries ?v=, so it
// can be cached for good.
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/inc/art.php';
[$css] = ansi_css_cached(__DIR__ . '/inc/art/sakura.ans'); // render (or fail) before the headers
header('Content-Type: text/css; charset=utf-8');
header('Cache-Control: public, max-age=31536000, immutable');
readfile($css);
Binary file not shown.
-299
View File
@@ -1,299 +0,0 @@
#!/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)