Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ All notable changes to `github-showcase-skill` are recorded here. Versions follo

### Fixed

- Custom transparent icon art (`--icon`, `showcase.yml` `icon`, `assets/icon.png`), including PNG mode P with transparency and RGBA, is fitted by its alpha bounds in the icon box in the `iconbox` compositor, so transparent margins no longer shrink it. The raw and normalized PNG are unchanged; anything under `assets/brand` is bundled artwork protected by its resolved path, not by the selection route, so `--icon` or `showcase.yml` `icon` pointing at `logo-dark.png` or `logo-dark-full.png` is no longer cropped (2400x1198 and 1200x599 keep their sizes); golden plate geometry, opaque or full-bleed icons, pixel-art sizing and fully blank transparency are unchanged. With `icon_pixel_art` set, alpha fit never applies, including an unrecognized native grid, which keeps the retained image size and placement (#130; reuses the #126 normalizer, no new normalizer).
- `readme --bootstrap --write` no longer regenerates a marked footer that holds authored content. When the edited-template upgrade is refused by the never-drop-a-line check and the marked footer carries a partner or foreign image, the command now exits with a check failure (`readme-shrink`) and leaves `README.md` unchanged instead of falling back to a header refresh that rewrote the footer; a plain old footer copy still takes the fallback. The same refusal now applies in every bootstrap state (pristine and unmarked as well as edited), checked before any write, so a pristine or unmarked template can no longer silently replace a partner or foreign footer image.
- Fork (G) and backup (H) READMEs migrate an unmarked trailing footer whose logo is a configured own-site historical source (`footer.legacy_logo_urls` or `docs/images/logo.svg`) to the single current footer; partner, foreign and look-alike images stay authored and are reported as no allowance.
- Own-site historical footer logo recognition is URL-derived: the company-website logo lives in `footer.legacy_logo_urls` in `references/brand.yaml` (lock refreshed through `config --write-lock`) and matches only exactly (scheme, host case-insensitively, port, path; no userinfo), so no brand host or path remains in Python. The generic local `docs/images/logo.svg` migration is a file convention, not brand data, and stays distinct.
Expand Down
2 changes: 1 addition & 1 deletion references/banner-layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ First match wins; the same file serves the banner card and the card's icon tile:
3. the org mark (`orgs.<Org>.mark`: the approved Neon Night avatar for every T1 org except CompleteTech-LLC), logging `needs-input:repo-icon`;
4. the family fallback, logging `needs-input:repo-icon`: for CompleteTech orgs the light CompleteTech logo (`brand_assets.logo_light`, the golden look), or in an icon box under 200 px (the card tile) its committed cloud-and-bulb crop `brand_assets.mark_light`, because the full wordmark would be about 3 px tall there; for every other family the family `company_label` typeset in its `display` font and `card_ink` colour on the family plate, over a short accent rule (one to three balanced lines, largest size that fits). A T2 card never shows the CompleteTech logo (decision 13; TouchedByAnAgent hold).

**Plate rule.** When the icon's four corner pixels are opaque and within ΔE 3 of each other, the icon brings its own ground: the card is filled with that colour and the icon is drawn full-bleed (inset 0, clipped to the card radius), with its ground feathered into the card where a square icon does not reach the card's sides. There is never a plate inside a plate. T2 families without an icon get a dark plate (`card_fill`: Jev `#0C075C`, LibreEvolve `#032530`, Touched By An Agent `#2F0132`, romgenie `#183B31`); AI Ascension keeps its paper `#EDE4D3`; jevplay.games uses pink `#F386A1`. Every plate darker than 20% luminance gets a 2 px edge in the family accent at 60%. jevplay.games icons (`icon_pixel_art`) are reduced to their native pixel grid and scaled nearest-neighbour at an integer factor (the §9.5 avatar rule), never filtered.
**Plate rule.** When the icon's four corner pixels are opaque and within ΔE 3 of each other, the icon brings its own ground: the card is filled with that colour and the icon is drawn full-bleed (inset 0, clipped to the card radius), with its ground feathered into the card where a square icon does not reach the card's sides. There is never a plate inside a plate. T2 families without an icon get a dark plate (`card_fill`: Jev `#0C075C`, LibreEvolve `#032530`, Touched By An Agent `#2F0132`, romgenie `#183B31`); AI Ascension keeps its paper `#EDE4D3`; jevplay.games uses pink `#F386A1`. Every plate darker than 20% luminance gets a 2 px edge in the family accent at 60%. jevplay.games icons (`icon_pixel_art`) are reduced to their native pixel grid and scaled nearest-neighbour at an integer factor (the §9.5 avatar rule), never filtered. A custom transparent icon (`--icon`, `showcase.yml` `icon`, `assets/icon.png`; PNG mode P with transparency or RGBA) is fitted by its alpha bounds in the icon box, so transparent margins do not shrink it; this is compositor behaviour only (the PNG file is unchanged), and anything under `assets/brand` (bundled marks and logos, protected by resolved path, not by selection route, so `--icon` or `showcase.yml` `icon` pointing at `logo-dark.png` or `logo-dark-full.png` is never cropped), opaque or full-bleed icons, pixel-art icons and fully blank transparency keep their existing sizing. With `icon_pixel_art` set, alpha fit never applies, including an unrecognized native grid, which keeps the retained image size and placement.

### Refusals

Expand Down
40 changes: 38 additions & 2 deletions scripts/ctshowcase/iconbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,17 @@
Plate rule (review P1.2): an icon that brings its own opaque ground (four corners opaque and within ΔE 3 of each other)
fills the card with that ground and is drawn full-bleed, clipped to the card radius, so there is never a plate inside a
plate. Dark plates get a 2 px family-accent edge at 60% so they read on the navy canvas and on GitHub dark.

Custom transparent icons (--icon, showcase.yml icon, assets/icon.png) are fitted by their alpha bounds so transparent
margins do not shrink the art. Any file under assets/brand is treated as bundled artwork and is never fitted, even when
it is selected through --icon or showcase.yml icon; opaque or full-bleed icons, pixel art and blank images are unchanged.
"""
from __future__ import annotations

import math
import re
from itertools import combinations
from pathlib import Path

from PIL import Image, ImageChops

Expand All @@ -27,6 +32,7 @@
GROUND_DE = 3.0 # corner colours within this ΔE are one ground
DARK_PLATE = 0.2 # relative luminance under which a plate gets the accent edge
PIXEL_GRIDS = (16, 24, 32, 48, 64, 96, 128)
CUSTOM_ICON_SOURCES = frozenset({"cli", "override", "repo"}) # ctx.light_icon sources that are user art, not bundled


def corner_spread(im: Image.Image) -> float | None:
Expand Down Expand Up @@ -79,6 +85,30 @@ def native_pixels(im: Image.Image) -> Image.Image | None:
return None


def alpha_fit(im: Image.Image) -> Image.Image | None:
"""Crop custom transparent art to its alpha bounds, or None when no crop applies.

Fits custom transparent art by its alpha bounds so transparent margins do not shrink it. Compositor only: the
source file is never modified, and the input image is neither mutated nor reassigned. Returns None when the image
is fully transparent (blank) or when the alpha bounds are the whole image (opaque or full-bleed art)."""
rgba = im.convert("RGBA")
bbox = rgba.getchannel("A").getbbox()
if bbox is None or bbox == (0, 0, rgba.width, rgba.height):
return None
return rgba.crop(bbox)


def is_bundled_brand_asset(path, root) -> bool:
"""True when `path` resolves under <skill root>/assets/brand, whatever the selection route (cli, override or repo).

Bundled artwork under assets/brand is never alpha-cropped. Returns False when either path cannot be resolved."""
try:
Path(path).resolve().relative_to((Path(root) / "assets" / "brand").resolve())
return True
except (ValueError, OSError):
return False


def feather(im: Image.Image, sides: bool, ends: bool, frac: float = 0.14) -> Image.Image:
"""Blend a full-bleed icon's own ground into the card where the icon does not reach the card edge: an alpha ramp
over the outer 14% of the ground only (glyphs sit inside the avatar live area, so the mark itself is untouched).
Expand All @@ -96,7 +126,7 @@ def feather(im: Image.Image, sides: bool, ends: bool, frac: float = 0.14) -> Ima
return rgba


def _icon_nodes(ctx: RepoContext, im: Image.Image, box, clip=None) -> tuple[list, float | None]:
def _icon_nodes(ctx: RepoContext, im: Image.Image, box, clip=None, fit_alpha: bool = False) -> tuple[list, float | None]:
x, y, w, h = box
resample = "lanczos"
if ctx.style.get("icon_pixel_art"):
Expand All @@ -109,6 +139,10 @@ def _icon_nodes(ctx: RepoContext, im: Image.Image, box, clip=None) -> tuple[list
pic = Picture(x + (w - iw) / 2, y + (h - ih) / 2, iw, ih, im, "webp", 84, 2, resample)
# pixel art is meant to be enlarged: no icon-resolution warning (fit None)
return ([Clip(clip[2], clip[3], clip[4], [pic], clip[0], clip[1])] if clip else [pic]), None
# icon_pixel_art keeps the retained image size even when the native grid is not recognized (no alpha crop)
if fit_alpha and not ctx.style.get("icon_pixel_art"):
trimmed = alpha_fit(im)
im = trimmed if trimmed is not None else im
k = min(w / im.width, h / im.height)
iw, ih = im.width * k, im.height * k
if clip and (iw < w - 1 or ih < h - 1):
Expand Down Expand Up @@ -147,7 +181,9 @@ def plate(ctx: RepoContext, fonts: Fonts, card: tuple[float, float, float, float
except ValueError:
pass
box = (x, y, w, h) if full_bleed else inner
icon_nodes, fit = _icon_nodes(ctx, im, box, (x, y, w, h, radius) if full_bleed else None)
icon_nodes, fit = _icon_nodes(ctx, im, box, (x, y, w, h, radius) if full_bleed else None,
fit_alpha=src in CUSTOM_ICON_SOURCES and not full_bleed
and not is_bundled_brand_asset(path, ctx.brand.root))
nodes += icon_nodes
info = {"path": shown, "source": src, "plate": fill, "full_bleed": full_bleed}
else:
Expand Down
Loading
Loading