本站左上角的标志是一个「易」字:上面三道细斜杠是「日」,下面三道粗斜杠是「勿」,六根杠的右端切齐在同一条竖线上。下面三道杠的颜色跟着站点的颜色主题走,切到红色主题它就是红的。
这篇讲它从一个想法变成组件的过程:为什么用斜杠,在纸上怎么推出来,怎样把手稿收成可以检查的几何,以及放到页面上时遇到的问题。
意图:一个「易」字,两组三道杠
「易」上面是「日」,下面是「勿」。这个字有变易、简易、交易三层意思,对一个写代码的人来说,把复杂的东西变简单,本身就是「易」。

这个方向不是一开始就有的。上一版标志是一个带 >_ 终端提示符的方块,下面接三道 45° 斜撇,用了几天总觉得复杂:外轮廓 18 个顶点,加两个孔,缩小以后细节全糊在一起。但整个标志里最有辨识度的,一直是下面那三道斜撇。
于是反过来想:既然三道杠最好认,能不能整个字都用杠来写?「勿」本来就是几道撇,三道粗杠可以留下;「日」是一个扁框,里面一横,正好也是三道横画,可以换成三道细杠。上下两组,各三道。
推演:在打印的辅助线上用铅笔涂
这种几何在屏幕上拖点很难找到感觉,我干脆打印出来手画。先做了一张 A4 辅助线稿,一页四格,每格里预先填好下面三道粗杠,斜线统一 1:2,杠厚 72,杠距 26,方格边长也是 26。然后用铅笔在上半部分试各种「日」的画法:

右下那格是最直白的写法:「日」是一个封闭的框,里面三道杠。其余几格在试杠的长短和起点。涂完能看出两件事:上面的杠要比下面细,否则两组粘成一团,分不出上下;杠与杠之间可以再靠近一点。
接下来就是做减法,每一步都比上一步少一样东西:

- 上一版标志里,被圈出来的是下面三道斜撇;
- 把它们放到下面做「勿」,「日」先画成一个实心块加一个孔;
- 「日」拆成三道细杠,左右两边各用一根竖条封住,还是一个框;
- 去掉左边的竖条,「日」不再闭合;
- 右边的竖条也去掉,每道杠缝都从右边穿出去。
到最后一步,六根杠彼此独立,只靠右端齐平连成一个整体,看上去像六道向右上扫过去的笔画。
收形:把手稿变成可以检查的几何
手稿只定方向,尺寸要在电脑上定。上组的杠厚和左端位置有好几种合理的组合,我把它们排成一张表,同一个尺寸、同一种配色并排看,也顺便看了双色的效果:

最后选的是上杠厚 36、上组左端和下组第三道杠对齐的那一版。然后把“看起来对齐”全部写成“必须落在这条线上”:
- 外框 416 × 584;
- 所有斜边都是 1:2,每根杠两端竖直切齐;
- 下组三道杠厚 72,左端依次是 0 / 78 / 156,每道比上一道往右缩 78;
- 上组三道杠厚 36,左端都在 156,和下组第三道杠对齐;
- 杠与杠之间、两组之间的缝都是 26;
- 六根杠的右端都切在 x=416。

几何由一个 Python 脚本生成,脚本里有三条自检:所有顶点必须是整数,斜边必须是精确的 1:2,总高必须是 584,不满足就直接报错。1:2 的好处是每横走 2 个单位正好升 1 个单位,只要左右端的 x 都是偶数,所有顶点就落在整数上,不会出现 130.5 这种坐标。脚本同时输出一张核对图,每条边的辅助线和每个尺寸都标在上面:

最终的标志是六个平行四边形,上下两组各一条路径:
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 416 584">
<path fill="var(--logo-upper, currentColor)" d="M156 130 416 0 416 36 156 166ZM156 192 416 62 416 98 156 228ZM156 254 416 124 416 160 156 290Z"/>
<path fill="var(--logo-lower, currentColor)" d="M0 394 416 186 416 258 0 466ZM78 453 416 284 416 356 78 525ZM156 512 416 382 416 454 156 584Z"/>
</svg>不设置 CSS 变量时两组都是 currentColor,就是单色版;设置以后可以分别上色。

28px 以上两组都清楚。到 24px 和 16px,上组的细杠会糊成一片斜纹,剪影还认得出来,但已经看不清是六根杠了。
落地:组件与主题色
站点里的标志是一个 React 组件,直接内联 SVG。上组用 currentColor,跟着文字颜色走;下组加一个 fill-primary,跟着主题色走:
export function SiteLogo({ className, ...props }: React.ComponentProps<"svg">) {
return (
<svg
viewBox="0 0 416 584"
fill="currentColor"
aria-hidden="true"
className={cn("h-8 w-auto overflow-visible", className)}
{...props}
>
<path data-part="upper" d="M156 130 416 0 …Z" />
<path data-part="lower" className="fill-primary" d="M0 394 416 186 …Z" />
</svg>
)
}站点的亮暗模式和颜色主题是两个独立的维度,颜色全部来自主题变量。这样写以后,标志不需要知道当前是哪个主题:暗色模式下上组变白,切到橙色主题下组变橙。默认的中性主题里,主题色本身就接近黑白,两组看起来是同一个颜色,切到彩色主题才看得出双色。
宽高从固定的正方形改成了 h-8 w-auto,高度对齐页头,宽度按 416:584 自己算。浏览器标签页的图标不能跟着页面主题变,用的是单色版;SVG 图标里写了 prefers-color-scheme,系统切到暗色时变白。
立体:挤出成等轴测 3D
我写过一个通用组件 isometric-mark:传入平面多边形,自动挤出成等轴测线条 3D。顶面填斜纹,侧面填背景色挡住后面的线,线条上有一圈跟着鼠标走的高光,按下时顶面带着斜纹一起弹簧下沉。

