Build hero 3D window stack from real app screenshot
Stop cropping the design mock on the right. Compose layered perspective
windows, tag sidebar, and a subtle reflection from hero-source-grid.
4 files modified
3 files added
| | |
| | | <meta name="viewport" content="width=device-width, initial-scale=1"> |
| | | <title>TagLauncher — Tag-based Mac app launcher</title> |
| | | <meta name="description" content="TagLauncher organizes Mac apps with tags: scrollable grid, multi-tag apps, searchable notes, JSON layout backup—beyond Launchpad folders and pages."> |
| | | <meta property="og:image" content="https://shanghai3168.github.io/taglauncher/assets/hero-design-original.jpg"> |
| | | <meta property="og:image" content="https://shanghai3168.github.io/taglauncher/assets/hero-visual-stack.jpg"> |
| | | <link rel="stylesheet" href="styles.css"> |
| | | <link rel="alternate" hreflang="en" href="index-en.html"> |
| | | <link rel="alternate" hreflang="zh-Hans" href="index.html"> |
| | |
| | | |
| | | <figure class="hero__art"> |
| | | <img |
| | | src="assets/hero-visual-only.jpg" |
| | | alt="" |
| | | width="592" |
| | | height="544" |
| | | src="assets/hero-visual-stack.jpg" |
| | | alt="TagLauncher App Grid with stacked 3D window presentation" |
| | | width="1180" |
| | | height="640" |
| | | fetchpriority="high" |
| | | decoding="async" |
| | | role="presentation"> |
| | | decoding="async"> |
| | | </figure> |
| | | </div> |
| | | </section> |
| | |
| | | <meta name="viewport" content="width=device-width, initial-scale=1"> |
| | | <title>TagLauncher — 标签化 Mac 应用启动器</title> |
| | | <meta name="description" content="在全球,这可能是Mac最好用的应用启动台。不用怀念 macOS 26 Tahoe 上消失的 Launchpad,TagLauncher 用标签管理 Mac 应用。"> |
| | | <meta property="og:image" content="https://shanghai3168.github.io/taglauncher/assets/hero-design-original.jpg"> |
| | | <meta property="og:image" content="https://shanghai3168.github.io/taglauncher/assets/hero-visual-stack.jpg"> |
| | | <link rel="stylesheet" href="styles.css"> |
| | | <link rel="alternate" hreflang="en" href="index-en.html"> |
| | | <link rel="alternate" hreflang="zh-Hans" href="index.html"> |
| | |
| | | |
| | | <figure class="hero__art"> |
| | | <img |
| | | src="assets/hero-visual-only.jpg" |
| | | alt="" |
| | | width="592" |
| | | height="544" |
| | | src="assets/hero-visual-stack.jpg" |
| | | alt="TagLauncher 应用网格:多层窗口立体堆叠展示" |
| | | width="1180" |
| | | height="640" |
| | | fetchpriority="high" |
| | | decoding="async" |
| | | role="presentation"> |
| | | decoding="async"> |
| | | </figure> |
| | | </div> |
| | | </section> |
| New file |
| | |
| | | #!/usr/bin/env python3 |
| | | """Compose a 3D stacked-window hero from the real TagLauncher screenshot.""" |
| | | |
| | | from __future__ import annotations |
| | | |
| | | import math |
| | | from pathlib import Path |
| | | |
| | | import numpy as np |
| | | from PIL import Image, ImageDraw, ImageEnhance, ImageFilter, ImageOps |
| | | |
| | | ROOT = Path(__file__).resolve().parents[1] |
| | | ASSETS = ROOT / "assets" |
| | | _jpg = ASSETS / "hero-source-grid.jpg" |
| | | _png = ASSETS / "hero-source-grid.png" |
| | | SRC = _jpg if _jpg.exists() else _png |
| | | OUT = ASSETS / "hero-visual-stack.jpg" |
| | | |
| | | CANVAS_W, CANVAS_H = 1180, 640 |
| | | BG = (255, 255, 255, 255) |
| | | |
| | | |
| | | def find_coeffs(src: list[tuple[float, float]], dst: list[tuple[float, float]]) -> list[float]: |
| | | matrix = [] |
| | | for (x, y), (u, v) in zip(src, dst): |
| | | matrix.append([x, y, 1, 0, 0, 0, -u * x, -u * y]) |
| | | matrix.append([0, 0, 0, x, y, 1, -v * x, -v * y]) |
| | | a = np.matrix(matrix, dtype=float) |
| | | b = np.array(dst).reshape(8) |
| | | res = np.dot(np.linalg.inv(a.T * a) * a.T, b) |
| | | return np.array(res).reshape(8).tolist() |
| | | |
| | | |
| | | def warp_quad(img: Image.Image, quad: list[tuple[float, float]], out_size: tuple[int, int]) -> Image.Image: |
| | | w, h = img.size |
| | | src = [(0, 0), (w, 0), (w, h), (0, h)] |
| | | coeffs = find_coeffs(src, quad) |
| | | return img.transform(out_size, Image.PERSPECTIVE, coeffs, Image.BICUBIC) |
| | | |
| | | |
| | | def rounded_mask(size: tuple[int, int], radius: int) -> Image.Image: |
| | | mask = Image.new("L", size, 0) |
| | | draw = ImageDraw.Draw(mask) |
| | | draw.rounded_rectangle((0, 0, size[0] - 1, size[1] - 1), radius=radius, fill=255) |
| | | return mask |
| | | |
| | | |
| | | def crop_app_content(src: Image.Image) -> Image.Image: |
| | | w, h = src.size |
| | | if w > 2000: |
| | | top = int(h * 0.055) |
| | | left = int(w * 0.018) |
| | | right = int(w * 0.982) |
| | | bottom = int(h * 0.93) |
| | | else: |
| | | top = int(h * 0.04) |
| | | left = int(w * 0.02) |
| | | right = int(w * 0.98) |
| | | bottom = int(h * 0.96) |
| | | return src.crop((left, top, right, bottom)) |
| | | |
| | | |
| | | def make_window(content: Image.Image, title_h: int = 34, radius: int = 14) -> Image.Image: |
| | | content = content.convert("RGBA") |
| | | cw, ch = content.size |
| | | frame = Image.new("RGBA", (cw, ch + title_h), (0, 0, 0, 0)) |
| | | body = Image.new("RGBA", (cw, ch + title_h), (245, 245, 247, 255)) |
| | | mask = rounded_mask((cw, ch + title_h), radius) |
| | | body.putalpha(mask) |
| | | frame.alpha_composite(body) |
| | | |
| | | draw = ImageDraw.Draw(frame) |
| | | bar_h = title_h |
| | | draw.rounded_rectangle((0, 0, cw - 1, bar_h + 6), radius=radius, fill=(236, 236, 238, 255)) |
| | | lights = [(18, bar_h // 2), (38, bar_h // 2), (58, bar_h // 2)] |
| | | colors = [(255, 95, 86, 255), (255, 189, 46, 255), (39, 201, 63, 255)] |
| | | for (x, y), color in zip(lights, colors): |
| | | r = 7 |
| | | draw.ellipse((x - r, y - r, x + r, y + r), fill=color) |
| | | |
| | | shadow = Image.new("RGBA", (cw, ch + title_h), (0, 0, 0, 0)) |
| | | shadow_draw = ImageDraw.Draw(shadow) |
| | | shadow_draw.rounded_rectangle((8, 10, cw - 2, ch + title_h - 2), radius=radius, fill=(0, 0, 0, 42)) |
| | | shadow = shadow.filter(ImageFilter.GaussianBlur(10)) |
| | | out = Image.new("RGBA", (cw + 16, ch + title_h + 16), (0, 0, 0, 0)) |
| | | out.alpha_composite(shadow, (0, 0)) |
| | | out.alpha_composite(frame, (8, 4)) |
| | | out.alpha_composite(content, (8, 4 + title_h)) |
| | | return out |
| | | |
| | | |
| | | def tint_layer(img: Image.Image, brightness: float, alpha: float) -> Image.Image: |
| | | layer = ImageEnhance.Brightness(img).enhance(brightness) |
| | | layer = ImageEnhance.Color(layer).enhance(0.75) |
| | | r, g, b, a = layer.split() |
| | | a = a.point(lambda v: int(v * alpha)) |
| | | layer.putalpha(a) |
| | | return layer |
| | | |
| | | |
| | | def draw_tag_sidebar(size: tuple[int, int]) -> Image.Image: |
| | | w, h = size |
| | | side = Image.new("RGBA", size, (0, 0, 0, 0)) |
| | | draw = ImageDraw.Draw(side) |
| | | draw.rounded_rectangle((0, 0, w - 1, h - 1), radius=12, fill=(34, 34, 38, 235)) |
| | | colors = [ |
| | | (76, 175, 80), |
| | | (156, 39, 176), |
| | | (33, 150, 243), |
| | | (244, 67, 54), |
| | | (255, 152, 0), |
| | | (0, 188, 212), |
| | | (233, 30, 99), |
| | | ] |
| | | gap = 8 |
| | | bar_w = w - 18 |
| | | bar_h = max(18, (h - gap * (len(colors) + 1)) // len(colors)) |
| | | y = 10 |
| | | for color in colors: |
| | | draw.rounded_rectangle((9, y, 9 + bar_w, y + bar_h), radius=6, fill=(*color, 255)) |
| | | y += bar_h + gap |
| | | return side |
| | | |
| | | |
| | | def place_layer( |
| | | canvas: Image.Image, |
| | | layer: Image.Image, |
| | | quad: list[tuple[float, float]], |
| | | shadow_blur: int = 18, |
| | | shadow_alpha: int = 55, |
| | | ) -> None: |
| | | xs = [p[0] for p in quad] |
| | | ys = [p[1] for p in quad] |
| | | pad = 30 |
| | | min_x, max_x = int(min(xs)) - pad, int(max(xs)) + pad |
| | | min_y, max_y = int(min(ys)) - pad, int(max(ys)) + pad |
| | | box_w = max(1, max_x - min_x) |
| | | box_h = max(1, max_y - min_y) |
| | | local_quad = [(x - min_x, y - min_y) for x, y in quad] |
| | | warped = warp_quad(layer, local_quad, (box_w, box_h)) |
| | | |
| | | shadow = Image.new("RGBA", (box_w, box_h), (0, 0, 0, 0)) |
| | | alpha = warped.split()[3] |
| | | shadow.putalpha(alpha.point(lambda v: min(v, shadow_alpha))) |
| | | shadow = shadow.filter(ImageFilter.GaussianBlur(shadow_blur)) |
| | | canvas.alpha_composite(shadow, (min_x + 10, min_y + 16)) |
| | | canvas.alpha_composite(warped, (min_x, min_y)) |
| | | |
| | | |
| | | def add_reflection(canvas: Image.Image, subject_box: tuple[int, int, int, int], strength: float = 0.22) -> None: |
| | | x0, y0, x1, y1 = subject_box |
| | | subject = canvas.crop((x0, y0, x1, y1)).convert("RGBA") |
| | | refl_h = int((y1 - y0) * 0.14) |
| | | if refl_h < 8: |
| | | return |
| | | strip = subject.crop((0, subject.height - refl_h, subject.width, subject.height)) |
| | | reflected = ImageOps.flip(strip) |
| | | grad = Image.linear_gradient("L").resize((reflected.width, reflected.height)) |
| | | reflected.putalpha(grad.point(lambda v: int(v * strength))) |
| | | canvas.alpha_composite(reflected, (x0, y1 + 2)) |
| | | |
| | | |
| | | def build() -> Image.Image: |
| | | src = Image.open(SRC).convert("RGBA") |
| | | content = crop_app_content(src) |
| | | target_w = 820 if content.width < 2000 else 760 |
| | | scale = target_w / content.width |
| | | target_h = int(content.height * scale) |
| | | content = content.resize((target_w, target_h), Image.LANCZOS) |
| | | |
| | | front = make_window(content, title_h=30, radius=12) |
| | | back_content = content.resize((int(target_w * 0.92), int(target_h * 0.92)), Image.LANCZOS) |
| | | back = tint_layer(make_window(back_content, title_h=26, radius=10), brightness=0.42, alpha=0.92) |
| | | deep = tint_layer(make_window(back_content.resize((int(target_w * 0.84), int(target_h * 0.84)), Image.LANCZOS), title_h=22, radius=10), brightness=0.28, alpha=0.88) |
| | | sidebar = draw_tag_sidebar((74, int(target_h * 0.78))) |
| | | |
| | | canvas = Image.new("RGBA", (CANVAS_W, CANVAS_H), BG) |
| | | fw, fh = front.size |
| | | |
| | | ox, oy = 210, 36 |
| | | front_quad = [ |
| | | (ox, oy + fh * 0.1), |
| | | (ox + fw * 0.97, oy + fh * 0.02), |
| | | (ox + fw * 0.99, oy + fh * 0.96), |
| | | (ox + fw * 0.05, oy + fh), |
| | | ] |
| | | back_quad = [ |
| | | (ox + 88, oy - 24), |
| | | (ox + fw + 62, oy - 56), |
| | | (ox + fw + 78, oy + fh - 42), |
| | | (ox + 104, oy + fh - 12), |
| | | ] |
| | | deep_quad = [ |
| | | (ox + 138, oy - 64), |
| | | (ox + fw + 104, oy - 98), |
| | | (ox + fw + 120, oy + fh - 78), |
| | | (ox + 154, oy + fh - 40), |
| | | ] |
| | | side_quad = [ |
| | | (ox + fw + 22, oy + 24), |
| | | (ox + fw + 98, oy + 8), |
| | | (ox + fw + 104, oy + fh - 52), |
| | | (ox + fw + 30, oy + fh - 18), |
| | | ] |
| | | |
| | | place_layer(canvas, deep, deep_quad, shadow_blur=24, shadow_alpha=40) |
| | | place_layer(canvas, back, back_quad, shadow_blur=20, shadow_alpha=48) |
| | | place_layer(canvas, sidebar, side_quad, shadow_blur=12, shadow_alpha=70) |
| | | place_layer(canvas, front, front_quad, shadow_blur=22, shadow_alpha=62) |
| | | |
| | | all_x = [p[0] for quad in (deep_quad, back_quad, side_quad, front_quad) for p in quad] |
| | | all_y = [p[1] for quad in (deep_quad, back_quad, side_quad, front_quad) for p in quad] |
| | | add_reflection(canvas, (int(min(all_x)), int(min(all_y)), int(max(all_x) + fw * 0.2), int(max(all_y) + fh * 0.05))) |
| | | |
| | | return canvas.convert("RGB") |
| | | |
| | | |
| | | def main() -> None: |
| | | img = build() |
| | | img.save(OUT, quality=93, optimize=True, progressive=True) |
| | | print(f"wrote {OUT} ({img.size[0]}x{img.size[1]})") |
| | | |
| | | |
| | | if __name__ == "__main__": |
| | | main() |
| | |
| | | max-width: 22em; |
| | | } |
| | | |
| | | /* Right art: original design pixels — subtle reflection only, capped height */ |
| | | /* Right art: composited 3D stack from real screenshot (built asset, no extra CSS warp) */ |
| | | .hero__art { |
| | | margin: 0; |
| | | line-height: 0; |
| | |
| | | |
| | | .hero__art img { |
| | | display: block; |
| | | width: auto; |
| | | width: 100%; |
| | | max-width: 100%; |
| | | height: auto; |
| | | max-height: clamp(300px, 42vw, 500px); |
| | | } |
| | | |
| | | /* Main content */ |