From e63be3e571bc449f4f7aadc5a34834a6aa0ffd96 Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Sat, 06 Jun 2026 18:52:08 +0800
Subject: [PATCH] Build hero 3D window stack from real app screenshot

---
 docs/index-en.html                |   13 +-
 docs/styles.css                   |    5 
 docs/assets/hero-source-grid.jpg  |    0 
 docs/scripts/build-hero-stack.py  |  225 +++++++++++++++++++++++++++++++++++++++++++++
 docs/assets/hero-source-grid.png  |    0 
 docs/assets/hero-visual-stack.jpg |    0 
 docs/index.html                   |   13 +-
 7 files changed, 239 insertions(+), 17 deletions(-)

diff --git a/docs/assets/hero-source-grid.jpg b/docs/assets/hero-source-grid.jpg
new file mode 100644
index 0000000..088b4c7
--- /dev/null
+++ b/docs/assets/hero-source-grid.jpg
Binary files differ
diff --git a/docs/assets/hero-source-grid.png b/docs/assets/hero-source-grid.png
new file mode 100644
index 0000000..a797c15
--- /dev/null
+++ b/docs/assets/hero-source-grid.png
Binary files differ
diff --git a/docs/assets/hero-visual-stack.jpg b/docs/assets/hero-visual-stack.jpg
index 2259342..cc52fce 100644
--- a/docs/assets/hero-visual-stack.jpg
+++ b/docs/assets/hero-visual-stack.jpg
Binary files differ
diff --git a/docs/index-en.html b/docs/index-en.html
index 698af6a..0c7cb21 100644
--- a/docs/index-en.html
+++ b/docs/index-en.html
@@ -5,7 +5,7 @@
   <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">
@@ -39,13 +39,12 @@
 
       <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>
diff --git a/docs/index.html b/docs/index.html
index 2ce0855..8ea23cc 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -5,7 +5,7 @@
   <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">
@@ -39,13 +39,12 @@
 
       <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>
diff --git a/docs/scripts/build-hero-stack.py b/docs/scripts/build-hero-stack.py
new file mode 100644
index 0000000..c04e2e7
--- /dev/null
+++ b/docs/scripts/build-hero-stack.py
@@ -0,0 +1,225 @@
+#!/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()
\ No newline at end of file
diff --git a/docs/styles.css b/docs/styles.css
index 1c575b7..3a657a0 100644
--- a/docs/styles.css
+++ b/docs/styles.css
@@ -187,7 +187,7 @@
   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;
@@ -198,10 +198,9 @@
 
 .hero__art img {
   display: block;
-  width: auto;
+  width: 100%;
   max-width: 100%;
   height: auto;
-  max-height: clamp(300px, 42vw, 500px);
 }
 
 /* Main content */

--
Gitblit v1.9.3