From b4fb3383d53eb6ef8ad162d24d93b2614e875562 Mon Sep 17 00:00:00 2001 From: Daniel Martin <56157528+dmartinochoa@users.noreply.github.com> Date: Tue, 19 May 2026 12:44:09 +0200 Subject: [PATCH] design: refresh marketplace icon (navy shield + teal accent + teal check) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the wireframe shield with a solid navy fill, a teal accent border, a 25%-opacity inner ribbon for depth, and a thick teal check with round caps. The shape stays brand-coherent with the existing galleryBanner.color (#04101a) and matches concept B from .claude/design_system/vscode-icon.html. Why B over A/C/D - A (solid teal shield) reads "security check" but loses the duotone brand palette and looks like every other security tool. - C (// pc monogram) is the most distinctive but cold-start users have to know "pc = pipeline-check" before the icon tells them anything — bad bet for a still-young extension. - D (pipeline stages with scan node) is the most literal but the three-node detail blurs at the 36×36 search-result thumbnail size. - B keeps the duotone, reads instantly as a security tool, and stays legible at every size the marketplace renders. Implementation - media/icon-source.svg: new file. The source of truth, easy to edit by hand if the design needs to evolve. - scripts/gen_icon.py: rewritten. Renders at 4× supersample (512×512) and downsamples with Lanczos for smooth edges that PIL's default antialiasing can't produce. Quadratic-Bezier sampling for the shield curves; explicit round-cap ellipses on the check endpoints to match SVG's stroke-linecap="round". - icon.png: regenerated. 7.9 KB. - .vscodeignore: excludes media/icon-source.svg and .claude/** so the source SVG and design references don't ship in the .vsix. Activity-bar icon (media/pipeline-check.svg, the inverted-Y pipeline glyph) deliberately stays as-is — different surface, different communication need. The marketplace icon says "this checks something"; the activity-bar icon says "this is about pipelines". Other design surfaces audited and found OK as-is: galleryBanner.color (navy, matches), status bar codicon ($(shield), matches), Findings tree severity icons (standard codicons, intentional VS Code convention), viewsWelcome state (polished in ux-polish PR). Also: .gitignore gains .claude/ (the user-added design references stay local; they're not shipped or git-tracked). Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 1 + .vscodeignore | 2 + icon.png | Bin 889 -> 7918 bytes media/icon-source.svg | 32 ++++++++ scripts/gen_icon.py | 180 +++++++++++++++++++++++++++--------------- 5 files changed, 150 insertions(+), 65 deletions(-) create mode 100644 media/icon-source.svg 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 da40684bb0b7ff9f0c7d115d1e81ef27ae8b17ff..0dafffef3e2f9212e3c6180dad5b0b8bba4766fa 100644 GIT binary patch literal 7918 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 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__":