diff --git a/docs/assets/apple-touch-icon.png b/docs/assets/apple-touch-icon.png index 2fe70e5..615096e 100644 Binary files a/docs/assets/apple-touch-icon.png and b/docs/assets/apple-touch-icon.png differ diff --git a/docs/assets/favicon-16x16.png b/docs/assets/favicon-16x16.png index d30d95f..5996c39 100644 Binary files a/docs/assets/favicon-16x16.png and b/docs/assets/favicon-16x16.png differ diff --git a/docs/assets/favicon-32x32.png b/docs/assets/favicon-32x32.png index 2ca2469..affa380 100644 Binary files a/docs/assets/favicon-32x32.png and b/docs/assets/favicon-32x32.png differ diff --git a/docs/assets/favicon.ico b/docs/assets/favicon.ico index e1912fb..7349150 100644 Binary files a/docs/assets/favicon.ico and b/docs/assets/favicon.ico differ diff --git a/docs/assets/logo-hero.png b/docs/assets/logo-hero.png index e2a7959..f43aa58 100644 Binary files a/docs/assets/logo-hero.png and b/docs/assets/logo-hero.png differ diff --git a/docs/assets/logo-shield.png b/docs/assets/logo-shield.png index 509e7ad..946ff60 100644 Binary files a/docs/assets/logo-shield.png and b/docs/assets/logo-shield.png differ diff --git a/docs/assets/logo.png b/docs/assets/logo.png index 7c978a5..4bc4c23 100644 Binary files a/docs/assets/logo.png and b/docs/assets/logo.png differ diff --git a/docs/assets/social-card.png b/docs/assets/social-card.png index 2f2a656..6604986 100644 Binary files a/docs/assets/social-card.png and b/docs/assets/social-card.png differ diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css index 3763248..28e16da 100644 --- a/docs/stylesheets/extra.css +++ b/docs/stylesheets/extra.css @@ -222,25 +222,29 @@ margin-bottom: 1rem; } +/* The mark is a landscape shield on transparency: sized by width with the + height left to follow, and shadowed with a drop-shadow that traces the alpha + silhouette. A box-shadow would outline the image box, not the shield. */ .hero-logo-img { - border-radius: 50%; - box-shadow: 0 4px 24px rgba(0, 0, 0, 0.18); + filter: drop-shadow(0 4px 18px rgba(0, 0, 0, 0.22)); transition: transform 0.3s ease; - width: 200px; - height: 200px; + width: 320px; + height: auto; } .hero-logo-img:hover { transform: scale(1.05); } -/* Header logo — bigger than default, clipped to circle */ +/* Header logo — bigger than default. `object-fit: cover` in a 3rem square used + to crop the round seal harmlessly; on the landscape shield it would cut off + the banner, so the box follows the mark instead of the mark being cropped + to the box. */ .md-header__button.md-logo img, .md-nav__button.md-logo img { - height: 3rem; - width: 3rem; - border-radius: 50%; - object-fit: cover; + height: 2.4rem; + width: auto; + object-fit: contain; } /* ── D2 diagrams — constrain size and center ── */ diff --git a/scripts/regenerate_logo_assets.py b/scripts/regenerate_logo_assets.py new file mode 100644 index 0000000..1b54e3d --- /dev/null +++ b/scripts/regenerate_logo_assets.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python3 +# © Copyright 2025-2026, Query.Farm LLC - https://query.farm +# SPDX-License-Identifier: Apache-2.0 + +r"""Regenerate every brand asset in ``docs/assets`` from one master logo. + +The master is committed as ``docs/assets/logo-master.png``: the shield mark on +a white background, at the highest resolution we have. Every other asset is +derived, so a new master is a one-command reroll rather than eight hand edits +that drift apart: + + uv run --with pillow --with numpy python scripts/regenerate_logo_assets.py + +Pass ``--master PATH`` to cut the assets from a different source, which also +replaces the committed master. + +Two things here are less obvious than they look. + +**Background is every near-white pixel, not just the outer margin.** Keying +only what the border can reach leaves the enclosed gaps opaque — the sky +between the tree and the barn wall, the holes between the cloud's connector +traces — which read as white specks on the cream page and glare on a dark one. +The mark's lightest real ink is cream at min-channel 193, far below the +threshold, so a global key cannot eat it. That margin is not guaranteed for +some future master, so the script reports how much keyed area was *enclosed* +rather than border-connected: a master with genuine white ink shows up there +as a large number instead of a few hundred stray pixels. + +**Antialiased edges need partial alpha, not a hard cut.** The outline blends +into white over 1-2px. Those pixels get alpha from how far they are from +white, and their colour is then un-premultiplied so the result composites back +over white exactly as the master did — and degrades gracefully over the cream +page background rather than showing a white fringe. +""" + +from __future__ import annotations + +import argparse +import shutil +from pathlib import Path + +import numpy as np +from PIL import Image + +# Repository paths. +_REPO = Path(__file__).resolve().parent.parent +_ASSETS = _REPO / "docs" / "assets" +_MASTER = _ASSETS / "logo-master.png" + +# A pixel is "background candidate" when every channel is at least this. The +# master's white is not perfectly flat (252-255), so an exact-white test leaves +# a speckled halo. +_WHITE_FLOOR = 244 + +# How far the partial-alpha band reaches into the mark from the keyed region. +# Two pixels covers the master's antialiasing; more would eat into solid ink. +_EDGE_BAND = 2 + +# Apple composites a transparent home-screen icon onto black on some iOS +# versions, so that one asset stays opaque. Cream matches the page background +# the mark was drawn for. +_ICON_BACKGROUND = (250, 248, 240) + +# The social card is regenerated by replacing just the logo block, so the text +# rendered into it stays byte-identical rather than being re-typeset with +# whatever fonts happen to be installed. +_CARD_LOGO_CENTER = (600, 180) +_CARD_LOGO_WIDTH = 300 + + +def _connected_to_border(candidate: np.ndarray) -> np.ndarray: + """Return the subset of *candidate* reachable from the image border. + + Args: + candidate: 2-D bool array marking every near-white pixel. + + Returns: + A 2-D bool array: the background proper, with interior white left out. + + """ + reached = np.zeros_like(candidate) + reached[0, :] = candidate[0, :] + reached[-1, :] = candidate[-1, :] + reached[:, 0] = candidate[:, 0] + reached[:, -1] = candidate[:, -1] + + # Iterated 8-connected dilation clipped to the candidate set. Simple enough + # to trust by reading, and a few seconds on a 1200x900 master. + while True: + grown = reached.copy() + for shift, axis in ((1, 0), (-1, 0), (1, 1), (-1, 1)): + grown |= np.roll(reached, shift, axis=axis) + # Diagonals, so a 1px-wide gap between two shapes still lets the fill through. + for dy in (-1, 1): + for dx in (-1, 1): + grown |= np.roll(np.roll(reached, dy, axis=0), dx, axis=1) + grown &= candidate + if np.array_equal(grown, reached): + return reached + reached = grown + + +def _dilate(mask: np.ndarray, radius: int) -> np.ndarray: + """Grow *mask* by *radius* pixels in 8-connectivity.""" + out = mask.copy() + for _ in range(radius): + grown = out.copy() + for dy in (-1, 0, 1): + for dx in (-1, 0, 1): + grown |= np.roll(np.roll(out, dy, axis=0), dx, axis=1) + out = grown + return out + + +def key_out_background(master: Image.Image) -> Image.Image: + """Replace the master's white background with transparency. + + Args: + master: The logo on a white background, in any mode. + + Returns: + An RGBA image cropped to the mark's bounding box. + + """ + rgb = np.asarray(master.convert("RGB")).astype(np.float64) + background = rgb.min(axis=2) >= _WHITE_FLOOR + edge = _dilate(background, _EDGE_BAND) & ~background + + # Enclosed keyed area is the tell that a master carries genuine white ink + # rather than gaps in the silhouette. A few hundred pixels is the sky + # showing through the artwork; a few thousand means read the output before + # trusting it. + enclosed = int((background & ~_connected_to_border(background)).sum()) + print(f"keyed {int(background.sum()):,} px, of which {enclosed:,} enclosed (gaps in the mark, not its margin)") + + # Distance from white drives alpha in the edge band: solid ink is far from + # white and stays opaque, a pixel half-blended into the page is half + # transparent. Keyed pixels snap to zero rather than inheriting the + # master's not-quite-white noise — otherwise the margin survives at alpha + # 1-3, invisible but enough to defeat the bounding-box crop below. + alpha = np.ones(rgb.shape[:2], dtype=np.float64) + alpha[background] = 0.0 + alpha[edge] = 1.0 - rgb.min(axis=2)[edge] / 255.0 + + # Un-premultiply against white. Without this every edge keeps the white it + # was blended with and the mark wears a pale outline on a coloured page. + safe = np.maximum(alpha, 1e-6)[..., None] + straight = np.where( + alpha[..., None] > 0.0, + (rgb - (1.0 - safe) * 255.0) / safe, + 0.0, + ) + + out = np.empty((*rgb.shape[:2], 4), dtype=np.uint8) + out[..., :3] = np.clip(straight, 0, 255).astype(np.uint8) + out[..., 3] = np.clip(alpha * 255.0, 0, 255).astype(np.uint8) + image = Image.fromarray(out, mode="RGBA") + + # Crop the dead margin so every derived size is tight and predictable. + bbox = image.getbbox() + return image.crop(bbox) if bbox else image + + +def _scaled_to_width(logo: Image.Image, width: int) -> Image.Image: + """Resample *logo* to *width*, preserving aspect ratio.""" + height = round(logo.height * width / logo.width) + return logo.resize((width, height), Image.LANCZOS) + + +def _letterboxed(logo: Image.Image, size: int, background: tuple[int, int, int] | None) -> Image.Image: + """Centre *logo* in a square canvas of *size*, padding rather than cropping. + + Args: + logo: The transparent mark. + size: Side length of the square result. + background: Fill colour, or ``None`` for a transparent canvas. + + Returns: + A square RGBA image. + + """ + # Inset slightly: a mark that touches the icon's edge reads as clipped. + fitted = _scaled_to_width(logo, round(size * 0.94)) + if fitted.height > size: + fitted = fitted.resize((round(fitted.width * size / fitted.height), size), Image.LANCZOS) + canvas = Image.new("RGBA", (size, size), (*background, 255) if background else (0, 0, 0, 0)) + canvas.alpha_composite(fitted, ((size - fitted.width) // 2, (size - fitted.height) // 2)) + return canvas + + +def _rebuild_social_card(logo: Image.Image, card_path: Path) -> None: + """Swap the logo block on the existing social card, leaving its text alone. + + Args: + logo: The transparent mark. + card_path: The card to rewrite in place. + + Raises: + FileNotFoundError: If the card is missing — it carries text this script + cannot re-typeset, so regenerating from scratch is not a fallback. + + """ + if not card_path.exists(): + raise FileNotFoundError(f"{card_path} is required: the card's text cannot be regenerated from the master") + card = Image.open(card_path).convert("RGBA") + background = card.getpixel((0, 0)) + assert isinstance(background, tuple) + + fitted = _scaled_to_width(logo, _CARD_LOGO_WIDTH) + cx, cy = _CARD_LOGO_CENTER + # Paint out whatever occupied the block before, generously, then draw. + old = Image.new("RGBA", (max(_CARD_LOGO_WIDTH, 240) + 40, max(fitted.height, 240) + 40), background) + card.paste(old, (cx - old.width // 2, cy - old.height // 2)) + card.alpha_composite(fitted, (cx - fitted.width // 2, cy - fitted.height // 2)) + card.convert("RGB").save(card_path) + + +def main() -> None: + """Cut every derived asset from the master and report what was written.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--master", + type=Path, + default=None, + help="Source logo on a white background. Replaces the committed master when given.", + ) + args = parser.parse_args() + + if args.master is not None: + shutil.copyfile(args.master, _MASTER) + if not _MASTER.exists(): + parser.error(f"no master at {_MASTER}; pass --master PATH") + + logo = key_out_background(Image.open(_MASTER)) + print(f"master {Image.open(_MASTER).size} -> keyed mark {logo.size}") + + # Full-resolution transparent mark. The README embeds this directly. + logo.save(_ASSETS / "logo-shield.png") + + # Served on the landing / describe / 404 / 401 pages, which render it at + # 120-200 CSS px, and as the docs hero at 200. 600 keeps that 3x. + _scaled_to_width(logo, 600).save(_ASSETS / "logo-hero.png") + + # mkdocs nav logo, rendered around 48px tall. + _scaled_to_width(logo, 512).save(_ASSETS / "logo.png") + + _letterboxed(logo, 180, _ICON_BACKGROUND).save(_ASSETS / "apple-touch-icon.png") + _letterboxed(logo, 32, None).save(_ASSETS / "favicon-32x32.png") + _letterboxed(logo, 16, None).save(_ASSETS / "favicon-16x16.png") + _letterboxed(logo, 48, None).save( + _ASSETS / "favicon.ico", + sizes=[(16, 16), (32, 32), (48, 48)], + ) + + _rebuild_social_card(logo, _ASSETS / "social-card.png") + + for name in [*sorted(p.name for p in _ASSETS.glob("*.png")), "favicon.ico"]: + path = _ASSETS / name + print(f" {name:24} {Image.open(path).size!s:12} {path.stat().st_size // 1024:>5} KiB") + + +if __name__ == "__main__": + main() diff --git a/vgi_rpc/http/_common.py b/vgi_rpc/http/_common.py index d10ac08..5f0faef 100644 --- a/vgi_rpc/http/_common.py +++ b/vgi_rpc/http/_common.py @@ -141,8 +141,12 @@ def decode_content_encoding( margin: 0 auto; padding: 60px 20px 0; color: #2c2c1e; text-align: center; background: #faf8f0; }} .logo {{ margin-bottom: 24px; }} - .logo img {{ width: 120px; height: 120px; border-radius: 50%; - box-shadow: 0 4px 24px rgba(0,0,0,0.12); }} + /* The mark is a landscape shield on transparency, so it is sized by width + with the height left to follow, and the shadow is a drop-shadow that + traces the alpha silhouette. A box-shadow would draw a rectangle's + shadow around a shield-shaped hole. */ + .logo img {{ width: 170px; height: auto; + filter: drop-shadow(0 3px 10px rgba(0,0,0,0.18)); }} h1 {{ color: #2d5016; margin-bottom: 8px; font-weight: 700; }} code {{ font-family: 'JetBrains Mono', monospace; background: #f0ece0; padding: 2px 6px; border-radius: 3px; font-size: 0.9em; color: #2c2c1e; }} diff --git a/vgi_rpc/http/server/_pages.py b/vgi_rpc/http/server/_pages.py index 6e03520..6fe15fd 100644 --- a/vgi_rpc/http/server/_pages.py +++ b/vgi_rpc/http/server/_pages.py @@ -31,8 +31,10 @@ margin: 0 auto; padding: 60px 20px 0; color: #2c2c1e; text-align: center; background: #faf8f0; }} .logo {{ margin-bottom: 24px; }} - .logo img {{ width: 140px; height: 140px; border-radius: 50%; - box-shadow: 0 4px 24px rgba(0,0,0,0.12); }} + /* Sized by width with the height following, and a drop-shadow that traces + the alpha silhouette — see the note in http/_common.py. */ + .logo img {{ width: 220px; height: auto; + filter: drop-shadow(0 4px 12px rgba(0,0,0,0.18)); }} h1 {{ color: #2d5016; margin-bottom: 8px; font-weight: 700; }} code {{ font-family: 'JetBrains Mono', monospace; background: #f0ece0; padding: 2px 6px; border-radius: 3px; font-size: 0.9em; color: #2c2c1e; }} @@ -137,8 +139,8 @@ def on_get(self, req: falcon.Request, resp: falcon.Response) -> None: body {{ font-family: 'Inter', system-ui, -apple-system, sans-serif; max-width: 900px; margin: 0 auto; padding: 40px 20px 0; color: #2c2c1e; background: #faf8f0; }} .header {{ text-align: center; margin-bottom: 40px; }} - .header .logo img {{ width: 80px; height: 80px; border-radius: 50%; - box-shadow: 0 3px 16px rgba(0,0,0,0.10); }} + .header .logo img {{ width: 130px; height: auto; + filter: drop-shadow(0 3px 8px rgba(0,0,0,0.15)); }} .header h1 {{ margin-bottom: 4px; color: #2d5016; font-weight: 700; }} .header .subtitle {{ color: #6b6b5a; font-size: 1.1em; margin-top: 0; }} .header .meta {{ color: #6b6b5a; font-size: 0.9em; }}