Skip to content

Commit 4507a25

Browse files
authored
Merge pull request #18 from greylag-ci/redesign/marketplace-icon
design: refresh marketplace icon (navy shield + teal accent)
2 parents 49b5a68 + b4fb338 commit 4507a25

5 files changed

Lines changed: 150 additions & 65 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,3 +149,4 @@ out/
149149

150150
# VS Code dev sandbox
151151
.vscode-test/
152+
.claude/

.vscodeignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
.vscode/**
22
.vscode-test/**
3+
.claude/**
34
.github/**
45
src/**
56
test-fixtures/**
@@ -14,6 +15,7 @@ ROADMAP.md
1415
tsconfig.json
1516
tsconfig.integration.json
1617
vitest.config.ts
18+
media/icon-source.svg
1719
**/*.map
1820
**/*.ts
1921
**/tsconfig.json

icon.png

6.86 KB
Loading

media/icon-source.svg

Lines changed: 32 additions & 0 deletions
Loading

scripts/gen_icon.py

Lines changed: 115 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,111 +1,161 @@
1-
"""Generate the marketplace icon (128x128 PNG) from the brand SVG.
1+
"""Generate the marketplace icon (128×128 PNG) from the brand SVG.
22
3-
Re-run when the brand mark changes; the resulting ``icon.png`` is
4-
committed alongside this script so contributors don't need to install
5-
Pillow just to read it. The script depends on Pillow (``pip install
6-
pillow``); a single one-shot, not part of the extension build.
3+
The source of truth is ``media/icon-source.svg`` (concept B: navy
4+
shield with a teal accent border + teal check). This script is a
5+
pure-Python renderer that produces the matching PNG so contributors
6+
don't need to install Inkscape / Cairo / a browser to read the icon.
77
8-
The mark is the same shield + teal checkmark used in the
9-
pipeline-check docs hero. Path data lifted verbatim from
10-
``dmartinochoa/pipeline-check`` ``docs/index.md`` so the marketplace
11-
icon, the docs hero, and the favicon share a single visual identity.
8+
Implementation notes
9+
--------------------
10+
11+
* Renders at 4× supersampling (512×512) and downsamples to 128×128
12+
with Lanczos resampling. PIL's straight ``ImageDraw`` antialiasing is
13+
blocky on diagonal strokes; the supersample → downsample pipeline
14+
gives clean edges that match what a real SVG renderer would produce.
15+
16+
* The shield path uses two quadratic Beziers (top-left and top-right
17+
arcs at the base) — sampled into polygon points for ``ImageDraw``.
18+
19+
* The check uses ``ImageDraw.line`` with ``joint="curve"`` for the
20+
vertex join, plus explicit circles at the two endpoints to simulate
21+
``stroke-linecap="round"``.
22+
23+
Dependencies: Pillow (``pip install pillow``). Run once after the
24+
brand mark changes; the resulting ``icon.png`` is committed alongside
25+
this script.
1226
"""
1327
from __future__ import annotations
1428

1529
from pathlib import Path
1630

1731
from PIL import Image, ImageDraw
1832

33+
# Output size + supersample factor. The renderer composes everything in
34+
# the supersampled space then downsizes — keeps the diagonal stroke of
35+
# the check from going staircase-blocky.
1936
ICON_SIZE = 128
20-
SCALE = 2 # viewBox is 64x64 in the brand SVG.
37+
SS = 4
38+
SCALE = SS # design SVG viewBox is already 128×128, so we just scale by SS
2139

22-
# Pipeline-Check design tokens (lifted from
23-
# pipeline_check/core/_design_tokens.css + extra.css).
40+
# Pipeline-Check design tokens (mirrors media/icon-source.svg and
41+
# pipeline_check/core/_design_tokens.css).
2442
NAVY_950 = "#04101a"
25-
WHITE = "#f0f2f5"
2643
TEAL = "#1ba3a9"
2744

2845
OUT_DIR = Path(__file__).resolve().parent.parent
2946
OUT_PATH = OUT_DIR / "icon.png"
3047

3148

32-
def _cubic_bezier(
49+
def _quadratic_bezier(
3350
p0: tuple[float, float],
3451
p1: tuple[float, float],
3552
p2: tuple[float, float],
36-
p3: tuple[float, float],
37-
steps: int = 32,
53+
steps: int = 48,
3854
) -> list[tuple[float, float]]:
39-
"""Sample a cubic Bezier curve into ``steps + 1`` polygon points."""
40-
points: list[tuple[float, float]] = []
55+
"""Sample a quadratic Bezier curve into ``steps + 1`` polygon points."""
56+
out: list[tuple[float, float]] = []
4157
for i in range(steps + 1):
4258
t = i / steps
4359
mt = 1 - t
44-
x = (
45-
mt ** 3 * p0[0]
46-
+ 3 * mt ** 2 * t * p1[0]
47-
+ 3 * mt * t ** 2 * p2[0]
48-
+ t ** 3 * p3[0]
49-
)
50-
y = (
51-
mt ** 3 * p0[1]
52-
+ 3 * mt ** 2 * t * p1[1]
53-
+ 3 * mt * t ** 2 * p2[1]
54-
+ t ** 3 * p3[1]
55-
)
56-
points.append((x, y))
57-
return points
60+
x = mt * mt * p0[0] + 2 * mt * t * p1[0] + t * t * p2[0]
61+
y = mt * mt * p0[1] + 2 * mt * t * p1[1] + t * t * p2[1]
62+
out.append((x, y))
63+
return out
5864

5965

6066
def _shield_outline() -> list[tuple[float, float]]:
61-
"""Return the shield polygon in viewBox (64x64) coordinates.
62-
63-
Mirrors the SVG path::
67+
"""Shield polygon in the design's 128×128 viewBox coords.
6468
65-
M32 6 L54 13 V31
66-
C54 44.5 44.5 53.5 32 58
67-
C19.5 53.5 10 44.5 10 31
68-
V13 Z
69+
Mirrors media/icon-source.svg::
6970
70-
Two cubic Beziers form the bottom curve; the rest is straight.
71+
M 64 12 L 110 26 L 110 62 Q 110 92 64 116 Q 18 92 18 62 L 18 26 Z
7172
"""
7273
pts: list[tuple[float, float]] = [
73-
(32.0, 6.0), # top center
74-
(54.0, 13.0), # top right
75-
(54.0, 31.0), # vertical line down right side
74+
(64.0, 12.0), # top-centre cusp
75+
(110.0, 26.0), # top-right
76+
(110.0, 62.0), # right-side straight down
7677
]
77-
# First bezier: down-right curve to bottom apex.
78-
pts.extend(_cubic_bezier(
79-
(54.0, 31.0), (54.0, 44.5), (44.5, 53.5), (32.0, 58.0),
80-
))
81-
# Second bezier: bottom apex back up to left side.
82-
pts.extend(_cubic_bezier(
83-
(32.0, 58.0), (19.5, 53.5), (10.0, 44.5), (10.0, 31.0),
84-
))
85-
pts.append((10.0, 13.0)) # vertical line up left side
78+
# Bottom-right curve down to the apex.
79+
pts.extend(_quadratic_bezier((110.0, 62.0), (110.0, 92.0), (64.0, 116.0)))
80+
# Bottom-left curve back up.
81+
pts.extend(_quadratic_bezier((64.0, 116.0), (18.0, 92.0), (18.0, 62.0)))
82+
pts.append((18.0, 26.0)) # left-side straight up
8683
return pts
8784

