openpencil/templates/step0/_generators/tpl_gradient_tech.py
Fini cc9b7889e2 feat(editor): grow the template library to five deck styles and a card tier
Adds a knowledge-card scene (vertical and square social cards) and
four presentation styles beside the original warm-white deck: a dark
pitch deck, a paper-white lecture deck, a minimal keynote and a
gradient-tech deck with glassmorphism built from opaque composited
stops — the linter drops alpha channels, and a truly translucent card
would exempt its text from contrast checking forever. Every palette
pair ships with measured WCAG ratios baked into the generators, and
low-contrast probe documents prove the audit actually fires on each
template family.

Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
2026-08-05 00:47:50 +08:00

417 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""gradient-tech.op — 渐变科技风 deck(6 页 1920×1080)
深底 + 大面积渐变 + 玻璃拟态卡,开发者产品口吻。这套同时是引擎能力的展台:
线性渐变、径向光晕、玻璃卡三样都是 schema 原生支持、渲染栈真画得出来的
(渲染探针见交付说明)。
## 玻璃卡为什么不用真 alpha
玻璃拟态的自然写法是半透明白 `#FFFFFF1F`。渲染没问题,但 op-design-lint
的对比度检测器 `parse_hex_color` 会丢掉 alpha 通道,把这张卡当成**纯白不
透明**:卡里的白字于是被判成 1.00:1,而真实合成结果是深紫上的白字。后果
不只是误报——它会让这张卡内的所有文字都失去有效检测,真出现低对比配色也
照样不报。
所以这里的玻璃卡用「不透明渐变」:stops 是「白 @ GLASS_ALPHA 合成到页面
渐变对应 stop」的结果,光学上与真 alpha 一致(对照渲染验证过),但不含
alpha 通道。代价是 stops 是烤死的——改了下面的 GRADIENT 就必须重跑本生成
器,不能只改 .op 里的变量。
排版遵循 skills/domains/slides.md 的硬契约:
- 每帧固定 1920×1080,绝不 fit_content;内容距边 ≥100(这里 120)
- 正文 ≥24(取 26-30),标题 ≥40,关键数字 80-200(数据页取 132)
- 行高:标题 1.1-1.2,正文 1.45+
- 最多 2 个字体族(Noto Sans SC 排中文,Inter 排数字与代码)
- 强调色只用于强调
渐变底上的对比度按**渐变两端分别实测**(比单点检测更严),最低一对
4.85:1,全部高于 4.5。数值见文件末尾。
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from oplib import Ids, color_vars, frame, rect, solid, text, write_doc
ids = Ids()
# 页面渐变的三个 stop:深紫 → 靛 → 蓝。玻璃卡的合成色由它们算出来,所以
# 这里是整套配色的唯一真源。
GRADIENT = [(0.0, "#1B0B3B"), (0.55, "#2A1E6E"), (1.0, "#0A2C6E")]
# 玻璃的白覆盖率。这个数是承重的:0.12 时强调色压在最亮那段玻璃上是
# 4.85:1,提到 0.18 就掉到 4.00 —— 想让玻璃更亮,得先把强调色一起调。
GLASS_ALPHA = 0.12
GLASS_EDGE_ALPHA = 0.30
def over(alpha, background):
"""白色以 alpha 覆盖 background 后的不透明结果(直接的 source-over)。"""
channels = [int(background[i:i + 2], 16) for i in (1, 3, 5)]
return "#" + "".join(
f"{round(alpha * 255 + (1 - alpha) * c):02X}" for c in channels
)
VARS = color_vars({
"c-grad-a": GRADIENT[0][1],
"c-grad-b": GRADIENT[1][1],
"c-grad-c": GRADIENT[2][1],
"c-ink": "#FFFFFF",
"c-muted": "#C9D4F5",
"c-accent": "#3AC8FF",
# 对比条里「被比下去」的那根。用正文的 c-muted 会让基线条比强调条还
# 抢眼(它更长),所以单独给一档更收的合成色;条不是文字,不参与对比
# 度门槛。
"c-bar-base": over(0.45, GRADIENT[1][1]),
# 玻璃卡的三段合成色(由 GRADIENT + GLASS_ALPHA 算出,改了要重跑)。
"c-glass-a": over(GLASS_ALPHA, GRADIENT[0][1]),
"c-glass-b": over(GLASS_ALPHA, GRADIENT[1][1]),
"c-glass-c": over(GLASS_ALPHA, GRADIENT[2][1]),
# 描边不参与对比度检测,这里可以放心用真 alpha 取那道细白边。
"c-glass-edge": "#FFFFFF4D",
})
W, H = 1920, 1080
EDGE = 120
CJK = "Noto Sans SC"
NUM = "Inter"
# 角度 135 = 左上 → 右下(渲染实测)。
PAGE_ANGLE = 135
def page_fill():
return [{
"type": "linear_gradient",
"angle": PAGE_ANGLE,
"stops": [{"offset": offset, "color": f"$c-grad-{key}"}
for (offset, _), key in zip(GRADIENT, "abc")],
}]
def glass_fill():
"""玻璃卡体。见文件头:不透明渐变,不是 alpha。"""
return [{
"type": "linear_gradient",
"angle": PAGE_ANGLE,
"stops": [{"offset": offset, "color": f"$c-glass-{key}"}
for (offset, _), key in zip(GRADIENT, "abc")],
}]
def glass_edge():
return {"thickness": 2, "fill": solid("$c-glass-edge")}
def slide(name, *, gap=56, justify="start"):
"""一帧。固定 1920×1080,底是整页渐变。"""
node = frame(ids, name, width=W, height=H, layout="vertical",
padding=[EDGE, EDGE], gap=gap, justifyContent=justify,
alignItems="start", fill=page_fill(), clipContent=True)
node["children"] = []
return node
def col(name, children, *, gap=16, width="fill_container",
height="fit_content", align="start", fill=None, **props):
node = frame(ids, name, width=width, height=height, layout="vertical",
gap=gap, alignItems=align, fill=fill or [], **props)
node["children"] = children
return node
def row(name, children, *, gap=32, align="start", width="fill_container",
height="fit_content", fill=None, **props):
node = frame(ids, name, width=width, height=height, layout="horizontal",
gap=gap, alignItems=align, fill=fill or [], **props)
node["children"] = children
return node
def glass_card(name, children, *, gap=18, height="fit_content",
padding=(40, 32), **props):
return col(name, children, gap=gap, height=height,
padding=[padding[0], padding[1]], cornerRadius=20,
fill=glass_fill(), stroke=glass_edge(), **props)
def centered_body(name, child):
"""内容块在页标题以下的整块空间里垂直居中,页标题每页同高。"""
return col(name, [child], gap=0, height="fill_container",
justifyContent="center")
def eyebrow(label):
node = frame(ids, "标签", width="fit_content", height="fit_content",
layout="horizontal", padding=[10, 22], gap=0,
cornerRadius=999, alignItems="center",
justifyContent="center", fill=glass_fill(),
stroke=glass_edge())
node["children"] = [
text(ids, "标签文字", label, 26, 600, "$c-accent", family=CJK,
width="fit_content", growth="auto", line_height=1.4,
spacing=1.0),
]
return node
def page_head(title, subtitle=None, *, size=60):
kids = [text(ids, "页标题", title, size, 700, "$c-ink", family=CJK,
line_height=1.15)]
if subtitle:
kids.append(text(ids, "页副标题", subtitle, 30, 400, "$c-muted",
family=CJK, line_height=1.45))
return col("页头", kids, gap=18)
def mono(name, content, size=26, color="$c-accent"):
"""代码腔的一行。Inter 不是等宽,但整套只允许两个字族,用字重区分。"""
return text(ids, name, content, size, 500, color, family=NUM,
width="fit_content", growth="auto", line_height=1.5)
# ------------------------------------------------------------------ 01 封面
def cover():
lede = col("主张", [
text(ids, "主标题", "让每一次调用\n都在 20 毫秒内返回", 96, 700,
"$c-ink", family=CJK, line_height=1.12),
text(ids, "副标题", "边缘推理网关 v3.0,现已开放公测。", 32, 400,
"$c-muted", family=CJK, line_height=1.45),
], gap=32)
install = glass_card("安装行", [
mono("安装命令", "$ npm i -g edgegate && edgegate up", 28),
], gap=0, padding=(26, 32), width="fit_content")
s = slide("01 封面", gap=48, justify="space_between")
s["children"] = [eyebrow("v3.0 · 公测"), lede, install]
return s
# ------------------------------------------------------------------ 02 痛点
PAINS = [
("冷启动吃掉了首包",
"函数实例每次唤醒要 800ms,用户看到的却是整整一秒的白屏。"),
("账单没法归因",
"月底一张总额,分不清哪个模型、哪条链路花掉的。"),
("换云就得重写",
"每家的运行时都不一样,迁移一次等于把接入层重做一遍。"),
]
def problems():
cards = [
glass_card("痛点卡", [
text(ids, "痛点序号", f"0{index}", 30, 700, "$c-accent",
family=NUM, width="fit_content", growth="auto",
line_height=1.1),
text(ids, "痛点标题", title, 36, 600, "$c-ink", family=CJK,
line_height=1.25),
text(ids, "痛点说明", desc, 27, 400, "$c-muted", family=CJK,
line_height=1.55),
], gap=18, height="fill_container", padding=(44, 36))
for index, (title, desc) in enumerate(PAINS, 1)
]
s = slide("02 痛点")
s["children"] = [
page_head("在边缘跑推理,卡在三件事上",
"都不是模型的问题,是它前面那一层的问题。"),
centered_body("痛点区", row("痛点网格", cards, gap=32,
align="stretch")),
]
return s
# ------------------------------------------------------------------ 03 架构
LAYERS = [
("接入层", "Gateway", "一个 Anycast 入口,就近落到最快的可用区。"),
("调度层", "Scheduler", "按延迟预算和单价实时选模型,超时自动降级。"),
("运行层", "Runtime", "常驻实例池,冷启动摊薄到 3 毫秒以内。"),
]
def architecture():
cards = []
for index, (name, en, desc) in enumerate(LAYERS, 1):
head = row("层头", [
text(ids, "层名", name, 36, 600, "$c-ink", family=CJK,
width="fit_content", growth="auto", line_height=1.25),
mono("层英文名", en, 26, "$c-accent"),
], gap=16, align="center", width="fit_content")
cards.append(glass_card("架构层", [
head,
text(ids, "层说明", desc, 27, 400, "$c-muted", family=CJK,
line_height=1.55),
], gap=12, padding=(32, 36)))
s = slide("03 架构")
s["children"] = [
page_head("三层,一层只做一件事",
"自上而下,每一层都能单独替换。"),
centered_body("架构区", col("架构堆叠", cards, gap=20)),
]
return s
# ------------------------------------------------------------------ 04 性能
METRICS = [
("20", "ms", "P50 端到端", "含调度与网络"),
("3", "ms", "冷启动", "常驻池摊薄后"),
("62", "%", "成本下降", "同等 QPS 对比自建"),
]
# 对比条:条长按真实数值成比例(越短越快),数字写在标签里。
BAR_TRACK = 1600
BAR_OURS_MS = 20
BAR_BASE_MS = 210
def metric_card(value, unit, label, note):
head = row("数值", [
text(ids, "数值文字", value, 132, 700, "$c-accent", family=NUM,
width="fit_content", growth="auto", line_height=1.0),
# 单位跟着数字基线走:lineHeight 1.0 下 `end` 会把小字压到基线以
# 下,这层只有下内距的盒子把它抬回来(jian 没有真 baseline 对齐)。
col("单位", [
text(ids, "单位文字", unit, 40, 600, "$c-accent", family=NUM,
width="fit_content", growth="auto", line_height=1.0),
], gap=0, width="fit_content", padding=[0, 0, round((132 - 40) * 0.2), 0]),
], gap=8, align="end", width="fit_content")
return glass_card("性能卡", [
head,
text(ids, "指标名", label, 30, 600, "$c-ink", family=CJK,
line_height=1.25),
text(ids, "指标说明", note, 26, 400, "$c-muted", family=CJK,
line_height=1.45),
], gap=16, height="fill_container", padding=(40, 32))
def bar(label, milliseconds, color):
width = round(BAR_TRACK * milliseconds / BAR_BASE_MS)
return col("对比条组", [
text(ids, "条目标签", label, 26, 500, "$c-muted", family=CJK,
line_height=1.4),
rect(ids, "对比条", width=width, height=16, cornerRadius=999,
fill=solid(color)),
], gap=10)
def performance():
bars = glass_card("延迟对比", [
text(ids, "对比标题", "端到端延迟对比", 30, 600, "$c-ink",
family=CJK, line_height=1.25),
bar("本产品 · 20ms", BAR_OURS_MS, "$c-accent"),
bar("自建方案 · 210ms", BAR_BASE_MS, "$c-bar-base"),
], gap=20, padding=(32, 40))
# 三张卡与对比条合成一块「性能区」:直接摊在帧下会让页头、网格、对比
# 卡成为三个平级兄弟,其中只有对比卡有圆角,mixed-sibling-corner-radius
# 会照实报出来(也确实读着像手滑)。
body = col("性能区", [
row("性能网格", [metric_card(*entry) for entry in METRICS],
gap=32, align="stretch"),
bars,
], gap=32)
s = slide("04 性能", gap=44)
s["children"] = [
page_head("数字是压测出来的,不是估的"),
centered_body("性能居中", body),
]
return s
# ------------------------------------------------------------------ 05 客户
LOGO_SLOTS = 8
def customers():
# 每个格子是 frame 而不是 rectangle:frame 会递归渲染子节点(里面那行
# 提示才显示得出来),同时它是合法的图片拖放目标,把客户 logo 拖上去
# 就直接替换掉占位。
slots = []
for index in range(LOGO_SLOTS):
slot = frame(ids, f"Logo 位 {index + 1}", width="fill_container",
height="fill_container", layout="vertical", gap=0,
alignItems="center", justifyContent="center",
cornerRadius=16, fill=glass_fill(), stroke=glass_edge())
slot["children"] = [
text(ids, "占位提示", "拖入 Logo", 24, 500, "$c-muted",
family=CJK, width="fit_content", growth="auto",
line_height=1.4),
]
slots.append(slot)
grid = col("Logo 网格", [
row("Logo 行", slots[0:4], gap=28, align="stretch",
height="fill_container"),
row("Logo 行", slots[4:8], gap=28, align="stretch",
height="fill_container"),
], gap=28, height=420)
s = slide("05 客户")
s["children"] = [
page_head("已经在生产环境上跑",
"八家团队,日均 27 亿次调用。"),
centered_body("Logo 区", grid),
]
return s
# ------------------------------------------------------------------ 06 CTA
def call_to_action():
lede = col("结语", [
text(ids, "结语标题", "十分钟接完\n剩下的交给我们", 84, 700,
"$c-ink", family=CJK, line_height=1.15),
text(ids, "结语副标题", "公测期不限调用量,接入文档一页写完。",
30, 400, "$c-muted", family=CJK, line_height=1.45),
], gap=30)
actions = row("行动区", [
glass_card("安装卡", [
mono("安装命令", "$ npm i -g edgegate", 28),
], gap=0, padding=(26, 32), width="fit_content"),
glass_card("文档卡", [
mono("文档地址", "docs.edgegate.dev", 28, "$c-ink"),
], gap=0, padding=(26, 32), width="fit_content"),
], gap=24, align="center", width="fit_content")
s = slide("06 CTA", gap=48, justify="space_between")
s["children"] = [eyebrow("公测开放中"), lede, actions]
return s
# 顶层 frame 必须显式写 x/y:缺省时每帧都落在原点,六页会叠成一页。纵向
# gap 比横向多留 240,是给画布上固定在帧顶部的帧名标签让位。
BOARD_GAP_X = 120
BOARD_GAP_Y = BOARD_GAP_X + 240
BOARDS_PER_ROW = 3
def build():
boards = [cover(), problems(), architecture(), performance(),
customers(), call_to_action()]
for index, board in enumerate(boards):
board["x"] = (index % BOARDS_PER_ROW) * (W + BOARD_GAP_X)
board["y"] = (index // BOARDS_PER_ROW) * (H + BOARD_GAP_Y)
return boards
# 对比度(WCAG,按渐变两端分别量,取最差值;门槛 4.5):
# 压在页面渐变上(三个 stop):
# c-ink 18.14 / 13.91 / 13.14 c-muted 12.28 / 9.42 / 8.90
# c-accent 9.40 / 7.21 / 6.81
# 压在玻璃卡上(白 @0.12 合成后的三段):
# c-ink 13.25 / 9.82 / 9.36 c-muted 8.97 / 6.65 / 6.34
# c-accent 6.87 / 5.09 / 4.85 ← 最低一对
# 注意 GLASS_ALPHA 是承重参数:提到 0.18,c-accent 会掉到 4.00。
if __name__ == "__main__":
write_doc(sys.argv[1], VARS, build(), "渐变科技风 · 16:9")