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 .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -149,3 +149,4 @@ out/

# VS Code dev sandbox
.vscode-test/
.claude/
2 changes: 2 additions & 0 deletions .vscodeignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
.vscode/**
.vscode-test/**
.claude/**
.github/**
src/**
test-fixtures/**
Expand All @@ -14,6 +15,7 @@ ROADMAP.md
tsconfig.json
tsconfig.integration.json
vitest.config.ts
media/icon-source.svg
**/*.map
**/*.ts
**/tsconfig.json
Expand Down
Binary file modified icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
32 changes: 32 additions & 0 deletions media/icon-source.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
180 changes: 115 additions & 65 deletions scripts/gen_icon.py
Original file line number Diff line number Diff line change
@@ -1,111 +1,161 @@
"""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

from pathlib import Path

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__":
Expand Down
Loading