diff --git a/.gitignore b/.gitignore index 5fd5446..3514032 100644 --- a/.gitignore +++ b/.gitignore @@ -149,3 +149,4 @@ out/ # VS Code dev sandbox .vscode-test/ +.claude/ diff --git a/.vscodeignore b/.vscodeignore index f3a1513..110ab9f 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -1,5 +1,6 @@ .vscode/** .vscode-test/** +.claude/** .github/** src/** test-fixtures/** @@ -14,6 +15,7 @@ ROADMAP.md tsconfig.json tsconfig.integration.json vitest.config.ts +media/icon-source.svg **/*.map **/*.ts **/tsconfig.json diff --git a/icon.png b/icon.png index da40684..0dafffe 100644 Binary files a/icon.png and b/icon.png differ diff --git a/media/icon-source.svg b/media/icon-source.svg new file mode 100644 index 0000000..27a5d48 --- /dev/null +++ b/media/icon-source.svg @@ -0,0 +1,32 @@ + + + + + + + + + + diff --git a/scripts/gen_icon.py b/scripts/gen_icon.py index 2e0463a..ce2a096 100644 --- a/scripts/gen_icon.py +++ b/scripts/gen_icon.py @@ -1,14 +1,28 @@ -"""Generate the marketplace icon (128x128 PNG) from the brand SVG. +"""Generate the marketplace icon (128×128 PNG) from the brand SVG. -Re-run when the brand mark changes; the resulting ``icon.png`` is -committed alongside this script so contributors don't need to install -Pillow just to read it. The script depends on Pillow (``pip install -pillow``); a single one-shot, not part of the extension build. +The source of truth is ``media/icon-source.svg`` (concept B: navy +shield with a teal accent border + teal check). This script is a +pure-Python renderer that produces the matching PNG so contributors +don't need to install Inkscape / Cairo / a browser to read the icon. -The mark is the same shield + teal checkmark used in the -pipeline-check docs hero. Path data lifted verbatim from -``dmartinochoa/pipeline-check`` ``docs/index.md`` so the marketplace -icon, the docs hero, and the favicon share a single visual identity. +Implementation notes +-------------------- + +* Renders at 4× supersampling (512×512) and downsamples to 128×128 + with Lanczos resampling. PIL's straight ``ImageDraw`` antialiasing is + blocky on diagonal strokes; the supersample → downsample pipeline + gives clean edges that match what a real SVG renderer would produce. + +* The shield path uses two quadratic Beziers (top-left and top-right + arcs at the base) — sampled into polygon points for ``ImageDraw``. + +* The check uses ``ImageDraw.line`` with ``joint="curve"`` for the + vertex join, plus explicit circles at the two endpoints to simulate + ``stroke-linecap="round"``. + +Dependencies: Pillow (``pip install pillow``). Run once after the +brand mark changes; the resulting ``icon.png`` is committed alongside +this script. """ from __future__ import annotations @@ -16,96 +30,132 @@ from PIL import Image, ImageDraw +# Output size + supersample factor. The renderer composes everything in +# the supersampled space then downsizes — keeps the diagonal stroke of +# the check from going staircase-blocky. ICON_SIZE = 128 -SCALE = 2 # viewBox is 64x64 in the brand SVG. +SS = 4 +SCALE = SS # design SVG viewBox is already 128×128, so we just scale by SS -# Pipeline-Check design tokens (lifted from -# pipeline_check/core/_design_tokens.css + extra.css). +# Pipeline-Check design tokens (mirrors media/icon-source.svg and +# pipeline_check/core/_design_tokens.css). NAVY_950 = "#04101a" -WHITE = "#f0f2f5" TEAL = "#1ba3a9" OUT_DIR = Path(__file__).resolve().parent.parent OUT_PATH = OUT_DIR / "icon.png" -def _cubic_bezier( +def _quadratic_bezier( p0: tuple[float, float], p1: tuple[float, float], p2: tuple[float, float], - p3: tuple[float, float], - steps: int = 32, + steps: int = 48, ) -> list[tuple[float, float]]: - """Sample a cubic Bezier curve into ``steps + 1`` polygon points.""" - points: list[tuple[float, float]] = [] + """Sample a quadratic Bezier curve into ``steps + 1`` polygon points.""" + out: list[tuple[float, float]] = [] for i in range(steps + 1): t = i / steps mt = 1 - t - x = ( - mt ** 3 * p0[0] - + 3 * mt ** 2 * t * p1[0] - + 3 * mt * t ** 2 * p2[0] - + t ** 3 * p3[0] - ) - y = ( - mt ** 3 * p0[1] - + 3 * mt ** 2 * t * p1[1] - + 3 * mt * t ** 2 * p2[1] - + t ** 3 * p3[1] - ) - points.append((x, y)) - return points + x = mt * mt * p0[0] + 2 * mt * t * p1[0] + t * t * p2[0] + y = mt * mt * p0[1] + 2 * mt * t * p1[1] + t * t * p2[1] + out.append((x, y)) + return out def _shield_outline() -> list[tuple[float, float]]: - """Return the shield polygon in viewBox (64x64) coordinates. - - Mirrors the SVG path:: + """Shield polygon in the design's 128×128 viewBox coords. - M32 6 L54 13 V31 - C54 44.5 44.5 53.5 32 58 - C19.5 53.5 10 44.5 10 31 - V13 Z + Mirrors media/icon-source.svg:: - Two cubic Beziers form the bottom curve; the rest is straight. + M 64 12 L 110 26 L 110 62 Q 110 92 64 116 Q 18 92 18 62 L 18 26 Z """ pts: list[tuple[float, float]] = [ - (32.0, 6.0), # top center - (54.0, 13.0), # top right - (54.0, 31.0), # vertical line down right side + (64.0, 12.0), # top-centre cusp + (110.0, 26.0), # top-right + (110.0, 62.0), # right-side straight down ] - # First bezier: down-right curve to bottom apex. - pts.extend(_cubic_bezier( - (54.0, 31.0), (54.0, 44.5), (44.5, 53.5), (32.0, 58.0), - )) - # Second bezier: bottom apex back up to left side. - pts.extend(_cubic_bezier( - (32.0, 58.0), (19.5, 53.5), (10.0, 44.5), (10.0, 31.0), - )) - pts.append((10.0, 13.0)) # vertical line up left side + # Bottom-right curve down to the apex. + pts.extend(_quadratic_bezier((110.0, 62.0), (110.0, 92.0), (64.0, 116.0))) + # Bottom-left curve back up. + pts.extend(_quadratic_bezier((64.0, 116.0), (18.0, 92.0), (18.0, 62.0))) + pts.append((18.0, 26.0)) # left-side straight up return pts -def _scaled( - points: list[tuple[float, float]], -) -> list[tuple[float, float]]: - return [(x * SCALE, y * SCALE) for x, y in points] +def _scaled(pts: list[tuple[float, float]]) -> list[tuple[float, float]]: + return [(x * SCALE, y * SCALE) for x, y in pts] + + +def _round_cap( + draw: ImageDraw.ImageDraw, + centre: tuple[float, float], + width: float, + fill: str, +) -> None: + """Draw a filled circle to simulate ``stroke-linecap="round"``.""" + r = width / 2.0 + draw.ellipse( + (centre[0] - r, centre[1] - r, centre[0] + r, centre[1] + r), + fill=fill, + ) def main() -> None: - img = Image.new("RGBA", (ICON_SIZE, ICON_SIZE), NAVY_950) - draw = ImageDraw.Draw(img) + big_size = ICON_SIZE * SS + img = Image.new("RGBA", (big_size, big_size), (0, 0, 0, 0)) + draw = ImageDraw.Draw(img, "RGBA") - # Shield outline, no fill, 5px stroke (≈2.5 in viewBox * SCALE). shield = _scaled(_shield_outline()) - draw.line(shield + [shield[0]], fill=WHITE, width=5, joint="curve") - - # Checkmark: M22 32 L29 39 L43 24, 6px stroke (3 in viewBox * SCALE). - check = _scaled([(22.0, 32.0), (29.0, 39.0), (43.0, 24.0)]) - draw.line(check, fill=TEAL, width=6, joint="curve") + # Fill the navy shield. + draw.polygon(shield, fill=NAVY_950) + + # Stroke the teal accent border (width 4 in viewBox space). + border_width = 4 * SCALE + draw.line( + shield + [shield[0]], + fill=TEAL, + width=border_width, + joint="curve", + ) + # Round-cap the implicit "close path" join at the top cusp so it + # doesn't read as a notch. + _round_cap(draw, shield[0], border_width, TEAL) + + # Inner ribbon at 25% opacity. Implemented by drawing the path at + # full opacity onto a transparent layer, then alpha-compositing + # with reduced opacity onto the main image. + ribbon = _scaled( + [(64.0, 22.0), (100.0, 32.0), (100.0, 62.0)] + + _quadratic_bezier((100.0, 62.0), (100.0, 86.0), (64.0, 106.0)) + + _quadratic_bezier((64.0, 106.0), (28.0, 86.0), (28.0, 62.0)) + + [(28.0, 32.0)] + ) + ribbon_layer = Image.new("RGBA", (big_size, big_size), (0, 0, 0, 0)) + ribbon_draw = ImageDraw.Draw(ribbon_layer, "RGBA") + ribbon_draw.line( + ribbon + [ribbon[0]], + fill=TEAL, + width=1 * SCALE, + joint="curve", + ) + # Reduce to 25% opacity. + alpha = ribbon_layer.split()[-1].point(lambda v: int(v * 0.25)) + ribbon_layer.putalpha(alpha) + img.alpha_composite(ribbon_layer) + + # Check mark. + check_pts = _scaled([(40.0, 64.0), (56.0, 80.0), (88.0, 48.0)]) + check_width = 11 * SCALE + draw.line(check_pts, fill=TEAL, width=check_width, joint="curve") + _round_cap(draw, check_pts[0], check_width, TEAL) + _round_cap(draw, check_pts[-1], check_width, TEAL) + + # Downsample with Lanczos for smooth edges. + img = img.resize((ICON_SIZE, ICON_SIZE), Image.LANCZOS) img.save(OUT_PATH, "PNG", optimize=True) - print(f"wrote {OUT_PATH} ({ICON_SIZE}x{ICON_SIZE})") + print(f"wrote {OUT_PATH} ({ICON_SIZE}×{ICON_SIZE}) from media/icon-source.svg") if __name__ == "__main__":