|
| 1 | +"""Generate the Skill Issue favicon from the product's score-ring motif. |
| 2 | +
|
| 3 | +Final mark (candidate C): score-ring gauge (#27272a track + #60a5fa arc with a |
| 4 | +rounded cap, 78% sweep from top) wrapping a bold Inter "S" on the app's #09090b |
| 5 | +tile. Rendered supersampled (4x) then downscaled with LANCZOS per icon size for |
| 6 | +crisp edges down to 16px. |
| 7 | +
|
| 8 | +Outputs: |
| 9 | + src/app/favicon.ico multi-size 16/32/48/64/256 |
| 10 | + src/app/apple-icon.png 180x180 square tile for iOS |
| 11 | + .next/shots/fav-final-* preview PNGs (legibility check) |
| 12 | +""" |
| 13 | +import math |
| 14 | +import os |
| 15 | +from PIL import Image, ImageDraw, ImageFont |
| 16 | + |
| 17 | +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) |
| 18 | +APP = os.path.join(ROOT, "src", "app") |
| 19 | +SHOTS = os.path.join(ROOT, ".next", "shots") |
| 20 | +FONT_BOLD = os.path.join(ROOT, "public", "fonts", "Inter-Bold.ttf") |
| 21 | +os.makedirs(SHOTS, exist_ok=True) |
| 22 | + |
| 23 | +S = 1024 # supersample canvas |
| 24 | +BG = (9, 9, 11, 255) # #09090b app background |
| 25 | +TRACK = (39, 39, 42, 255) # #27272a app border / faint ring |
| 26 | +ACCENT = (96, 165, 250, 255) # #60a5fa app accent |
| 27 | +LIGHT = (250, 250, 250, 255) # #fafafa foreground |
| 28 | + |
| 29 | + |
| 30 | +def draw_arc(d, cx, cy, radius, width, color, start_deg, sweep_deg, rounded=True): |
| 31 | + """0deg = top (12 o'clock); positive sweep = clockwise.""" |
| 32 | + pil_start = start_deg - 90 |
| 33 | + pil_end = pil_start + sweep_deg |
| 34 | + bbox = [cx - radius, cy - radius, cx + radius, cy + radius] |
| 35 | + d.arc(bbox, pil_start, pil_end, fill=color, width=width) |
| 36 | + if rounded: |
| 37 | + cap = width / 2 |
| 38 | + for ang in (pil_start, pil_end): |
| 39 | + a = math.radians(ang) |
| 40 | + ex = cx + radius * math.cos(a) |
| 41 | + ey = cy + radius * math.sin(a) |
| 42 | + d.ellipse([ex - cap, ey - cap, ex + cap, ey + cap], fill=color) |
| 43 | + |
| 44 | + |
| 45 | +def render(radius_frac=0.22, s_scale=1.0, stroke_scale=1.0): |
| 46 | + """Render the mark at the supersample size. radius_frac=0 -> square tile.""" |
| 47 | + img = Image.new("RGBA", (S, S), (0, 0, 0, 0)) |
| 48 | + d = ImageDraw.Draw(img) |
| 49 | + r = int(S * radius_frac) |
| 50 | + if r > 0: |
| 51 | + d.rounded_rectangle([0, 0, S - 1, S - 1], radius=r, fill=BG) |
| 52 | + else: |
| 53 | + d.rectangle([0, 0, S - 1, S - 1], fill=BG) |
| 54 | + |
| 55 | + cx = cy = S / 2 |
| 56 | + radius = S * 0.345 |
| 57 | + width = int(S * 0.105 * stroke_scale) |
| 58 | + draw_arc(d, cx, cy, radius, width, TRACK, 0, 360, rounded=False) |
| 59 | + draw_arc(d, cx, cy, radius, width, ACCENT, 0, 281, rounded=True) |
| 60 | + |
| 61 | + font = ImageFont.truetype(FONT_BOLD, int(S * 0.40 * s_scale)) |
| 62 | + bb = d.textbbox((0, 0), "S", font=font) |
| 63 | + tw, th = bb[2] - bb[0], bb[3] - bb[1] |
| 64 | + d.text((cx - tw / 2 - bb[0], cy - th / 2 - bb[1]), "S", font=font, fill=LIGHT) |
| 65 | + return img |
| 66 | + |
| 67 | + |
| 68 | +# Tab-favicon master (rounded tile). At very small sizes nudge the S a touch |
| 69 | +# larger and the stroke a touch thicker so both survive the downscale. |
| 70 | +master = render(radius_frac=0.22) |
| 71 | +master_small = render(radius_frac=0.22, s_scale=1.06, stroke_scale=1.06) |
| 72 | + |
| 73 | +ico_sizes = [16, 32, 48, 64, 256] |
| 74 | +frames = [] |
| 75 | +for sz in ico_sizes: |
| 76 | + src = master_small if sz <= 32 else master |
| 77 | + frames.append(src.resize((sz, sz), Image.LANCZOS)) |
| 78 | + |
| 79 | +ico_path = os.path.join(APP, "favicon.ico") |
| 80 | +frames[-1].save(ico_path, format="ICO", sizes=[(s, s) for s in ico_sizes], |
| 81 | + append_images=frames[:-1]) |
| 82 | +print("wrote", ico_path) |
| 83 | + |
| 84 | +# Apple touch icon: square (iOS applies its own mask), opaque, 180px. |
| 85 | +apple = render(radius_frac=0.0).resize((180, 180), Image.LANCZOS) |
| 86 | +apple_path = os.path.join(APP, "apple-icon.png") |
| 87 | +apple.convert("RGB").save(apple_path) |
| 88 | +print("wrote", apple_path) |
| 89 | + |
| 90 | +# Legibility previews. |
| 91 | +for sz in (16, 32, 48): |
| 92 | + (master_small if sz <= 32 else master).resize((sz, sz), Image.LANCZOS).save( |
| 93 | + os.path.join(SHOTS, f"fav-final-{sz}.png")) |
| 94 | +# Side-by-side strip at native sizes on a neutral chip. |
| 95 | +strip = Image.new("RGBA", (16 + 32 + 48 + 64 + 80, 80), (24, 24, 27, 255)) |
| 96 | +x = 16 |
| 97 | +for sz in (16, 32, 48): |
| 98 | + im = Image.open(os.path.join(SHOTS, f"fav-final-{sz}.png")) |
| 99 | + strip.alpha_composite(im, (x, (80 - sz) // 2)) |
| 100 | + x += sz + 16 |
| 101 | +strip.alpha_composite(master.resize((64, 64), Image.LANCZOS), (x, 8)) |
| 102 | +strip.save(os.path.join(SHOTS, "fav-final-strip.png")) |
| 103 | +print("preview:", os.path.join(SHOTS, "fav-final-strip.png")) |
0 commit comments