8885

89-
def _scaled(
90-
points: list[tuple[float, float]],
91-
) -> list[tuple[float, float]]:
92-
return [(x * SCALE, y * SCALE) for x, y in points]
86+
def _scaled(pts: list[tuple[float, float]]) -> list[tuple[float, float]]:
87+
return [(x * SCALE, y * SCALE) for x, y in pts]
88+
89+
90+
def _round_cap(
91+
draw: ImageDraw.ImageDraw,
92+
centre: tuple[float, float],
93+
width: float,
94+
fill: str,
95+
) -> None:
96+
"""Draw a filled circle to simulate ``stroke-linecap="round"``."""
97+
r = width / 2.0
98+
draw.ellipse(
99+
(centre[0] - r, centre[1] - r, centre[0] + r, centre[1] + r),
100+
fill=fill,
101+
)
93102

94103

95104
def main() -> None:
96-
img = Image.new("RGBA", (ICON_SIZE, ICON_SIZE), NAVY_950)
97-
draw = ImageDraw.Draw(img)
105+
big_size = ICON_SIZE * SS
106+
img = Image.new("RGBA", (big_size, big_size), (0, 0, 0, 0))
107+
draw = ImageDraw.Draw(img, "RGBA")
98108

99-
# Shield outline, no fill, 5px stroke (≈2.5 in viewBox * SCALE).
100109
shield = _scaled(_shield_outline())
101-
draw.line(shield + [shield[0]], fill=WHITE, width=5, joint="curve")
102-
103-
# Checkmark: M22 32 L29 39 L43 24, 6px stroke (3 in viewBox * SCALE).
104-
check = _scaled([(22.0, 32.0), (29.0, 39.0), (43.0, 24.0)])
105-
draw.line(check, fill=TEAL, width=6, joint="curve")
106110

