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
417 lines
16 KiB
Python
417 lines
16 KiB
Python
#!/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")
|