Ariver
2026-06-06 e63be3e571bc449f4f7aadc5a34834a6aa0ffd96
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
256 ■■■■■ changed files
docs/assets/hero-source-grid.jpg patch | view | raw | blame | history
docs/assets/hero-source-grid.png patch | view | raw | blame | history
docs/assets/hero-visual-stack.jpg patch | view | raw | blame | history
docs/index-en.html 13 ●●●● patch | view | raw | blame | history
docs/index.html 13 ●●●● patch | view | raw | blame | history
docs/scripts/build-hero-stack.py 225 ●●●●● patch | view | raw | blame | history
docs/styles.css 5 ●●●●● patch | view | raw | blame | history
docs/assets/hero-source-grid.jpg
docs/assets/hero-source-grid.png
docs/assets/hero-visual-stack.jpg

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>
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>
docs/scripts/build-hero-stack.py
New file
@@ -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()
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 */