111+
# Fill the navy shield.
112+
draw.polygon(shield, fill=NAVY_950)
113+
114+
# Stroke the teal accent border (width 4 in viewBox space).
115+
border_width = 4 * SCALE
116+
draw.line(
117+
shield + [shield[0]],
118+
fill=TEAL,
119+
width=border_width,
120+
joint="curve",
121+
)
122+
# Round-cap the implicit "close path" join at the top cusp so it
123+
# doesn't read as a notch.
124+
_round_cap(draw, shield[0], border_width, TEAL)
125+
126+
# Inner ribbon at 25% opacity. Implemented by drawing the path at
127+
# full opacity onto a transparent layer, then alpha-compositing
128+
# with reduced opacity onto the main image.
129+
ribbon = _scaled(
130+
[(64.0, 22.0), (100.0, 32.0), (100.0, 62.0)]
131+
+ _quadratic_bezier((100.0, 62.0), (100.0, 86.0), (64.0, 106.0))
132+
+ _quadratic_bezier((64.0, 106.0), (28.0, 86.0), (28.0, 62.0))
133+
+ [(28.0, 32.0)]
134+
)
135+
ribbon_layer = Image.new("RGBA", (big_size, big_size), (0, 0, 0, 0))
136+
ribbon_draw = ImageDraw.Draw(ribbon_layer, "RGBA")
137+
ribbon_draw.line(
138+
ribbon + [ribbon[0]],
139+
fill=TEAL,
140+
width=1 * SCALE,
141+
joint="curve",
142+
)
143+
# Reduce to 25% opacity.
144+
alpha = ribbon_layer.split()[-1].point(lambda v: int(v * 0.25))
145+
ribbon_layer.putalpha(alpha)
146+
img.alpha_composite(ribbon_layer)
147+
148+
# Check mark.
149+
check_pts = _scaled([(40.0, 64.0), (56.0, 80.0), (88.0, 48.0)])
150+
check_width = 11 * SCALE
151+
draw.line(check_pts, fill=TEAL, width=check_width, joint="curve")
152+
_round_cap(draw, check_pts[0], check_width, TEAL)
153+
_round_cap(draw, check_pts[-1], check_width, TEAL)
154+
155+
# Downsample with Lanczos for smooth edges.
156+
img = img.resize((ICON_SIZE, ICON_SIZE), Image.LANCZOS)
107157
img.save(OUT_PATH, "PNG", optimize=True)
108-
print(f"wrote {OUT_PATH} ({ICON_SIZE}x{ICON_SIZE})")
158+
print(f"wrote {OUT_PATH} ({ICON_SIZE}×{ICON_SIZE}) from media/icon-source.svg")
109159

110160

111161
if __name__ == "__main__":

0 commit comments

Comments
 (0)