|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Generate a homepage mockup PNG for the openElement redesign proposal.""" |
| 3 | +from PIL import Image, ImageDraw, ImageFont |
| 4 | +import os |
| 5 | + |
| 6 | +WIDTH = 1440 |
| 7 | +HEIGHT = 1900 |
| 8 | + |
| 9 | +# Palette |
| 10 | +BG_BASE = (11, 12, 15) |
| 11 | +BG_SURFACE = (19, 21, 26) |
| 12 | +BG_ELEVATED = (26, 29, 36) |
| 13 | +BG_CODE = (13, 14, 18) |
| 14 | +BG_HOVER = (255, 255, 255, 10) |
| 15 | +TEXT_PRIMARY = (242, 243, 245) |
| 16 | +TEXT_SECONDARY = (156, 163, 175) |
| 17 | +TEXT_MUTED = (107, 114, 128) |
| 18 | +BRAND = (99, 102, 241) |
| 19 | +BRAND_HOVER = (129, 140, 248) |
| 20 | +BORDER = (255, 255, 255, 20) |
| 21 | +BORDER_STRONG = (255, 255, 255, 36) |
| 22 | +ACCENTS = { |
| 23 | + "Elements": (34, 211, 238), |
| 24 | + "UI": (167, 139, 250), |
| 25 | + "Framework": (52, 211, 153), |
| 26 | + "Protocols": (251, 191, 36), |
| 27 | +} |
| 28 | + |
| 29 | +FONT_SANS = "C:/Windows/Fonts/segoeui.ttf" |
| 30 | +FONT_SANS_BOLD = "C:/Windows/Fonts/segoeuib.ttf" |
| 31 | +FONT_MONO = "C:/Windows/Fonts/consola.ttf" |
| 32 | +FONT_CN = "C:/Windows/Fonts/msyh.ttc" |
| 33 | +FONT_CN_BOLD = "C:/Windows/Fonts/msyhbd.ttc" |
| 34 | + |
| 35 | + |
| 36 | +def load_fonts(): |
| 37 | + fonts = {} |
| 38 | + try: |
| 39 | + fonts["display"] = ImageFont.truetype(FONT_CN_BOLD, 68) |
| 40 | + fonts["title"] = ImageFont.truetype(FONT_CN_BOLD, 44) |
| 41 | + fonts["h2"] = ImageFont.truetype(FONT_CN_BOLD, 34) |
| 42 | + fonts["h3"] = ImageFont.truetype(FONT_CN_BOLD, 22) |
| 43 | + fonts["body"] = ImageFont.truetype(FONT_CN, 20) |
| 44 | + fonts["body-sm"] = ImageFont.truetype(FONT_CN, 17) |
| 45 | + fonts["caption"] = ImageFont.truetype(FONT_CN, 15) |
| 46 | + fonts["mono"] = ImageFont.truetype(FONT_MONO, 17) |
| 47 | + except Exception as e: |
| 48 | + print("Font load error:", e) |
| 49 | + fonts["display"] = ImageFont.load_default() |
| 50 | + fonts["title"] = ImageFont.load_default() |
| 51 | + fonts["h2"] = ImageFont.load_default() |
| 52 | + fonts["h3"] = ImageFont.load_default() |
| 53 | + fonts["body"] = ImageFont.load_default() |
| 54 | + fonts["body-sm"] = ImageFont.load_default() |
| 55 | + fonts["caption"] = ImageFont.load_default() |
| 56 | + fonts["mono"] = ImageFont.load_default() |
| 57 | + return fonts |
| 58 | + |
| 59 | + |
| 60 | +def draw_text(draw, text, pos, font, fill, anchor="lt"): |
| 61 | + draw.text(pos, text, font=font, fill=fill, anchor=anchor) |
| 62 | + |
| 63 | + |
| 64 | +def text_size(draw, text, font): |
| 65 | + bbox = draw.textbbox((0, 0), text, font=font) |
| 66 | + return bbox[2] - bbox[0], bbox[3] - bbox[1] |
| 67 | + |
| 68 | + |
| 69 | +def wrap_text(draw, text, font, max_width): |
| 70 | + """Simple greedy word wrap for Chinese/English mixed text.""" |
| 71 | + words = [] |
| 72 | + for ch in text: |
| 73 | + words.append(ch) |
| 74 | + lines = [] |
| 75 | + current = "" |
| 76 | + for word in words: |
| 77 | + test = current + word |
| 78 | + w, _ = text_size(draw, test, font) |
| 79 | + if w <= max_width: |
| 80 | + current = test |
| 81 | + else: |
| 82 | + if current: |
| 83 | + lines.append(current) |
| 84 | + current = word |
| 85 | + if current: |
| 86 | + lines.append(current) |
| 87 | + return lines |
| 88 | + |
| 89 | + |
| 90 | +def main(): |
| 91 | + img = Image.new("RGBA", (WIDTH, HEIGHT), BG_BASE) |
| 92 | + draw = ImageDraw.Draw(img) |
| 93 | + fonts = load_fonts() |
| 94 | + |
| 95 | + # Nav |
| 96 | + nav_h = 72 |
| 97 | + draw.rectangle((0, 0, WIDTH, nav_h), fill=BG_BASE) |
| 98 | + draw.line((0, nav_h, WIDTH, nav_h), fill=BORDER_STRONG, width=1) |
| 99 | + |
| 100 | + # Logo mark (colored quadrants) |
| 101 | + logo_x, logo_y = 64, 26 |
| 102 | + mark_size = 22 |
| 103 | + gap = 3 |
| 104 | + cell = (mark_size - gap) // 2 |
| 105 | + quadrants = ["Elements", "Framework", "UI", "Protocols"] |
| 106 | + for idx in range(4): |
| 107 | + i = idx % 2 |
| 108 | + j = idx // 2 |
| 109 | + x = logo_x + i * (cell + gap) |
| 110 | + y = logo_y + j * (cell + gap) |
| 111 | + color = ACCENTS[quadrants[idx]] |
| 112 | + draw.rectangle((x, y, x + cell, y + cell), outline=color, width=2) |
| 113 | + |
| 114 | + draw_text(draw, "openElement", (logo_x + mark_size + 14, logo_y + 1), fonts["body"], TEXT_PRIMARY) |
| 115 | + |
| 116 | + # Nav links right aligned |
| 117 | + nav_links = ["Docs", "Architecture", "Roadmap", "Blog"] |
| 118 | + link_spacing = 72 |
| 119 | + total_links_width = sum(text_size(draw, link, fonts["body-sm"])[0] for link in nav_links) + link_spacing * (len(nav_links) - 1) |
| 120 | + nx = WIDTH - 260 - total_links_width |
| 121 | + for link in nav_links: |
| 122 | + draw_text(draw, link, (nx, 28), fonts["body-sm"], TEXT_SECONDARY) |
| 123 | + w, _ = text_size(draw, link, fonts["body-sm"]) |
| 124 | + nx += w + link_spacing |
| 125 | + |
| 126 | + draw_text(draw, "v0.40.7", (WIDTH - 210, 28), fonts["caption"], TEXT_MUTED) |
| 127 | + draw_text(draw, "GitHub", (WIDTH - 130, 28), fonts["body-sm"], TEXT_SECONDARY) |
| 128 | + |
| 129 | + # Hero |
| 130 | + y = nav_h + 130 |
| 131 | + draw_text(draw, "v0.40.7 已发布", (80, y), fonts["caption"], BRAND) |
| 132 | + |
| 133 | + y += 42 |
| 134 | + headline = "用 JSX 构建 Web Components" |
| 135 | + draw_text(draw, headline, (80, y), fonts["display"], TEXT_PRIMARY) |
| 136 | + y += 84 |
| 137 | + draw_text(draw, "无需框架锁定。", (80, y), fonts["display"], TEXT_PRIMARY) |
| 138 | + |
| 139 | + y += 100 |
| 140 | + sub = "Static-first · DSD 默认渲染 · 单一 VNode 管线 · Preact islands · Vite + Nitro" |
| 141 | + draw_text(draw, sub, (80, y), fonts["body"], TEXT_SECONDARY) |
| 142 | + |
| 143 | + y += 70 |
| 144 | + # Primary button |
| 145 | + btn_h = 48 |
| 146 | + draw.rounded_rectangle((80, y, 220, y + btn_h), radius=8, fill=BRAND) |
| 147 | + draw_text(draw, "开始构建", (150, y + 13), fonts["body-sm"], (255, 255, 255), anchor="mt") |
| 148 | + # Secondary button |
| 149 | + draw.rounded_rectangle((240, y, 420, y + btn_h), radius=8, fill=BG_SURFACE, outline=BORDER_STRONG, width=1) |
| 150 | + draw_text(draw, "查看 GitHub", (330, y + 13), fonts["body-sm"], TEXT_PRIMARY, anchor="mt") |
| 151 | + |
| 152 | + y += 100 |
| 153 | + # Code block |
| 154 | + code_x, code_y = 80, y |
| 155 | + code_w = 720 |
| 156 | + code_h = 170 |
| 157 | + draw.rounded_rectangle((code_x, code_y, code_x + code_w, code_y + code_h), radius=12, fill=BG_CODE, outline=BORDER, width=1) |
| 158 | + # Header |
| 159 | + draw.rectangle((code_x, code_y, code_x + code_w, code_y + 40), fill=BG_SURFACE) |
| 160 | + draw.line((code_x, code_y + 40, code_x + code_w, code_y + 40), fill=BORDER, width=1) |
| 161 | + draw.ellipse((code_x + 18, code_y + 14, code_x + 28, code_y + 24), fill=(248, 113, 113)) |
| 162 | + draw.ellipse((code_x + 36, code_y + 14, code_x + 46, code_y + 24), fill=(251, 191, 36)) |
| 163 | + draw.ellipse((code_x + 54, code_y + 14, code_x + 64, code_y + 24), fill=(52, 211, 153)) |
| 164 | + draw_text(draw, "bash", (code_x + code_w - 60, code_y + 11), fonts["caption"], TEXT_MUTED) |
| 165 | + # Body |
| 166 | + lines = [ |
| 167 | + ("$ ", "deno task create my-app", TEXT_MUTED, TEXT_PRIMARY), |
| 168 | + ("$ ", "cd my-app && deno task dev", TEXT_MUTED, TEXT_PRIMARY), |
| 169 | + ("", "Server ready at http://localhost:3000", TEXT_SECONDARY, TEXT_SECONDARY), |
| 170 | + ] |
| 171 | + ly = code_y + 60 |
| 172 | + for prefix, content, pc, cc in lines: |
| 173 | + draw_text(draw, prefix, (code_x + 24, ly), fonts["mono"], pc) |
| 174 | + pw, _ = text_size(draw, prefix, fonts["mono"]) |
| 175 | + draw_text(draw, content, (code_x + 24 + pw, ly), fonts["mono"], cc) |
| 176 | + ly += 30 |
| 177 | + |
| 178 | + # Four products |
| 179 | + y = code_y + code_h + 140 |
| 180 | + draw_text(draw, "四大产品", (80, y), fonts["caption"], BRAND) |
| 181 | + y += 32 |
| 182 | + draw_text(draw, "一个框架,四层抽象", (80, y), fonts["h2"], TEXT_PRIMARY) |
| 183 | + |
| 184 | + y += 80 |
| 185 | + cards = [ |
| 186 | + ("Elements", "Shadow/DSD 组件", "用 JSX 编写原生 Web Components,默认 Shadow DOM 渲染。"), |
| 187 | + ("UI", "open-* 组件库", "基于 Elements 的第一方组件:button、modal、tabs、dropdown。"), |
| 188 | + ("Framework", "应用框架", "Vite + Nitro 驱动的路由、SSR、API routes、构建管线。"), |
| 189 | + ("Protocols", "协议边界", "渲染器、路由、islands、signals 的运行时无关契约。"), |
| 190 | + ] |
| 191 | + card_w = 310 |
| 192 | + card_h = 200 |
| 193 | + gap_x = 24 |
| 194 | + start_x = (WIDTH - (4 * card_w + 3 * gap_x)) // 2 |
| 195 | + for i, (title, label, desc) in enumerate(cards): |
| 196 | + cx = start_x + i * (card_w + gap_x) |
| 197 | + accent = ACCENTS[title] |
| 198 | + draw.rounded_rectangle((cx, y, cx + card_w, y + card_h), radius=12, fill=BG_SURFACE, outline=BORDER, width=1) |
| 199 | + # Accent top line |
| 200 | + draw.rectangle((cx + 1, y, cx + card_w - 1, y + 4), fill=accent) |
| 201 | + draw_text(draw, title, (cx + 22, y + 26), fonts["h3"], TEXT_PRIMARY) |
| 202 | + draw_text(draw, label, (cx + 22, y + 58), fonts["caption"], accent) |
| 203 | + wrapped = wrap_text(draw, desc, fonts["body-sm"], card_w - 44) |
| 204 | + line_y = y + 96 |
| 205 | + for line in wrapped: |
| 206 | + draw_text(draw, line, (cx + 22, line_y), fonts["body-sm"], TEXT_SECONDARY) |
| 207 | + line_y += 26 |
| 208 | + |
| 209 | + # Why section |
| 210 | + y += card_h + 140 |
| 211 | + draw_text(draw, "为什么选 openElement", (80, y), fonts["caption"], BRAND) |
| 212 | + y += 32 |
| 213 | + draw_text(draw, "一条渲染路径,而不是 N 条", (80, y), fonts["h2"], TEXT_PRIMARY) |
| 214 | + |
| 215 | + y += 80 |
| 216 | + why = [ |
| 217 | + ("单一渲染器", "VNode 直接输出 DSD 或 DOM,一套事件模型,没有适配器混战。"), |
| 218 | + ("按需 Hydrate", "Preact islands 只在需要的地方升级,其余页面保持纯静态。"), |
| 219 | + ("Web 标准", "输出真正的 Web Components,不绑定特定前端框架。"), |
| 220 | + ] |
| 221 | + why_w = 420 |
| 222 | + why_h = 170 |
| 223 | + why_gap = 30 |
| 224 | + why_start = (WIDTH - (3 * why_w + 2 * why_gap)) // 2 |
| 225 | + for i, (title, desc) in enumerate(why): |
| 226 | + wx = why_start + i * (why_w + why_gap) |
| 227 | + draw.rounded_rectangle((wx, y, wx + why_w, y + why_h), radius=12, fill=BG_SURFACE, outline=BORDER, width=1) |
| 228 | + draw_text(draw, title, (wx + 24, y + 26), fonts["h3"], TEXT_PRIMARY) |
| 229 | + wrapped = wrap_text(draw, desc, fonts["body-sm"], why_w - 48) |
| 230 | + line_y = y + 64 |
| 231 | + for line in wrapped: |
| 232 | + draw_text(draw, line, (wx + 24, line_y), fonts["body-sm"], TEXT_SECONDARY) |
| 233 | + line_y += 26 |
| 234 | + |
| 235 | + # Footer |
| 236 | + footer_y = HEIGHT - 150 |
| 237 | + draw.rectangle((0, footer_y, WIDTH, HEIGHT), fill=BG_SURFACE) |
| 238 | + draw.line((0, footer_y, WIDTH, footer_y), fill=BORDER_STRONG, width=1) |
| 239 | + draw_text(draw, "openElement", (80, footer_y + 40), fonts["h3"], TEXT_PRIMARY) |
| 240 | + draw_text(draw, "JSX-first Web Components platform.", (80, footer_y + 76), fonts["body-sm"], TEXT_SECONDARY) |
| 241 | + footer_links = ["Docs", "Architecture", "Roadmap", "Blog", "GitHub"] |
| 242 | + fx = WIDTH - 80 |
| 243 | + for link in reversed(footer_links): |
| 244 | + w, _ = text_size(draw, link, fonts["body-sm"]) |
| 245 | + fx -= w |
| 246 | + draw_text(draw, link, (fx, footer_y + 44), fonts["body-sm"], TEXT_SECONDARY) |
| 247 | + fx -= 40 |
| 248 | + draw_text(draw, "© 2026 openElement contributors · MIT", (80, footer_y + 116), fonts["caption"], TEXT_MUTED) |
| 249 | + |
| 250 | + out_path = os.path.join(os.path.dirname(__file__), "homepage-mockup.png") |
| 251 | + img.save(out_path) |
| 252 | + print("Saved:", out_path) |
| 253 | + |
| 254 | + |
| 255 | +if __name__ == "__main__": |
| 256 | + main() |
0 commit comments