diff --git a/apps/desktop/assets/icon.icon/Assets/art-dark.png b/apps/desktop/assets/icon.icon/Assets/art-dark.png new file mode 100644 index 0000000000..2031563e62 Binary files /dev/null and b/apps/desktop/assets/icon.icon/Assets/art-dark.png differ diff --git a/apps/desktop/assets/icon.icon/Assets/art-light.png b/apps/desktop/assets/icon.icon/Assets/art-light.png new file mode 100644 index 0000000000..a64468c9ec Binary files /dev/null and b/apps/desktop/assets/icon.icon/Assets/art-light.png differ diff --git a/apps/desktop/assets/icon.icon/Assets/border-dark.png b/apps/desktop/assets/icon.icon/Assets/border-dark.png new file mode 100644 index 0000000000..0c12da9b45 Binary files /dev/null and b/apps/desktop/assets/icon.icon/Assets/border-dark.png differ diff --git a/apps/desktop/assets/icon.icon/Assets/border-light.png b/apps/desktop/assets/icon.icon/Assets/border-light.png new file mode 100644 index 0000000000..2ffc3dd6ae Binary files /dev/null and b/apps/desktop/assets/icon.icon/Assets/border-light.png differ diff --git a/apps/desktop/assets/icon.icon/icon.json b/apps/desktop/assets/icon.icon/icon.json new file mode 100644 index 0000000000..69f56ac53d --- /dev/null +++ b/apps/desktop/assets/icon.icon/icon.json @@ -0,0 +1,65 @@ +{ + "fill-specializations": [ + { + "value": { + "solid": "srgb:1.00000,1.00000,1.00000,1.00000" + } + }, + { + "appearance": "dark", + "value": { + "solid": "srgb:0.05098,0.06667,0.09020,1.00000" + } + } + ], + "groups": [ + { + "lighting": "individual", + "specular": false, + "translucency": { + "enabled": false, + "value": 0 + }, + "shadow": { + "kind": "none", + "opacity": 0 + }, + "blur-material": null, + "layers": [ + { + "name": "border", + "image-name": "border-light.png", + "image-name-specializations": [ + { + "value": "border-light.png" + }, + { + "appearance": "dark", + "value": "border-dark.png" + } + ], + "glass": false, + "hidden": false + }, + { + "name": "art", + "image-name": "art-light.png", + "image-name-specializations": [ + { + "value": "art-light.png" + }, + { + "appearance": "dark", + "value": "art-dark.png" + } + ], + "glass": false, + "hidden": false + } + ] + } + ], + "supported-platforms": { + "squares": "shared" + } +} diff --git a/scripts/generate_icons.py b/scripts/generate_icons.py index 815f3c4b23..d6b6a9692e 100644 --- a/scripts/generate_icons.py +++ b/scripts/generate_icons.py @@ -24,6 +24,10 @@ Sources of truth — two axes, composed per target: its icns targets render from an in-memory mac master that puts the same squircle on Apple's 824x824 (r=185.4) grid — centered in 1024 with 100px margins — so the icon matches the size of Apple-template neighbors. + macOS 26 additionally gets apps/desktop/assets/icon.icon, an Icon Composer + package of canvas-filling layers (border band + girl, light/dark) that the + system masks into its own squircle; electron-builder compiles it to + Assets.car next to the .icns, which macOS <= 15 keeps showing. Desktop build identity comes from HERMES_PAYLOAD_TAG / HERMES_BUILD_COMMIT: Canary uses yellow/dark-yellow backgrounds. Commit builds use red/dark-red @@ -47,7 +51,7 @@ Dependencies: Pillow and resvg-py are core runtime dependencies; run this file with a Hermes runtime interpreter (scripts/generate-icons.mjs uses HERMES_PYTHON). -Outputs (30 files): +Outputs (40 files): assets/icon-master.svg generated light master assets/icon-master-dark.svg generated dark master apps/desktop/assets/icon.png 1024x1024 squircle (light) @@ -56,6 +60,9 @@ Outputs (30 files): apps/desktop/assets/icon-dark.png 1024x1024 squircle (dark) apps/desktop/assets/icon-dark.ico 16,24,32,48,64,128,256 apps/desktop/assets/icon-dark.icns 16..1024 (real ICNS) + apps/desktop/assets/icon.icon/icon.json Icon Composer manifest (macOS 26) + apps/desktop/assets/icon.icon/Assets/border-*.png 1024 border band, light/dark ink + apps/desktop/assets/icon.icon/Assets/art-*.png 1024 girl (+ commit badge), light/dark apps/desktop/assets/appx/Wide310x150Logo.png 310x150, squircle 100 centered apps/desktop/assets/appx/StoreLogo.png 50x50 squircle apps/desktop/assets/appx/Square44x44Logo.png 44x44 squircle @@ -86,6 +93,8 @@ from __future__ import annotations import argparse import io +import json +import math import os import re import sys @@ -133,6 +142,10 @@ GIRL_VIEWBOX = 5487.0615 CHECK_SIZES: dict[str, tuple[str, tuple[int, int]]] = { "apps/desktop/assets/icon.png": ("PNG", (1024, 1024)), "apps/desktop/assets/icon-dark.png": ("PNG", (1024, 1024)), + "apps/desktop/assets/icon.icon/Assets/border-light.png": ("PNG", (1024, 1024)), + "apps/desktop/assets/icon.icon/Assets/border-dark.png": ("PNG", (1024, 1024)), + "apps/desktop/assets/icon.icon/Assets/art-light.png": ("PNG", (1024, 1024)), + "apps/desktop/assets/icon.icon/Assets/art-dark.png": ("PNG", (1024, 1024)), "apps/desktop/assets/appx/Wide310x150Logo.png": ("PNG", (310, 150)), "apps/desktop/assets/appx/Wide310x150Logo-dark.png": ("PNG", (310, 150)), "apps/desktop/assets/appx/StoreLogo.png": ("PNG", (50, 50)), @@ -167,6 +180,11 @@ TARGETS: list[tuple[str, str, object]] = [ ("apps/desktop/assets/icon-dark.png", "png_dark", 1024), ("apps/desktop/assets/icon-dark.ico", "ico_dark", [16, 24, 32, 48, 64, 128, 256]), ("apps/desktop/assets/icon-dark.icns", "icns_dark", None), + ("apps/desktop/assets/icon.icon/icon.json", "icon_manifest", None), + ("apps/desktop/assets/icon.icon/Assets/border-light.png", "icon_border", "#000000"), + ("apps/desktop/assets/icon.icon/Assets/border-dark.png", "icon_border", "#ffffff"), + ("apps/desktop/assets/icon.icon/Assets/art-light.png", "icon_art", "black"), + ("apps/desktop/assets/icon.icon/Assets/art-dark.png", "icon_art", "white"), ("apps/desktop/assets/appx/Wide310x150Logo.png", "wide", (310, 150)), ("apps/desktop/assets/appx/StoreLogo.png", "png", 50), ("apps/desktop/assets/appx/Square44x44Logo.png", "png", 44), @@ -348,6 +366,23 @@ def drag_bottom_nodes(path: ET.Element, *, cutoff: float, band: float, distance: path.set("d", " ".join(tokens)) +def portrait_layer(art: IconArt, girl: str, bg: str, join_bottom: float) -> ET.Element: + """The registered girl with her lowest nodes dragged down to `join_bottom` + (the border's inner edge) so hair meets the ring instead of floating.""" + box = GIRL_BOXES[bg] + portrait = ET.fromstring(girl_layer(art, girl, box, align="xMidYMax")) + _, y, portrait_width, portrait_height = box + _, by, bw, bh = girl_bbox(art, girl) + scale = min(portrait_width / bw, portrait_height / bh) + drag_bottom_nodes( + portrait[0], cutoff=by + bh * 0.97, band=bh * 0.02, + distance=max(0.0, join_bottom - (y + portrait_height)) / scale, + ) + # Keep the fitted viewBox fixed, but let edited nodes reach into the border. + portrait.set("overflow", "visible") + return portrait + + def compose_svg(art: IconArt, girl: str, bg: str) -> str: """Full svg text: background + girl layer, in the background's native coordinate space (resvg scales to whatever output size is requested, so @@ -368,18 +403,7 @@ def compose_svg(art: IconArt, girl: str, bg: str) -> str: tile.set("stroke-width", str(thickness)) inner = "".join(ET.tostring(child, encoding="unicode") for child in background) clip = ET.tostring(silhouette, encoding="unicode") - box = GIRL_BOXES[bg] - portrait = ET.fromstring(girl_layer(art, girl, box, align="xMidYMax")) - _, y, portrait_width, portrait_height = box - _, by, bw, bh = girl_bbox(art, girl) - scale = min(portrait_width / bw, portrait_height / bh) - join_bottom = geometry["y"] + geometry["height"] - thickness + 10 - drag_bottom_nodes( - portrait[0], cutoff=by + bh * 0.97, band=bh * 0.02, - distance=max(0.0, join_bottom - (y + portrait_height)) / scale, - ) - # Keep the fitted viewBox fixed, but let edited nodes reach into the border. - portrait.set("overflow", "visible") + portrait = portrait_layer(art, girl, bg, geometry["y"] + geometry["height"] - thickness + 10) badge = f" {commit_layer(art.commit, bg)}\n" if art.commit else "" return ( f'\n' @@ -391,6 +415,182 @@ def compose_svg(art: IconArt, girl: str, bg: str) -> str: ) +# ─── macOS 26 layered icon (.icon → Assets.car) ───────────────────────────── +# +# macOS 26 clips every app icon to its own mask and shows legacy .icns art +# inside a glass plate, so a ring drawn around our tile no longer follows the +# visible outline. The .icon package gives the system canvas-filling layers +# to mask itself: the border layer is a band whose OUTER edge is the canvas +# (the system's mask becomes the outline) and whose INNER edge is the mask +# offset inward by the border thickness — constant thickness by construction. +# +# The mask is Apple's smoothed rounded square: a circular arc flanked by two +# cubic easing segments (the Figma corner-smoothing construction). Fitted to +# macOS 26.6.2's own render of a system icon on the 824-on-1024 grid: +# radius 214px, smoothing 0.645 (rms 0.32px, worst 0.81px). Layers are +# placed on that grid by the system, so in layer coordinates the mask fills +# the canvas. +MAC_MASK_RADIUS_FRACTION = 214.0 / 824.0 +MAC_MASK_SMOOTHING = 0.645 +ICON_CANVAS = 1024 + + +def _cubic(p0, p1, p2, p3, t: float) -> tuple[float, float]: + u = 1.0 - t + return (u ** 3 * p0[0] + 3 * u * u * t * p1[0] + 3 * u * t * t * p2[0] + t ** 3 * p3[0], + u ** 3 * p0[1] + 3 * u * u * t * p1[1] + 3 * u * t * t * p2[1] + t ** 3 * p3[1]) + + +def _mask_corner(side: float, samples: int) -> list[tuple[float, float]]: + """Top-left corner with the tile corner at the origin, running clockwise + from the left edge (0, p) around to the top edge (p, 0).""" + radius = side * MAC_MASK_RADIUS_FRACTION + smoothing = MAC_MASK_SMOOTHING + p = min((1 + smoothing) * radius, side / 2) + arc_measure = 90 * (1 - smoothing) + arc_len = math.sin(math.radians(arc_measure / 2)) * radius * math.sqrt(2) + angle_alpha = (90 - arc_measure) / 2 + p3p4 = radius * math.tan(math.radians(angle_alpha / 2)) + angle_beta = 45 * smoothing + c = p3p4 * math.cos(math.radians(angle_beta)) + d = c * math.tan(math.radians(angle_beta)) + b = (p - arc_len - c - d) / 3 + a = 2 * b + # Trace the construction's top-right corner (corner at the origin, x <= 0): + # easing cubic, circular arc, easing cubic. + pts: list[tuple[float, float]] = [] + p0 = (-p, 0.0) + p1, p2, p3 = (p0[0] + a, 0.0), (p0[0] + a + b, 0.0), (p0[0] + a + b + c, d) + for i in range(samples): + pts.append(_cubic(p0, p1, p2, p3, i / samples)) + length = math.hypot(c, d) + nx, ny = -d / length, c / length + cx, cy = p3[0] + nx * radius, p3[1] + ny * radius + start = math.atan2(p3[1] - cy, p3[0] - cx) + for i in range(samples): + angle = start + math.radians(arc_measure) * i / samples + pts.append((cx + radius * math.cos(angle), cy + radius * math.sin(angle))) + p4 = (p3[0] + arc_len, p3[1] + arc_len) + p5, p6, p7 = (p4[0] + d, p4[1] + c), (p4[0] + d, p4[1] + b + c), (0.0, p) + for i in range(samples + 1): + pts.append(_cubic(p4, p5, p6, p7, i / samples)) + # Mirror into the top-left corner and reverse so the run is clockwise. + return [(-x, y) for x, y in reversed(pts)] + + +def mac_mask_outline(side: float, samples: int = 96) -> list[tuple[float, float]]: + """Closed clockwise outline of the macOS 26 icon mask for a tile of `side`.""" + tl = _mask_corner(side, samples) + tr = [(side - y, x) for x, y in tl] + br = [(side - x, side - y) for x, y in tl] + bl = [(y, side - x) for x, y in tl] + outline: list[tuple[float, float]] = [] + for point in tl + tr + br + bl: + if not outline or math.hypot(point[0] - outline[-1][0], point[1] - outline[-1][1]) > 1e-9: + outline.append(point) + return outline + + +def offset_inward(points: list[tuple[float, float]], distance: float) -> list[tuple[float, float]]: + """Parallel curve `distance` inside a convex closed outline.""" + count = len(points) + cx = sum(x for x, _ in points) / count + cy = sum(y for _, y in points) / count + result = [] + for i, (x, y) in enumerate(points): + px, py = points[i - 1] + qx, qy = points[(i + 1) % count] + tx, ty = qx - px, qy - py + length = math.hypot(tx, ty) or 1.0 + nx, ny = -ty / length, tx / length + if (cx - x) * nx + (cy - y) * ny < 0: + nx, ny = -nx, -ny + result.append((x + nx * distance, y + ny * distance)) + return result + + +def closed_path(points: list[tuple[float, float]]) -> str: + """SVG path through the points as a closed Catmull-Rom spline (cubic Beziers).""" + count = len(points) + parts = [f"M{points[0][0]:.3f} {points[0][1]:.3f}"] + for i in range(count): + p0, p1 = points[(i - 1) % count], points[i] + p2, p3 = points[(i + 1) % count], points[(i + 2) % count] + c1 = (p1[0] + (p2[0] - p0[0]) / 6.0, p1[1] + (p2[1] - p0[1]) / 6.0) + c2 = (p2[0] - (p3[0] - p1[0]) / 6.0, p2[1] - (p3[1] - p1[1]) / 6.0) + parts.append(f"C{c1[0]:.3f} {c1[1]:.3f} {c2[0]:.3f} {c2[1]:.3f} {p2[0]:.3f} {p2[1]:.3f}") + parts.append("Z") + return "".join(parts) + + +def icon_border_svg(ink: str) -> str: + """Border layer: the canvas minus the mask's inward offset (even-odd).""" + thickness = ICON_CANVAS * BORDER_FRACTION + inner = closed_path(offset_inward(mac_mask_outline(float(ICON_CANVAS)), thickness)) + return ( + f'\n' + f' \n' + "\n" + ) + + +def icon_art_svg(art: IconArt, girl: str) -> str: + """Art layer: the girl registered as on the canvas-filling squircle, joined + to the border band; the commit badge rides along for commit builds.""" + thickness = ICON_CANVAS * BORDER_FRACTION + bg = "squircle-light.svg" # canvas-filling registration; the system supplies the grid + portrait = portrait_layer(art, girl, bg, ICON_CANVAS - thickness + 10) + badge = f" {commit_layer(art.commit, bg)}\n" if art.commit else "" + return ( + f'\n' + f"{badge}" + f" {ET.tostring(portrait, encoding='unicode')}\n" + "\n" + ) + + +def icon_color(hex_color: str) -> str: + """Icon Composer colour literal for an opaque sRGB hex colour.""" + value = hex_color.lstrip("#") + r, g, b = (int(value[i:i + 2], 16) / 255.0 for i in (0, 2, 4)) + return f"srgb:{r:.5f},{g:.5f},{b:.5f},1.00000" + + +def icon_manifest(art: IconArt) -> str: + """icon.json: flat brand fill (flavoured for canary/commit builds), a + border layer and an art layer, each with a dark-appearance variant.""" + light, dark = art.colors or ("#ffffff", DARK_HEX) + + def layer(name: str) -> dict: + return { + "name": name, + "image-name": f"{name}-light.png", + "image-name-specializations": [ + {"value": f"{name}-light.png"}, + {"appearance": "dark", "value": f"{name}-dark.png"}, + ], + "glass": False, + "hidden": False, + } + + manifest = { + "fill-specializations": [ + {"value": {"solid": icon_color(light)}}, + {"appearance": "dark", "value": {"solid": icon_color(dark)}}, + ], + "groups": [{ + "lighting": "individual", + "specular": False, + "translucency": {"enabled": False, "value": 0}, + "shadow": {"kind": "none", "opacity": 0}, + "blur-material": None, + "layers": [layer("border"), layer("art")], + }], + "supported-platforms": {"squares": "shared"}, + } + return json.dumps(manifest, indent=2) + "\n" + + # ─── rendering ────────────────────────────────────────────────────────────── def render(master: str, size: int, *, background: str | None = None) -> Image.Image: @@ -458,6 +658,8 @@ def target_bytes(art: IconArt, kind: str, arg: object) -> bytes: return art.master_dark.encode("utf-8") if kind == "svg_copy": return art.master.encode("utf-8") + if kind == "icon_manifest": + return icon_manifest(art).encode("utf-8") buf = io.BytesIO() if kind == "png": @@ -472,6 +674,10 @@ def target_bytes(art: IconArt, kind: str, arg: object) -> bytes: render(art.master_dark, arg, background=DARK_HEX).convert("RGB").save(buf, "PNG", optimize=True) elif kind in ("girl_light", "girl_dark"): save_png(girl_mark(art, kind, arg), buf) + elif kind == "icon_border": + save_png(render_svg(icon_border_svg(arg), ICON_CANVAS), buf) + elif kind == "icon_art": + save_png(render_svg(icon_art_svg(art, arg), ICON_CANVAS), buf) elif kind == "ico": img = render(art.master, max(arg)) img.save(buf, format="ICO", sizes=[(s, s) for s in arg]) @@ -542,8 +748,8 @@ def cmd_write(source: Path, out: Path) -> int: for rel, kind, arg in TARGETS: path = out / rel try: - if kind in ("svg", "svg_dark", "svg_copy"): - print(f" {rel}: {path.stat().st_size} bytes SVG") + if kind in ("svg", "svg_dark", "svg_copy", "icon_manifest"): + print(f" {rel}: {path.stat().st_size} bytes {'JSON' if kind == 'icon_manifest' else 'SVG'}") continue im = Image.open(path) if kind in ("ico", "ico_dark"): diff --git a/tests/scripts/test_icon_flavors.py b/tests/scripts/test_icon_flavors.py index 81e1e203ec..38527e8e9f 100644 --- a/tests/scripts/test_icon_flavors.py +++ b/tests/scripts/test_icon_flavors.py @@ -14,6 +14,7 @@ import pytest ROOT = Path(__file__).resolve().parents[2] +LAYERED_ICON = "icon.icon" # apps/desktop/assets/icon.icon: the macOS 26 Icon Composer package @pytest.fixture(scope="module") @@ -140,8 +141,8 @@ def test_canary_changes_only_desktop_background_preserving_art_and_native_geomet stable = generate("v1.2.3") canary = generate("v1.2.3+canary.20260911T010203Z") for path in (stable / "apps/desktop").rglob("*"): - if not path.is_file(): - continue + if not path.is_file() or LAYERED_ICON in path.parts: + continue # the layered macOS icon carries its flavor in icon.json, tested below original_frames = list(frames(path)) canary_frames = list(frames(canary / path.relative_to(stable))) assert len(original_frames) == len(canary_frames) @@ -168,6 +169,8 @@ def test_commit_icons_are_red_and_print_only_the_actual_seven_digit_prefix(gener continue rel = path.relative_to(first) assert path.read_bytes() == (same_prefix / rel).read_bytes(), rel + if LAYERED_ICON in path.parts: + continue # the layered macOS icon carries its flavor in icon.json, tested below first_frames = list(frames(path)) other_frames = list(frames(changed / rel)) stable_frames = list(frames(stable / rel)) @@ -213,6 +216,80 @@ def test_commit_icons_are_red_and_print_only_the_actual_seven_digit_prefix(gener assert_unbranded_outputs(stable, first) +def load_generator(monkeypatch): + import importlib.util + import types + + monkeypatch.setitem(sys.modules, "resvg_py", types.ModuleType("resvg_py")) + spec = importlib.util.spec_from_file_location("generate_icons", ROOT / "scripts/generate_icons.py") + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def manifest_fills(package): + """(light, dark) fill colours of an Icon Composer package as 0..1 RGB tuples.""" + import json + + manifest = json.loads((package / "icon.json").read_text(encoding="utf-8")) + fills = {} + for spec in manifest["fill-specializations"]: + prefix, _, channels = spec["value"]["solid"].partition(":") + assert prefix == "srgb" + fills[spec.get("appearance", "light")] = tuple(float(v) for v in channels.split(","))[:3] + layers = [layer for group in manifest["groups"] for layer in group["layers"]] + referenced = {layer["image-name"] for layer in layers} | { + spec["value"] for layer in layers for spec in layer["image-name-specializations"]} + assert referenced == {p.name for p in (package / "Assets").iterdir()}, "every layer image is referenced, none dangle" + return fills["light"], fills["dark"] + + +def test_layered_macos_icon_border_hugs_the_system_mask_and_flavor_stays_in_the_fill(generate, monkeypatch): + """macOS 26 masks the layers itself: the border layer must be a band of the + brand thickness measured perpendicular to Apple's mask everywhere along it, + reaching the canvas edge so the mask supplies the outline. Build flavors + recolour the fill in icon.json only; the ring and the girl never change.""" + module = load_generator(monkeypatch) + stable = generate("v1.2.3") / "apps/desktop/assets" / LAYERED_ICON + canary = generate("v1.2.3+canary.20260911T010203Z") / "apps/desktop/assets" / LAYERED_ICON + commit = generate(commit="0123456" + "a" * 33) / "apps/desktop/assets" / LAYERED_ICON + + canvas = module.ICON_CANVAS + thickness = canvas * module.BORDER_FRACTION + outline = module.mac_mask_outline(float(canvas), 160) + for name, ink in (("border-light.png", (0, 0, 0)), ("border-dark.png", (255, 255, 255))): + layer = Image.open(stable / "Assets" / name).convert("RGBA") + assert layer.size == (canvas, canvas) + assert layer.getpixel((0, 0))[3] == 255 and layer.getpixel((canvas - 1, canvas - 1))[3] == 255 + for (xi, yi), (xo, yo) in zip(module.offset_inward(outline, thickness - 1.5), + module.offset_inward(outline, thickness + 1.5), strict=True): + inside = layer.getpixel((int(xi), int(yi))) + assert inside[3] >= 250 and inside[:3] == ink, (name, xi, yi, inside) + assert layer.getpixel((int(xo), int(yo)))[3] <= 5, (name, xo, yo) + + for name in ("border-light.png", "border-dark.png", "art-light.png", "art-dark.png"): + assert (stable / "Assets" / name).read_bytes() == (canary / "Assets" / name).read_bytes(), name + for name in ("border-light.png", "border-dark.png"): + assert (stable / "Assets" / name).read_bytes() == (commit / "Assets" / name).read_bytes(), name + for name in ("art-light.png", "art-dark.png"): + # The commit badge is the only difference, and it lives in the top quarter. + plain = Image.open(stable / "Assets" / name).convert("RGBA") + badged = Image.open(commit / "Assets" / name).convert("RGBA") + changed = ImageChops.difference(plain, badged).convert("L").getbbox() + assert changed is not None and changed[3] <= canvas * 0.25 + 3, (name, changed) + + light, dark = manifest_fills(stable) + assert light == (1.0, 1.0, 1.0) and max(dark) < 0.1 + for package, low, high in ((canary, 0.10, 0.18), (commit, -0.05, 0.05)): + light, dark = manifest_fills(package) + for fill, dark_fill in ((light, False), (dark, True)): + hue, saturation, value = colorsys.rgb_to_hsv(*fill) + hue = hue - 1 if hue > 0.5 else hue # red straddles the hue wrap + assert low < hue < high and saturation > 0.6, (package, fill) + assert (value < 0.4) if dark_fill else (value > 0.8), (package, fill) + + @pytest.mark.parametrize("tag,commit", [ ("", "abcdef0"), ("", "a" * 41), ("", "A" * 40), ("", "a" * 39 + "g"), ("", "a" * 40 + "\n"),