新标志的六根杠是六个互不相连的平行四边形,正好可以直接传进去,每根杠各自挤出、各自遮挡。试着晃一晃鼠标、按住它:
"use client"
import { cn } from "@/lib/utils"
import { Volume2, VolumeX } from "lucide-react"
import {
IsometricMark,
useIsometricMarkMuted,
type IsometricShape,
} from "@/components/ui/isometric-mark/isometric-mark"
// 本站标志「易」:上三道细斜杠是「日」,下三道粗斜杠是「勿」,右端齐平;坐标与站点 SVG 标志相同
const LOGO: readonly (readonly [number, number])[][] = [
// 上组:细斜杠
[
[156, 130],
[416, 0],
[416, 36],
[156, 166],
],
[
[156, 192],
[416, 62],
[416, 98],
[156, 228],
],
[
[156, 254],
[416, 124],
[416, 160],
[156, 290],
],
// 下组:粗斜杠
[
[0, 394],
[416, 186],
[416, 258],
[0, 466],
],
[
[78, 453],
[416, 284],
[416, 356],
[78, 525],
],
[
[156, 512],
[416, 382],
[416, 454],
[156, 584],
],
]
// 朝向 -65°、标准等轴测俯仰 35°、挤出 52:斜杠立起往右上斜,细的一组在后、粗的一组在前;
// 辅助线用 "edges",贴着斜杠的长边与齐平的切口;斜杠本身向右上斜,顶面用横纹,与斜杠长边错开不产生视差
// 上三道细斜杠用主题色由浅到深(从后往前),下三道粗斜杠保持单色线稿,主次分明
const TONES = [
"color-mix(in oklab, var(--primary) 55%, var(--background))",
"color-mix(in oklab, var(--primary) 78%, var(--background))",
"var(--primary)",
]
const MARK_SHAPES: readonly IsometricShape[] = LOGO.map((bar, index) => ({
outer: [...bar],
tone: TONES[index],
}))
// 标注标签:圆点取自 --flow-* 色调,说明这块几何由哪个部分生成
function Callout({
tone,
className,
children,
}: {
tone: "blue" | "violet" | "amber" | "green"
className?: string
children: React.ReactNode
}) {
return (
<span
className={cn(
"absolute hidden items-center gap-1.5 rounded-md border border-edge bg-background/90 px-2 py-1 font-mono text-[10px] text-muted-foreground shadow-xs backdrop-blur-sm sm:flex",
className,
)}
>
<span
aria-hidden="true"
className="size-1.5 rounded-full"
style={{ backgroundColor: `var(--flow-${tone})` }}
/>
{children}
</span>
)
}
/** 音效开关:静音偏好存在本地,全站的等轴测标志共用 */
function SoundToggle() {
const [muted, setMuted] = useIsometricMarkMuted()
return (
<button
type="button"
aria-pressed={!muted}
aria-label={muted ? "打开点击音效" : "关闭点击音效"}
title={muted ? "打开点击音效" : "关闭点击音效"}
onClick={() => setMuted(!muted)}
className="absolute top-3 right-3 z-10 inline-flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground [&_svg]:size-3.5"
>
{muted ? <VolumeX /> : <Volume2 />}
</button>
)
}
// 地面网格(floor)与辅助线同一平面、对齐标志的每条边;主题色光晕与接触阴影让标志落在地面上,标注标签讲清几何怎么来
export default function IsometricMarkDemo() {
return (
<div className="relative isolate flex min-h-72 w-full max-w-2xl items-center justify-center overflow-hidden rounded-lg border border-edge bg-card px-4 pt-6 pb-12 sm:min-h-96">
<SoundToggle />
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 -z-10 bg-[radial-gradient(ellipse_55%_42%_at_50%_58%,color-mix(in_oklab,var(--primary)_14%,transparent),transparent_75%)]"
/>
<div
aria-hidden="true"
className="pointer-events-none absolute top-[62%] left-1/2 -z-10 h-10 w-[34%] -translate-x-1/2 -rotate-[22deg] rounded-[50%] bg-foreground/10 blur-2xl dark:hidden"
/>
<div className="relative w-full max-w-[17rem] sm:max-w-80">
<IsometricMark
shapes={MARK_SHAPES}
rotation={-65}
elevation={35}
depth={52}
material="lines"
label="易"
guides="edges"
floor
sound
/>
</div>
<Callout tone="blue" className="top-[18%] left-5">
TOP · 横纹顶面
</Callout>
<Callout tone="violet" className="bottom-[26%] left-5">
WALL · 侧墙自动消隐
</Callout>
<Callout tone="amber" className="top-[24%] right-5">
depth = 52
</Callout>
<Callout tone="green" className="right-5 bottom-[26%]">
SVG · 可用 className 缩放
</Callout>
<p className="absolute inset-x-0 bottom-3 flex items-center justify-center gap-2 font-mono text-[10px] tracking-widest text-muted-foreground">
<span aria-hidden="true" className="size-1 bg-primary" />
移动指针看高光 · 按住压下
</p>
</div>
)
}
组件里没有手画的路径,投影、背面剔除和遮挡排序都由代码从平面多边形算出来,换了标志只需要换一组顶点。
组件的用法和几何细节见 等轴测标志。
现在这组坐标同时用在三个地方:页头的标志、分享卡片左上角,以及首页那个可以拖动视角的等轴测 3D,改一处就得三处一起改。下次再动它,我大概还是会先打印一张辅助线稿,拿铅笔涂几格再说。