paper-ui
briffy 的视觉标准——纸质拟物、全直角、中英文双语排版。改任何 renderer 的 CSS / HTML(workspace、shelf、bubble、pet、onboarding、region、extension popup、install page)之前先读;也用于评审一处 UI 改动是否合规。不适用于 logo / 品牌标记(那是 scripts/gen-brand.js 的事)。
SKILL.md.claude/skills/paper-ui/SKILL.md
GitHubPaper UI · briffy 的视觉标准
范围:只适用于 briffy 这个仓库。 这不是通用设计规范——其它项目不要套用,也不要抄进 ~/.claude 的全局配置。
一句话:窗口是一张暖灰的底纸,每条记录是一张躺在上面的白便签。 界面本身(导航、按钮、筛选、输入框)是印在底纸上的字,永远不浮起来;只有记录是真实物件, 它才有影子、才会被抬起、才会被拿走。
唯一来源是 assets/paper/tokens.css,每个 renderer 页面在自己的 css 之前 link 它
(扩展带一份拷贝在 extension/paper/)。两张样张:node dev/preview/serve.js 后
/lang(版面与字)和 /system(层 / 墨 / 空)。
抄谁:Steidl(德国摄影书出版社)。它的网站不是"像纸",它就是一张纸,东西放在上面—— 一种字体、一个字号梯度、零彩色、纯文字导航、零线条零方框、图片自己带影子。
硬约束(违反即错)
- 全直角。
border-radius全仓为 0,包括圆点、药丸、滚动条、头像框。 - 没有边框、没有分隔线、没有底纹。 分组只靠留白。仅剩的一条线是输入框底下那道发丝线(
--hair)。 - 只有真实物件有影子。 记录、详情窗、书架、气泡、小猫。按钮、导航、筛选、输入框永远层 0—— 按钮一旦浮起来,"便签是真实的纸"就不成立了。
- 影子是软的,光在上方,向下落。硬偏移影 + 深色描边是海报 / neo-brutalism,不是纸。
- 一种字体。 衬线和等宽都不进界面(
--mono只给日志和代码)。 - 一屏一个彩色。 蓝墨只给"正在发生",印泥只给 录音 / 出错 / 删除;通常一屏一滴都不出现。
- 底纸是近白,不是中灰。 2026-09-05 量过四个参照物:Steidl
#fff、Teenage Engineering#fff、 Cosmos 纯白、are.na#1a1a1a+ 1px 描边——顶级的纸感设计要么近白要么深板,没有一个停在中灰。 中灰(L80 左右)是设计工具画布的颜色,读起来是水泥、是"还没做完"。 现在--page #f3f1f0(L95),纸--slip #fbf9f8(L98)——只差 3 L*,边界全交给影子,所以--e1..--e4比原来重一档。 整扇窗用--ground(一道 3 L 的顶光渐变):一块绝对平的色是色板,不是面,真实的桌面有光。--page是它的平均实色,留给需要一块实色的地方。 连带的坑:底纸近白之后,"打穿的孔透出底纸"这件事本身看不见了(只差 3 L*)——孔要靠它自己的内影。 - 灰阶不带色相。 灰阶只带一丝暖(彩度 3,肉眼分不出,但不是水泥),不偏黄也不偏蓝——
截图里的真实颜色不该被底色拖偏,几种淡色混在一屏也只会浑。暗色是同一个色相坐标,只是明暗相反。
五种纸各有各的颜色(2026-09-09 用户定的「颜色区分明显一下」,推翻了原来「只有随手记是黄的」):
牛皮
--p-voice #f6efe3· 相纸--p-shot #fdfcfb· 便利贴--p-note #fbf6dc· 索引卡--p-mark #edf2f5· 复写纸--p-clip #f1eeea。 全部留在 L* 93–98、彩度 ≤ 8:再往下就不是纸了,是彩色卡片,而且会把嵌在纸里的截图缩略图 的真实颜色拖偏。原来那条的理由(「五种淡色混在一屏只会浑」)靠这个上限守住。 蓝墨是钢笔水的#2f4f7a,不是屏幕蓝。 - 没有纹理。 纸的真实感来自颜色、影子和排版,不是噪点图。(第二版加过纸纹,被否。)
- 图标线条
stroke-linecap: square; stroke-linejoin: miter。 - 字体随应用打包,不联网取字。
四把梯子(没有第五种)
层 · 离纸面多远
| 层 | 是什么 | token |
|---|---|---|
| 0 | 底纸 + 一切印刷元素(导航、按钮、筛选、输入框) | 无影,永不动 |
| 1 | 一条记录躺着 | --e1 |
| 2 | 鼠标经过 | --e2 + translateY(-2px) |
| 3 | 拖拽中 | --e3 + translateY(-4px) + 偏 1.5°(两处允许旋转之一,另一处见剪贴板碎片) |
| 4 | 详情窗 | --e4 + 底纸压暗(--scrim) |
选中不是层级:选中是荧光笔(--marker)划过,纸没有动。做成抬起会和"经过"撞车,
一屏选了六条时会浮起一片。处理中同理,只换一行墨色。
浮在别人桌面上的两样(书架、小猫)都不带影子——那圈阴影只会把它们和屏幕割开。
影子的大小跟着物件走,不跟着层级走。 托盘(搜索、关闭、底下那三只、批量条、详情卡片上的动作、
看图窗那一排)用 --e-tray,不用 --e1/--e2:一只 30px 的小方块套上给 200px 便签调的影子,
那道 26px 的模糊比托盘本身还宽,看着就是一团脏。(2026-09-07,用户原话「托盘的阴影太重了」。)
--e-tray 是 10px 的模糊、8% 的浓度——在只差 3 L* 的底纸上仍然读得出是一件东西,但不糊。
顺带纠一个旧错:托盘一直写的是 --e2,而 --e2 是「鼠标经过」那一档——托盘从不 hover,
它本来就该在「躺着」这一档以下。
墨 · 写得多黑
三级墨的步长要匀,铅笔要够到 WCAG AA 4.5:1。 2026-09-05 审计发现旧的阶梯是 98→59→45→9(步长 39/14/36):
两个淡墨挤在一起、都离黑很远,"三级"实际只有两级;而铅笔只有 3.1:1,低于 AA。
现在亮色 98 / 48 / 34 / 20(步长 14/14,铅笔 4.6:1),暗色 20 / 62 / 75 / 92(铅笔 4.5:1)。
墨是 L*20 不是纯黑——印刷黑,正好落在 Steidl 的 #333 那一档;纯黑(16.7:1)是屏幕黑。
--ink 日期/标题/正文/当前页 · --ink-2 摘要/工具/说明 · --ink-3 时间/来源/标签/未选中导航/placeholder ·
--hair 唯一的线 · --marker 选中 · --accent 正在发生 · --stamp 录音·出错·删除。
--tone / --tone-2 是"印在纸上、能按的地方"(按钮底)。
空 · 唯一的分隔手段
--a1 4 同一行字之间 · --a2 8 正文与元数据 · --a3 14 便签内边距 ·
--a4 26 便签之间 / 侧栏两组之间 / 一天和下一天之间 · --a5 54 一屏之内最大的一口气 · --pad 40 页边。
一天和下一天之间原来是 --a5(54),那是日期抬头还不显示时定的:那块空白当时是唯一说
「换天了」的东西。现在每条带子自己写着日期(.jg-hour),空块就只剩「多空一手」这一件事,
而 54 再加上带子自己的 --a2,一天结束的地方会空出 62px 的白——比一条记录还高
(2026-09-07 用户原话「这里间距怎么那么大」)。现在 .jg-day / .lv-day 都是 --a4:
带子之间 8,天和天之间 34,还是四倍多,读得出来,但不是个洞。
字 · 六级,一种字体
--fs-day 21/600 日期(全页最大,产品的主语是"一天")· --fs-title 13/600 · --fs-body 13/400 ·
--fs-nav 13 · --fs-act 12 工具与筛选(比导航小一档:动作不是地点)· --fs-meta 11 · --fs-brand 12(品牌最弱,它永远不变)。
排版规矩:拉丁在前中文在后;中文正文 13/1.6,≤14px 最粗 600,永远无斜体,字距 ≥ 0;
汉字与拉丁之间交给 text-autospace: normal,不手打空格;全角标点 text-spacing-trim;line-break: strict;
日期是一个词(抬头 text-autospace: no-autospace);数字半角 + tabular-nums;<html lang> 跟第一语言(zh-Hans)。
版面(H5:顶栏一条,右边一条轴,中间全是素材,托盘浮着)
版面的目标是把面积全让给素材。 窗口只有两行:顶栏、内容;托盘不占行,浮在内容上面。
顶栏那一行不是一整条托盘,但里面可以站托盘。(2026-09-07 改。原先的规矩是"托盘只属于底部, 顶部只有纸上的字",理由是把整条顶栏做成白板会和底部对不上、"筛选"会被误读成"切视图"—— 那个理由现在仍然成立,筛选那一行永远是纸上的字。改掉的是另一半: 两件能按、能往里打字的东西——搜索和关闭——做成托盘之后才认得出来。 用户原话:「搜索框看不出来是搜索框」。) 所以顶栏第一行的读法是:两只托盘立在两头,中间「怎么看」是印在纸上的字;第二行整行都是字。
顶栏(第一行住在 .winbar 里,第二行是 .top,都从页边那条线起):
第一行 搜索托盘(.search,320px)· 右端「怎么看」· 最右一只关闭托盘;第二行 筛选(见下)。
「怎么看」用图标不用词:网格=四格、列表=三横杠、选择=方框打勾——它们是三件不同的事,
并排三个词会被读成一组同类。
第一行里没有日期了(2026-09-09):那儿曾经有个叫「全部日期」的 <select>,它和筛选
是同一件事(把这一屏变短),却待在另一行、长着另一副样子,而且一天一格,翻到第三十天才找得到
「上周」。它现在是筛选那一行最前面的一排词。
字号只有两种:搜索 13(要往里打字),其余 12;第一行 align-items: baseline。
时间轴(右边,--axis 60px):一天一行,相对日 + 条数,右对齐、11px——它是索引,不是内容。
日期短到 9/4。点一行跳过去,滚动时自己跟着走。
卡片区:只有记录。日期抬头不显示——哪一天由右边的时间轴说。
.jg-day 留着当锚点和一天与一天之间的留白,它是 height: var(--a5) 的一块空白,
不能再 sticky、不能有底色——一个没有字的不透明块钉在滚动区顶上,就是一条划过去会盖住卡片的横带。
筛选是两级的,一级就是屏幕上那五种纸。(2026-09-09 用户定的,第六版。)
不限 · 今天 · 昨天 · 近 7 天 全部 · 剪贴板 · 截图 · 收藏 · 文件 · 录音
全部 · 文字 · 图片 · 链接 ← 二级,只在选了一级之后才出现
| 是什么 | 值 | |
|---|---|---|
| 时间 | #fdates,一排词 |
不限 · 今天 · 昨天 · 近 7 天 |
| 一级 | #fbuckets,一排词 |
全部 + 五种纸(store.js 的 entryBucket) |
| 二级 | #fsub,缩进一行,跟着一级换 |
store.js 的 entrySub |
一级必须和 cardKind() 是同一个轴。 前五版筛的都是「格式」(文字 / 图片 / 链接),
而一张从剪贴板来的图和一张截图都算「图片」,在屏幕上却是两种完全不一样的纸——
筛的轴和看的轴不是同一个,那五版之所以都难看,根在这儿。
五格互斥、全覆盖:每条记录落在且只落在一格里,五个数加起来一定等于全库
(「文件」是兜底那一格,不留孤儿)。
二级每一格问的问题不一样,这是这套分法好用的地方:收藏 / 截图问「从哪儿来」(站点、应用), 文件问「是什么」(真的扩展名),录音问「哪只麦克风」,剪贴板问「是什么」(文字 / 图片 / 链接)。 一个维度对所有东西问同一句话,就总有一半答不上来——旧那一档「来源」最大的一格是 「未知」,144 条,占 44%。分了级就没这问题:问截图从哪儿来是有答案的,剪贴板压根不问这一句。
二级的值是开集,只有剪贴板那一格是闭集。(2026-09-09 用户点出来的。)
收藏的站点、截图的应用、录音的麦克风、文件的扩展名——都是「库里出现过什么就有什么」,
不能写死一张表。文件那一格踩过这个坑:一开始用的是 entryFormat 那张 11 项的固定表,
于是 .sketch 和 .dmg 一起进了「其它」——实测 11 样拖进来的东西,固定表分出 8 格
(「其它」装着三样不相干的),真扩展名分出 10 格。
剪贴板是例外,因为它装得下的东西就那么几样,那才是闭集该在的地方。
二级只在选了一级之后才出现,所以默认那一屏仍然只有一行字;只剩一个值时整排不出现
(一个筛不掉任何东西的词不值得占一行)。缩进 --a4 是唯一说得出「父子」的手段——
这套里没有线、没有框、没有底纹。
数平时不写出来,只在选中的那个和鼠标停住的那个上出现。 一行十几个数字读起来是一条数字带,
不是一排能按的词。位置一直留着(.n 定宽 2.1em + opacity,不是 display:none):
鼠标扫过去整行字跟着左右跳,比十几个数字还难受。
每个数必须等于点下去之后屏幕上的条数。 这是这个筛选器唯一的正确性要求,而 2026-09-09
之前它是错的:「筛」和「数」是两段各自算的代码,界面上「全部」偷偷扣掉剪贴板(331 条里 262 条),
数数那一路不知道,于是行上写「文字 203」、屏上摆 19 条。修法不是给数数那一路也补上那条规矩
(那只是把同一个错误抄两遍),而是让两条路共用同一个判据:store.entryMatches。
stats() 收和 listEntries() 同一套条件,每一档放开自己那一维、照筛别的。
守卫在 dev/filter-count-test.js:每个数都真的去 listEntries 要一遍,一万两千个数,
外加互斥和全覆盖两条结构不变量。
「全部」里默认不含剪贴板(一天到晚自己往里掉,一屏九成是它就不叫「全部」了)。
这条规矩以前是写死的一句 if,现在是设置里一个开关(clipboardInAll),而且在屏幕上看得见:
「全部 69 · 剪贴板 262」两个数摆在一起,本身就说明了「全部」里没有剪贴板。
判据是 listEntries / stats 的 hideInAll——它只在没选一级时生效,
点了「剪贴板」那一格就必须看得见剪贴板,一条无条件的排除做不到这件事。
顺序写死,不按数排:一排词的位置每次进来都不一样,肌肉记忆就没了;数会变,位置不该跟着变。 同理,「永远筛不窄任何东西」的词才隐藏,判据看整个库不看眼下这一刀(库里只有六天时 「近 7 天」和「不限」是同一批)——按眼下这一刀算的话,点一下别的格子,「今天」会当场消失。
筛选仍然是单选:一次只看一类(2026-09-06 从多选改回来的——两个能同时按的东西会让 「现在在看什么」说不清楚)。
这一处走了六版:① 三个维度名一排 + 当前维度的值一排——维度名和筛选值长得一模一样,
点一下换的是「列什么」不是「筛什么」;② 同样两群并到一行——选中的值在同一行上出现了两次;
③ 类型一排词 + 右边一个「+ 筛选」弹出面板——多一层要点开才知道里面有什么;
④ 原生 <select>——系统画的那个菜单和这张纸格格不入(用户原话:「别用下拉框,而是一个下拉了
大托盘类似的」);⑤ 自己的大托盘 .ftray + 一排类型词 + 一个剪贴板开关——一行上并排站着
四种长得一模一样、按起来却各不相同的东西(单选、单选、开关、下拉),而且行上一个数都不对;
⑥ 现在这个。②③④⑤ 的共同毛病是在长相上找答案,而问题一直在那个轴上。
注意 ⑥ 和被否掉的 ① 不是一回事:① 上面那排是维度名,点一下换的是「列什么」; 这里上面那排自己就在筛(点「截图」当场只剩 34 条),下面那排是在它里头再收一刀。 一个是换挡,一个是父子。
关闭键在右上角,是一只 30px 的正方托盘(.wtray)——和看图窗右上角那只、详情卡片上那几只
同一件东西:--slip 的底、直角、--e2 的影子。(2026-09-07 改。原先写的是"关闭键在左上角,
macOS 的窗口控件历来在那儿"——但这扇窗自己画的那个 × 不是红绿灯,左上角一个孤零零的小图标
既不像系统控件,又和搜索框在同一行留下两条差 26px 的左边线。)
它必须是拖拽带里的普通流内元素:-webkit-app-region: no-drag 在 drag 区里挖的那个洞,
是按未脱离文档流、未变形的盒子算的,所以这只托盘既不能 position: absolute/fixed,
也不能靠 transform 定位——否则看得见的托盘和能点的那块洞会错开,症状是「叉叉点了没反应」。
(关闭本身也别用渲染进程的 window.close():loadFile 出来的顶层窗口不是脚本打开的,
Chromium 会直接忽略。走 ws:close 到主进程。)
这扇窗四边是同一个数:--tray-inset: 20px,而且 workspace.css 里把页边直接接上去
(:root { --pad: var(--tray-inset) })。于是搜索托盘、筛选那行、日期抬头、第一张便签、
左下角的加号,左边全站在同一条线上;右边关闭托盘、设置托盘、时间轴也都停在同一条线上。
(2026-09-07:之前是五个数——上 14、纸 40、托盘 20、时间轴顶到 0、卡片 60——每个都差一点,
用户原话「整体的左右边距和上边距都不相等」。)
拖拽带自己仍然要从 6px 起(见下面那条),所以它补 14 的上内边距,里面那排托盘才落在 20。
状态用图标,不用一个会呼吸的彩点:浏览器扩展就是那块拼图(Chrome / Edge / Firefox 都用它,用户已经认识)。
状态用「实心 / 空心」:连上了是实心的一块,没连上只是个轮廓——16px 上"深一点 / 浅一点"读不出来。
不要划斜杠(会把图标本身划糊),更不要花掉"一屏一个彩色"的额度。
同一只托盘里的图标要等大等重:都 18px;轮廓简单的图形(拼图)比密齿的图形(齿轮)看着轻,
所以拼图画得更满一点、描边 1.9(齿轮是 1.6)——对的是看上去的分量,不是数值。
托盘(.dock,浮在底部,--slip + --e2):左边加号(记一句话),右边计数 · 扩展状态 · 设置。
(2026-09-20 之前中间还有一只:小猫头像开「问」那一页、问的输入框、模型托盘、送出——随整层 AI 服务一起拆掉了。)卡片从它底下过去,所以滚动区底下留 92px。
这是「只有记录才有影子」的唯一例外——它确实是一只托盘,一件东西。
删掉的:左侧导航栏、每日摘要(整个功能)、右边那条迷你滚动轨、底部那条通栏。
设置页(同一张纸,不是报告):六组是顶上一排词,站在记录页筛选行的位置(.sg-nav,选中加粗 + 钢笔线)。
往下每组一行两栏:.card-section = 组名 140px | 内容。整页只有两条竖线:
左边一条,那排标签和所有组名(14px 半粗)都站在上面;右边一条,控件、勾选框、按钮、状态全部从它起头。
中间的小灰词(.fl,124px)右对齐,顶住右边那条线——它属于控件,不另起一条线。
左栏是词,不是句子(模型 / 语言 / 端口 / 镜像 / 最少字数);句子只出现在勾选项后面和 tip 里。控件宽 240px,短数字 72px。
说明收进组名旁一个 14px 的小 i(.info > i + .tip),停上去才展开成一张 --e2 小便签;页面上不放任何段落。
顶上那排标签不参与滚动:.settings 是 flex 列,.sg-nav 定住,只有 .sg-panes 滚;换一组把它的 scrollTop 归零(别用 scrollIntoView,那会把标签行卷出去)。
没有保存键:改一项立刻生效(workspace.js queueSave:change 立存,文本 700ms 防抖,密码只在离开时存,#petSearch 除外),右端 #settingsStatus 亮 1.4s「已保存」。
2026-09-20 起已不存在:整层 AI 服务(「问」、模型托盘、设置里的 AI 服务组、一键翻译)连同代码一起拆掉了(量到 15 天只问了 14 次)。下面这段留作设计史,别照着它做。
AI 服务那一组是三段,一次只看得见两段(2026-09-09 重构,用户原话「重构一下 ai 服务的设计,
现在贼乱,层级乱,也不知道怎么用」)。原先顶上是一个叫「来源」的 <select>,选中哪一家才显示
哪一家的面板——「现在在用什么」和「怎么把它配好」绞成了一件事。现在:
| 段 | 回答的问题 |
|---|---|
| 常驻模型 | 现在用哪一块(.seg:本地模型 / 网络模型)· 脱敏 · 问它一句 |
| 本地模型 | Ollama 装了没、跑着没;架子上摆着谁;有哪些、还能下哪些 |
| 网络模型 | Key;架子上摆着谁(那一片词);加一个目录里没有的 |
没选中的那一块整段不显示(#sec-local:not(.in-use) { display: none })。
中途试过两块都常驻在页面上、用组名旁一个「在用」的小词区分——被否了,用户原话
「我选了网络模型了就不要有这一堆了」:本地那一段是一张长表(状态、常驻、已下的、能下的三档、地址),
你选了网络模型的时候它一条也不是你要读的东西,堆在那儿只是把真正该读的几行推到屏幕外。
所以那一排词既换常驻也换显示哪一块——它俩本来就是同一件事,
也因此那一段的组名旁边不挂「在用」:屏幕上只有一块,没有第二个可能。
切换器是 <input type="hidden" id="provider"> 加一排词,不是 <select>:这一行只有两个值,
用下拉去装两个值是把一次点击拆成两次。「当前使用:…」那句话只在还没配好的时候出现——
配好了它就是把那一排词已经说过的话再说一遍。
「常驻」和「在用」是两件事,每一边最多三个常驻(2026-09-09 用户定的,原话「常驻模型是允许选择 三个的」,追问后是「本地三个网络三个」):
- 常驻 = 架子上摆着的那几个(
ollamaResident/openrouterResident,各 ≤3)。 输入框旁边那只托盘列的就是它们,一边一组,每条前面一个铅笔色小词说是哪一边。 - 在用 = 架子上正拿着的那一个(
provider+ollamaModel/openrouterModel),必须是架子上的一个。 所以设置里一个值有两个动作:摆上架子(本地是「设为常驻」,网络是点那块词)和 拿起来用(本地是「用它」,网络是再点一下已经在架子上的那块词,或者从托盘里点)。 架子满了那个键就点不动,写清楚为什么——没有「悄悄挤掉最早那个」这种事: 一个你自己摆上去的东西不该被别的操作顺手扔掉。摆上架子的那块词换荧光笔,正拿着的那个再加粗 (两级,一眼分得出「架子上的」和「手里的」),右边一个小×把它拿下来。 本地那份名单里,「在架子上」写在名字旁边(.tag-pin),不写进右边那串元信息——那一列窄, 一个词就把它挤成两行,而这不是元信息,是这一行现在的身份。 「在用哪一个」不跟着整张表单走:它由「用它」和托盘立刻存(saveResident), 而settingsPatch每改一项就把所有字段整份送一遍,送的是渲染时的旧值—— 把openrouterModel/ollamaModel留在那份 patch 里,等于把你刚换的那个踩回去。
下载模型分成两份名单(同一次重构,用户原话「下载模型功能也看不懂」):
这台电脑上(已经有的,动作是摆上架子 / 用它 / 删除)和可以下载(还没有的,
按硬件切成 轻松 / 适中 / 勉强 三档,动作只有「下载」)。以前两份混在同一片三档里,
一行上同时挂着 下载 / 使用 / 删除,而「已下载」只是元信息里的一个词。
「按这台电脑推荐哪个」那句话跟在可以下载底下(它回答的是「我该下哪个」),不加粗——
它不能比自己的标题响。安装、下载的进度条和日志(#ollamaJob)和按钮在同一段里。
「在用哪个」和「要下哪个」必须是两个框:以前共用 #ollamaModel 一个输入框,
往里打一个还没下下来的名字,常驻模型当场就被改成了它。现在 #ollamaModel 是隐藏的载体,
#pullOther 才是「下别的」,#orAdd 是网络那边的「加一个」,pullModel(model) 的下载对象由调用方给。
联网模型的名字要带版本号。 OpenRouter 那批 ~…-latest 别名自己的名字里没有版本
(「Google Gemini Pro Latest」「OpenAI GPT Latest」),一片写着「Gemini Pro」「GPT」的词
看不出你在用哪一代(2026-09-09 用户原话「最基本的版本数字都没有,比如 gemini 好像是 3.7 了吧」)。
目录里也没有一个字段说别名指向谁,所以主进程 resolveLatest() 按价目表认亲:
同一家里上下文长度、进价、出价三样全都一样的那几个具体型号是候选,取版本号最大的那个
(~x-ai/grok-latest → 「SpaceXAI: Grok 4.6」,名字对不上也没关系)。认不出来就留空,
界面退回别名自己的名字——宁可少说一句,也不能说错一个版本号。
显示时去掉公司名(行首已经写着了):先按 id 里的厂商键去,再兜一道通用的「某某:」(orName)。
2026-09-20 起已不存在:整层 AI 服务(「问」、模型托盘、设置里的 AI 服务组、一键翻译)连同代码一起拆掉了(量到 15 天只问了 14 次)。下面这段留作设计史,别照着它做。
输入框旁边那只模型托盘列的就是两只架子(2026-09-09,用户原话「聊天窗口的模型选择也乱七八糟的, 我们只需要显示本地和网络的选中的常驻模型」):本地常驻的那几个、网络常驻的那几个(各 ≤3), 外加一条「在设置里选…」。以前它把本机下载过的模型一条条全列出来。 挑模型是设置页那两段的事(摆架子:有分档、有大小、有分数);这只托盘只做一件事—— 在架子上的那几个之间翻一下。每条前面一个铅笔色小词说它是哪一边(
.mdl-tray .w),正拿着的加粗。 本地那几条判的是「这一刻跑不跑得起来」(Ollama 在不在、那个模型下没下), 不是llm.missing那份「配没配」——地址和模型名都有默认值,所以那份对本地永远说「好了」, 而「Ollama 根本没装」正是最常见的那一种不能用。跑不起来就点不动,并且写清楚差什么。 「没选过时用哪个」那两个默认只有一份,在主进程(llm.config), 随providerStatus.resident送到界面;界面再写一遍就会和主进程真正用的那个漂开。2026-09-20 起已不存在:整层 AI 服务(「问」、模型托盘、设置里的 AI 服务组、一键翻译)连同代码一起拆掉了(量到 15 天只问了 14 次)。下面这段留作设计史,别照着它做。
「问」那一页左边每条对话是一张便签(2026-09-09,用户原话「至少历史聊天得有托盘吧」)。 原来那一列是「只有字,没有框没有线」,照的是「导航是纸上的字」那条——判断错了: 一次问答不是导航,它是你留下的东西,能打开、能改名、能删掉,跟主页上一张记录是同一类。 同一类东西就该是同一种纸:
--slip的底、直角、有影子;hover 抬 2px 换深一档; 选中是--marker划过、纸不动。「新的一条」同样是一张(这一页的动作,不是一个筛选词)。 影子用--e-tray(10px 模糊)不是--e1(16px)——一张 40px 高的小纸套上给 200px 便签调的影子 就是一团脏,和托盘那次是同一条规矩。「路过」那一页的主语是「读过的东西」,不是「时段」。(2026-09-09 重做,两版。 第一版做成了一张时段表加一条按应用的时间统计,用户原话「我还是看不出来自动监听页面的 可视化价值」——他是对的:「今天你在 Claude 上花了 6 小时」你自己知道, 时间报表是 RescueTime 的产品,briffy 不是。) 这一层唯一不可替代的那件事是:你记得读过一个东西,但你没存它。 记录页是「你决定留下的」,路过是「你没留、但确实经过的」——别的软件帮不了你这件事, 因为只有这台机器上这一层记了;而时间统计谁都能给。 所以这一页是: ① 一行「这一天什么样」(日期
--fs-day+ 在电脑前多久 + 前四个应用各多久)——是背景,不是一节; ② 读过的东西:按站点分组,组内一行一页(时间 · 标题 · 一行正文露在外面),点一行回到那一页。 不按「工作 / 消遣」分——那是替你下判断;按站点分同样能让你找工作那几页时不必翻过 60 条视频(实测 2026-09-09:63 页散在 12 个站点,bilibili 22 / facebook 18 / 那两个工作站点各 1)。 ③ 都在做什么:一段一行,折叠,默认收起——它是佐证,不是内容。 搜索框在这一页搜的是网页正文,而且不限这一天:「我记得读过一个东西」这句话本来就不带日期。 右边那条轴在这一页不是索引,是换台的旋钮:一次只讲一天,点一天=换一天(state.trailDate, 和记录页那个日期筛选是两回事);列的是有痕迹的那些天(trailDays),而且不带条数—— 那个数在记录页上是「这天存了几条」,搬到这儿会被读成「这天有几段」,而它不是。 (之前这一页压根没管轴:dayScroller()把它当成网格页,于是轴去找一个 hidden 容器里的锚点, 高亮不动、点一天也跳不动。loadEntries()每次也会重画轴,所以那儿要认一下当前是哪一页。) 筛选那一行在这一页藏起来(body[data-view="trail"] .filters-row):它筛的是记录, 而这一页上没有记录,留着只会让人以为它没生效。 一段一行那一层的两条规矩:第三格永远有字(没有窗口标题的应用——Claude、AnyDesk—— 原来在正中间留下一排洞,现在应用名顶上去),应用名跟在标题后面不顶到右边缘(顶到右边, 标题一短中间就是一大截空白);时长底下一道按长短缩放的细线,开方缩放: 线性的话一段 8 小时会把其余全部压成看不见的一根。 那些条子的类不能叫.bar——那个名字已经被安装/下载那条进度条占了(--tone-2的槽 + 满墨的芯),照抄的结果是每一条都先画出一整条槽,看上去人人都是满的。2026-09-20 起已不存在:整层 AI 服务(「问」、模型托盘、设置里的 AI 服务组、一键翻译)连同代码一起拆掉了(量到 15 天只问了 14 次)。下面这段留作设计史,别照着它做。
「问」那一页的空状态是一块有重心的东西,不是一句吊着的灰字(2026-09-09,用户原话 「这个页面像一个散沙」)。原来一条都没问过时,整栏只有一句
--ink-3的说明,还被推到贴着 输入框(justify-content: flex-end)——那是在给一句没有分量的话找依靠,结果是四角各一件东西 (左上「← 记录」、其下「新的一条」、左边一列对话、底下托盘),中间一整片空,读起来就是散沙。 现在这一栏正中放一块:主语(--fs-day21/600)→ 一行说明(--ink-2),就这两行。 中间有过一排「试试这么问」的例句(--tone底、点一下填进输入框),2026-09-09 去掉了—— 用户原话「我们不要这些」:四条替你想好的问题摆在一页正中间,比你自己要问的那句还响, 而这一页的主语是你的问题。 「新的一条」不再是.src-chip:它是这一页的动作,满墨半粗 + 一个+, 和底下那列问过的标题拉开一档,否则它和它们混成同一摊字。 坑:.askcol为了竖向居中变成 flex 列之后,.md身上那句margin: 0 auto会把它 收成内容那么宽(auto 外边距吃掉剩余空间,覆盖 stretch)——答案栏从 660 缩到 442、右移 109px, 和底下那只输入托盘对不上,页面又歪一次。补width: 100%,max-width才重新说了算。
身份
它是 briffy 这个回形针,不是宠物,更不是猫。 界面文案里不出现「小猫 / the cat」—— 桌面上那个东西就叫 briffy。(2026-09-05 用户原话:「和猫没有任何关系,就是 briffy 这个回形针」。)
- 拖拽带要给窗口边框让开 6px(
.winbar { top: 6px; left: 6px; right: 6px })。 无边框窗口靠边上那几个像素改大小,而-webkit-app-region: drag会把它们抢走—— 顶到 0 的话上边、左上、右上三处都拖不动,症状是「想改窗口大小非常困难」。 普通内容不会挡(macOS 的 resize 边框在网页命中测试之外),只有 drag 区会。
窗口
工作区窗口是无边框的(frame: false, roundedCorners: false):macOS 只给无边框窗口关掉圆角,
而全直角是硬约束。代价是没有红绿灯,所以自己画:顶上一条 44px 的透明拖拽带 .winbar
(top: 6px 起,padding-top: 14px),里面左边是搜索托盘、右边是关闭托盘。
组件
- 五种纸
.jg-tile.k-*:一条记录长成哪种纸由cardKind()定,形状落在SIZE_RATIO的宽高比上 (瀑布流是等高行,所以「形」只能靠宽高比,做不出「比邻居矮」):k-voice磁带条 3.2(底边一条磁粉 + 时长)·k-shot拍立得(白边包画面,底边更宽写时间)·k-note便利贴 1.0(字 15px、时间退到左下、右下角折起)·k-mark索引卡 2.4(粗标题 + 域名 + 书签舌)·k-clip撕下来的碎片 1.15(上下都撕,clip-path+ 两层drop-shadow——box-shadow只会画矩形)。cardKind()里剪贴板排在图片前面:从剪贴板来的东西就该长成撕下来的那一片纸,是图也一样—— 一屏里「哪些是我复制来的」比「哪些有画面」更要紧,而拍立得会把它伪装成一张截图。 图嵌在纸里、四周留着纸边,时间和来源还写在纸上。 尺寸只有两种(2026-09-08 用户定的):正方(小)· 两个正方那么宽的长方(大),tileRatio()只返回 1 或 2。 之前是五种纸各自一套 s/m/l 的宽高比(0.85 到 3.4,十五个数),一屏上每张卡都是不同的形状,排出来没有节奏。 算的是「格」,不是宽高比。 大卡的宽 = 两个方格 + 它们之间那道缝(2 * cell + gap), 不是「方格宽的两倍」——差的正好是一个gap,用宽高比 2 去摆,一张大卡总比它上下那两张小卡窄 8px, 列就永远对不齐(2026-09-07 用户原话「大卡片的宽度应该刚好等于俩小卡片」)。 列数是整页固定的(gridPlan()):cols = round((width + gap) / (target + gap)),cell = (width - (cols-1) * gap) / cols,于是全页每张卡同高、每张小卡同宽。 以前是 Flickr 那种等高行——每行自己算高度去铺满宽度,一行 4 格的行格子比一行 6 格的大三分之一, 同一张小卡这行 196、下行 150,说是「只有两种尺寸」其实有十几种 (2026-09-08 用户原话「也就是所有卡片高度的都一样」)。 一行 U 格时行宽 = U * cell + (U-1) * gap,占 u 格的卡宽= u * cell + (u-1) * gap。 一行只剩一格、下一张又是大卡时,把它收成小卡,不留窟窿——记录是按时间排的,不能为了填满 而重排顺序;而「大还是小」本来就是个看内容多少的估计,收一格不损失什么, 行末空一个格子却会被读成「这儿少了一张」。 大还是小,取决于这条记录有多少东西可看,不是取决于它是哪一类(isBigTile())—— 一段 7 秒的录音和一段 5 分钟的录音占同样大的地方,是这套尺寸最早那版最刺眼的毛病: 录音看时长(≥20s 大),收藏看标题长度(≥18 字),别的看正文字数(≥40 字)。 图片也只有这两种:横的(宽高比 ≥1.4)占两格,竖的和方的占一格,object-fit: cover裁掉多的—— 之前图片是「保留自己的宽高比」的例外,一屏上就它们是任意形状。 卡片上仍然分.z-s/.z-l,小的那张里的字降一档,否则一句话被放大成标语。 卡片上没有类型图标——类型由形状说,图标是第二遍。去掉图标后,区分靠这四样: ⓪ 卡片上的名字吃标题那一档(13/600 满墨,2026-09-09 用户定的「标题再显眼一些」)。 在这之前它和时间、来源一样是 11px 的铅笔色——一张卡上最该被看见的那行字反而是最弱的一行。 来源留在 11px 铅笔色,两档拉开;名字挤不下时仍然是来源先收(收缩系数 6 比 1)。 ① 墨色按「谁的话」分:你写的(随手记)满墨 15px、你选的(收藏标题)满墨半粗、 别人的(剪贴板引文)和机器转的(录音转写)都是--ink-2。这是墨阶的自然延伸,不是新装饰。 ② 内边距按材质分:便利贴 18px(本来就留白多)、撕下来的碎片 10px(贴着字撕的)、索引卡 14px、相纸 8px。 ③ 物证是东西,不是底纹:它们共用一种材料色--evid(深色)。把它们做成几乎看不见的浅灰, 五种卡片就退回成五张带字的白方块——这是 2026-09-05 那次"区分度还是不够"的真正原因。 录音=底边一盘磁带(深色带 + 两个卷轴窗透出纸 + 时长印在带子上);收藏=上边垂下一条深色书签带(燕尾口); 文件=左边两个装订孔(打穿的,所以透出底纸色,还有一道内凹的影);剪贴板=上下撕口,深浅要不匀 (一排等高的齿是邮票齿孔,不是撕的);截图=照片压进相纸,接缝一道极浅的凹陷。 剪贴板那片碎片是歪的(2026-09-09 用户定的):0.6–1.5 度,左右各一半,还有三成完全不歪 (先做的 1.6–3.0 度全歪,用户说「3° 太多了」)。每张都歪就成了一种规律—— 「随手扔的」这件事恰恰要靠有的正有的歪才成立。 角度由记录 id 哈希定死(--tilt写在行内)——随机的话每次重排都换一个角度, 一屏卡片会集体抖一下。 它是全应用第二处静止的旋转(另一处是拖拽中的 1.5°),理由说得通:这是一片撕下来的纸, 随手扔在那儿,正着贴反而不像撕的。hover 的抬起要接在旋转后面 (translateY(-2px) rotate(var(--tilt))),否则鼠标一碰它就正过来了。 坑:网格是整句tile.style.cssText = …写行内样式的,在tileEl里setProperty('--tilt')会被那一句抹掉——倾角得拼进那串 cssText。而且vars那串结尾必须带分号, 否则接上来的--tilt会粘成--clines:3--tilt:-2.5deg,两条一起失效。 ④ 时间的位置是签名:便利贴左下、录音左上、索引卡右上、碎片右下、相纸在底边白边上——五种各待一处。 同一条边上有两样东西,就得有一个让位:碎片的底边左边是来源、右边是时间,两个都绝对定位各贴一边, 来源一长就直接压在时间上(「来自 Google Chr22:19」)。让位的那个要留出对方的宽度(right: 52px) 并用省略号收掉——元信息不值得换行,更不值得盖住时间。 纸色 token 还在(--p-*),但只有--p-note是黄的,其余四种都等于--slip;-2结尾的是物证用的深一档中性灰:磁带的磁粉、折角的纸背、书签舌。 - 便签
.jg-tile:--slip底 +--e1,无边框;hover 抬 2px 换--e2;picked 换--marker。 头一行只有图标 + 时间:类型写在图标里,来源应用名留给详情。头一行等长,一排看过去才是齐的。 截断行数由网格写进--lines((行高 − 78) / 18),clamp 放在.bb上——.b是 flex 子项会被块化,clamp 在它身上失效。 - 按钮:
--tone底、无边、无影;hover--tone-2;:active下沉 1px。primary = 墨底纸字。 - 输入框:无框,只有底下一道
--hair;聚焦变--ink。 - 导航 / 筛选 / 视图切换:纯文字,选中加粗变满墨(筛选另加一道钢笔下划线)。
- 详情:摊开一条记录,一列从上往下走完:头带 → 东西 → 你写的 → 机器写的 → 一排动作。
两栏做过,被否了(2026-09-06):左右两条阅读线互相抢,眼睛不知道从哪进。窗口 760px,
列表页右侧那半张纸是同一套版面,只是窄一点。
预览就是那张图本身,四边一律不许有底色。 宽或高哪个先顶到上限就哪个说了算
(
max-width: 100%+max-height: 46vh,宽高都auto,margin: 0 auto), 元素自己收成图的大小——没有可留边的地方。这条走了三版:先是死封一个高度再补一块灰底 (一张宽截图被压成中间一条、两边两块灰),改成width: 100%+object-fit: contain之后 横的对了、竖的又不对——元素占满整行、图在里面居中,一张竖的 PDF 首页左右各留一条底色 (2026-09-09 用户原话「不能有左右两侧黑边,我们就是直接用最大高度或宽度来」)。 带缩略图的文件那一块(.file.has-thumb)连底色和内边距一起去掉:有图的时候图就是内容, 那块--slip-2只会在图两边留出两条边。 三个层级要用三把不同的梯子,不要都用字重(连着两轮「没有层级」换来的): ① 标题吃字阶最高那一档--fs-day21/600——详情窗的主语是这一条记录,元信息只有一行 (日期 · 类型 · 来自某应用 · 模型),标题旁不挂类型图标; ② 你写的那一句换材质:备注还是--p-note那张便利贴的黄,但只有一行高, 横跨整张纸(在.dt-head那个网格里grid-column: 1 / -1——塞进标题那一列会被右边的托盘挤短)。 顺序是标题 → 日期 → 备注,占位词就是「添加备注」——一句「给以后的自己留一句话」在一行里太长。 它默认只读,点一下才交出光标:一打开详情就有个光标在等你打字是不对的。 整套界面只有这一处纸色,它本来就属于「你自己写的东西」。 坑:通用那条input:not([type="checkbox"])是 0-1-1,光一个类名压不过它, 那道发丝线会一直挂在黄纸上——选择器得写成.dt-note .note-line; ③ 机器写的坐进一块浅底(摘要 / 画面内容 / 识别文字 整体放在--slip-2上,段标题 11px 铅笔色、 正文--ink-2)——白纸上摞白纸分不出段。三种材质各说各的。 一个面板只能有一条滚动条:识别文字那块曾经自己带max-height + overflow, 于是一张卡里两条滚动条,谁也不知道该滚哪个。 复制不在右上角:复制的对象不是「这条记录」,而是某一样具体的东西,所以它长在那样东西旁边—— 图片右上角一个(顺序是「文字位置」在前、「复制」在后,悬停才现), 每一段机器写的字,标题右边都有一个(摘要 / 画面内容 / 识别文字,各复制各的,data-of指哪一个字段)。 它们是层 0 的小词(.mini/.pic-btn),不是托盘——托盘只留给右上角那三个「对整条记录动手」的。 图片复制走的是shelf:copy(图片进剪贴板当图片,别的当文字),不必再写一份一样的 handler。 - 钉在滚动区顶上的只有标题和那三只托盘(
.dt-head):「这是哪一条、能对它做什么」,翻到哪儿都得在手边。 日期和备注跟着滚(.dt-sub)——它们读一遍就够了。钉住的那块自带底色(--slip)和上边距, 滚动容器的padding-top让给它,而且必须是.dt的直接子元素: sticky 只在自己父元素的范围里生效,包一层就会跟着那一层一起滚出去。 坑:position: sticky认的是「最近的那个滚动祖先」。列表页那半张纸原来.lv-prev和.detail两层都是overflow: auto,头钉的是.detail的滚动口、真正在滚的是.lv-prev,钉了等于没钉—— 得让.lv-prev只当框(overflow: hidden+ flex),.detail自己当唯一的滚动容器。 - 详情的动作:全部收在右上角,和标题一条水平线上,顺序 收藏 · 删除 · 叉叉。
三个是同一级的东西(都是「对整条记录做点什么」),所以是同一种托盘:
--slip的底 +--e2的影, 28px 等高、直角、都不带 icon(叉叉正方 28×28,三个词的自己撑开)。 这是「只有记录才有影子」的第二个例外——和底部那三只一样,它们确实是东西。 收藏了=实心(墨底纸字),和扩展状态那个图标同一条规矩;删除是印泥色的字,点了先问一句。 叉叉跟着面板滚,所以 Esc 和点面板外仍然要能关。 底下那一排动作没有了;打开原文件 / 编辑 / 在文件夹中显示 / 重新处理 / 复制链接 一并删掉 (2026-09-06 用户定的:只留这三个,剩下的都不重要)。代价:没有别的入口能改标题和文字了。 - 收藏 =
pinned(2026-09-06 定名)。界面上一律叫「收藏」,包括筛选行; 从浏览器收藏夹抓进来的那一类(source: bookmark)让出这个词,改叫「书签」。存储字段没动。 - 表单行
.f:词右对齐顶住控件线,控件、勾选、按钮、状态共用这一条起始线;说明藏进.info的小 i。表单里没有段落、没有卡、没有保存键。 - 别用
empty当修饰类名:.empty已经是空状态那块 90px 内边距的居中竖排。计数为 0 的来源标签叫.src-chip.zero。 - 五个词
.chip:小字并排,用空隙分开,没有框。状态.pill:一个词。 - 常驻头像:就是它自己——一整只回形针,透明底,没有相框也没有图片(2026-09-06 重做)。
画的是
briffy-anim.js的body: 'full'。 回形针是一根连着的铁丝,不是两个分开的圈——这是 2026-09-06 返工的原因: 第一版把外面那道弧在底下自己合拢,成了一枚胶囊,用户一眼就说「这不是回形针」。 正确的是:大环的左侧在底下用一个半圆兜过去,接上小环的右侧,剩下两个头是自由的 (大环的右腿、小环的左腿)。所以整只只有一条路径,一笔画完: 大环右腿↑ → 大环顶弧 → 大环左腿↓ → 底下的半圆 → 小环右腿↑ → 小环顶弧 → 小环左腿↓。 数还是标记里的那几个(腿 x 156.5 / 480、弧 r161.75、内弧 (318.5, 493.5) r73), 新的只有那个兜底的半圆(圆心 x274、r117.5)。一根铁丝只有一个粗细,所以整条都是 46。 直腰要比标记长一截(大环左腿走到 y710,不是标记切口的 638):标记是切开的,看不出腰身; 整只站起来之后腰太短会读成一颗药丸,拉长一点才是一枚回形针。 两个自由端:大环右腿收在 y720,小环左腿收在 y710——小环那条正好齐着它自己那一侧转进半圆的点 (大环左腿也是走到 710 才兜过去),所以它看着是「到底了」,不是随便断的。 它按脚下的真实底色换一档蓝(2026-09-09):亮底#2a6cf0,暗底#75a4ff—— 同一个色相 219°,只动明度,所以还是它,不是变了个颜色(#2a6cf0压在纯黑上只有 4.1:1,#75a4ff是 8.4:1)。仍然没有描边、没有影子:先试过镶一圈白边,被否了 (「好丑,还不如不加」)——一根干净的蓝铁丝镶上白边就成了贴纸,那也正是上面「墨线描边=海报」那条。 不看系统的亮/暗开关,也不能用@media (prefers-color-scheme: dark):那个开关说的是 「别的应用窗口该画成什么色」,跟壁纸、跟这一刻压在它底下的那个窗口都没关系 (2026-09-09 实测:这台机器系统是暗色,而回形针脚下量出来的亮度是 0.91,一块大白底); 何况 briffy 的「配色」一改,主进程就把nativeTheme.themeSource设死,那条 media query 连系统都跟不上了。做法是src/main/pet-ground.js抓一张 1024 宽的屏幕缩略图、裁出它站的那一块 算 sRGB 平均亮度(<0.42 算暗底),结果写在body[data-ground]上。 是事件触发,不是轮询:出生 / 被拖走 / 换屏 / 前台窗口换了(最后一条白蹭trail.js已经在跑的 2 秒轮询,采样本身有 8 秒节流)。常驻取流能做到真·实时(每帧 0.1ms), 但要挂 2.4% 的一个核 和一颗永不熄灭的录屏指示灯——那等于把 pet.css 顶上那条 「一个infinite都不许有」的账卖掉了。 脸一个像素没动,viewBox只是换了一块取景。 标记(图标 / 安装页 / 引导页)保持切开的那一版——它要蹲在一个方形图标里; 桌面上那只不一样,一个被裁掉半截的东西看着像贴纸,一整只站在那儿才是「它在这儿」。 状态各有各的位置:录音=底下一行印泥色的 REC;处理中=底下三个点; 可以往上拖东西=整扇窗镶一圈蓝墨虚线(相框没了,那道描边就画在窗口上);摘要角标在右上角,静止。 待机的小动作是推它一下(face.nudge),不改表情,弹簧走完自己停—— 「从相框里探个头」那个手势跟着相框一起没了。 禁止一切把它放大的动画(attach(..., { noScale: true }),CSS 里也没有scale): 一枚贴在别人桌面上的回形针一鼓一鼓地涨大,既不像纸也不像铁丝,而且 80×80 的窗口里放大就是把自己顶出边界。 hover 不再抬起,拖拽只歪 4°,截图那一下的 pop 也不给它。 - (历史)常驻头像曾经是拍立得:白框 4px / 底边 12px,直角,转 −3°。
.live必须写box-sizing: border-box:pet.css 没有* { box-sizing },默认 content-box, 于是width: 110%之外再加左右各 9% 的内边距,整只 briffy 被推出去一截,看着就是「头像没居中」。 没有影子(贴在屏幕边上的一张纸,那圈阴影只会把它和屏幕割开)。 不吸边(2026-09-06 取消):放哪儿是放的人说了算,松手就停在那儿,只保证它整个还在屏幕里(clampToDisplays)。 - 展开列表(shelf):站在头像正上方、右边缘和头像右边缘对齐,从下往上抬 14px 滑进来。
里面每条是一张便签,和工作区同一套纸(
--slip+--e1、直角、五种纸各带自己那件物证), 不是一条一行的清单——2026-09-06 改的。 三条让它不卡、不乱关的规矩: ① 窗口的下沿正好压在头像窗口的上沿,不多一个像素,面板和头像之间那 10px 的空隙由#panel { bottom: 10px }让出来。把空隙留在窗口外面,指针从头像挪到面板的路上会经过 「谁也不在」的地带,关闭计时器跑起来——症状是「鼠标还在上面它就消失」; 但也一个像素都不能再往下探:这扇窗 300px 宽、右边 262px 是不透明的面板, 只要盖到头像窗口上,那枚回形针就被压在下面看不见了(2026-09-06 两头都踩过); ② 内容没变就别重画:每次停到头像上都innerHTML =一次,十五张缩略图跟着重解码一遍,这是「卡」的大头。 比一个签名,一样就什么都不做; ③ 卡片要flex: none:.list是竖着的 flex,装不下的时候每个孩子默认被压扁, 而卡片里那张 84px 的图不跟着压——于是图溢出自己那张卡,盖住下面一张(「卡片会互相遮挡」); ④ 别给每条加transition-delay:十五条各错开 18ms,最后一条要等到 180ms 之后才动,整块读起来就是慢。 整张纸一起滑进来,220ms 结束。 - 拖文件进工作区窗口 = 拖到常驻头像上:同一个
pet:drop,窗口上压一层--scrim说「松手就存进来」。 - 看图的窗口(
src/renderer/viewer/,点详情里那张图打开):一张图自己的一扇窗,像聊天软件点开大图那样。 这里的底是深的(--vbg #1c1b1a),是全应用唯一一处——一张暖白的纸围着一张彩色照片会一直跟照片抢眼睛, 所有看图的东西都用深底,理由一样。纸质那套语言没丢,只是纸变成了托盘:每个工具是一只 34px 的--slip正方托盘 +--e2,和详情窗右上角、底部那三只是同一种东西;选中=实心。 无边框、全直角,关闭是右上角一只 30px 的正方托盘。 图只能放大,不能缩小:下限就是「铺满这个框的宽或高」(fitZoom),按钮、滚轮、窗口变大之后都按这个夹住。 再往下缩只是把图丢进一片空底里,没人想看那个;缩回下限时平移也归位。 底栏三组:左=展开(右侧铺开 briffy 里所有的图)· 缩小 · 放大 · 1:1 · 旋转; 中=画框 · 画圆 · 画笔 · 马赛克 · 添加文字 · 裁剪(把当前这张换成框住的那一块,画上去的东西一并烙进去; 原文件不动,只是眼前这一张)· 撤销; 右=一键翻译 · 复制 · 图钉(setAlwaysOnTop(true, 'screen-saver'),普通的 floating 压不住全屏应用)。 粗细和颜色是从当前那个工具正上方升起来的一条(照 macOS 标注那条做的),只在真会用到时才出现。 画的东西存成一串形状,不糊到像素上:所以能撤销、放大之后位置还对得上、导出时按原图分辨率重画一遍—— 屏幕上是缩放后的样子,复制出去的是原尺寸。坐标一律记在图片自己的像素里。 马赛克是唯一要读原图的(按格子重采样)。 三个反复踩的坑:① 带transform的祖先会成为position: fixed的包含块—— 框选那个.marq放进.canvasWrap里就永远对不上鼠标,它得待在外面; ②pointerdown的默认行为会把焦点交给被点的元素,刚focus()的输入框当场被抢走、 blur 立刻触发——所以选中工具时要preventDefault(),焦点也放到setTimeout(…, 0)里给; ③ 缩放要围着图自己的中心算,不是窗口中心:台面有内边距,展开网格之后右边还会缩进去一截, 拿窗口中心当图的中心,缩放就跟不住鼠标。 它取代了详情里那个.lightbox(点图往 body 上贴一张全屏图的老做法)——加窗口的时候必须把老的删掉, 两个都留着的话点一下会同时发生:开窗口,再贴一张z-index: 20的大图, 它盖住整个主页又压在z-index: 60的详情窗底下,看着就是「详情卡片后面的主页变成了图片」。 一键翻译走的是已经识别好的文字,不是把图发出去—— 图片从不出这台电脑那条规矩在这儿一样算数。 - 没有对话气泡(2026-09-06 删掉):桌面上那只自己会换表情,底下还有 REC 和三个点,
一个漂在旁边的框既遮住它、又和书架抢地方。
setPetState仍然收message,只写进日志。 代价记在这里:只出现在气泡里的那些错误文案(麦克风被拒、截图失败、快捷键冲突) 现在只剩下一张出错的脸,没有字。 - emoji:
grayscale(1)+ 75%。 - 多选:主页的字不给选(
user-select: none打在.jg / .jg-tile / .lv-rows / .axis / .top / .dock上)—— 这里每张便签都是可点、可框、可拖的物件,拖的时候顺手选中一行字,拉框就变成了刷高亮。 要读字就点开详情,那里user-select: text。入口有五个,都跟系统习惯对齐: 顶栏那个勾选图标、Cmd/Ctrl 点一张便签、空白处按下往外拖(拉框)、Cmd/Ctrl+A 全选、Cmd 点右轴上的某一天(当日多选)。 Shift 点是连选(按state.entries的顺序取区间),Esc 或点空白退出。 拉框拖到上下边缘要自己接着滚,起点钉在纸上而不是屏幕上(滚了多少,起点跟着走多少)—— 否则一屏装不下的时候只能松手、滚一段、重来一次。网格和列表用的是同一个startMarquee(down, scroller, itemSel)。 批量条不是第四只托盘,是中间那只换了内容:同一处、同高、同边距,左边的加号和中间的问句一起让位 (body.picking)——两只托盘叠在一处就成了一堆按钮,那是「栏」,不是托盘。
会动的 briffy
唯一实现:assets/brand/briffy-anim.js(无依赖、无构建,node 里 require 得到、页面里 <script> 挂上就有 window.briffyAnim)。
样张 /briffy,测试 npx electron dev/briffy-anim-test.js。
- 两道弧永远不变形。 半径、圆心、描边全部照抄
briffy.svg,代码里没有任何一条路径能改它们。 会动的只有一张脸(两只眼、一张嘴),和整只回形针作为一个刚体被平移 / 歪 / 缩放(支点在脚下 318.25, 638)。 静止那一帧和briffy.svg逐字符相同——嘴那条d就是文件里的M291.5 320 A27.6 27.6 0 0 0 345.5 320。 - 弹簧,不是关键帧。 阻尼谐振,固定 120Hz 积分(掉帧不改变观感)。每个状态只说「去哪儿」, 中途被打断是免费的:从当前位置、当前速度改朝新目标去。截图落在录音中间、出错落在保存中间,这是常态。
- 弹簧必须会停,这是它能存在的前提。桌面窗口透明、置顶、永不关闭,每要一帧就是一次永不停止的 alpha 合成
(pet.css 那笔账:3 个
infinite= 一个核的 11.4%,静止 0.9%)。所以到了目标附近且速度耗尽, 就精确落到目标并cancelAnimationFrame——两个状态之间一帧都不画。不要在这里加任何infinite。 - 动作靠
kick(速度),不是关键帧位移。 一个阻尼弹簧以 v0 释放只能走到约v0/(2·ω_d): 638 的画布上 kick 90 只换来 2.7 单位,在桌面 50px 上是五分之一像素。 改 kick 之后去看dev/briffy-anim-test.js打的那张峰值表,别靠脑补。 现在:点头 9.0° · 跳 30.2 单位 · 摇头 22.1 单位 · 吓一跳 1.10×。 - 嘴用「矢高」不用半径。 半径过不了「平」——笑变哭要在两个 sweep 之间跳一次、经过无穷大。 矢高有正负、平滑过零,零就是一条直线,那正是嘴从笑走到哭的中途该有的形状。
- 持续状态的差别必须是「形状」,不是「动作」。 录音要跑几分钟,弹簧在头一秒之后就睡了, 所以说明「在听」的东西得在什么都不动的时候还在说。第一版把录音做成「眼睛大 6%、笑小一点」, 在实际尺寸上和待机分不出来。现在:录音=窄而高的专注眼 + 一条直嘴;截图=又圆又大的眼 + 一个小小的 o。 两个状态在时间上紧挨着(录完就截),不能收敛成同一张脸。
- 六个状态(就是 pet.js 已经在广播的那六个,没有新事件): 待机 · 录音 · 处理中 · 截图 · 存好了 · 出错。
- 别人的标记不做表情。 桌面形象只有用内置那张(
petskin.isBuiltin)时才画活的 briffy; 从图库里挑了别的 logo,就退回成框里的一张图片。 - 用在:桌面常驻形象(
.live,和<img>同一个盒子,所以「探头」手势推谁都一样)、 引导页第一步和最后一步(最后那个绿方块 ✓ 换成了它点头)、扩展安装页(那只 🐱 换成了它)。 安装页是 http 出来的,够不着 file:// 资源,所以install-page.js把这个文件读进来内联。
动效
三种:按钮下沉 1px;记录 hover 抬 2px;面板滑入(shelf .22s cubic-bezier(.16,1,.3,1))。
存东西是一瞬间的事,动画就该是一瞬间。(2026-09-06)桌面那只的状态时长对着弹簧真正的时长来
(点头 1.07s、摇头 1.14s、pop 0.42s):capturing 260 / success 1200 / error 3000。
原来 capturing 是 4000——按下快门之后那只回形针要瞪四秒钟眼睛,读起来像它在犯难。
更要紧的是:后处理的时间不能挂在脸上。 OCR、起标题、转写都是后台的活,东西早就存下了;
以前它们会一路把脸按在「处理中」(三个点一直跳)直到队列清空,那等于把后处理算进了「存东西」这个动作里。
现在存完就是一次点头,然后回待机;只有你确实在等的两处还用「处理中」:
自动配置(在下模型)和长截图开始(等你滚)。
同一个状态再来一次要重演:连着存两样东西时主进程会发两遍同样的状态,
而 face.set 在状态没变时什么都不做——第二次就一点反应也没有。pet.js 里靠 poke 补上。
常驻窗口(pet / shelf / bubble)不跑 infinite 动画——pet.css 顶部那笔 CPU 账是硬约束。
历史(别再走回去)
- 硬偏移影 + 墨线描边 + 白卡片 → 否。那是 neo-brutalism,不是纸。
- 桌垫 + 纸纹卡片 + 纸胶带 + 索引卡横格 → 否。"加格式和横线只会让版面更乱"。
- 全删边界 → 记录散了。便签需要边界,但边界不是框:便签比底纸亮并且有影子。
- 左侧导航栏 + 每日摘要 → 删。壳越少越好:页面只剩卡片,去处和动作全下到底栏。
- 右边那条时间轨(
.jg-rail,40px 宽的迷你滚动条 + 小时数字)→ 删。日期头和时段小标题已经在说同一件事,它是第三遍,还贴着窗口边缘被切。 - 现在这版(2026-09-05):A 暖灰 + 白便签 + H2 版面,抄 Steidl 的设计语言。 (H1 把搜索和筛选也放进侧栏,做出来后用户选了 H2——搜索该压在它要搜的东西上方。)
涉及审美方向时,先用 AskUserQuestion 问 3–4 个问题,再动手。