补入 oil-ui-pro(上一提交按授权清空时移出,本轮按用户要求加回)
This commit is contained in:
1 parent
777f7fe5d0
commit
0abd33b3a0
26 files changed
+4313
No files matched your search
@@ -0,0 +1,2 @@
|
||||
.DS_Store
|
||||
__pycache__/
|
||||
@@ -0,0 +1,54 @@
|
||||
[中文](README.md) · English
|
||||
|
||||
# oil-ui-pro
|
||||
|
||||
An interface design workflow for AI agents: explore distinct directions side by side, choose one, then refine it against real screenshots. Use it for websites, apps, dashboards, and components, or ask for a design review.
|
||||
|
||||
See more designs at [ui.oiloil.org](https://ui.oiloil.org).
|
||||
|
||||
## Installation
|
||||
|
||||
Send this to your agent:
|
||||
|
||||
```text
|
||||
Install Oil UI Pro for me: run npx github:oil-oil/oil-cli install oil-ui-pro
|
||||
```
|
||||
|
||||
You can also run the command in your terminal. On first install, a browser opens. Sign in with the oiloil account you used to purchase it and click “Allow.” The design workflow is ready to use after installation, with no extra configuration or other skills. Installing with the command requires Node.js 18 or later. With Oil UI Pro installed, you don't need Oil UI (open source); installing both makes them compete for the same requests.
|
||||
|
||||
Using this skill triggers a version check, at most once every 10 minutes. If the server takes longer than 2 seconds, the check is skipped so it never slows down your task. Failed checks retry later when you use the skill again. The check only reads the public version list on ui.oiloil.org and does not upload project content. Offline checks cannot discover new versions, so they produce no reminder.
|
||||
|
||||
Automatic updates need Python 3 and Node.js 18 or later. When a new version is found, the skill tries to update itself. If dependencies are missing, authorization has expired, or there is no purchase record, the agent ends its reply with recovery steps and a command containing the current installation's absolute path: `npx github:oil-oil/oil-cli update oil-ui-pro --path "<installation path>"`. Network errors stay silent and retry later. Set `OIL_NO_AUTO_UPDATE=1` for reminders without automatic updates, or `OIL_NO_UPDATE_CHECK=1` to disable checks entirely.
|
||||
|
||||
Python 3 can run as `python3` or `python`, or as `py -3` on Windows. If Python 3 is missing, the agent reminds you once and continues the design task.
|
||||
|
||||
## Usage
|
||||
|
||||
Tell your agent what you need, for example:
|
||||
|
||||
- “Use oil-ui-pro to explore a few design directions for this course booking product and build pages I can preview.”
|
||||
- “Create three designs with different typography, colors, and compositions. Put them on a comparison page so I can choose.”
|
||||
- “Keep the current brand and layout. Improve this form’s hierarchy, error messages, and mobile experience.”
|
||||
- “Polish this page using the project’s existing design guidelines.”
|
||||
- “Review these interfaces and explain what to change and how. Leave the files untouched.”
|
||||
|
||||
Share any brand assets, screenshots, or existing project you have. It can start without references and only asks questions when the deliverable is unclear.
|
||||
|
||||
## How it works
|
||||
|
||||
- **Explore distinct directions, then let you choose.** Identify the product category, study its best examples, and develop a tone and concrete directions from the product itself. Decide the first screen before writing copy, then check that the directions really differ. Compare the previews on one page, choose a direction, and say what you like and dislike.
|
||||
- **Create a memorable moment.** Focus on one or two details: the response to a tap, a successful payment, AI at work, or a 404 page.
|
||||
- **Polish a single component to the extreme.** It first creates a visual anchor, such as a realistic product render, takes the palette from it, and picks one graphic motif to carry throughout. From two or three sketches it deepens just one: every state gets its own visual, the main interaction acts directly on the anchor so results appear as you drag, and review rounds check it up close. It finishes with a showcase image and a short screen recording.
|
||||
- **Refine against real screenshots.** Check desktop and mobile views. An independent reviewer who has not seen the work in progress identifies issues and scores the result. Revise and review again, aiming for 9/10 within three rounds. With several directions, a cross-review catches sections that look alike; for new interfaces, the reviewer also completes real tasks on the page.
|
||||
- **Start an existing project by working out what you want.** If it looks dated, it first decides whether the current design system is worth keeping, then offers options from a light cleanup to a full reshape. If it is hard to use, it walks the real task, finds why the extra steps exist, and fixes the structure instead of adding hints. For a new feature, it reads the product’s existing habits and proposes genuinely different ways to do the job, keeping the current visual system. Changes land in shared components and design tokens. Small changes, such as a single button, go straight to implementation.
|
||||
|
||||
The workflow covers visual hierarchy, typography, color, spacing, interactions and states, layout and responsive behavior, imagery, motion, scroll storytelling, and effects such as light trails, dot patterns, and flowing gradients. Designs vary by product; generated images, animation, and effects are used where they help.
|
||||
|
||||
## Built-in style comparison page
|
||||
|
||||
Compare designs side by side as HTML files, images, or running development pages. Put the current version first as a baseline. Switch between desktop and mobile sizes, open previews at actual size, browse with arrow keys, and add notes. Click “Select” on your choice, then paste the copied sentence into your agent to continue.
|
||||
|
||||
## Use with
|
||||
|
||||
- [draw-ui](https://github.com/oil-oil/draw-ui): Generate design images first, choose one, then build from it. Try this when code-based iterations keep falling short and your agent can generate images.
|
||||
- [oil-motion](https://github.com/oil-oil/oil-motion): Turn generated videos or frame sequences into web animation controlled by scrolling or dragging, such as product teardowns or camera fly-throughs. Use it for a striking opening animation.
|
||||
@@ -0,0 +1,54 @@
|
||||
中文 · [English](README.en.md)
|
||||
|
||||
# oil-ui-pro
|
||||
|
||||
给 AI 用的界面设计方法:先拉开几种真正不同的方向放在一起让你挑,再照着真实画面反复打磨。网站、App、后台和组件都能做,也可以只做评审。
|
||||
|
||||
想看更多设计效果,可以去 [ui.oiloil.org](https://ui.oiloil.org)。
|
||||
|
||||
## 安装
|
||||
|
||||
把这句话发给你的 Agent:
|
||||
|
||||
```text
|
||||
帮我安装 Oil UI Pro:运行 npx github:oil-oil/oil-cli install oil-ui-pro
|
||||
```
|
||||
|
||||
也可以自己在终端运行这行命令。第一次安装会打开浏览器,登录购买时用的 oiloil 账号,点“允许”就行。装好后可以直接使用设计方法,无需额外配置,也不需要其他 Skill。命令安装需要 Node.js 18 以上。装了 Oil UI Pro 就不用再装 Oil UI 开源版,两个同时装会抢着接同一类请求。
|
||||
|
||||
使用这个 Skill 时会触发版本检查,最多每 10 分钟联网一次,超过 2 秒没有响应就跳过,不会拖慢任务;失败后会在后续使用时稍后重试。检查只读取 ui.oiloil.org 公开的版本列表,不上传项目内容。离线时无法得知新版本,所以不会提示。
|
||||
|
||||
自动更新需要 Python 3 和 Node.js 18 以上。发现新版本后会尝试自动更新;如果缺少依赖、授权失效或没有购买记录,Agent 会在回复末尾给出恢复提示,并附上带有当前安装绝对路径的命令:`npx github:oil-oil/oil-cli update oil-ui-pro --path "<安装路径>"`。网络错误保持静默,稍后重试。设置环境变量 `OIL_NO_AUTO_UPDATE=1` 只提醒不自动更新,`OIL_NO_UPDATE_CHECK=1` 完全关掉检查。
|
||||
|
||||
Python 3 可以用 `python3` 或 `python` 启动,Windows 也可以用 `py -3`。缺少 Python 3 时只提醒一次,设计任务照常继续。
|
||||
|
||||
## 使用
|
||||
|
||||
直接把任务告诉 Agent,例如:
|
||||
|
||||
- “用 oil-ui-pro 给这个课程预约产品探索几种设计方向,做成可以预览的页面。”
|
||||
- “做三种字体、配色和构图都不同的设计,放在对比页里让我选。”
|
||||
- “保留现在的品牌和布局,把这个表单的层级、报错和手机上的操作改好。”
|
||||
- “帮我润色这个页面,按项目现有的设计规范来。”
|
||||
- “只评审这些界面,告诉我哪里要改、怎么改,不要动文件。”
|
||||
|
||||
品牌资料、截图、现成项目都可以一起给它;什么参考都没有也能开始,它只在不清楚要交付什么时才问你。
|
||||
|
||||
## 它怎么做
|
||||
|
||||
- **先拉开方向,再由你挑。** 先认清品类、看同类最好的产品怎么做,再从产品本身推出调性和几个具体的方向,每个方向先定首屏骨架再写文案,并检查方向之间是否真的不同。小样放进同一个对比页,由你选定,再说说喜欢和不喜欢哪里。
|
||||
- **留一处让人记住的地方。** 不求整页都用力,只把一两处做到极致:一次点按的反馈、付款成功的那一下、AI 干活的过程、一个 404 页面。
|
||||
- **一个组件也能打磨到极致。** 先生成一个视觉主角,比如一张写实的产品渲染图,从它取色、定一个贯穿始终的图形母题;出两三张草图选一张,只深化这一张:所有状态都做出画面,主操作直接作用在主视觉上,拖动时结果当场发生;再一轮轮放大检查打磨,最后附一张作品图和一段操作录屏。
|
||||
- **照着真实画面改。** 在电脑和手机尺寸下截图检查。请一位没看过制作过程的独立评审挑问题、打分,改完再评,目标 9 分,最多三轮。几个方向之间会再比一次,找出雷同的区块;新界面还会让评审真的按任务把页面用一遍。
|
||||
- **老项目先分清你要什么。** 嫌不好看,先判断现有规范值不值得保留,再按推翻程度给你几个方案,从“整理一下”到“重塑”;嫌难用,先亲手把任务走一遍,找到步骤多出来的原因,从结构上改,不加提示了事;要加新功能,先读出产品原有的习惯,再给出几种真正不同的做法,视觉沿用现有系统。改动都落在共享的组件和设计变量上。只改一个按钮这种小事,直接改。
|
||||
|
||||
方法覆盖视觉层级、字体、配色、留白、交互和状态、布局与屏幕适配、配图和动效、滚动叙事,以及流光、点阵、流动渐变这类特效。不会把所有产品做成同一种风格,也不会要求每个页面都生图、加动画或加特效。
|
||||
|
||||
## 自带的风格对比页
|
||||
|
||||
几份设计并排放在一起比,可以是 HTML 文件、图片,也可以是正在运行的开发页面;现在的版本可以放在第一个当对照。可以切换电脑和手机尺寸,点开看实际大小,用方向键翻看,写备注。看中了点“选择”,把复制出来的一句话粘贴给 Agent,它就接着做。
|
||||
|
||||
## 搭配使用
|
||||
|
||||
- [draw-ui](https://github.com/oil-oil/draw-ui):先出设计图,挑中了再照图实现。代码做出来的设计反复不满意、而 Agent 又能生成图片时,换它试试。
|
||||
- [oil-motion](https://github.com/oil-oil/oil-motion):用生成的视频或序列帧做随滚动、拖动变化的网页动画,比如产品拆解、镜头穿越。想要很强的首屏动画时用它。
|
||||
@@ -0,0 +1,123 @@
|
||||
---
|
||||
name: oil-ui-pro
|
||||
description: "设计、改进和评审网站、App、后台与组件的 UI/UX,完成设计方向探索、多风格同屏比较、视觉层级、任务流程、状态反馈、响应式布局和基于实际画面的迭代;按请求交付对比页、设计说明、设计稿或可运行界面。当用户需要新界面设计、比较不同设计风格、已有界面体验优化、视觉精修、截图还原或 UI/UX 评审时使用。只负责设计与体验判断,组件归属、数据流、状态正确性和测试等代码实现质量不在范围内。不用于纯业务逻辑、接口、构建部署、无界面行为变化的代码整理、单独绘制普通插画或操作现有网站。"
|
||||
allowed-tools:
|
||||
- Bash(sh *check_update.sh*)
|
||||
- Bash(python3 *check_update.py*)
|
||||
- Bash(python *check_update.py*)
|
||||
- Bash(py -3 *check_update.py*)
|
||||
metadata:
|
||||
version: "0.15.4"
|
||||
compatibility: "核心为宿主中立的文本流程,不依赖其他 Skill 或指定模型。可选风格对比页生成器需要 Python 3.10+ 标准库,产物仅需现代浏览器;本机地址候选需要对应的本地开发服务器在运行。实际视觉验收需要看图能力;交互验收需要可操作环境;独立评审需要隔离上下文且能看图的执行者。"
|
||||
---
|
||||
|
||||
# oil-ui-pro
|
||||
|
||||
以明确的设计北极星组织界面,让构图、字体、色彩、素材和交互相互呼应,形成有辨识度的表达。克制是保留最有力量的选择、删除无贡献的元素,不是把所有风格磨成中庸。视觉表现与任务完成分别验收。
|
||||
|
||||
风格从这个页面的品类、首屏的主角和这个品牌里推出来,不从模型的默认模板里长出来。动手前先回答三件事:它是什么品类;用户来首屏看什么、做什么;同类最好的产品在首屏和控件上是怎么做的。方向只在这之上偏离,偏离要说得出来自这个产品的理由。
|
||||
|
||||
方法和评审协议均在本目录内,直接使用宿主提供的文件、浏览、看图、生成与隔离执行能力;不要求加载其他 Skill。截图、录屏、状态并排和遮字图用本目录的截图工具,写可操作小样的方式也在同一处,见 [工具](references/tools.md);不为取证另外加载浏览器类 Skill。前端实现规范以项目自己的约定为准,不另读前端类 Skill。相对链接以本文件所在目录解析。
|
||||
|
||||
## 开始前
|
||||
|
||||
版本检查:!`sh "${CLAUDE_SKILL_DIR}/scripts/check_update.sh" 2>/dev/null || true`
|
||||
|
||||
支持加载时运行命令的宿主会自动做这次检查,把结果填在上面;上面仍是一条命令时,运行本目录的 `scripts/check_update.sh`。它会先找 `python3`,再找 `python`,确认是 Python 3 后运行检查。没有 POSIX shell 的宿主,依次尝试 `python3`、`python`、`py -3`,确认是 Python 3 后运行本目录的 `scripts/check_update.py`。
|
||||
|
||||
检查由使用本 Skill 触发,最多每 10 分钟联网一次,网络失败后稍后重试,有新版本就尝试自动更新。结果为空就直接开始;说已自动更新时,重新读取本文件再开始;是一行更新提示时,照常完成任务,在最终回复末尾用当前对话的语言转述这行提示,命令、版本号和路径保持原样。
|
||||
|
||||
缺少 Python 3 时检查不会运行:照常完成任务,在最终回复里提醒一次“自动更新需要 Python 3”。结果是 `OIL_UPDATE_CHECK_SKIPPED: missing_python` 时说明已经提醒过,不再提醒,也不转述这个标记。手动运行检查的宿主,同一次对话里只提醒一次。
|
||||
|
||||
## 选择范围
|
||||
|
||||
- 从请求、现有页面和参考资料明确用户、主要任务、真实内容、目标设备与交付形式。检查相关位置即可,不先通读整个项目。
|
||||
- 用户已确定的品牌、参考、页面结构和交互约束优先。分清借鉴风格、重新设计与精准还原,不能自行切换目标。
|
||||
- 仅在缺失信息会改变核心方向且无法合理推断时集中提问;可逆的细节自行决定并简述假设。
|
||||
- 设计说明、设计稿、原型和可运行产品是不同交付物。只要求评审时保持只读;只要求设计时不默认改业务代码。
|
||||
|
||||
| 当前范围 | 路径 |
|
||||
| --- | --- |
|
||||
| 单个元素、一处间距、文案或颜色这类小改动 | 直接改,在目标视口看改动处和相邻元素,交付时一两句话说明;不盘点现状、不开方向、不启动评审 |
|
||||
| 新界面或明确要求重新探索 | 按步骤 1–5 执行;只交付设计说明时止于相应方案,并说明未渲染 |
|
||||
| 已有方向的深化或局部优化 | 保留方向,从步骤 2 进入,只检查受影响的区域与状态 |
|
||||
| 单个组件,要打磨到极致或做出辨识度 | 读 [组件](references/component.md)。组件在存量项目里时,样式按 [存量项目](references/existing-project.md) 的“从源头修改”落到现有变量和组件上 |
|
||||
| 存量项目里优化 UI、改流程或加新功能 | 读 [存量项目](references/existing-project.md),先分清是哪一种。改流程和加新功能不走步骤 1 的视觉方向探索,但本文开头的三问照答 |
|
||||
| 截图还原 | 按 [布局与视口](references/layout-and-viewport.md) 的“精准还原”,以参考图为视觉基线,确定参考视口与布局约束后进入步骤 2,不发散新风格 |
|
||||
| UI/UX 评审 | 按问题读取对应参考,只做步骤 4 的诊断,交付证据与建议 |
|
||||
|
||||
## 1. 探索并收敛方向
|
||||
|
||||
方向未确定时按 [设计方向](references/design-direction.md) 走:认品类、拆标杆、定调性、起方向,写文案之前先定首屏骨架;多个方向时填方向卡、做差异检验。小样只做首屏和最能体现方向的一两个区块,方案数量按任务定。需要并排比较时,用 [风格对比页](references/style-explorer.md) 的模板和生成器。
|
||||
|
||||
方向由用户的品味来定:给出对比页和推荐理由,请用户选定,再说出具体喜欢和不喜欢哪里,按反馈收紧后再深化。用户让你自己决定或无法交互时,仍先出对比页留档,再选定并写明理由。
|
||||
|
||||
留一份简短的设计说明:用户任务、主动作、选定方向、关键状态、设备约束和验收重点。已有说明直接更新。
|
||||
|
||||
## 2. 建立视觉与交互结构
|
||||
|
||||
根据当前决策按需读取,不预先加载全部参考:
|
||||
|
||||
| 要解决的问题 | 参考 |
|
||||
| --- | --- |
|
||||
| 视觉层级、字体、色彩、空间、图标、风格统一或减法精修 | [视觉语言](references/visual-language.md) |
|
||||
| 用户流程、文案、动作、表单、选择、状态与失败恢复 | [交互与状态](references/interaction-and-state.md) |
|
||||
| 页面、分栏、弹窗、浮层、滚动、响应式或截图还原 | [布局与视口](references/layout-and-viewport.md) |
|
||||
| 素材选择、视频与 3D | [素材](references/media.md) |
|
||||
| 生成配图:让图承担意思、写提示词、和页面接成一体 | [配图](references/imagery.md) |
|
||||
| 三处基本动效与呼应、动效手感、界面过渡、首屏动画与滚动叙事、时长与检查 | [动效](references/motion.md) |
|
||||
| 点缀与特效:流动渐变、流光、点阵、颗粒、SVG 线条与排字 | [点缀与特效](references/ornament.md) |
|
||||
|
||||
新产品要交代主要内容的层级和关键操作链,不只做漂亮的默认状态。新界面和改动了操作流程时,按 [动效](references/motion.md) 做出三处基本动效;小改动不加。
|
||||
|
||||
构图阶段先按 [素材](references/media.md) 判断图像能否承担主信息或情绪重心。需要关键素材时先选定或制作它,再围绕它安排文字和操作,不等布局填满后再补图。
|
||||
|
||||
既有项目先复用有效的设计变量、组件和交互习惯,修问题从源头修,不为一次调整另造设计系统,也不把无关代码重构混入视觉工作。具体做法见 [存量项目](references/existing-project.md)。
|
||||
|
||||
## 3. 制作并取得实际证据
|
||||
|
||||
- 先完成代表性页面或关键操作链,检查后再延展到其他页面。
|
||||
- 示例内容按真实产品的样子写。模拟数据、生成图片、未接入的动作写在交付说明里,不写进界面:画面上不出现“示例”“示意图”“按钮未接入”“仅保存在本机”这类制作说明。不伪造业务成功、客户评价或产品指标。
|
||||
- 在目标视口运行并查看,用 [工具](references/tools.md) 里的截图工具取证:涉及的状态、200% 局部、动效的录屏或开始、中间、结束三帧。只有静态截图,证明不了动效。
|
||||
- 在实际阅读尺寸检查中文多行标题、正文和窄屏换行。文字重叠、被裁切或靠大量小字才装得下时,回到内容和布局修,不靠缩放掩盖。长页要检查整体节奏,不能只看首屏。截图保存成功不等于已经看过。
|
||||
|
||||
## 4. 独立评审与修正
|
||||
|
||||
按改动选评审方式,协议都在 [视觉评审协议](references/visual-review.md):
|
||||
|
||||
| 改动 | 评审 |
|
||||
| --- | --- |
|
||||
| 方向探索阶段的对比页 | 先交给用户;推荐每个小样评审一轮、多方向再加一次横向评审,说明能改善什么、要多少时间,由用户决定 |
|
||||
| 新界面、整体改版,选定方向之后 | 截图、评审、修改循环,10 分制,目标 9 分,默认最多三轮 |
|
||||
| 已有界面润色一整页以上,或只要求评审现有界面 | “给已有界面评审”,一轮,不打分 |
|
||||
| 存量项目里优化 UI、改流程、加新功能 | 按 [存量项目](references/existing-project.md) 的“各类改动怎样评审” |
|
||||
| 单个组件 | 按 [组件](references/component.md) 的“打磨轮次” |
|
||||
| 小改动、局部低风险修改 | 主 Agent 自己看,不派评审者 |
|
||||
|
||||
- 有隔离、能看图的执行者时,派一个没有历史上下文的评审者;没有就自检,并标明未独立评审。评审者只看当前画面、任务和约束,不看制作过程和代码。
|
||||
- 新界面或改动了操作流程时,另做一次任务走查,和视觉评分分开判断;单个组件不做。
|
||||
- 评分循环在这些情况下停止:到 9 分、连续两轮没有提升、到达轮次上限,或者连续两轮的最大差距都指向骨架常见、认不出是谁家,这时换方向或停下写明原因,不耗完三轮。风格开始漂移、意见反复时,保留最合适的版本并说明取舍;没完成的必需功能照实列出。
|
||||
- 用户反复不满意写代码做出的设计、宿主又能生成图片时,可以建议先用 [draw-ui](https://github.com/oil-oil/draw-ui) 生成设计图,挑中再照图实现;同一次对话只提一次。
|
||||
- 并行制作时按独立页面、素材或方向分工,不让多个执行者同时改同一份共享样式。
|
||||
|
||||
## 5. 验证与交付
|
||||
|
||||
检查深度跟着改动走:小改动只看改动处和相邻元素;精修和基线做前后对比;新页面、改版和局部重构做下面全部检查。
|
||||
|
||||
- 交互从真实入口走到可观察结果;保存后重新读取,失败后检查输入和当前位置是否保留。
|
||||
- 只跑项目的类型检查和 lint,不跑生产构建和全量测试,用户要求时除外。构建失败、接口报错、依赖链接这类和画面无关的环境问题,在交付说明里记一行后绕过,不排查、不改项目配置。
|
||||
- 按 [视觉语言](references/visual-language.md) 过三遍:“克制”一节做减法,一页只有一个主角,逐区块删一轮文字;“细节”一节在 100% 和 200% 下看形状、对齐和状态;“模型默认审美自查”看整页截图,出现三项以上没有理由的默认做法,回到方向重定主构图。减法只删装饰和重复,对象名称、判断依据、当前状态和主动作必须留下。
|
||||
- 交付前对一遍,交付说明里逐项写到:
|
||||
- 选定的方向和理由;
|
||||
- 改了什么,哪些没做、为什么;
|
||||
- 每轮评分,或者写明没有评审;
|
||||
- 删掉了哪些文字;
|
||||
- 证据文件在任务目录的哪里。
|
||||
|
||||
设计说明、素材、截图和评审记录放在任务目录,不写进 Skill 安装目录。
|
||||
|
||||
## 能力边界
|
||||
|
||||
没有看图能力时可以产出设计说明、实现代码或源码层面的检查,但明确“未验证实际视觉”,不生成虚假的视觉评分。没有可运行环境时不能声称交互通过;请求要求可运行界面却只能交付方案时,明确标为未完成部分。
|
||||
|
||||
没有生成能力时优先使用已有合适素材,或交付明确的素材需求和可用占位版本。只使用任务已授权的服务与配置,不因缺少素材自动接入付费服务;本 Skill 不管理密钥、安装工具或发布网站。网络不可用时使用已有参考,并说明来源范围。
|
||||
@@ -0,0 +1,363 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN"><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="connect-src 'none'"><meta name="viewport" content="width=device-width,initial-scale=1"><title>风格对比</title><style>
|
||||
:root{--canvas:#f4f4f4;--surface:#fff;--well:#e8e8e8;--raised:#fff;--line:#e2e2e2;--line-2:#d0d0d0;--text:#161616;--soft:#5c5c5c;--faint:#8c8c8c;--ink:#161616;--hl:#ffe28a;--bar-h:48px;--mono:ui-monospace,"SF Mono",Menlo,Consolas,monospace;--brand:"Helvetica Neue",Helvetica,Arial,sans-serif;color-scheme:light;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;color:var(--text);background:var(--canvas);font-synthesis:none;-webkit-font-smoothing:antialiased;font-variant-numeric:tabular-nums;
|
||||
/* 动效:咔嗒用于控件,滑行用于画面与切换,弹出用于确认;弹簧曲线由刚度/阻尼采样 */
|
||||
--snap:cubic-bezier(.2,.9,.25,1.04);--snap-d:330ms;--glide:cubic-bezier(.2,.8,.2,1);--glide-d:440ms;--pop:cubic-bezier(.3,1.5,.5,1);--exit:cubic-bezier(.4,0,1,1)}
|
||||
@supports (transition-timing-function:linear(0,1)){:root{
|
||||
--snap:linear(0,.058,.186,.339,.491,.626,.739,.827,.893,.941,.973,.994,1.005,1.011,1.013,1.013,1.012,1.01,1.008,1.006,1);
|
||||
--glide:linear(0,.028,.097,.186,.286,.384,.48,.565,.644,.71,.768,.816,.856,.889,.915,.936,.953,.966,.976,.983,.989,.993,1);
|
||||
--pop:linear(0,.087,.289,.533,.765,.954,1.084,1.156,1.176,1.161,1.126,1.083,1.042,1.009,.986,.973,.969,.971,.977,.985,.992,.998,1.002,1.005,1.005,1.005,1)}}
|
||||
*{box-sizing:border-box}body{margin:0;min-width:320px;background:var(--canvas)}h1,h2,p,dl,dd{margin:0}button,textarea,input{font:inherit;color:inherit}button{background:none;border:0;padding:0;cursor:pointer;-webkit-tap-highlight-color:transparent}:focus-visible{outline:2px solid var(--ink);outline-offset:3px}[hidden]{display:none!important}
|
||||
|
||||
.bar{position:sticky;top:0;z-index:10;min-height:48px;display:grid;grid-template-columns:minmax(0,1fr) auto minmax(0,1fr);align-items:center;gap:14px;padding:6px 20px;background:#f4f4f4e6;backdrop-filter:saturate(1.4) blur(14px);border-bottom:1px solid var(--line);view-transition-name:bar}
|
||||
.bar-l{display:flex;align-items:center;gap:12px;min-width:0}
|
||||
.brand{display:flex;align-items:center;gap:8px;flex:none;color:var(--ink);text-decoration:none}.brand img{width:28px;height:28px;display:block;transition:transform .5s var(--snap)}.brand:hover img{transform:rotate(-10deg) scale(1.06)}.brand:focus-visible{outline:2px solid var(--ink);outline-offset:4px;border-radius:6px}
|
||||
.wm{font-family:var(--brand);font-size:19px;line-height:1;font-weight:700;letter-spacing:-.045em;display:flex;align-items:flex-start;gap:3px}.wm b{font-size:9.5px;line-height:1;font-weight:700;letter-spacing:.06em;padding:2px 4px 1.5px;margin-top:-1px;border-radius:4px;background:var(--hl);box-shadow:inset 0 0 0 1.4px var(--ink)}
|
||||
.sep{width:1px;height:18px;background:var(--line-2);flex:none}.proj{font-size:14px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.round{font-size:13px;color:var(--faint);white-space:nowrap}
|
||||
.bar-c{display:flex;gap:8px}.bar-r{display:flex;justify-content:flex-end;align-items:center;gap:12px}
|
||||
.seg{position:relative;display:flex;padding:2px;border-radius:8px;background:var(--well)}
|
||||
.seg button{position:relative;z-index:1;font-size:13px;line-height:1.5;padding:4px 11px;border-radius:6px;color:var(--soft);white-space:nowrap;transition:color .2s}.seg button:hover{color:var(--text)}.seg button[aria-pressed=true]{color:var(--text)}
|
||||
.thumb{position:absolute;left:0;top:0;width:0;border-radius:6px;background:var(--raised);box-shadow:0 1px 2px #0000001f,0 2px 8px -2px #00000014,inset 0 0 0 .5px #0000000f;transition:transform var(--snap-d) var(--snap),width var(--snap-d) var(--snap)}
|
||||
.toggle{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--soft);cursor:pointer;white-space:nowrap}.toggle input{appearance:none;width:28px;height:16px;margin:0;border-radius:99px;background:var(--line-2);position:relative;cursor:pointer;transition:background .2s}.toggle input::after{content:"";position:absolute;top:2px;left:2px;width:12px;height:12px;border-radius:99px;background:#fff;box-shadow:0 1px 2px #0003;transition:transform var(--snap-d) var(--snap),width .15s}.toggle input:checked{background:var(--ink)}.toggle input:checked::after{transform:translateX(12px)}.toggle:active input::after{width:14px}.toggle:active input:checked::after{transform:translateX(10px)}
|
||||
.filter{position:relative;font-size:13px}.filter summary{list-style:none;cursor:pointer;color:var(--soft);padding:4px 10px;border-radius:6px;box-shadow:inset 0 0 0 1px var(--line);white-space:nowrap;transition:color .15s,box-shadow .15s}.filter summary::-webkit-details-marker{display:none}.filter summary:hover,.filter[open] summary{color:var(--text);box-shadow:inset 0 0 0 1px var(--line-2)}
|
||||
.filter-list{position:absolute;right:0;top:calc(100% + 8px);min-width:220px;display:grid;gap:2px;padding:6px;border-radius:10px;background:var(--raised);box-shadow:0 18px 44px -8px #00000026,0 0 0 1px #00000012;transform-origin:top right}.filter[open]>.filter-list{animation:pop-in var(--snap-d) var(--snap) both}
|
||||
.filter-list label{display:flex;align-items:center;gap:10px;padding:7px 8px;border-radius:6px;cursor:pointer;font-size:14px;transition:background .12s}.filter-list label:hover{background:#0000000a}.filter-list input{accent-color:var(--ink);width:15px;height:15px;margin:0}.filter-list .n{font-family:var(--mono);color:var(--faint);font-size:13px}
|
||||
|
||||
.intro{max-width:1840px;margin:0 auto;padding:28px 24px 0}.intro p{font-size:14px;line-height:1.75;color:var(--soft);max-width:68ch}
|
||||
|
||||
.grid{max-width:1840px;margin:0 auto;padding:24px 24px 64px;display:grid;grid-template-columns:repeat(var(--cols,3),minmax(0,1fr));gap:48px 28px;align-items:start}
|
||||
.grid.first .card,.intro.first{animation:rise .7s var(--glide) both;animation-delay:calc(var(--i,0) * 70ms + 60ms)}
|
||||
.card{min-width:0;display:flex;flex-direction:column}
|
||||
.cap{display:flex;align-items:center;gap:12px;padding-top:14px}.cap .no{font-family:var(--mono);font-size:13px;color:var(--faint);transition:color .2s}.cap .name{font-size:16px;font-weight:600;line-height:1.4;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.cap .strip-mini{margin:0 auto 0 4px;width:40px;flex:none}.cap .no.base{font-family:inherit;font-size:12px;font-weight:600;line-height:1.6;padding:0 8px;border-radius:99px;background:var(--well);color:var(--soft)}
|
||||
.lede{margin-top:8px;font-size:13.5px;line-height:1.7;color:var(--soft);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.grid[data-notes=off] .lede{display:none}
|
||||
.name{font-size:19px;line-height:1.35;font-weight:600;overflow-wrap:anywhere}.strip-mini{display:flex;height:4px;width:96px;margin-top:9px;border-radius:2px;overflow:hidden;box-shadow:0 0 0 1px #0000001f}.strip-mini i{flex:1}
|
||||
.frame{position:relative;background:var(--surface);border-radius:4px;box-shadow:0 0 0 1px #00000014,0 24px 48px -30px #00000040;transition:box-shadow .25s,transform .45s var(--glide)}.frame:hover{box-shadow:0 0 0 1px #0000002e,0 36px 64px -30px #00000052;transform:translateY(-3px)}.frame:active{transform:translateY(-1px) scale(.995);transition-duration:.25s,.12s}.frame[data-viewport=mobile]{padding:20px 0;background:var(--well)}
|
||||
.peek{position:absolute;inset:auto 0 0 0;z-index:2;padding:10px 12px;font-size:12.5px;color:#fff;background:linear-gradient(transparent,#000b);opacity:0;transform:translateY(6px);transition:opacity .18s,transform .3s var(--glide);pointer-events:none;border-radius:0 0 4px 4px}.frame:hover .peek,.hit:focus-visible~.peek{opacity:1;transform:none}
|
||||
.stage{position:relative;overflow:hidden;margin:0 auto;border-radius:3px;background:#ebebeb}.stage iframe,.stage img{position:absolute;left:0;top:0;border:0;display:block;transform-origin:top left;background:#fff;opacity:0;transition:opacity .4s ease-out}.stage.ready iframe,.stage.ready img{opacity:1}.stage iframe{pointer-events:none}.stage img{object-fit:contain;background:#ebebeb}
|
||||
.hit{position:absolute;inset:0;width:100%;height:100%;z-index:1;border-radius:4px;cursor:zoom-in}.hit:focus-visible{outline-offset:-2px}
|
||||
.service-note{position:absolute;inset:0;z-index:2;display:flex;align-items:center;justify-content:center;padding:20px;background:var(--canvas);overflow:auto;pointer-events:none}
|
||||
.service-message{width:100%;max-width:440px;min-width:0;margin:auto;font-size:14px;line-height:1.7;color:var(--soft)}.service-message h3{margin:0 0 6px;font-size:16px;font-weight:600;color:var(--text)}
|
||||
.service-command{display:flex;flex-wrap:wrap;align-items:flex-start;gap:8px;margin-top:16px;pointer-events:auto}.service-command code{flex:1 1 200px;min-width:0;padding:6px 10px;border-radius:6px;background:var(--well);font-family:var(--mono);font-size:12.5px;line-height:1.6;white-space:pre-wrap;overflow-wrap:anywhere;user-select:all}.service-command .tool{flex:none;background:var(--raised)}
|
||||
.stage.offline .static{display:none}
|
||||
.slide .static{display:none}.static{position:absolute;left:10px;bottom:10px;z-index:2;font-size:12.5px;line-height:1.4;padding:3px 8px;border-radius:4px;background:#000c;color:#ddd;pointer-events:none}
|
||||
.pick-mark{position:absolute;top:10px;right:10px;z-index:3;display:none;align-items:center;gap:6px;padding:4px 10px 4px 8px;border-radius:99px;background:var(--ink);color:#fff;font-size:13px;font-weight:600;line-height:1.4;box-shadow:0 6px 16px #0000003d;pointer-events:none;transform-origin:85% 50%}.pick-mark::before{content:"";width:8px;height:8px;border-radius:50%;background:var(--hl)}
|
||||
.is-chosen .pick-mark{display:inline-flex}.is-chosen .frame{box-shadow:0 0 0 2px var(--ink),0 30px 60px -30px #00000052}.is-chosen .cap .no{color:var(--text)}
|
||||
|
||||
.spec{display:grid;grid-template-columns:32px minmax(0,1fr);gap:10px 12px;font-size:13px;line-height:1.6}.spec dt{color:var(--faint)}.spec dd{color:var(--soft);min-width:0}
|
||||
.pick{display:inline-flex;align-items:center;justify-content:center;gap:8px;font-size:14px;line-height:1.5;padding:8px 16px;border-radius:8px;box-shadow:inset 0 0 0 1px var(--line-2);color:var(--text);white-space:nowrap;transition:background .2s,color .2s,box-shadow .2s,transform .35s var(--snap)}.pick:hover{background:#0000000a}.pick:active{transform:scale(.96);transition-duration:.2s,.2s,.2s,.08s}
|
||||
.pick kbd{font-family:var(--mono);font-size:12px;color:var(--faint);padding:0 5px;border-radius:4px;box-shadow:inset 0 0 0 1px var(--line-2);transition:color .2s,box-shadow .2s}
|
||||
.pick[data-on]{background:var(--ink);color:#fff;box-shadow:none}.pick[data-on]:hover{background:#333}.pick[data-on] kbd{color:#bdbdbd;box-shadow:inset 0 0 0 1px #ffffff40}
|
||||
.pick[data-on]::before{content:"";width:9px;height:5px;margin:-3px 1px 0 0;border-left:1.6px solid;border-bottom:1.6px solid;transform:rotate(-45deg)}
|
||||
.cap .pick{flex:none;font-size:13px;padding:4px 12px;border-radius:6px}
|
||||
|
||||
.loupe{position:fixed;top:var(--bar-h);left:0;right:0;bottom:0;display:grid;grid-template-columns:minmax(0,1fr) 300px;grid-template-rows:minmax(0,1fr)}
|
||||
.view{position:relative;min-width:0;min-height:0;display:flex;flex-direction:column}
|
||||
.surface{position:relative;flex:1;min-height:0;overflow:clip}
|
||||
.track{display:flex;height:100%;transition:transform var(--glide-d) var(--glide)}
|
||||
.slide{flex:0 0 100%;min-width:0;height:100%;overflow:auto;padding:20px 72px;display:flex;justify-content:center;align-items:center}.slide[data-zoom=actual]{display:block}
|
||||
.slide .stage{flex:none;box-shadow:0 0 0 1px #00000014,0 40px 80px -40px #00000059;transition:transform var(--glide-d) var(--glide),opacity var(--glide-d) ease-out}.slide:not(.is-current) .stage{transform:scale(.92);opacity:.4}.slide.is-current .stage iframe{pointer-events:auto}
|
||||
.nav{position:absolute;top:50%;z-index:2;width:44px;height:44px;margin-top:-22px;border-radius:50%;display:grid;place-items:center;font-size:18px;background:#ffffffd9;color:var(--ink);box-shadow:0 0 0 1px #00000014,0 8px 24px -6px #00000033;backdrop-filter:blur(8px);opacity:.85;transition:opacity .2s,transform .35s var(--snap)}.nav:hover{opacity:1}.nav:active{transform:scale(.9);transition-duration:.2s,.08s}.nav.prev{left:14px}.nav.next{right:14px}.nav:disabled{opacity:0;pointer-events:none;transform:scale(.8)}
|
||||
.info{grid-row:1;grid-column:2;overflow:auto;border-left:1px solid var(--line);background:var(--surface);display:flex;flex-direction:column;view-transition-name:info}
|
||||
.film{position:relative;display:grid;gap:2px;padding:10px;border-bottom:1px solid var(--line)}
|
||||
.film-ind{position:absolute;left:0;top:0;width:0;height:0;border-radius:8px;background:#f0f0f0;box-shadow:inset 0 0 0 1px var(--line);transition:transform var(--snap-d) var(--snap),width var(--snap-d) var(--snap),height var(--snap-d) var(--snap)}
|
||||
.film button{position:relative;z-index:1;display:grid;grid-template-columns:auto minmax(0,1fr);align-items:center;gap:3px 10px;padding:7px 10px;border-radius:8px;color:var(--soft);text-align:left;transition:color .2s,background .15s}.film button:hover{color:var(--text)}.film button:not([aria-current=true]):hover{background:#00000006}.film button[aria-current=true]{color:var(--text)}
|
||||
.film .fn{font-family:var(--mono);font-size:12.5px;color:var(--faint);grid-row:span 2}.film .ft{font-size:13.5px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.film .strip-mini{margin:0;width:48px;height:3px}
|
||||
.film .ft.chosen::after{content:"";width:6px;height:6px;border-radius:50%;background:var(--ink);display:inline-block;margin-left:8px;vertical-align:middle}
|
||||
.info-body{padding:20px 20px 24px;display:flex;flex-direction:column;gap:16px}
|
||||
.idx{font-size:48px;line-height:.9;font-weight:200;letter-spacing:-.02em;color:var(--faint)}.info .name{font-size:20px;line-height:1.35;margin-top:-6px}.north{font-size:14px;line-height:1.7;color:#2b2b2b}
|
||||
.big-sw{display:grid;gap:6px}.big-sw div{display:grid;grid-template-columns:32px 1fr;align-items:center;gap:10px;font-family:var(--mono);font-size:12.5px;color:var(--soft)}.big-sw i{height:20px;border-radius:4px;box-shadow:inset 0 0 0 1px #00000017}
|
||||
.info .pick{align-self:stretch;padding:8px 14px;font-size:13.5px}
|
||||
.tools{display:flex;gap:10px;flex-wrap:wrap;align-items:center}.tool{font-size:13px;padding:5px 12px;border-radius:6px;color:var(--soft);box-shadow:inset 0 0 0 1px var(--line-2);transition:color .15s,background .2s}.tool:hover{color:var(--text)}.tool[aria-pressed=true]{background:var(--ink);color:#fff;box-shadow:none}
|
||||
.hint{font-size:12px;line-height:1.9;color:var(--faint)}.hint kbd{font-family:var(--mono);font-size:12px;padding:0 5px;border-radius:4px;box-shadow:inset 0 0 0 1px var(--line-2);color:var(--soft)}
|
||||
.src{font-size:12px;line-height:1.6;color:var(--faint);overflow-wrap:anywhere}
|
||||
|
||||
.picked{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--soft);white-space:nowrap;max-width:220px;padding-left:12px;border-left:1px solid var(--line);overflow:hidden}.picked #decide-name{display:block;overflow:hidden;text-overflow:ellipsis}.picked.on #decide-name{color:var(--text);font-weight:600}
|
||||
.picked .strip-mini,.choice-empty{flex:none;margin:0;width:22px;height:12px;border-radius:3px}.choice-empty{box-shadow:inset 0 0 0 1px var(--line-2)}
|
||||
.notes-panel{width:340px;padding:10px}.notes-panel textarea{width:100%;min-height:110px;resize:vertical;padding:10px 12px;border:0;border-radius:6px;background:#f6f6f6;box-shadow:inset 0 0 0 1px var(--line);font-size:14px;line-height:1.7;transition:box-shadow .15s,background .15s}.notes-panel textarea:focus{outline:0;background:#fff;box-shadow:inset 0 0 0 1px var(--faint)}.notes-panel textarea::placeholder{color:var(--faint)}
|
||||
.state{font-size:12px;line-height:1.6;color:var(--faint);padding:8px 2px 0}.state:empty{display:none}.state[data-warn]{color:#b3401f}
|
||||
|
||||
.toast{position:fixed;left:50%;bottom:24px;z-index:40;display:flex;align-items:center;gap:12px;max-width:min(560px,calc(100vw - 32px));padding:10px 16px 10px 12px;border-radius:12px;background:var(--ink);color:#fff;box-shadow:0 18px 44px -10px #00000066;font-size:13.5px;line-height:1.5;opacity:0;transform:translate(-50%,14px) scale(.96);pointer-events:none;transition:opacity .16s var(--exit),transform .2s var(--exit)}
|
||||
.toast.show{opacity:1;transform:translate(-50%,0);pointer-events:auto;transition:opacity .18s ease-out,transform var(--snap-d) var(--snap)}
|
||||
.toast .tick{flex:none;width:22px;height:22px;border-radius:50%;background:var(--hl);display:grid;place-items:center}.toast .tick::before{content:"";width:8px;height:4px;margin-top:-2px;border-left:1.6px solid var(--ink);border-bottom:1.6px solid var(--ink);transform:rotate(-45deg)}.toast[data-fail] .tick{background:#c2462e}.toast[data-fail] .tick::before{width:2px;height:9px;margin:0;border:0;background:#fff;transform:none}
|
||||
.toast b{font-weight:600;white-space:nowrap}.toast code{font-family:var(--mono);font-size:12.5px;color:#a8a8a8;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;user-select:all}.toast[data-fail] code{white-space:pre-wrap}
|
||||
|
||||
.none,.empty{max-width:560px;margin:18vh auto;padding:24px;text-align:center;font-size:15px;line-height:1.8;color:var(--soft)}.none button{margin-top:12px;color:var(--text);text-decoration:underline;text-underline-offset:4px}
|
||||
|
||||
@keyframes rise{from{opacity:0;transform:translateY(16px)}}
|
||||
@keyframes pop-in{from{opacity:0;transform:translateY(-4px) scale(.96)}}
|
||||
@keyframes vt-out{to{opacity:0;transform:scale(.97)}}
|
||||
@keyframes vt-in{from{opacity:0;transform:scale(.98)}}
|
||||
@keyframes info-in{from{transform:translateX(100%)}}
|
||||
@keyframes info-out{to{transform:translateX(100%)}}
|
||||
/* 并排与单张之间、视口与筛选变化用页面过渡:同一方向的画面从卡片放大到单张,其他卡片淡出,侧栏从右侧滑入 */
|
||||
::view-transition-group(*){animation-duration:var(--glide-d);animation-timing-function:var(--glide)}
|
||||
::view-transition-old(*),::view-transition-new(*){height:100%;object-fit:cover;object-position:50% 0}
|
||||
::view-transition-old(root),::view-transition-new(root){animation-duration:.2s}
|
||||
::view-transition-old(*):only-child{animation:vt-out .18s var(--exit) both}
|
||||
::view-transition-new(*):only-child{animation:vt-in .3s .08s ease-out both}
|
||||
::view-transition-new(info):only-child{animation:info-in var(--glide-d) var(--glide) both}
|
||||
::view-transition-old(info):only-child{animation:info-out .22s var(--exit) both}
|
||||
::view-transition-old(bar){display:none}::view-transition-new(bar){animation:none}
|
||||
/* 视口、筛选、说明开关和缩放只改变形状与位置:外框直接变形,内容不做叠影 */
|
||||
html[data-vt=reshape]::view-transition-old(*){animation:none;opacity:0}
|
||||
html[data-vt=reshape]::view-transition-new(*){animation:none}
|
||||
html[data-vt=reshape]::view-transition-old(*):only-child{animation:vt-out .18s var(--exit) both;opacity:1}
|
||||
html[data-vt=reshape]::view-transition-new(*):only-child{animation:vt-in .3s .08s ease-out both}
|
||||
|
||||
@media(max-width:1180px){.grid{--cols:2!important}.bar{grid-template-columns:minmax(0,1fr) auto;row-gap:10px}.bar-r{grid-column:1/-1;justify-content:flex-start;flex-wrap:wrap}.loupe{grid-template-columns:minmax(0,1fr) 280px}}
|
||||
@media(max-width:760px){.bar{grid-template-columns:1fr;padding:10px 16px}.bar-c{flex-wrap:wrap}.intro{padding:24px 16px 0}.grid{--cols:1!important;padding:24px 16px 48px;gap:48px}.loupe{position:static;display:flex;flex-direction:column}.surface{flex:none}.track{height:auto;align-items:flex-start}.slide{height:auto;padding:16px 12px}.info{border-left:0;border-top:1px solid var(--line)}.film{display:flex;overflow-x:auto}.film button{flex:none}.info-body{padding:20px 16px}.nav{display:none}.picked{border-left:0;padding-left:0}.notes-panel{width:min(340px,calc(100vw - 32px))}.filter-list{right:auto;left:0;transform-origin:top left}}
|
||||
@media(prefers-reduced-motion:reduce){*,*::before,*::after{transition-duration:0s!important;animation-duration:0s!important;animation-delay:0s!important}.frame:hover{transform:none}}
|
||||
</style></head><body>
|
||||
<header class="bar" id="bar" hidden>
|
||||
<div class="bar-l"><a class="brand" href="https://www.oiloil.org" target="_blank" rel="noopener" title="Oil UI · oiloil.org" aria-label="Oil UI,打开 oiloil.org"><img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGAAAABgCAYAAADimHc4AAAAAXNSR0IArs4c6QAAAHhlWElmTU0AKgAAAAgABAEaAAUAAAABAAAAPgEbAAUAAAABAAAARgEoAAMAAAABAAIAAIdpAAQAAAABAAAATgAAAAAAAABIAAAAAQAAAEgAAAABAAOgAQADAAAAAQABAACgAgAEAAAAAQAAAGCgAwAEAAAAAQAAAGAAAAAAmrxreQAAAAlwSFlzAAALEwAACxMBAJqcGAAAJzZJREFUeAHtXQeAVEXSrsk7m4FdMuyCGAiCCioKKgb0PAUURDCBIhgxe+b/znQnqChGVO7MijmfYg6ACqKSc16UILJsYOPs9P993dPz3gwLLriKnDS8ee917qrq6uqq6rciu8NuCOyGwG4I7IbAnxUCvj/rwDnujIzQ3j6fZ0Q47JWqquiqnQEL785odGe32bJly3BKin9UWXn15JqoOtLj8VTu7D79adpv3DitiT/g+9wf8C5MS/MfJ0o8f5rB7+yBZiMEAr5pQMDHzZtn5GytPx07dgw2aNAgKz9fUraWZ3f8dkJAgdIDQd8LQMBXubm56cnFs7JS2obCgcuDQd+7gaB3Aa7loRT/F5wxyXl3v+8ABNLTg6f6/d712dkpee7iXIgB9CeQtgFsaXYg5L83HPYNCof9h6SlBTt3FAm689f38x+e/5Fy99ijQWZRUXXziorqVkpFW0UiNc2i0WiDaFRSARCOQYlXyv1eb5HH49vg9XrWgtILgsG01SNHjlw7d+5czxtvvvqtz+t7AHX8h0BkvSkpgctrampuEK/nu5SgZ9Tee3eZ/O2331Yz/U8dOO1TU33H+4PefwIQH+BajqsMl+IVCgZVVmamys3NUU0aN1Y5jRqqTLynhII6PZYvgvtPuL72+z1vBoPe+S1ahFty0c3Pz09JSfE95Qt4V4bDwYFEBvLtlLDTGk4ebevWWQ1++qn06MrKmgGg7MOR3tzv80nr1q2lffsO0rFjB9lzzz0lLy9PGjVqJFlZWRIOh8Xj9UqkOiKVlRVSUlIiRcXFsubHH2XZsmUyb948mTd3rixZulRKN2+uQZ3rIHdP8fo9GV6vL69Jk+xjCgo2/Gj7QkRkZ4faRiLSFs/BYNC/ukWL/PmYQVU2T33fdzoCGjUK7lNaGh1WWRk5BYNrk5GRId27d5e/HHec9OjRQ/baay9p0LBhreNWgJKZFB6BLF9rnvLyclmxYoVM/+Yb+fDDD+Xzzz+XVQUFzFuIGfBRSjjweKPslKnrN5T1j0bUuVGJtkJNnG0R3MO4F/n9gRvLyirfZaH/mdCgQXqnQMA7HgMqwqX2329/NXrUKLVgwQIF/g7YmgAeraqrq2NXFe68qlUkHpf4bNKdfDWRiK1K39evX69efOEFdeIJJ6hQKEQMRjGJlvv83o3pqaELuUhzo0ZRtEmT9MZYkAdjoe77PwN4DioY8t+FARWDwtRRRx2lXnvtNbV58+Y4oByAW8An3w0SqquqgIgtEcL46ipbxsnLuCgQqgOQ/M20aWrE8BEqPT2diKjAWvFKVlZq1/8ZYLsHQv4aTg8OBqdYgnh10IEHqbfeeENTMwFCSjWAjwHMReFbQ4iZBcxvyjizwsbVVlcsb6TaIAK/M2fMUGeecbry+XxExOZQyD+6bdsGWe7+79LPTZum50LWfpyAb9iwobpr9GhVWlqiARABILYGYMYTqPZKzOcA1wLenc/GETnm2SDFncfWZzHx7rv/Vft16aIXFr/PMz0jI3zILg14dj41K9DN65cZBP5RRx6pvv7qKz3emqjl7bUD0gLfAskBnAGkm+ptGtmR8+ysDU6cYVl8t/XaO+MYNm7cqK64/HLl83qJiE2pqYERnL0cyy4XwHIGoNPYGHnVNVdfrSAeqqrKShWJs5tEQFhAWaDY+5bxFmmWut3AdtKc8jaO+cyzG1k2H++QADQiuFBzj4H+K+wV7ujVq5d/l0IAKOdidLgqMyNDPfrII2pTYaH6ecMGVVFRAQTUDvhkQNv35Lub+i3wmMc+2/uWcRb4boQ5zyyn24pJTt9/953q0rmzRgLE1We6dm3OnfcfP4Sh1EIvo7k5Oerll15SG3/+WVN/WVlZHPgWOHrAMeDZ59ruFqjuu63DxNnZ4Kb2ROAaALvTbZkY4F1IZN0MP6xerY44/HA7E17q06frHxsJoPyLAPyali1aqInvvafWrV2rgY8NUZz1WMBZQNcG1OQ0yzLceROfCVgHuBrYFEcT4gzATftOXnd/TH6TxniGn376SR3bu7eZCSmBZ7t27Rqor2lQr4sLeOUp5eVVzzdt0jQwfvxj0qVLFyi9lN7Jpqam6mewVPTdNMtf580OycTwd2vBlEss6UFNNsa9Zto2TF1MMW3b+lmO/bHvzGfL2Lvf7xfMYunXr59MnjJFIKbeXVERuQYitbuYaWI7f01vtrNQbdmzs1P3Lyoq+zA9Na3Rgw8+KD0PO0wPhGqEzKxMwdqWUMw2bGItyNwjYorNlVBUv1jAEYW2tMllytmaTJpt24KUOS0qbKnE9kzLTpwPSFi1apUcDxXJvAULJDU1dAHUE4+a0jv+Wy82YSrSSksrIOd7Gl13/XVy2OGHA+BRSYGyjLqd2oDPoRmwmEGaZ/dA3MB3UhlrUmx6IiBtqilhczqlTLop49TKdm19tg9MNXF8guSmFYNPPvWUwFom5eWVd2Vmhg+2uXf0Xi9eEWUVkXsi1dE+gwedKiNHXgIPA6M8xKaLKsVt9M2CwA0ggXreKz6/TyC+xi+td9MoI/AM1SeDzNZi4p1U58mC1HbJANmWs7F8dzMri2ISVatWraRpkyby5ptvhWpqIl2z8hu8UFFYscNG/V+NgLS00LEV5dVj8lvnee8dOxb8MSTQtwiBT0qxILaDS7jHEzFk/CevJdDLyjZLQcEqWbpkiVYtE6FpWEOCQdQNIFgwGkC5a3QzHKaawCeHZdlY3m0NBsQmn52ZTLcddPJxTdt///1lKVTcM2bMaqbKol4oDD9i7t890LYK69NM6O3Vww8+qBYvWqT1Kguh0dxcWgr9jls2T5ZIkOaSUqj1nDJ5sho6dKhq2yZfpaelqZSUkEpNSVEZeO7YoYN67tlntabUSD9uicZpx0pPtd0TpSanjK0vMd3210pLsTsVeuCpFE/z8/KIobJfw4p+1e6uuHjTMPSl84BTTpajjj5aG0QCwYA0yM7W/J/U6lCeXRZJJ+i3AlVpHb7SlI/9gpx33nmaus4//wLp3LmznkXMvWbNGvl66lRdP98NPTr1Wfpkmptm7bs7nXHmnTn5ZIJlM3wzdThpNoe+IxqsR5q3aCH/93//J+cOHx4uK6u6ZeBAOeHll4VGn+0Kya3UufB1F+7bduzjc7/w+lJaPPPU09IBFqvKykrNglqgc6lpaTGx01aZOGAbyztmEdjNUg3gA7puWxuMXTRKJHfbgiwRKazbpFig25hYeS4s+tGjiYB1kw3a+qPRmpgAYWtheVOWBiAuzMeA8CCaKkhFfSAV/Zc5tifskBSkZh6btmZNySMVldEWfU44Qfbt3EkvvFDnShoA7wB/y447IHHSKCXR3Ejgc1DbujQA4kXtg4MQPjlvDijMIs53prIcLp0RtI/79dddK10P6Co9e/SU8zETX3v1VSkt3awR4ra2sYguhgq53lHq8yID1qm/9eol281RdggBi9d5B3475+fe4XCqDD7tNKkorwDQDPXkNm6MLtqgu2pfYoCxcebOX15mG2UWYlKhvYhUEyywYwV0pJtxONQfA2+c+k1WV3lToVMRkvbdt7OcfPLJmvXNnj1bzjrrLCDkAHnyiScwQ41ExmLsp62fwkbv3r3lkEMPxfhVz+nTUw6LV13Hh+3GmFrYJ+exCauvnbukRGDW07JxBB0hMmgkBzWgl3awvBtAOyBmjAEW83pjAF6/bp0sX74c0k8BKK8UG51UXXebNvnSpGkzPRzLEgzCnJpNbebXItJJZVG3dMR3U4Oth5LN6WecwQQduHZBkJBHHnlELrzoQvnqq69k7H33QQoLOENDzqiKasnsggsukClTpvgqK6vOQ/Snppbf6FfN63/uqce10ETwwP33q9mzZmnJZ87s2WoDdCZYoGIGEEoZVoJwSxwmDoNW1A+9DnMktviqcW6ulnYawWDTvFkzlduokcpIT1N8h4FevTBhgoJ4ymIJWk8j7bDO2tpK7oPNY+4s60g+Ng2KuZg2lG198vHHqtcRRyiIxDEJzJUP5Sm90YbQJj+fMCmETTl/e0C/XSxIqfMC8xYXDfl06jpp26atdOvWTS+0pCSyCj82T5yf7ImhR9MVpttAOZ/sBYo68NsecsaZZ8CtpFruuOMOrWeZM2eO8JqNC1QlY8aM0TPrnGHDpMehPeTjjz7S5fVMQ6WaEjRFu1sxsaZNG296xTjLuJwYPjlzBnA36xDcXY486ij56OOPpXVea0g/FHJsfbG2sVBzv9O3r7bbZ5eX1/xVV/Zb/Ki5Aw5+9o5u3PWp/iefrGbNnKm++/ZbNX/ePLVqxQot1ydSlZvCjEG8qqpS3XD9dSoQCKhBgwbpOkhpNtBoTvtw3HgeS2BbJ2GmBFFuzN1361hS6haUb43xeo+R2D6p3ZkxJo0y/bYC1ra4Ct2ZLaasqctoTD/++CNFwxMM++8Bfw6WfgER27cGqOre38z+WesWDoHvDqkZnddXgCqHeLOWtmJ35PH6vFKNhfq8EefJq5AwuLhZvguAa0omg6XEofk49wmgLhv2xb7gtddfl/vBLine truncated
|
||||
<div class="bar-c">
|
||||
<div class="seg" role="group" aria-label="查看方式"><span class="thumb" aria-hidden="true"></span><button type="button" data-layout="compare" aria-pressed="true">并排</button><button type="button" data-layout="loupe" aria-pressed="false">单张</button></div>
|
||||
<div class="seg" role="group" aria-label="预览视口"><span class="thumb" aria-hidden="true"></span><button type="button" data-viewport="desktop" aria-pressed="true">桌面 1280</button><button type="button" data-viewport="mobile" aria-pressed="false">手机 390</button></div>
|
||||
</div>
|
||||
<div class="bar-r">
|
||||
<label class="toggle"><input id="notes-toggle" type="checkbox" checked>设计说明</label>
|
||||
<details class="filter"><summary id="filter-summary"></summary><div id="filter-list" class="filter-list"></div></details>
|
||||
<span class="picked" id="picked" title="当前选择的方向"><span id="choice-chip" class="choice-empty"></span><span id="decide-name">未选择方向</span></span>
|
||||
<details class="filter"><summary id="notes-open">备注</summary><div class="filter-list notes-panel"><textarea id="notes" aria-label="备注" placeholder="喜欢哪里、要改哪里,可以用编号指明方向。"></textarea><p id="state" class="state"></p></div></details>
|
||||
</div>
|
||||
</header>
|
||||
<div id="compare-view" hidden>
|
||||
<section class="intro"><p id="brief"></p></section>
|
||||
<main id="grid" class="grid" aria-label="候选方向"></main>
|
||||
</div>
|
||||
<main id="loupe" class="loupe" hidden aria-label="单张查看">
|
||||
<div class="view">
|
||||
<button type="button" class="nav prev" id="prev" aria-label="上一个方向">←</button>
|
||||
<div id="surface" class="surface"><div id="track" class="track"></div></div>
|
||||
<button type="button" class="nav next" id="next" aria-label="下一个方向">→</button>
|
||||
</div>
|
||||
<aside id="info" class="info"><nav id="film" class="film" aria-label="切换方向"></nav><div id="info-body" class="info-body" aria-live="polite"></div></aside>
|
||||
</main>
|
||||
<div id="none" class="none" hidden>当前没有要比较的方向。<br><button type="button" id="show-all">显示全部</button></div>
|
||||
<div id="empty" class="empty" hidden>尚未加入设计小样。按 references/style-explorer.md 准备 manifest 后用生成器组装。</div>
|
||||
<div id="toast" class="toast" role="status" aria-live="polite"><span class="tick"></span><b></b><code></code></div>
|
||||
<script>
|
||||
const DATA = /*__OIL_UI_DATA__*/ null;
|
||||
(()=>{
|
||||
const $=s=>document.querySelector(s),$$=s=>document.querySelectorAll(s);
|
||||
if(!DATA?.candidates?.length){$('#empty').hidden=false;return;}
|
||||
const C=DATA.candidates,SIZES={desktop:{w:1280,h:900},mobile:{w:390,h:844}},KEY=`oil-ui:${DATA.fingerprint||DATA.project}`;
|
||||
const byId=Object.fromEntries(C.map(c=>[c.id,c])),DIRS=C.filter(c=>!c.baseline),no=c=>c.baseline?'现状':String(DIRS.indexOf(c)+1).padStart(2,'0'),vtName=c=>`stage-${C.indexOf(c)}`;
|
||||
const root=document.documentElement,calm=matchMedia('(prefers-reduced-motion: reduce)'),narrow=()=>matchMedia('(max-width:760px)').matches;
|
||||
const ease=n=>getComputedStyle(root).getPropertyValue(`--${n}`).trim()||'ease-out';
|
||||
let st={layout:'compare',viewport:'desktop',notesOn:true,visible:C.map(c=>c.id),current:C[0].id,chosen:null,notes:''},stored=true,zoomActual=false,compareScroll=0,gridKey='',loupeKey='',toastTimer=0;
|
||||
try{const saved=JSON.parse(localStorage.getItem(KEY)||'null');if(saved&&typeof saved==='object'){Object.assign(st,saved);st.visible=(Array.isArray(st.visible)?st.visible:[]).filter(id=>byId[id]);if(!byId[st.chosen])st.chosen=null;if(!byId[st.current])st.current=C[0].id;if(!['compare','loupe'].includes(st.layout))st.layout='compare';if(!SIZES[st.viewport])st.viewport='desktop';}}catch{stored=false;}
|
||||
function save(){if(!stored)return;try{localStorage.setItem(KEY,JSON.stringify(st));}catch{stored=false;status();}}
|
||||
const el=(tag,cls,text)=>{const n=document.createElement(tag);if(cls)n.className=cls;if(text!=null)n.textContent=text;return n;};
|
||||
const shown=()=>C.filter(c=>st.visible.includes(c.id));
|
||||
const services=new Map(C.filter(c=>c.kind==='url').map(c=>[c.content,{ready:null,pending:false}]));
|
||||
const startCommand=DATA.serve?(DATA.serve.cwd?`cd "${DATA.serve.cwd.replace(/[\\"$`]/g,'\\$&')}" && ${DATA.serve.command}`:DATA.serve.command):'';
|
||||
|
||||
function serviceNotice(){
|
||||
const note=el('div','service-note'),message=el('div','service-message');note.hidden=true;note.setAttribute('role','status');
|
||||
message.append(el('h3',null,'开发服务器没有运行'),el('p',null,'在对话里跟 Agent 说“打开对比页”就行。'));
|
||||
if(startCommand){const row=el('div','service-command'),button=el('button','tool','复制命令');button.type='button';button.dataset.copyServe='';button.setAttribute('aria-label','复制启动命令');button.addEventListener('click',()=>copy(startCommand));row.append(el('code',null,startCommand),button);message.append(row);}
|
||||
note.append(message);return note;
|
||||
}
|
||||
function syncService(s,c){
|
||||
const ready=services.get(c.content).ready,m=s.querySelector('iframe');
|
||||
s.classList.toggle('offline',ready===false);s.querySelector('.service-note').hidden=ready!==false;m.hidden=ready!==true;
|
||||
if(ready===true){if(m.getAttribute('src')!==c.content){s.classList.remove('ready');m.src=c.content;}}
|
||||
else{s.classList.remove('ready');if(m.hasAttribute('src'))m.removeAttribute('src');}
|
||||
}
|
||||
async function checkService(url){
|
||||
const state=services.get(url);if(state.pending)return;state.pending=true;
|
||||
const controller=new AbortController(),timer=setTimeout(()=>controller.abort(),2500);
|
||||
const target=new URL(url);if(target.hostname==='[::1]')target.hostname='localhost';
|
||||
try{await fetch(target.href,{mode:'no-cors',cache:'no-store',credentials:'omit',signal:controller.signal});state.ready=true;}catch{state.ready=false;}
|
||||
finally{clearTimeout(timer);state.pending=false;}
|
||||
$$('.stage').forEach(s=>{const c=byId[s.dataset.id];if(c.kind==='url'&&c.content===url)syncService(s,c);});
|
||||
}
|
||||
const checkServices=()=>services.forEach((state,url)=>checkService(url));
|
||||
|
||||
// 动效工具:尊重“减少动态效果”,浏览器不支持时直接落到最终状态
|
||||
function animate(node,frames,opts){if(calm.matches||!node?.animate)return;try{node.animate(frames,opts);}catch{node.animate(frames,{...opts,easing:'ease-out'});}}
|
||||
let vtSeq=0;
|
||||
function morph(update,kind){
|
||||
if(calm.matches||!document.startViewTransition||document.hidden){update();return;}
|
||||
const n=++vtSeq;root.dataset.vt=kind||'';
|
||||
try{document.startViewTransition(update).finished.finally(()=>{if(n===vtSeq)delete root.dataset.vt;});}catch{update();}
|
||||
}
|
||||
function slideTo(ind,target,instant){
|
||||
if(!ind||!target||!target.offsetWidth)return;
|
||||
if(instant)ind.style.transition='none';
|
||||
ind.style.width=`${target.offsetWidth}px`;ind.style.height=`${target.offsetHeight}px`;ind.style.transform=`translate(${target.offsetLeft}px,${target.offsetTop}px)`;
|
||||
if(instant){ind.offsetWidth;ind.style.transition='';}
|
||||
}
|
||||
const placeThumbs=instant=>$$('.seg').forEach(s=>slideTo(s.querySelector('.thumb'),s.querySelector('[aria-pressed=true]'),instant));
|
||||
const placeFilm=instant=>{const f=$('#film');slideTo(f.querySelector('.film-ind'),f.querySelector('[aria-current=true]'),instant);};
|
||||
|
||||
document.title=`${DATA.project} · Oil UI`;
|
||||
$('#project').textContent=DATA.project;$('#round').textContent=DATA.round||'';
|
||||
$('#brief').textContent=DATA.brief||'';
|
||||
|
||||
function stripMini(c){const s=el('span','strip-mini');(c.palette||[]).forEach(h=>{const i=el('i');i.style.background=h;s.append(i);});return s;}
|
||||
function stage(c,interactive){
|
||||
const s=el('div','stage');s.dataset.id=c.id;
|
||||
const m=document.createElement(c.kind==='image'?'img':'iframe');
|
||||
if(c.kind==='image'){m.alt=c.name;m.draggable=false;}
|
||||
else{m.title=c.name;m.setAttribute('sandbox',c.kind==='url'?'allow-scripts allow-same-origin allow-forms':c.interactive?'allow-scripts allow-forms':'');m.referrerPolicy='no-referrer';if(!interactive)m.tabIndex=-1;}
|
||||
m.addEventListener('load',()=>{if(c.kind==='url'&&services.get(c.content).ready!==true)return;s.classList.add('ready');fit(s);});
|
||||
if(c.kind==='image')m.src=c.content;else if(c.kind!=='url')m.srcdoc=c.content;
|
||||
s.append(m);
|
||||
if(c.kind==='url'){s.append(serviceNotice());syncService(s,c);}
|
||||
if(c.kind!=='html'||c.interactive)s.append(el('span','static',c.kind==='url'?'本地运行':c.kind==='image'?'静态图':'可操作'));
|
||||
return s;
|
||||
}
|
||||
function fit(s){
|
||||
if(!s.isConnected)return;
|
||||
const size=SIZES[st.viewport],m=s.querySelector('iframe,img'),box=s.parentElement;let r;
|
||||
if(box.classList.contains('slide')){const cs=getComputedStyle(box),w=box.clientWidth-parseFloat(cs.paddingLeft)-parseFloat(cs.paddingRight),h=narrow()?Infinity:box.clientHeight-parseFloat(cs.paddingTop)-parseFloat(cs.paddingBottom);r=box.dataset.zoom==='actual'?1:Math.min(w/size.w,h/size.h,1);}
|
||||
else r=Math.min(box.clientWidth/size.w,1);
|
||||
if(!(r>0))return;
|
||||
s.style.width=`${size.w*r}px`;s.style.height=`${size.h*r}px`;m.style.width=`${size.w}px`;m.style.height=`${size.h}px`;m.style.transform=`scale(${r})`;
|
||||
}
|
||||
const fitAll=()=>$$('.stage').forEach(fit),refit=()=>requestAnimationFrame(fitAll);
|
||||
|
||||
function specList(c){
|
||||
const dl=el('dl','spec');const row=(k,v)=>{dl.append(el('dt',null,k),el('dd',null,v));};
|
||||
row('字体',c.typography);row('特征',(c.traits||[]).join(' · '));return dl;
|
||||
}
|
||||
function pickButton(c){const b=el('button','pick');b.type='button';b.dataset.pick=c.id;b.addEventListener('click',()=>choose(c.id));return b;}
|
||||
function card(c,i){
|
||||
const a=el('article','card');a.dataset.id=c.id;a.style.setProperty('--i',i);a.setAttribute('aria-label',c.name);
|
||||
const frame=el('div','frame');frame.style.viewTransitionName=vtName(c);const s=stage(c,false);
|
||||
const hit=el('button','hit');hit.type='button';hit.setAttribute('aria-label',`单张查看 ${c.name}`);hit.addEventListener('click',()=>setLayout('loupe',c.id));
|
||||
s.append(hit);frame.append(s,el('span','pick-mark','已选择'),el('span','peek','点击单张查看 · 实际尺寸'));
|
||||
const cap=el('div','cap');cap.style.viewTransitionName=`cap-${C.indexOf(c)}`;cap.append(el('span',c.baseline?'no base':'no',no(c)),el('h2','name',c.name),stripMini(c),pickButton(c));
|
||||
const lede=el('p','lede',c.concept);lede.title=c.concept;lede.style.viewTransitionName=`lede-${C.indexOf(c)}`;
|
||||
a.append(frame,cap,lede);return a;
|
||||
}
|
||||
function syncCompare(){
|
||||
const list=shown(),g=$('#grid'),key=list.map(c=>c.id).join();
|
||||
if(key!==gridKey){gridKey=key;g.replaceChildren(...list.map(card));}
|
||||
g.dataset.notes=st.notesOn?'on':'off';g.style.setProperty('--cols',Math.min(Math.max(list.length,1),st.viewport==='mobile'?4:3));
|
||||
$$('.card .frame').forEach(f=>f.dataset.viewport=st.viewport);
|
||||
}
|
||||
|
||||
// 单张查看:所有方向预先排成一条横向轨道,切换时整条轨道滑动,不重新加载画面
|
||||
function syncLoupe(dir){
|
||||
const list=shown(),key=list.map(c=>c.id).join();if(!list.length)return;
|
||||
if(key!==loupeKey){
|
||||
loupeKey=key;dir=0;
|
||||
$('#track').replaceChildren(...list.map(c=>{const sl=el('div','slide');sl.dataset.id=c.id;sl.append(stage(c,true));return sl;}));
|
||||
$('#film').replaceChildren(el('span','film-ind'),...list.map(c=>{const b=el('button');b.type='button';b.dataset.id=c.id;b.append(el('span','fn',no(c)),el('span','ft',c.name),stripMini(c));b.addEventListener('click',()=>go(c.id));return b;}));
|
||||
}
|
||||
if(!list.some(c=>c.id===st.current))st.current=list[0].id;
|
||||
const i=list.findIndex(c=>c.id===st.current),track=$('#track');
|
||||
if(!dir)track.style.transition='none';
|
||||
track.style.transform=`translateX(${-i*100}%)`;
|
||||
if(!dir){track.offsetWidth;track.style.transition='';}
|
||||
$$('.slide').forEach(sl=>{const on=sl.dataset.id===st.current;sl.classList.toggle('is-current',on);sl.inert=!on;sl.dataset.zoom=on&&zoomActual?'actual':'fit';sl.firstElementChild.style.viewTransitionName=on?vtName(byId[sl.dataset.id]):'none';});
|
||||
$('#prev').disabled=i<=0;$('#next').disabled=i>=list.length-1;
|
||||
$$('#film button').forEach(b=>b.setAttribute('aria-current',String(b.dataset.id===st.current)));
|
||||
renderInfo(dir);placeFilm(!dir);
|
||||
const cur=$('#film [aria-current=true]'),f=$('#film');
|
||||
if(cur&&f.scrollWidth>f.clientWidth)f.scrollTo({left:cur.offsetLeft-16,behavior:calm.matches||!dir?'auto':'smooth'});
|
||||
}
|
||||
function renderInfo(dir){
|
||||
const c=byId[st.current],body=$('#info-body');
|
||||
const sw=el('div','big-sw');(c.palette||[]).forEach(h=>{const d=el('div');const x=el('i');x.style.background=h;d.append(x,h.toUpperCase());sw.append(d);});
|
||||
const tools=el('div','tools');const z=el('button','tool',zoomActual?'适应窗口':'实际尺寸 100%');z.id='zoom';z.type='button';z.setAttribute('aria-pressed',String(zoomActual));z.addEventListener('click',toggleZoom);tools.append(z);
|
||||
const hint=el('p','hint');hint.innerHTML='<kbd>←</kbd> <kbd>→</kbd> 切换方向 <kbd>P</kbd> 选择并复制<br><kbd>Esc</kbd> 回到并排';
|
||||
const blocks=[el('span','idx',no(c)),el('h2','name',c.name)];
|
||||
if(st.notesOn)blocks.push(el('p','north',c.concept),sw,specList(c));
|
||||
blocks.push(pickButton(c),tools,hint,el('p','src',c.kind==='image'?`静态图:${c.sourceLabel}。缩放不代表响应式重排。`:c.kind==='url'?`实时运行 ${c.sourceLabel}。空白时确认开发服务器已启动,且允许被嵌入。`:c.interactive?`单张查看时可以直接操作 ${c.sourceLabel};无法联网,也不保留浏览器存储。`:`预览禁用脚本与提交;交互请直接打开 ${c.sourceLabel}。`));
|
||||
body.replaceChildren(...blocks);paintChoice();
|
||||
if(dir)animate(body,[{opacity:0,transform:`translateX(${dir*18}px)`},{opacity:1,transform:'none'}],{duration:420,easing:ease('glide')});
|
||||
}
|
||||
function toggleZoom(){
|
||||
morph(()=>{zoomActual=!zoomActual;const sl=$('.slide.is-current');sl.dataset.zoom=zoomActual?'actual':'fit';fitAll();sl.scrollTo(0,0);const z=$('#zoom');z.textContent=zoomActual?'适应窗口':'实际尺寸 100%';z.setAttribute('aria-pressed',String(zoomActual));},'reshape');
|
||||
}
|
||||
|
||||
function render(){
|
||||
const list=shown(),loupe=st.layout==='loupe'&&list.length>0;
|
||||
$('#compare-view').hidden=st.layout!=='compare'||!list.length;$('#loupe').hidden=!loupe;$('#none').hidden=list.length>0;
|
||||
syncCompare();syncLoupe(0);
|
||||
$$('[data-layout]').forEach(b=>b.setAttribute('aria-pressed',String(b.dataset.layout===st.layout)));
|
||||
$$('[data-viewport]').forEach(b=>b.setAttribute('aria-pressed',String(b.dataset.viewport===st.viewport)));
|
||||
placeThumbs(false);placeFilm(true);
|
||||
$('#notes-toggle').checked=st.notesOn;
|
||||
$('#filter-summary').textContent=`比较范围 ${list.length}/${C.length}`;
|
||||
$$('#filter-list input').forEach(i=>i.checked=st.visible.includes(i.value));
|
||||
paintChoice();fitAll();
|
||||
}
|
||||
function setLayout(layout,id){
|
||||
if(layout===st.layout&&!(id&&id!==st.current))return;
|
||||
if(layout==='loupe'&&st.layout==='compare')compareScroll=scrollY;
|
||||
if(id)st.current=id;st.layout=layout;zoomActual=false;save();
|
||||
morph(()=>{
|
||||
render();
|
||||
if(layout==='loupe'){window.scrollTo(0,0);return;}
|
||||
window.scrollTo(0,compareScroll);
|
||||
const a=$(`.card[data-id="${CSS.escape(st.current)}"]`),r=a?.getBoundingClientRect();
|
||||
if(r&&(r.top<60||r.bottom>innerHeight))a.scrollIntoView({block:'center'});
|
||||
});
|
||||
}
|
||||
function go(id){
|
||||
const list=shown(),from=list.findIndex(c=>c.id===st.current),to=list.findIndex(c=>c.id===id);
|
||||
if(to<0||to===from)return;
|
||||
st.current=id;zoomActual=false;save();syncLoupe(Math.sign(to-from));fitAll();
|
||||
}
|
||||
function step(d){const list=shown(),i=list.findIndex(c=>c.id===st.current),n=list[i+d];if(n)go(n.id);else nudge(d);}
|
||||
function nudge(d){animate($('.slide.is-current .stage'),[{transform:'none'},{transform:`translateX(${d*14}px)`},{transform:'none'}],{duration:320,easing:'ease-out'});}
|
||||
|
||||
// 选择即复制:再次点击“已选择”会带上最新备注重新复制,不再有单独的复制按钮
|
||||
function choose(id){
|
||||
const fresh=st.chosen!==id;st.chosen=id;save();paintChoice();
|
||||
if(fresh){
|
||||
animate($(`.card[data-id="${CSS.escape(id)}"] .pick-mark`),[{opacity:0,transform:'scale(.4)'},{opacity:1,transform:'scale(1)'}],{duration:500,easing:ease('pop')});
|
||||
animate($('#decide-name'),[{opacity:0,transform:'translateY(10px)'},{opacity:1,transform:'none'}],{duration:380,easing:ease('glide')});
|
||||
animate($('#choice-chip'),[{transform:'scale(.3)'},{transform:'scale(1)'}],{duration:500,easing:ease('pop')});
|
||||
}
|
||||
copy();
|
||||
}
|
||||
function paintChoice(){
|
||||
$$('.card').forEach(a=>a.classList.toggle('is-chosen',a.dataset.id===st.chosen));
|
||||
$$('[data-pick]').forEach(b=>{const on=b.dataset.pick===st.chosen,short=!!b.closest('.cap');b.replaceChildren(document.createTextNode(on?(short?'已选择':'已选择 · 复制'):(short?'选择':'选这个方向')));if(!short)b.append(el('kbd',null,'P'));b.toggleAttribute('data-on',on);b.title=on?'再点一次,带上最新备注重新复制':'选择这个方向,并复制给 Agent';});
|
||||
$$('#film button').forEach(b=>b.querySelector('.ft').classList.toggle('chosen',b.dataset.id===st.chosen));
|
||||
const c=byId[st.chosen];$('#decide-name').textContent=c?`${no(c)} ${c.name}`:'未选择方向';$('#picked').classList.toggle('on',!!c);
|
||||
const chip=$('#choice-chip'),next=c?stripMini(c):el('span','choice-empty');next.id='choice-chip';chip.replaceWith(next);
|
||||
status();
|
||||
}
|
||||
function status(){$('#notes-open').textContent=st.notes?'备注 ·':'备注';const s=$('#state');if(!stored){s.textContent='浏览器存储不可用:关闭页面后选择和备注不会保留。';s.dataset.warn='';}else{s.textContent='备注会和选择一起复制。改完备注,再点一次“已选择”重新复制。';delete s.dataset.warn;}}
|
||||
function copyText(){const c=byId[st.chosen];let t=`${DATA.round?DATA.round+':':''}选 ${no(c)} ${c.name}`;if(st.notes.trim())t+=`\n备注:${st.notes.trim()}`;return t;}
|
||||
async function copy(t=copyText()){
|
||||
let ok=false;
|
||||
try{await navigator.clipboard.writeText(t);ok=true;}catch{const a=el('textarea');a.value=t;a.setAttribute('readonly','');a.style.cssText='position:fixed;opacity:0';document.body.append(a);a.select();try{ok=document.execCommand('copy');}catch{}a.remove();}
|
||||
toast(ok,t);
|
||||
}
|
||||
function toast(ok,t){
|
||||
const n=$('#toast');n.toggleAttribute('data-fail',!ok);n.querySelector('b').textContent=ok?'已复制,粘贴给 Agent':'复制失败,请手动复制';n.querySelector('code').textContent=ok?t.replace(/\n/g,' · '):t;
|
||||
n.classList.remove('show');n.offsetWidth;n.classList.add('show');
|
||||
clearTimeout(toastTimer);toastTimer=setTimeout(()=>n.classList.remove('show'),ok?2400:8000);
|
||||
}
|
||||
|
||||
const fl=$('#filter-list');
|
||||
C.forEach(c=>{const l=el('label'),i=el('input');i.type='checkbox';i.value=c.id;i.addEventListener('change',()=>{st.visible=C.map(x=>x.id).filter(id=>id===c.id?i.checked:st.visible.includes(id));save();morph(render,'reshape');});l.append(i,el('span','n',no(c)),c.name);fl.append(l);});
|
||||
$('#show-all').addEventListener('click',()=>{st.visible=C.map(c=>c.id);save();morph(render,'reshape');});
|
||||
$$('button[data-layout]').forEach(b=>b.addEventListener('click',()=>setLayout(b.dataset.layout)));
|
||||
$$('button[data-viewport]').forEach(b=>b.addEventListener('click',()=>{if(st.viewport===b.dataset.viewport)return;st.viewport=b.dataset.viewport;save();morph(render,'reshape');}));
|
||||
$('#notes-toggle').addEventListener('change',e=>{st.notesOn=e.target.checked;save();morph(render,'reshape');});
|
||||
$('#prev').addEventListener('click',()=>step(-1));$('#next').addEventListener('click',()=>step(1));
|
||||
document.addEventListener('keydown',e=>{
|
||||
if(e.target.closest?.('textarea,input,summary')||e.metaKey||e.ctrlKey||e.altKey||st.layout!=='loupe')return;
|
||||
if(e.key==='ArrowLeft')step(-1);else if(e.key==='ArrowRight')step(1);else if(e.key==='Escape')setLayout('compare');else if(e.key.toLowerCase()==='p')choose(st.current);
|
||||
});
|
||||
const notes=$('#notes');notes.value=st.notes||'';
|
||||
notes.addEventListener('input',()=>{st.notes=notes.value;save();status();});
|
||||
$('#notes-open').parentElement.addEventListener('toggle',e=>{if(e.target.open)notes.focus();});
|
||||
function syncBars(){root.style.setProperty('--bar-h',`${$('#bar').offsetHeight}px`);placeThumbs(true);placeFilm(true);refit();}
|
||||
window.addEventListener('resize',syncBars,{passive:true});
|
||||
document.fonts?.ready.then(()=>{placeThumbs(true);placeFilm(true);});
|
||||
$('#bar').hidden=false;
|
||||
if(st.layout==='compare'){$('#grid').classList.add('first');$('.intro').classList.add('first');setTimeout(()=>{$('#grid').classList.remove('first');$('.intro').classList.remove('first');},1400);}
|
||||
render();syncBars();
|
||||
if(services.size){checkServices();setInterval(()=>{if(!document.hidden)checkServices();},5000);window.addEventListener('focus',checkServices);document.addEventListener('visibilitychange',()=>{if(!document.hidden)checkServices();});}
|
||||
})();
|
||||
</script></body></html>
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
/*! Alpine.js 3.17.4 | MIT License | https://github.com/alpinejs/alpine */
|
||||
(()=>{var bt=!1,Et=!1,B=[],vt=-1,Fe=!1,wt=!1;function gr(e){ai(e)}function xr(){wt=!0}function yr(){wt=!1,br()}function ai(e){B.includes(e)||(B.push(e),e._x_schedulerPriority!==void 0&&(Fe=!0)),br()}function ue(e){let t=B.indexOf(e);t!==-1&&t>vt&&B.splice(t,1)}function br(){if(!Et&&!bt){if(wt)return;bt=!0,queueMicrotask(ci)}}function ci(){bt=!1,Et=!0;for(let e=0;e<B.length;e++)Fe&&li(e),B[e](),vt=e;B.length=0,vt=-1,Fe=!1,Et=!1}function li(e){let t=new Map,r=B.slice(e).sort((n,i)=>fi(n,i,t));for(let n=0;n<r.length;n++)B[e+n]=r[n];Fe=!1}function fi(e,t,r){return yt(e)?yt(t)?_r(e._x_schedulerPriority.el,r)-_r(t._x_schedulerPriority.el,r)||e._x_schedulerPriority.order-t._x_schedulerPriority.order:-1:yt(t)?1:0}function yt(e){return e._x_schedulerPriority!==void 0}function _r(e,t){if(t.has(e))return t.get(e);let r=0,n=e;for(;e;)r++,e._x_teleportBack?e=e._x_teleportBack:typeof ShadowRoot=="function"&&e.parentNode instanceof ShadowRoot?e=e.parentNode.host:e=e.parentElement;return t.set(n,r),r}var R,k,H,At,ui=0,St=!0;function Er(e){St=!1,e(),St=!0}function vr(e){R=e.reactive,H=e.release,k=t=>e.effect(t,{scheduler:r=>{St?gr(r):r()}}),At=e.raw}function Tt(e){k=e}function wr(e){let t=()=>{};return[(n,i)=>{let o=i?.priority==="structural"?ui++:void 0,s=k(n);return o!==void 0&&s!==void 0&&(s._x_schedulerPriority={el:e,order:o}),e._x_effects||(e._x_effects=new Set,e._x_runEffects=()=>{e._x_effects.forEach(a=>a())}),e._x_effects.add(s),t=()=>{s!==void 0&&(e._x_effects.delete(s),H(s))},s},()=>{t()}]}function Ve(e,t){let r=!0,n,i,o=k(()=>{let s=e(),a=JSON.stringify(s);if(!r&&(typeof s=="object"||s!==n)){let c=typeof n=="object"?JSON.parse(i):n;queueMicrotask(()=>{t(s,c)})}n=s,i=a,r=!1});return()=>{ue(o),H(o)}}async function Sr(e){xr();try{await e(),await Promise.resolve()}finally{yr()}}var Ar=[],Tr=[],Or=[];function Nr(e){Or.push(e)}function de(e,t){typeof t=="function"?(e._x_cleanups||(e._x_cleanups=[]),e._x_cleanups.push(t)):(t=e,Tr.push(t))}function Be(e){Ar.push(e)}function He(e,t,r){e._x_attributeCleanups||(e._x_attributeCleanups={}),e._x_attributeCleanups[t]||(e._x_attributeCleanups[t]=[]),e._x_attributeCleanups[t].push(r)}function Ot(e,t){e._x_attributeCleanups&&Object.entries(e._x_attributeCleanups).forEach(([r,n])=>{(t===void 0||t.includes(r))&&(n.forEach(i=>i()),delete e._x_attributeCleanups[r])})}function Cr(e){for(e._x_effects?.forEach(ue);e._x_cleanups?.length;)e._x_cleanups.pop()()}var Ue=new MutationObserver(qe),Nt=!1;function we(){Ue.observe(document,{subtree:!0,childList:!0,attributes:!0,attributeOldValue:!0}),Nt=!0}function Ct(){di(),Ue.disconnect(),Nt=!1}var Z=[];function di(){let e=Ue.takeRecords();Z.push(()=>e.length>0&&qe(e));let t=Z.length;queueMicrotask(()=>{if(Z.length===t)for(;Z.length>0;)Z.shift()()})}function Rr(){for(;Z.length>0;)Z.shift()();let e=Ue.takeRecords();e.length>0&&qe(e)}function h(e){if(!Nt)return e();Ct();try{return e()}finally{we()}}var Rt=!1,$e=[];function Dr(){Rt=!0}function Mr(){Rt=!1,qe($e),$e=[]}function qe(e){if(Rt){$e=$e.concat(e);return}let t=[],r=new Set,n=new Map,i=new Map;for(let o=0;o<e.length;o++)if(!e[o].target._x_ignoreMutationObserver&&(e[o].type==="childList"&&(e[o].removedNodes.forEach(s=>{s.nodeType===1&&s._x_marker&&r.add(s)}),e[o].addedNodes.forEach(s=>{if(s.nodeType===1){if(r.has(s)){r.delete(s);return}s._x_marker||t.push(s)}})),e[o].type==="attributes")){let s=e[o].target,a=e[o].attributeName,c=e[o].oldValue,l=()=>{n.has(s)||n.set(s,[]),n.get(s).push({name:a,value:s.getAttribute(a)})},f=()=>{i.has(s)||i.set(s,[]),i.get(s).push(a)};s.hasAttribute(a)&&c===null?l():s.hasAttribute(a)?(f(),l()):f()}i.forEach((o,s)=>{Ot(s,o)}),n.forEach((o,s)=>{Ar.forEach(a=>a(s,o))});for(let o of r)t.some(s=>s.contains(o))||Tr.forEach(s=>s(o));for(let o of t)o.isConnected&&Or.forEach(s=>s(o));t=null,r=null,n=null,i=null}function ze(e){return I(U(e))}function P(e,t,r){return e._x_dataStack=[t,...U(r||e)],()=>{e._x_dataStack=e._x_dataStack.filter(n=>n!==t)}}function U(e){return e._x_dataStack?e._x_dataStack:typeof ShadowRoot=="function"&&e instanceof ShadowRoot?U(e.host):e.parentNode?U(e.parentNode):[]}function I(e){return new Proxy({objects:e},pi)}function kr(e,t){return e===null||e===Object.prototype?null:Object.prototype.hasOwnProperty.call(e,t)?e:kr(Object.getPrototypeOf(e),t)}var pi={ownKeys({objects:e}){return Array.from(new Set(e.flatMap(t=>Object.keys(t))))},has({objects:e},t){return t==Symbol.unscopables?!1:e.some(r=>Object.prototype.hasOwnProperty.call(r,t)||Reflect.has(r,t))},get({objects:e},t,r){return t=="toJSON"?hi:Reflect.get(e.find(n=>Reflect.has(n,t))||{},t,r)},set({objects:e},t,r,n){let i;for(let s of e)if(i=kr(s,t),i)break;i||(i=e[e.length-1]);let o=Object.getOwnPropertyDescriptor(i,t);return o?.set&&o?.get?o.set.call(n,r)||!0:Reflect.set(i,t,r)}};function hi(){return Reflect.ownKeys(this).reduce((t,r)=>(t[r]=Reflect.get(this,r),t),{})}function pe(e,t=()=>{}){let r=i=>typeof i=="object"&&!Array.isArray(i)&&i!==null,n=(i,o="")=>{Object.entries(Object.getOwnPropertyLine truncated
|
||||
|
||||
${r?'Expression: "'+r+`"
|
||||
|
||||
`:""}`,t),setTimeout(()=>{throw e},0)}var he=!0;function We(e){let t=he;he=!1;let r=e();return he=t,r}function D(e,t,r={}){let n;return x(e,t)(i=>n=i,r),n}function x(...e){return Fr(...e)}var Fr=()=>{};function Vr(e){Fr=e}var $r;function Br(e){$r=e}function Hr(e,t){let r={};G(r,e);let n=[r,...U(e)],i=typeof t=="function"?xi(n,t):bi(n,t,e);return Ir.bind(null,e,t,i)}function xi(e,t){return(r=()=>{},{scope:n={},params:i=[],context:o}={})=>{if(!he){Se(r,t,I([n,...e]),i);return}let s=t.apply(I([n,...e]),i);Se(r,s)}}var kt={};function yi(e,t){if(kt[e])return kt[e];let r=Object.getPrototypeOf(async function(){}).constructor,n=/^[\n\s]*if.*\(.*\)/.test(e.trim())||/^(let|const)\s/.test(e.trim())?`(async()=>{ ${e} })()`:e,o=(()=>{try{let s=new r(["__self","scope"],`with (scope) { __self.result = ${n} }; __self.finished = true; return __self.result;`);return Object.defineProperty(s,"name",{value:`[Alpine] ${e}`}),s}catch(s){return Y(s,t,e),Promise.resolve()}})();return kt[e]=o,o}function bi(e,t,r){let n=yi(t,r);return(i=()=>{},{scope:o={},params:s=[],context:a}={})=>{n.result=void 0,n.finished=!1;let c=I([o,...e]);if(typeof n=="function"){let l=n.call(a,n,c).catch(f=>Y(f,r,t));n.finished?(Se(i,n.result,c,s,r),n.result=void 0):l.then(f=>{Se(i,f,c,s,r)}).catch(f=>Y(f,r,t)).finally(()=>n.result=void 0)}}}function Se(e,t,r,n,i){if(he&&typeof t=="function"){let o=t.apply(r,n);o instanceof Promise?o.then(s=>Se(e,s,r,n)).catch(s=>Y(s,i,t)):e(o)}else typeof t=="object"&&t instanceof Promise?t.then(o=>e(o)):e(t)}function Ur(...e){return $r(...e)}function qr(e,t,r={}){let n={};G(n,e);let i=[n,...U(e)],o=I([r.scope??{},...i]),s=r.params??[];if(t.includes("await")){let a=Object.getPrototypeOf(async function(){}).constructor,c=/^[\n\s]*if.*\(.*\)/.test(t.trim())||/^(let|const)\s/.test(t.trim())?`(async()=>{ ${t} })()`:t;return new a(["scope"],`with (scope) { let __result = ${c}; return __result }`).call(r.context,o)}else{let a=/^[\n\s]*if.*\(.*\)/.test(t.trim())||/^(let|const)\s/.test(t.trim())?`(()=>{ ${t} })()`:t,l=new Function(["scope"],`with (scope) { let __result = ${a}; return __result }`).call(r.context,o);return typeof l=="function"&&he?l.apply(o,s):l}}var Lt="x-";function N(e=""){return Lt+e}function zr(e){Lt=e}var Ge={};function p(e,t){return Ge[e]=t,{before(r){if(!Ge[r]){console.warn(String.raw`Cannot find directive \`${r}\`. \`${e}\` will use the default order of execution`);return}let n=Q.indexOf(r);Q.splice(n>=0?n:Q.indexOf("DEFAULT"),0,e)}}}function Kr(e){return Object.keys(Ge).includes(e)}function ee(e,t,r){if(t=Array.from(t),e._x_virtualDirectives){let o=Object.entries(e._x_virtualDirectives).map(([a,c])=>({name:a,value:c})),s=jt(o);o=o.map(a=>s.find(c=>c.name===a.name)?{name:`x-bind:${a.name}`,value:`"${a.value}"`}:a),t=t.concat(o)}let n={};return t.map(Yr((o,s)=>n[o]=s)).filter(Xr).map(vi(n,r)).sort(wi).map(o=>Ei(e,o))}function jt(e){return Array.from(e).map(Yr()).filter(t=>!Xr(t))}var Pt=!1,Ae=new Map,Wr=Symbol();function Gr(e){Pt=!0;let t=Symbol();Wr=t,Ae.set(t,[]);let r=()=>{for(;Ae.get(t).length;)Ae.get(t).shift()();Ae.delete(t)},n=()=>{Pt=!1,r()};e(r),n()}function Mt(e){let t=[],r=a=>t.push(a),[n,i]=wr(e);return t.push(i),[{Alpine:q,effect:n,cleanup:r,evaluateLater:x.bind(x,e),evaluate:D.bind(D,e)},()=>t.forEach(a=>a())]}function Ei(e,t){let r=()=>{},n=Ge[t.type]||r,[i,o]=Mt(e);He(e,t.original,o);let s=()=>{e._x_ignore||e._x_ignoreSelf||(n.inline&&n.inline(e,t,i),n=n.bind(n,e,t,i),Pt?Ae.get(Wr).push(n):n())};return s.runCleanups=o,s}var Ye=(e,t)=>({name:r,value:n})=>(r.startsWith(e)&&(r=r.replace(e,t)),{name:r,value:n}),Je=e=>e;function Yr(e=()=>{}){return({name:t,value:r})=>{let{name:n,value:i}=Jr.reduce((o,s)=>s(o),{name:t,value:r});return n!==t&&e(n,t),{name:n,value:i}}}var Jr=[];function me(e){Jr.push(e)}function Xr({name:e}){return Zr().test(e)}var Zr=()=>new RegExp(`^${Lt}([^:^.]+)\\b`);function vi(e,t){return({name:r,value:n})=>{r===n&&(n="");let i=r.match(Zr()),o=r.match(/:([a-zA-Z0-9\-_:]+)/),s=r.match(/\.[^.\]]+(?=[^\]]*$)/g)||[],a=t||e[r]||r;return{type:i?i[1]:null,value:o?o[1]:null,modifiers:s.map(c=>c.replace(".","")),expression:n,original:a}}}var It="DEFAULT",Q=["ignore","ref","id","data","anchor","bind","init","for","model","modelable","transition","show","if",It,"teleport"];function wi(e,t){let r=Q.indexOf(e.type)===-1?It:e.type,n=Q.indexOf(t.type)===-1?It:t.type;return Q.indexOf(r)-Q.indexOf(n)}function L(e,t){if(typeof ShadowRoot=="function"&&e instanceof ShadowRoot){Array.from(e.children).forEach(i=>L(i,t));return}let r=!1;if(t(e,()=>r=!0),r)return;let n=e.firstElementChild;for(;n;)L(n,t,!1),n=n.nextElementSibling}var j=!1;function w(e,t=()=>{}){return(...r)=>j?t(...r):e(...r)}function Qr(e){return(...t)=>j&&e(...t)}var en=[];function F(e){en.push(e)}function tn(e,t){en.forEach(r=>r(e,t)),j=!0,nn(()=>{S(t,(r,n)=>{n(r,()=>{})})}),j=!1}var Xe=!1;function rn(e,t){t._x_dataStack||(t._x_dataStack=e._x_dataStack),j=!0,Xe=!0,nn(()=>{Si(t)}),j=!1,Xe=!1}function Si(e){let t=!1;S(e,(n,i)=>{L(n,(oLine truncated
|
||||
/*! Bundled license information:
|
||||
|
||||
@vue/shared/dist/shared.esm-bundler.js:
|
||||
(**
|
||||
* @vue/shared v3.5.41
|
||||
* (c) 2018-present Yuxi (Evan) You and Vue contributors
|
||||
* @license MIT
|
||||
**)
|
||||
|
||||
@vue/reactivity/dist/reactivity.esm-bundler.js:
|
||||
(**
|
||||
* @vue/reactivity v3.5.41
|
||||
* (c) 2018-present Yuxi (Evan) You and Vue contributors
|
||||
* @license MIT
|
||||
**)
|
||||
*/
|
||||
@@ -0,0 +1,77 @@
|
||||
# 组件
|
||||
|
||||
用户要把一个组件做到极致时,力气全部集中在这一个对象上:它放大看也经得起,用起来有手感,放进任何产品都认得出。页面上的方法照常有效,但流程不同:方向少做,深度多做。把组件当成一个小页面,三个方向平均用力、各做一遍完整验收,结果往往是三个能用却都不出彩的组件。
|
||||
|
||||
本文的例子来自不同品类,只示意要具体到什么程度,不是模板。主视觉、母题、尺和状态的样子,每次都从当前产品里推出来;做出来和某个例子很像时,先问这是不是来自这个产品。
|
||||
|
||||
## 先写它回答的那个问题
|
||||
|
||||
用一句话写下用户看这个组件时最想知道或最想做的一件事,用用户的话写。例如行李追踪组件是“我的箱子到哪了”,不是“显示物流状态”。这句话决定谁是主角。
|
||||
|
||||
再写组件的构成,每项一两行:
|
||||
|
||||
- **主角**:用户最怕错过、会随时间变化的那个量,例如还要等多久、还剩多少。它在组件里最大、最先被看到。
|
||||
- **陪衬**:判断主角需要的其他信息,越少越好。
|
||||
- **主操作**:只有一个。写清它的手感:拖、按、长按还是滑;拖动时会不会吸附到常用值。
|
||||
- **状态**:列全。还没开始、进行中、暂停、计划中、完成、出错、极端值(0、满、超长名称),以及加载中。每个状态都要有画面,不能只做默认状态。状态的变化要落在主视觉上,例如洗碗机组件里,洗完时水汽散去、门缝亮起,故障时出问题的那根管道断开变红;只换标题文字和一个小圆点,等于主视觉没有参与。每个状态写明改主视觉的哪一层、改成什么样,三层见下一节。
|
||||
- **容器**:它会出现在哪些尺寸里,例如整页卡片、小组件、通知栏。没要求时只做一种。
|
||||
|
||||
## 先定主视觉,再画组件
|
||||
|
||||
组件也需要一个视觉主角,不能只靠文字、数字和进度条撑起来。按顺序找:
|
||||
|
||||
1. **实物**:组件背后有实物时,用它的写实渲染或产品照片,例如耳机、行李箱、相机、一盆植物。透明底,角度和光线按组件的版式定。组件的主色从实物里取:外壳、配件或内容里最认得出的那种颜色。实物只有银灰黑白时,可以把颜色交给一个会亮的部位,例如耳机的状态灯、相机的录制点,并让它随状态变化。生成提示词里写明这个颜色和这个部位。组件本体里,主视觉要么完整放下,要么明确设计成出血到组件边缘,不能被容器随意切掉一角;跨出边界的效果只用在作品图里。
|
||||
2. **数据画成的量**:没有实物时,把主角画成看得见的量,例如一格代表一分钟的网格、填满的长度、范围圈。见 [设计方向](design-direction.md) 的“让人记住的一刻”。
|
||||
|
||||
需要的图先按 [配图](imagery.md) 生成或选好,再围绕它排版。组件的颜色从这张图里取两三种:主色是图里最有辨识度的颜色,其余靠黑白灰。先定色板再去配图,图和界面会像两样东西。
|
||||
|
||||
主视觉分三层,生成图之前先分好:
|
||||
|
||||
- **照片层**:实物本身。状态需要实物变化时,例如耳机戴上和收进盒子、箱子合上和打开,用同一张图改提示词生成几个变体,角度、光向和大小完全一致;代码只切换图片。提示词写法见 [配图](imagery.md)。
|
||||
- **画在上面的层**:用代码画的线、量和标注,贴在实物对应的部位:降噪强度画成从耳罩向外扩散的声波,温度标在出问题的那个零件旁,剩余量沿容器的液面走。它随数据实时变,是状态变化的主要落点。
|
||||
- **背景层**:组件底色和实物身后的光。例如等待中光线变暗,出错时光熄掉,完成时亮起。这是状态的意思,不是装饰。
|
||||
|
||||
一个量只画一条尺。主视觉上表示主角的那条线、那圈格子,就是主操作的控件:目标直接拖在它上面,当前值和目标在同一条尺上,拖动时实物随之变化。主视觉下面再放一个滑块或一排刻度,就是把同一件事画了两遍,删掉其中一条。常用值做成尺上的刻度和吸附点,标上名字,例如降噪强度上的“通勤”“专注”;不另做一排快捷按钮。
|
||||
|
||||
同一个信息在组件里只出现一次。做完默认状态后列一张表:每个数字一行,写它出现在哪几处;文字、图形、控件上显示的值都算。出现两次以上,删到只剩最有力的那一处。目标值写在标题里、显示在滑块旁、又是快捷按钮的名字,就是三次。
|
||||
|
||||
所有状态并排后做一次遮字测试:把文字和数字全部遮掉或模糊,再看这张图。还分得出哪个在进行、哪个出错、哪个已完成,主视觉才算参与了状态;分不出来,回到三层里改画在上面的层和背景层,不靠加文字解决。
|
||||
|
||||
再定一个母题:一种从产品里来的图形,例如刻度、波形、点阵、等高线,从主视觉一路用到进度、加载、按钮按下和空状态。一个组件里只用一种母题。母题同时决定控件的形状:圆滚滚的毛绒玩具推出胶囊形的按钮和柱子,海报和硬照推出直角的按钮和图片,刻度推出细线和等宽数字。写下主按钮、次要开关和底部那行小字各自怎样带上母题;带不上的,先问能不能删。下半部分退回组件库默认的按钮和开关,整个组件就只有上半部分是设计。
|
||||
|
||||
大数字是组件的脸。给它选一款有性格的数字字体,用等宽数字,不用系统默认字体;标签用另一款安静的字。两款够了。
|
||||
|
||||
## 方向少做,深度多做
|
||||
|
||||
1. **先出两三张草图。** 只做默认状态的静态画面,不写交互、不取证。草图之间在主视觉、母题和主操作的手感上不同,不是同一个布局换颜色。放进 [风格对比页](style-explorer.md) 给用户挑;用户让你自己决定时,选最有辨识度、又最能回答那个问题的一张,写下理由。
|
||||
2. **选定后只深化这一个。** 把所有状态做完,主操作做出完整的手感,再按下面的轮次打磨。其他草图不再深化。
|
||||
|
||||
## 把一处做到极致
|
||||
|
||||
组件的记忆点通常在主操作发生的那一刻。逐项想清楚,写进设计说明:
|
||||
|
||||
- **跟手**:拖动时,被拖的东西和手指之间没有延迟;跟着变化的数字、颜色、主视觉同步变化,不等松手。
|
||||
- **阻尼和吸附**:经过常用值(例如 80%、100%)时有一次轻微的停顿,松手后吸附过去;拖过头会被拉回来。
|
||||
- **结果当场发生**:调了目标,主视觉跟着变,例如声波随降噪强度收拢或扩散,而不是只改一个数字。
|
||||
- **数字的变化**:数字滚动到新值,不是跳过去;等宽数字,单位比数字小一级、基线对齐。
|
||||
- **状态之间的过渡**:从发生变化的地方出发,例如开始时从实物上发生变化的那个部位出发,完成时最后一格亮起。
|
||||
|
||||
动效的时长和曲线见 [动效](motion.md)。只在主操作和关键状态切换上用力,其余保持安静。
|
||||
|
||||
## 打磨轮次
|
||||
|
||||
截图、状态并排图、遮字图和录屏用 [工具](tools.md) 里的截图工具一次出齐。派评审之前自己先看一遍证据:默认状态已经加载完,遮字图保留了图形,慢放帧里没有上一个状态的残影,200% 图截的是组件而不是整页。证据有错先重截,不算一轮。
|
||||
|
||||
深化后的组件按轮次打磨:截图、评审、修改。每轮给评审者这些东西:组件 200% 放大的截图;所有状态并排的图两份,一份原样,一份遮掉全部文字和数字;主操作 5 秒左右的录屏。评审任务:
|
||||
|
||||
> 评审这一个组件,不评审页面。先说出用户第一眼看到的主角是什么,是不是那句“它回答的问题”里的答案。再拿这个品类里最有名的两三个产品做对照,说出这个组件和它们不同在哪;遮住名字后认不出是谁家的,就直接指出来。数一遍同一个信息在组件里出现了几次,出现两次以上的指出来。看所有状态并排的那张图:状态之间除了文字,主视觉有没有变化。然后按“这个品类里最好的组件”的标准,指出放大以后最露怯的三处细节:对齐、标签和图形压在一起、数字排版、阴影和描边、状态之间不一致。再说主操作的手感:跟手吗,有没有吸附和阻尼,结果有没有当场发生在主视觉上。最后回答:有没有一个瞬间值得截图分享;没有的话,最有希望的是哪一处,怎么做。每条写清位置、观察和调整方向。按 10 分制打分;以下任一成立,总分不高于 7:遮字后分不出各个状态;同一个数字出现三次以上;主操作的控件不在主视觉上,而是另画了一条尺;遮住名字后说不出它和品类里常见产品的区别。只提供评审,不修改文件。
|
||||
|
||||
目标 9 分,默认最多三轮;分数连续两轮没有提升时停止。每轮只改评审指出的问题和主操作的手感,不换方向。
|
||||
|
||||
## 交付
|
||||
|
||||
- **组件本体**:能运行、能操作,所有状态可以切换查看,例如用一排状态按钮或键盘切换,切换控件放在组件外面。
|
||||
- **作品图**:组件放大两倍,放在从主视觉取色的背景上;背景从主视觉取色,选和组件形成清楚对比的那一档。实物另放一份在组件外面,比组件里的大三到五倍,压住组件的一条边或一个角,再被画布边缘切掉一部分;一张图里放一两个,不围一圈。可以再出一张:把组件的一处局部推近,画面旋转十到十五度,只看母题和细节。这些只用来展示,界面本身不需要这样。
|
||||
- **一段录屏**:主操作从开始到结果出现,5 到 8 秒。
|
||||
|
||||
验收证据只给选定的那一个:所有状态各一张截图、200% 截图、主操作录屏。草图阶段不取证。组件不做独立的任务走查,也不按 SKILL.md 步骤 4 另做评审;把时间留给打磨。
|
||||
@@ -0,0 +1,248 @@
|
||||
# 设计方向
|
||||
|
||||
模型不被推一把时,会给出让所有人都能接受的平均答案。“做得独特一点”“随机一点”推不动它,它只会换几处装饰。这里的方法都是在给模型一个具体的推力:先认清品类、看清同类最好的产品怎么做,再定调性、用生成引擎起方向,最后用可检验的规则逼出真正的差异。方向的目标是“这个品类里最好的团队会这样做,而且做出了这个产品自己的样子”;只是“和模型的默认不一样”还不够。高级感来自一套语言的完整和细节,不来自题材少见。
|
||||
|
||||
本文件以方法为主,偶尔给的例子只示意要具体到什么程度,不是风格清单。方向里的题材、材质、场景、字体和配色,每次都从当前产品的内容、受众和使用场合里推出;想到的方向如果和常见的“网红风格”撞上,要能说出来自这个产品的理由,否则重选。
|
||||
|
||||
## 认品类,拆标杆
|
||||
|
||||
动手前先认清这是什么品类的产品,再看这个品类里做得最好的产品是怎么做的。这一步给出地板:用户已经习惯的结构,以及一线产品的完成度。后面的调性和生成引擎,是在这块地板之上找这个产品自己的东西。
|
||||
|
||||
按三步做,每一步都写下来:
|
||||
|
||||
1. **说出品类和标杆。** 这是什么品类的产品,例如“语言学习 App”“组件库官网”“项目管理工具”“作品画廊”。这个品类里风格最鲜明的两三家是谁。选风格最鲜明的,不一定是最有名的。
|
||||
2. **拆开其中一家。** 拆成 6–10 条看得见的要素,每条写两件事:它是什么样,它为什么这样做。按这几层去拆:控件的形状和手感、字体、颜色、图标和插画、状态与反馈、动效、文案语气、首屏放了什么。要拆到能照着做的程度。例如一款学习 App 是“按钮厚实,按下去会陷下去,答对时有夸张的弹跳”,理由是答题是核心动作,要给利用零碎时间的普通人手感上的回报;一款开发工具是“深色、行距紧、每种状态一个统一的图标、快捷键印在菜单上”,理由是工程师一天要在里面待好几个小时。拆出来只有“干净、留白、一种强调色”,说明没拆到位,换一家重拆。
|
||||
3. **决定留什么、换什么。** 拿本产品的受众、内容和使用场合逐条对照:理由同样成立的留下,理由不成立的换掉,换掉的部分由后面的生成引擎给出替代。结果写进方向卡的“标杆与取舍”。
|
||||
|
||||
拆的时候分清三样东西:
|
||||
|
||||
- **基线**:用户已经期待的结构,例如画廊里作品先行、筛选在顶部,商店里购物车在右上角。沿用它,用户不用重新学。
|
||||
- **套路**:这个品类人人都在用的默认版式。一轮方向里至少一个要打破它,见“差异检验”。
|
||||
- **撞脸**:字体、配色、主视觉和图标都和某个标杆一样,只换了名字。任何方向都不允许。
|
||||
|
||||
检验方法:把一个按钮、一行列表、一个空状态或一次反馈单独拿出来,放进别的产品里,还能认出它属于这个产品。识别不一定靠形状和颜色,也可以靠密度、字体和动效的节奏;黑白方向同样能做到。
|
||||
|
||||
## 先定调性,再求差异
|
||||
|
||||
从产品的题材、受众、品牌语气和使用场合推出调性。用五个刻度描述,每个刻度都写出依据:
|
||||
|
||||
| 刻度 | 两端 | 在画面上看什么 |
|
||||
| --- | --- | --- |
|
||||
| 能量 | 安静 ↔ 喧闹 | 饱和度、对比、元素是否碰撞 |
|
||||
| 完成度 | 粗粝 ↔ 精致 | 边缘处理、颗粒与噪点、对齐是否严格 |
|
||||
| 密度 | 疏 ↔ 密 | 留白尺度、叠层、网格纪律 |
|
||||
| 分量 | 轻 ↔ 重 | 字重、色块面积、阴影硬度 |
|
||||
| 严肃度 | 活泼 ↔ 庄重 | 圆角、插画语言、动效幅度 |
|
||||
|
||||
例如“地下现场演出社区 → 喧闹、粗粝、密”,“冥想陪伴 → 安静、精致、疏”。两种都完全成立,失败的是没有依据的默认:
|
||||
|
||||
- 证据指向喧闹粗粝,交付的却是米色卡片、柔和阴影和大留白。这是最常见的失败,设计被磨回了模型默认的“安静高级”。
|
||||
- 反过来,为了显得有趣,给一个本该安静的产品硬加颗粒、撕纸边和粗边框。
|
||||
|
||||
证据已经锁定调性时,所有候选都留在这个调性里,在构图、色彩身份和主视觉上拉开差异。不要为了“平衡”放进一个安静极简的保底方案。只有证据确实模糊时,才让候选分布在不同调性上。
|
||||
|
||||
### 模糊词必须翻译
|
||||
|
||||
“高级、精致、克制、史诗、有文学感、简洁”不能当作设计理由。用户或你自己说出这类词时,先翻译成可观察的决定:
|
||||
|
||||
| 模糊词 | 翻译成什么 |
|
||||
| --- | --- |
|
||||
| 高级 | 先说清是哪一种高级,再决定字体的对比、分隔的方式、间距的尺度和色相的数量。只说高级什么也没决定 |
|
||||
| 精致 | 公差:字距怎么调、对齐到几像素的网格、阴影最多几级、色相最多几种 |
|
||||
| 克制 | 饱和度预算,例如强调色不超过画面 10%;动效幅度上限;色相数量上限 |
|
||||
| 史诗 | 尺度:展示字多大、主图占多少面积、明暗对比多强 |
|
||||
| 文学感 | 字族的性格、行长、行高和配色,各取什么值 |
|
||||
| 粗糙 | 用哪些手段,各用到什么程度,例如错位多少像素、旋转多少度 |
|
||||
| 简洁 | 密度档位,以及删掉什么、为什么删。简洁是结果,不是指令 |
|
||||
|
||||
翻译不出可观察决定的词,从理由里删掉。
|
||||
|
||||
## 用生成引擎起方向
|
||||
|
||||
每个方向都从一个具体的引擎出发,而不是从形容词出发。不同方向最好用不同的引擎:
|
||||
|
||||
- **材质 × 环境**:一种材质放在一个具体的环境里,比如“湿陶土 × 午后的工作室”。由材质推出光照、深度、边缘的处理和动效的阻尼。
|
||||
- **一个具体场景**:一个有时间、地点和光线的场景,比如“凌晨两点的便利店”。由场景里的光线、声音、材料和节奏推出色彩、质感和动效速度。适合情绪型工具。
|
||||
- **角色原型**:给产品一个性格,比如“一丝不苟的档案管理员”,让性格决定字体、色彩和动作。
|
||||
- **一场设计运动或文化语法**:选一个和产品的题材、受众或地域有真实联系的设计运动或视觉传统,借用它的语法,而不是贴它的图案。
|
||||
- **其他领域的整套表现形式**:把另一个领域的媒介或器物的表达方式借过来,比如把行程做成一张登机牌。只借它最有辨识度的一两个手法,页面本身仍是原来的体裁;不把那个媒介的部件一件件搬来,那会变成道具堆砌。
|
||||
- **有意打破常规**:激进的非对称版式、不和谐的配色与字体、让人不安的留白,但整体仍然好看、功能完整。
|
||||
|
||||
感受词从这个品牌出发,而不是从行业出发。“美味、温暖、有食欲”会把每个食品应用都推向橙色;“深夜治愈、独处、温柔”才属于某一个品牌。行业惯用色可以用,前提是理由来自这个品牌;如果理由只是“这个行业都这么用”,就换掉。
|
||||
|
||||
写方向卡之前,先在心里把方向想厚:用起来是什么感觉,让人联想到什么表面和物件,页面怎样流转,在讨好哪一类人。卡片是从这些思考里提炼出来的。跳过这一步,卡片只剩三十个字的空壳,后面做出的页面也会是平的。
|
||||
|
||||
随机种子也是一种引擎:让脚本生成一串随机字符,从中联想配色、布局和字体,种子不进入界面。它适合陷入重复时破局,但不如上面几种引擎可解释。
|
||||
|
||||
## 先定首屏骨架,再写文案
|
||||
|
||||
**先回答一个问题:用户来首屏是看什么、做什么?**
|
||||
|
||||
- **来看内容、挑东西或处理对象的页面**,例如画廊、商店、信息流、文档、作品集、工具和后台:首屏就是第一排内容本身。导航、标题和筛选合起来不超过一两行,第一排内容要完整出现在首屏。不放首屏口号,也不放解释这个页面是干什么的段落。下面的骨架表不用于这类页面;它们按“产品界面”一节定工作区骨架。
|
||||
- **来听一句话、看一张画、被说服的页面**,例如落地页、品牌页、产品发布页、展览页:才从下面的骨架表里选。
|
||||
- **两种都有时**,例如商店首页顶上有一块活动横幅,以用户最常来做的事为准:横幅可以留,但不能把第一排商品推出首屏。
|
||||
|
||||
以下是说服型页面的方法。
|
||||
|
||||
模型最顽固的习惯是左右分栏:一边大标题,另一边说明、按钮或一张图。换字体、换配色、换材质都改变不了它,因为它在写文案之前就定下来了:先想好标题、一段说明、一个按钮,把它们摆好,自然就是左右两栏。所以骨架要在写任何文案之前决定。
|
||||
|
||||
每个方向先从下表选一个首屏骨架,再用 3–5 个方框画出首屏线框:每块是什么、占首屏多大面积、阅读从哪里开始。
|
||||
|
||||
| 骨架 | 首屏长什么样 |
|
||||
| --- | --- |
|
||||
| 满版图像压字 | 一张图占满首屏,标题和动作压在图的安全区里 |
|
||||
| 居中单一物件 | 一个物件或一句话居中,四周是大面积空白 |
|
||||
| 画面即界面 | 首屏就是产品界面或一件实物本身,标题、导航和按钮都长在它上面 |
|
||||
| 满版文字 | 文字本身就是画面,字大到占满首屏、跨过边缘或被裁切 |
|
||||
| 通栏上下堆叠 | 通栏大标题横跨整个宽度,下面紧接内容 |
|
||||
| 网格拼贴 | 多块内容同时出现,按网格或拼贴排列,没有单一主角 |
|
||||
| 纵排或斜向 | 文字竖排、斜切或旋转,打破从左到右的阅读 |
|
||||
| 左右分栏 | 文字一侧,画面一侧。这是模型的默认,一轮最多一个方向使用,并写出为什么非它不可。另一侧必须是跨越结构的图像、色块或图形装置,不能是装在卡片里的产品界面 |
|
||||
|
||||
规则:
|
||||
- 同一轮的方向,骨架互不相同。
|
||||
- 左右分栏最多出现一次。所谓“大标题九栏、说明三栏”同样算左右分栏。
|
||||
- 骨架定好之后再写文案。文案去适应骨架,不让骨架去适应“标题 + 说明 + 按钮”的组合。
|
||||
|
||||
构图的大胆程度和调性无关。安静精致的产品,一样可以把一句话放在空白首屏正中,或让纵排标题贯穿整屏,或让界面本身占满首屏。调性决定音量,骨架决定结构;不要因为调性偏安静,就退回左右分栏。
|
||||
|
||||
每一轮至少有一个方向让你自己觉得有点冒险:放在同类产品里会显得格格不入,但仍然好看,功能完整。模型只有被明确要求时才会这样做。如果你心里想的是“这不可能行得通”,通常说明方向是对的,做出来往往比预想好。
|
||||
|
||||
最常见的模板核心不是左右分栏本身,而是“左边标题和两个按钮,右边一张装在圆角卡片里的产品界面”。那张卡片几乎总是该删的:要么让产品界面本身占满首屏,要么换成一张跨越版面结构的图、一个色块或一个图形装置。
|
||||
|
||||
## 产品界面:先定对象和工作区骨架
|
||||
|
||||
后台、工具、App,以及画廊、商店、信息流、文档站这类让人浏览和挑选内容的页面,都没有首屏海报,决定结构的是用户每天面对的对象、对它最常做的动作,以及多久用一次。模型的默认是“左侧栏 + 顶部四张指标卡 + 一张图表 + 一张表格”,不管产品是什么,因为这样不需要理解业务。跳出它的办法和落地页一样:画界面之前先定骨架。
|
||||
|
||||
先写三行:
|
||||
|
||||
- **核心对象**:用户每天处理的是什么,例如工单、合同、镜头、病床;一眼认出它靠哪两三个属性,例如缩略图、状态、金额、截止时间。再问一句:用户在这个对象上最怕错过什么?通常是一个会随时间变化的量,例如已经等了多久、离截止还剩多少、欠了多少。把它做成对象上最显眼的属性,而不是藏在详情里。
|
||||
- **主动词**:对它最常做的动作是逐条处理、比较、创作、安排、定位、推进还是监控。
|
||||
- **频率**:每天用几小时的专业用户,还是每月来一次的普通人。它决定密度。
|
||||
|
||||
再按主动词选工作区骨架:
|
||||
|
||||
| 主动词 | 骨架 | 长什么样 |
|
||||
| --- | --- | --- |
|
||||
| 逐条处理 | 队列 / 收件箱 | 左边队列,右边当前这一条的全部信息和处理动作;处理完自动跳到下一条,键盘可以一路按到底 |
|
||||
| 比较与筛选 | 表格 | 行是对象,列是比较依据;筛选、排序和批量操作长在表头与选择状态上,不另开页面 |
|
||||
| 创作与编辑 | 画布 / 文档 | 内容占满,工具贴着内容出现,面板按需展开 |
|
||||
| 沿时间安排 | 时间线 / 日历 | 横轴是时间,对象是时间上的块,拖动即改期 |
|
||||
| 在空间中定位 | 地图 / 平面图 / 座位图 | 对象画在真实位置上,列表只做辅助 |
|
||||
| 推进流程 | 看板 / 流水线 | 列是阶段,对象在列之间移动 |
|
||||
| 监控 | 仪表 | 只在真的需要持续盯着时使用;每个数字写出正常范围,以及出问题后该做什么 |
|
||||
| 对话后产出 | 对话 + 产物 | 对话收窄在一侧,产物占主区域并能直接编辑 |
|
||||
| 浏览与挑选 | 画廊 / 货架 / 信息流 | 内容铺满首屏,缩略图的比例就是版式;筛选和排序贴着内容顶部,占一行;点开时在原地展开或用浮层,关上回到原来的位置 |
|
||||
|
||||
首页先回答“我现在该处理什么”,而不是“总共有多少”。大多数产品的主动词不是监控,首页就不该是指标卡仪表盘。
|
||||
|
||||
**对象的样子就是产品界面的主视觉。** 先设计一个对象在列表行、卡片或画布上的样子:最重要的属性放在哪,状态怎样一眼看出,长名字怎么收,空值怎么显示,选中和处理中长什么样。一个设计好的列表行,比十张指标卡更能体现方向。
|
||||
|
||||
**密度按频率定。** 每天用几小时的专业工具,行高 32–40px、正文 13–14px、一屏看到 20 行以上都正常,操作以键盘为先;偶尔来一次的消费产品,点击区域 44px 以上,一屏只要求一个决定。不要给专业工具套消费级的大留白,也不要把一次性的流程塞满控件。密度要在截图上数出来:一屏能看到几个对象,主动作离当前对象有多远。高频工具一屏少于 10 个对象时,先查行高、页面头部和说明文字占了多少高度。
|
||||
|
||||
**产品界面的大胆放在哪。** 放在工作区结构、对象的样子和状态的表达上,例如时间在画面里怎样流动、超时怎样一眼跳出来。页面标题和品牌区保持小:一行,和区域标题同级或略大。在高频工具和内容页里放超大页面标题、通栏口号,或者用一整块深色卡片装“下一步”动作,都是把落地页的手法搬错了地方,占掉的是每天要看几百次的工作区。
|
||||
|
||||
多个方向时,工作区骨架、对象的样子、导航方式(侧栏、顶栏、命令面板、在空间里跳转)、密度四项,任意两个方向至多一项相同。这四项是在通用差异检验之外再比,不是替代:产品界面同样需要各自的色彩身份和字体性格,不能三个方向都是系统黑体加一种强调色,只换了布局。方向卡里的“首屏骨架”一栏,产品界面填工作区骨架和对象的样子。
|
||||
|
||||
## 方向卡
|
||||
|
||||
每个方向写一张方向卡,每项都填具体选择,不填形容词:
|
||||
|
||||
| 字段 | 要写到的程度 |
|
||||
| --- | --- |
|
||||
| 北极星 | 一句能指导取舍的体验意图:用起来像在什么地方、做什么事 |
|
||||
| 调性 | 五个刻度的取值及依据 |
|
||||
| 标杆与取舍 | 品类是什么,拆的是哪一家;沿用了哪几条、换掉了哪几条,各自为什么 |
|
||||
| 生成引擎 | 用了哪一种,具体是什么,来自这个产品的什么证据 |
|
||||
| 首屏骨架 | 先写用户来首屏看什么。内容页写首屏能看到几个对象、页头占几行;说服型页面写从骨架表里选的哪一种,附 3–5 个方框的首屏线框:每块是什么、占多大面积、阅读从哪里开始 |
|
||||
| 页面结构 | 首屏以下怎么组织,例如单列沉浸滚动,或导航、内容、详情三栏 |
|
||||
| 各区块的形式 | 首屏以下每个主要区块(产品实样、功能、价格、隐私、结尾等)在这个方向里长成什么东西。形式从生成引擎推出,不是同一个模块换颜色 |
|
||||
| 字体 | 具体字族、字重和尺度关系:展示、正文、数字各用什么,相差多大 |
|
||||
| 色彩 | 用到的十六进制色值及用途,说清这个方向的色彩身份;黑白灰加一种强调色也是完整的答案 |
|
||||
| 主视觉 | 哪张图或哪个图形承担情绪与识别,来自复用、生成还是排版本身 |
|
||||
| 图标 | 用哪一套、什么描边粗细和端点,或者不用图标、改用字符和编号 |
|
||||
| 控件语法 | 按钮、输入、选中、标签、状态和动效共用的一套形状和手感,用一句话写清,例如圆角多大、描边多粗、按下时怎样变化 |
|
||||
| 动效 | 手感用“动作 + 实物”写,例如“咔嗒:拨动开关”;三处基本动效各怎么动、怎样彼此呼应;有没有一处从产品里找到的巧思。见 [动效](motion.md) |
|
||||
| 结构性突破 | 改变首屏结构的那一处偏离常规,以及它怎样放大北极星。手写批注、质感按钮、划线这类局部细节不算 |
|
||||
| 记忆点 | 集中发力的一两处:在哪个时刻或位置,用什么材质、动作或画法,见“让人记住的一刻” |
|
||||
| 放弃什么 | 这个方向主动不要的常见做法 |
|
||||
|
||||
北极星、字体、色板和特征直接写进对比页 manifest,与画面一同展示。
|
||||
|
||||
## 差异检验
|
||||
|
||||
方向卡写完、做小样之前,逐条检验:
|
||||
|
||||
- **比线框,不比描述**:把各方向的首屏线框并排放在一起比。方向的描述可以各不相同,线框却可能都是“左边大标题,右边小块”。线框相似就是同一骨架,重选骨架。
|
||||
- **四项至多一项相同**:首屏骨架、字体、色彩、主视觉四项里,任意两个方向至多一项相同;否则就是同一方向的变体,换一个生成引擎重写其中一个。
|
||||
- **不按明暗分方向**:头号失败是“一个亮、一个暗、一个暖”。每个方向要有自己的色彩身份:不同的色相家族、不同的材质感,或者干脆是黑白。明暗是色彩身份的结果,不是区分方向的依据。
|
||||
- **黑白也是一种色彩身份**:黑白灰加一种强调色,甚至完全不用彩色,也是完整的色彩身份,这时颜色交给内容:产品截图、用户的照片、一张画。它和多色方案同样算拉开了差异。颜色选常见的还是少见的都可以,理由要来自这个产品。
|
||||
- **结构也要不同**:不同方向可以有不同的页面组织。三个方向页面结构完全相同、只是颜色和质感不同,就还没有拉开差异。候选只需共享核心内容和主要任务。
|
||||
- **至少一个走到极端**:至少一个方向在各项上都取明确的一端,不做折中;折中的取值最多出现在一个方向里。
|
||||
- **至少一个打破品类套路**:在同一调性内,至少一个方向不用这个品类的默认版式。常见套路如下:
|
||||
打破套路不等于抛弃基线:画廊仍然让作品先行,只是作品的排列、筛选和点开后的样子可以与众不同。
|
||||
- 后台:左侧栏、顶栏加卡片网格。
|
||||
- 电商:大横幅、商品网格加页脚。
|
||||
- 社交:底部标签栏、信息流加悬浮按钮。
|
||||
- 落地页:主视觉、特性、用户评价,最后一个行动按钮。
|
||||
- 聊天:左侧联系人,右侧消息。
|
||||
- **不撞脸**:把每个方向和它拆过的标杆并排看。字体、配色、主视觉和图标都一样,只换了名字,就是撞脸,重做。沿用基线不算撞脸,例如画廊让作品先行。
|
||||
- **盲看**:遮住名字和说明只看画面,仍能说出每个方向带给人的不同感受。
|
||||
|
||||
小样做出来后再比一次实际画面:在对比页并排看首屏,眯起眼睛只看明暗块面。块面的分布如果相似,比如都是左边一大块、右边一小块,即使字体和颜色都不同,也回到骨架表重做,不靠换装饰补救。首屏之后,再按区块逐一比较整页,规则见“方向贯穿整页”。
|
||||
|
||||
## 方向贯穿整页
|
||||
|
||||
方向不只属于首屏。首屏以下的每个区块,都要用这个方向自己的语言重新设计:同一份内容,在不同方向里应该是不同的东西,形式从各自的生成引擎推出。模型最容易在首屏用尽力气,到了第二屏就回到通用的卡片、分栏和居中结尾,三个方向的下半页变得一模一样。
|
||||
|
||||
规则:
|
||||
- **候选共享内容,不共享实现。** 同一份示例数据可以共用;为这个方向写的区块结构、样式和代码不共用。不要为了省事,把某个区块写成共用模块再给各方向换颜色,那等于亲手制造趋同。产品本身的组件不在此列:设计组件库官网或已有产品时,各方向照常使用同一套真实组件,差异体现在它们被怎样编排、呈现和讲述上。
|
||||
- **同一条修改意见,要翻译成每个方向自己的做法。** 评审常常对所有方向提出同一条建议,比如“展示产品真正交付的东西”。这条意见在每个方向里的落地形式都不同,不能用同一种实现套到所有方向上。
|
||||
- **区块的顺序和取舍也属于方向。** 各方向都按“首屏、对象介绍、三步流程、一句结尾”排下来,就算每块都换了样子,仍是同一张页面。由各自的引擎决定先讲什么、合并什么、删什么:有的方向把流程并进对象介绍,有的方向整页只讲一个对象。产品界面也一样,同一段内容、同一道题按同样的顺序出现,就是换了皮的同一个界面。
|
||||
- **外壳也是区块。** 导航、行动按钮、结尾区的句式和页脚最容易被忽略,常常三个方向共用同一套,只换颜色。它们同样要从生成引擎推出自己的形式。
|
||||
- **逐区块比较,而不是只比首屏。** 把各方向的整页截图并排,按区块一一对照:产品实样对产品实样,结尾对结尾。任何一个区块在两个方向里看起来是同一个东西,就回到那个方向的生成引擎重做这个区块。
|
||||
|
||||
## 北极星与呼应
|
||||
|
||||
北极星选定后,转成少量相互支持的关系:摄影的空旷与标题的留白呼应;窄字形与纵向画面呼应;低饱和环境色与唯一的行动色形成反差。每个方向明确主角、陪衬、重复的节奏和一处结构性突破。
|
||||
|
||||
突破要改变结构,例如首屏的骨架、比例或阅读顺序,而不是多加一个装饰;它应增强主题或引导关键动作。精修时不要把有辨识度的选择修平。新增元素不能强化同一意图时,优先省略。大胆的方案也必须可读、可用。
|
||||
|
||||
## 让人记住的一刻
|
||||
|
||||
模型做的页面常常处处及格,却没有一处让人记住。惊艳很少来自整页都用力,而是来自一处做到极致:通常是一个组件的一次动作,结果、材质、光影、手感和节奏都做到位。每个方向选一两处集中发力,其余保持安静。
|
||||
|
||||
去哪里找:
|
||||
|
||||
- **用户会做的动作。** 点按、拖动、切换、提交,比滚动更容易成为记忆点。
|
||||
- **关键时刻。** 付款成功、第一次完成任务、升级、删除、上传、等结果。这些时刻情绪最强,一个小动画的回报最高。
|
||||
- **边角页面。** 404、空状态、加载、页脚。没人期待,风险又低,最容易出彩。
|
||||
- **AI 在工作的时候。** 把过程按顺序展开:步骤一个个亮起,日志一行行流进来,做完的一步收起,正在做的一步展开。白底黑字照样成立,不需要材质和发光。
|
||||
|
||||
怎么想:
|
||||
|
||||
- **让结果当场发生。** 状态切换不只换一个图标,而是让被影响的东西当场变化:打开深色模式,整页像拉下遮光帘一样暗下去;拖动时间,画面的天色跟着走。用户看到的是结果,不是控件在表演。
|
||||
- **把数字画成看得见的量。** 百分比、时长、余额,先问能不能画成一个量:点亮的格子、填满的长度、一格代表一天的网格。数字只是标签,量才是画面。
|
||||
- **给抽象的状态一种材质。** 先问“这个状态如果是一个实物,会是什么”,答案从产品所在的领域里找。
|
||||
- **借物件的动作交付结果,物件从用户手上找。** 选这个领域的用户本来就拿在手上的东西:旅行产品用登机牌成立,作品集用登机牌只是借梗。票据、打印机、卡带、老游戏机这类复古实物在灵感站上已经泛滥,用之前先问:这个产品自己的实物是什么。
|
||||
- **给控件物理和性格。** 拖动有弹性和重量,按下有光影变化,拉过头会不情愿地弹回来。
|
||||
- **画法要说得出来源。** 点阵、字符画、像素、粒子、写实材质这类画法,只有能说出它来自产品做的事时才选:把数据渲染成点阵的研究工具用点阵,长在终端里的工具用字符画。说不出来源就不选;选了就从主视觉一路用到加载、图标和空状态。做法见 [点缀与特效](ornament.md)。
|
||||
- **一张画面垫底时,界面要退后。** 有情绪的摄影或绘画铺满,界面缩到只剩一行导航和一句话,排版严格;底图虚化或压暗,保证文字可读。
|
||||
|
||||
## 参考怎样转为设计决定
|
||||
|
||||
先说明参考用途:借鉴视觉关系、建立质量基线,还是精准还原。实际看过图片再描述它的视觉。借鉴时提取关系,例如“标题占主要视觉重量,图像提供情绪,辅助信息压低对比”,再用当前内容重新组织;不要照搬参考的业务模块、标识和文案。隐喻要转译成界面决策,不做成妨碍操作的仿真外壳。
|
||||
|
||||
参考里没有的东西也是决定。参考的首屏没有标题区和说明段,就不要替它补上;参考没有用的装饰,也不要加回去。
|
||||
|
||||
## 小样、选择与品味
|
||||
|
||||
先用几句话比较设计意向,再给有希望的方向做代表性小样,不一开始就做多套完整产品。单个页面的小样通常是首屏加最能体现方向的一两个区块;用户明确要完整页面时照做,评审深度仍按探索阶段。方案数量按任务定,窄小的任务不强求多方案。
|
||||
|
||||
挑能暴露方向优缺点的代表性页面:含复杂表单的产品不能只做封面,长页面的小样要包含关键内容结构。
|
||||
|
||||
介绍产品的页面,必须让用户看到产品真正交付的东西:一张生成好的报表、一份导出的合同、一段处理后的真实数据,而不是只用文字承诺“能做什么”。这个实样往往比功能列表更有说服力,也最容易成为页面的第二个主角。需要实际比较多个小样,或由 Agent 自行选定方向时,先使用 [风格对比页](style-explorer.md) 生成对比页留档,再选定方向。
|
||||
|
||||
比较任务适配、辨识度、内容承载、可读性和实现代价,主 Agent 给出推荐依据,由用户选定。选定后请用户用具体的话补充品味:想要什么感受,不要什么、为什么不要,哪些地方要保留。用户只说“再高级一点”时,用上面的翻译表追问,或拿两张小样让用户比较。
|
||||
|
||||
把选定方向和品味写进设计说明,作为后续判断基线。评审提出新风格时,先判断它是否解决原目标;只有当前方向确实不适配,或用户改变目标时,才重新探索。
|
||||
@@ -0,0 +1,286 @@
|
||||
# 存量项目
|
||||
|
||||
在已有仓库里改界面,先分清用户要什么。优化 UI、改流程、加新功能,要改的东西和要锁住的东西正好相反:优化 UI 改的是视觉语言,流程和功能沿用;改流程和加新功能改的是做法,视觉沿用。弄混了,要么在一个本来就平庸的系统上修修补补,要么三个方案只是同一条流程换了皮。
|
||||
|
||||
## 先分清意图
|
||||
|
||||
| 用户想要 | 常见说法 | 要改的东西 | 锁住的东西 | 多个方案差在哪 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 优化 UI | 不好看、太土、不统一、想更高级 | 层级、字体关系、色彩比例、密度、组件的样子 | 品牌资产,以及用户点名保留的部分 | 推翻现状的程度,见“优化 UI” |
|
||||
| 改流程 | 难用、步骤多、找不到、老出错 | 入口、步骤、承载方式、状态的表达 | 视觉沿用现有系统 | 做法,见“改流程与加新功能” |
|
||||
| 加新功能 | 加一个……、新做一页 | 新功能的做法 | 视觉沿用现有系统 | 做法,见“改流程与加新功能” |
|
||||
|
||||
从请求推断意图,推断不出来时问一句。一个请求同时包含几种意图时,先定流程,再定视觉:流程决定页面上有什么,视觉决定它们长什么样。流程方案选定后,视觉的几个方案都建在这个流程上;最初的基线截图留着,做最后的前后对比。
|
||||
|
||||
动手前在设计说明开头写一行:这次是哪种意图、优化 UI 时取哪一档、现在处在哪个阶段。后面选检查和评审规则时,以这一行为准,不套用新界面的流程。
|
||||
|
||||
改流程和加新功能时,现有视觉即使不够好,也照样沿用;发现的视觉问题单独列出,交给用户决定要不要另做一次优化 UI。
|
||||
|
||||
## 先摸清现状
|
||||
|
||||
只看和这次改动有关的范围,不通读整个项目:
|
||||
|
||||
- **组件和同类页面**:已有哪些组件,同类任务在别的页面是怎么做的,哪些做对了可以沿用,哪些本身就是问题。
|
||||
- **项目自己的门槛**:先看仓库的检查脚本和规范,例如动效只能用哪些时长变量、文案键怎样写才能通过 i18n 检查、各页面的性能预算、组件交付要声明哪些依赖。设计实现从第一行就按这些规则写,不要做完再返工。
|
||||
- **基线截图**:改动前,在目标视口截下相关页面和关键状态,包括空数据、加载、错误和长内容,存到任务目录。之后的前后对比用同样的视口、数据和状态。
|
||||
|
||||
优化 UI 时再看两项:
|
||||
|
||||
- **设计变量**:颜色、字号、间距、圆角、阴影和动效定义在哪里,页面实际用的是变量还是硬编码。
|
||||
- **实际取值**:搜索相关页面和组件里的颜色、圆角、字号,统计各有多少种。常见结果是十几种灰、七种圆角、三套按钮。
|
||||
|
||||
## 优化 UI
|
||||
|
||||
### 规范体检
|
||||
|
||||
动手前先判断现有规范值不值得当标准。用方向卡的格式给现状写一张卡:调性、字体、色彩、密度、图标、动效。逐项标出它是品牌资产,还是偶然形成的默认值:
|
||||
|
||||
- **品牌资产**:标志、主色、品牌字体、专有插画,以及用户说要保留的。默认不动。
|
||||
- **偶然的默认值**:组件库默认的灰阶和圆角、默认图标库、随手定的字号和间距、没人决定过的阴影。可以重新决定。
|
||||
|
||||
再给规范下结论:
|
||||
|
||||
- **规范是好的,页面没做到位。** 现有规范就是标准:整理照着它修,升级和重塑也保留它的品牌资产。
|
||||
- **规范本身平庸或混乱。** 偶然的默认值都可以重新决定,目标是给这个产品建立一套更好的规范。
|
||||
- **拿不准。** 把现状截图和同品类最好的两三个产品放在一起看,差距明显就按规范本身有问题处理。
|
||||
|
||||
结论和理由写进设计说明,后面的评审以它为准。
|
||||
|
||||
### 推翻程度
|
||||
|
||||
| 档位 | 改什么 | 留什么 |
|
||||
| --- | --- | --- |
|
||||
| 整理 | 把混乱的视觉值收拢成少数语义角色,统一同类组件,修层级、对齐和留白 | 品牌、结构和现有的视觉气质 |
|
||||
| 升级 | 重新决定字体关系、色彩比例、密度、组件的样子和图标 | 品牌资产、页面结构和全局导航 |
|
||||
| 重塑 | 按 [设计方向](design-direction.md) 重新探索,结构也可以变 | 品牌资产 |
|
||||
|
||||
先问一个问题:**这一页改了样子,别的页面要不要跟着改?**
|
||||
|
||||
- **要跟着改**:账户页、设置页、订单列表这类页面,和其他页面共用顶栏、组件和字体,只改其中一页就会和别的页面不搭。
|
||||
- **不用跟着改**:落地页、活动页、单页官网这类页面,自己就能定样子,改了也不牵连别的页面。
|
||||
|
||||
然后按下面的顺序决定做哪几档,前一条成立就不看后一条:
|
||||
|
||||
1. **用户说了改多少**,按用户说的做。
|
||||
2. **范围是整个产品,或者是一个不用跟着改的页面**,三档各出一个方案,和现状一起放进对比页,让用户直接看到改到哪一步是什么样。规范体检认为现有规范是好的,升级和重塑也照出,只是都保留品牌资产。
|
||||
3. **范围是一个要跟着改的页面、一个组件或一处区域**,只做整理:在现有的视觉语言里重排布局和层级,不换字体、色彩和组件的样子。体检认为规范本身有问题时,在交付说明里写明升级或重塑会连带改到哪些页面,问用户要不要把范围扩大。
|
||||
|
||||
只做整理时,也把现状和整理后的版本放进对比页,交付时问一句要不要看看升级或重塑的样子;不改完就交,让用户连原来是什么样都看不到。
|
||||
|
||||
范围是整个产品时,升级和重塑的方案要能推广出去:写明换掉的字体、颜色和组件会影响哪些页面,并拿至少一个其他页面检查新规范放上去是否成立。产品里任何地方都没有出现过的字体和颜色,只在重塑档里引入。
|
||||
|
||||
三档之间按推翻程度比较,不套用设计方向里的差异检验:整理和升级本来就和现状共享结构,这是档位决定的。只有重塑一档里探索多个视觉方向时,才用设计方向的差异检验。
|
||||
|
||||
各档要写的东西不同,不要每档都填完整的方向卡:
|
||||
|
||||
- **整理**:不写方向卡。列出要收拢的视觉值和要统一的组件,写清各自收到哪个语义角色。
|
||||
- **升级**:只写北极星、字体、色彩和记忆点四项,从这个产品的品类和内容里推出来,不是把组件库默认样式换成另一套默认样式。
|
||||
- **重塑**:按设计方向走完整流程,写完整的方向卡,默认保留品牌资产,除非用户要求更换。
|
||||
|
||||
选定以后,新规范先落到共享的设计变量和组件上,在一个代表性页面上做完整、确认,再逐页迁移,不一次重写所有页面。之后的评审以新规范为标准。
|
||||
|
||||
## 先用,再想
|
||||
|
||||
改流程和加新功能都从这里开始。存量项目的问题大多不在画面上,在流程里:看截图只能看出间距和颜色,要知道该改什么,先把任务亲手走一遍。
|
||||
|
||||
### 用用户的话写任务
|
||||
|
||||
动手前写一句话:用户是谁,来做什么,做完想拿到什么。只用用户自己的词,不出现界面上的名词。写“把上周没回复的客户标成待跟进”,不写“在列表里筛选并批量修改状态”。后面每一屏、每一步都拿这句话衡量:它让任务前进了,还是让用户在伺候界面。
|
||||
|
||||
写不出这句话,先问用户,不先看界面。
|
||||
|
||||
### 先定产出物
|
||||
|
||||
很多功能的结果是一样东西:一份清单、一个文件、一条消息、一页摘要。结果是一样东西时,先把它写出来,再想界面:
|
||||
|
||||
- 谁收到它,收到后要拿它做什么。收的是人,要一眼看懂;收的是系统或程序,要能直接用。
|
||||
- 里面缺了什么就用不了。按收件方的需要列,不按界面上现成有什么列。
|
||||
- 用户今天凑合时亲手带走的是什么。这是产出物的下限,新功能不能比它少。
|
||||
- 产品里已经有、用户却得自己翻出来的东西,系统补进去。
|
||||
|
||||
把最好的那一份产出物直接写成样例,放在设计说明里。例如食谱应用的“生成购物清单”,样例就是一份真的清单:按超市货架分区、合并重复的食材、标出家里常备的。样例写不出来,或者和用户凑合的办法没有区别,先回来修产出物,不开方案。
|
||||
|
||||
产出物定好后,各个方案都交付同一份。方案之间差的是用户为它做多少、系统替他做多少,不是谁的产出物好一点。
|
||||
|
||||
### 走一遍,边走边数
|
||||
|
||||
用真实或脱敏后的数据,从用户实际的入口走到能看到的结果。按 [视觉评审协议](visual-review.md) 的“任务走查”记下卡住、走错、犹豫和多余的步骤,同时数四个数,写进任务目录:
|
||||
|
||||
- **步数**:到结果要几次点击、几次页面切换。
|
||||
- **决定数**:界面问了用户几个问题,其中几个系统其实已经知道答案。
|
||||
- **记忆负担**:有几样东西要从一屏带到另一屏,靠脑子记、复制粘贴或开两个标签。
|
||||
- **回头路**:出错或改主意时要退回几步,丢掉什么。
|
||||
|
||||
记成一张表,现状、理想路径和每个方案各占一行,后面每次计数都往这张表里加:
|
||||
|
||||
| | 点击 | 页面切换 | 决定(其中系统已知) | 记忆负担 | 回头路 | 寻找与阅读 |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| 现状 | 8 | 6 | 6(1) | 3 件作品和用途 | 刷新后重选筛选,丢掉候选 | 滚动两屏才找到第三件 |
|
||||
|
||||
点击和页面切换分开记,不相加;回头路写退回几步、丢了什么;滚动找了很久、读了很多才做出的决定,即使不算点击,也记在最后一列。表格上方写一行计数口径,例如键盘操作算不算点击。现状、理想路径和每个方案都用同一个任务、同一批数据和同一套口径来数,否则数字不能互相比较。
|
||||
|
||||
再走一遍坏路径:输入错、名称很长、数据为空、上千行、权限不足、中途离开再回来。存量项目最常见的问题都来自真实数据。
|
||||
|
||||
每处犹豫归为一类:不知道从哪开始,不知道点了会怎样,不知道成功没有,不知道怎么回去。
|
||||
|
||||
加新功能时没有现成流程可走,就走它旁边的现有流程,再走用户今天凑合的办法:复制粘贴、导出表格、在备注里打标记。凑合的办法证明需求存在,也往往指向最自然的入口。
|
||||
|
||||
### 写理想路径
|
||||
|
||||
这一步不能省。假设现有界面不存在:用户从说出意图到看到结果,最少要做几个决定,每个决定需要看到什么。把它和现状的步骤并排,多出来的每一步都是结构上的机会。
|
||||
|
||||
找问题,得到的往往是补丁;从结果往回推,得到的才是结构上的改法。
|
||||
|
||||
### 从现象找到原因
|
||||
|
||||
走查记下的是现象。每条往下问为什么,直到落进下面五种原因之一,每种原因都有自己的改法方向:
|
||||
|
||||
| 原因 | 怎么认出来 | 往哪改 |
|
||||
| --- | --- | --- |
|
||||
| 做决定的地方没有做决定要用的信息 | 要开另一页、来回切换、靠记忆 | 把信息搬到做决定的地方:行内、侧面板、预览 |
|
||||
| 功能长在用户不会去找的地方 | 犹豫发生在找入口;对象没有自己的家 | 搬到对象上,或搬到用户出发的地方 |
|
||||
| 流程的形态和任务不匹配 | 一步能做完的拆成向导;可撤销的还要确认;几十条要逐条打开再退回 | 换骨架:队列、行内编辑、批量;去掉确认,改成可撤销。骨架见 [设计方向](design-direction.md) 的“产品界面” |
|
||||
| 系统知道的事让用户再做一遍 | 要填系统已有的值;要选只有一个合理答案的选项;要手动触发每次都做的事 | 预填、给默认值、自动执行并可撤销 |
|
||||
| 状态看不见 | 做完没有变化;进行中看不出来;出了错要自己去找 | 把状态做成对象上的属性,在列表里就能看到 |
|
||||
|
||||
间距、颜色、文案不在表里。它们是症状或精修项,记下来放到最后,不混进原因。
|
||||
|
||||
三个例子:
|
||||
|
||||
- **现象**:给上周没回复的客户标“待跟进”,每条都要点进详情、改状态、返回,列表回到顶部,再重新找下一条。**原因**:用户在逐条处理,骨架却是“表格加详情页”,改状态被放在了详情里。**补丁**:记住滚动位置。**结构上的改法**:筛选“未回复”后在行上直接改状态;或者左边队列、右边详情,处理完自动跳到下一条;或者全选后一步批量标记。
|
||||
- **现象**:新建合同有 14 个字段,填到一半去查客户编号,回来表单空了。**表面原因**:没有草稿。**根本原因**:表单在要系统已经知道的东西;入口在顶级菜单“合同”,用户却是从某个客户出发想建合同的,客户信息在入口处就丢了。**补丁**:加草稿,加“编号在哪里找”的提示。**结构上的改法**:入口移到客户详情页,客户相关字段全部带出,只剩三四个真正要判断的字段,放在侧面板里,下面的客户信息仍然看得见。
|
||||
- **现象**:首页是四张指标卡,用户每天进来直接点“工单”菜单。**原因**:首页回答“总共有多少”,用户每天要知道的是“现在该处理什么”。**补丁**:给指标卡加趋势箭头。**结构上的改法**:首页就是待处理队列的前几条,最显眼的属性是已经等了多久;指标收成一行小字。
|
||||
|
||||
### 补丁自查
|
||||
|
||||
每条建议写完,过一遍下面五个问题,任何一个答不上来就是补丁:
|
||||
|
||||
- 它是在加一个东西(提示、引导、确认、说明文字、徽章、空状态插画),还是改掉或删掉一个东西?加东西之前,先回答界面为什么让人看不明白,那个答案才是真问题。
|
||||
- 把根本原因修掉以后,它还需要存在吗?
|
||||
- 改完之后,步数、要记住的事、要切换的页面,至少有一样变少了吗?
|
||||
- 第十次用的人会被它打扰吗?引导和确认对熟手是负担。
|
||||
- 能用用户的话说出改善吗:“现在不用再……”。
|
||||
|
||||
补丁可以做,单独列为“今天就能改”,不冒充方案。
|
||||
|
||||
### 不知道从哪改时
|
||||
|
||||
用户只说“优化一下这页”,没说哪里难用,就按这页的主要任务走一遍、写理想路径,把差距最大的三处按影响排好,每处写清用户能少做什么,附截图,交给用户挑。用户挑了再继续。
|
||||
|
||||
## 改流程与加新功能
|
||||
|
||||
走查、理想路径和产出物样例都写完之后,才开始想方案;还没看过现有代码和页面就定下的方案,只是在三种立场的格子里填空。
|
||||
|
||||
视觉全部沿用:同一套设计变量、组件、图标和动效,不换字体和主色,不另做控件。多个方案之间能看出差别的只有三件事:出现在哪里,要几步,谁在做事。产出物不在其中:它在“先定产出物”里定过一次,每个方案都交付同一份最好的。产品里没有现成的控件时,例如第一次出现输入框,单独列出,从现有控件的样子推出来:圆角、描边、字号、文字颜色,以及悬停、聚焦和出错的样子,都和现有的按钮、筛选和菜单一致。不用浏览器默认样式,也不随手写一套通用表单样式。
|
||||
|
||||
### 读出产品的习惯
|
||||
|
||||
新功能和新流程要像这个产品原来就有的。从现有界面读出下面几项,各写一行,后面每个方案都遵守:
|
||||
|
||||
- **对象**:导航和列表第一列里的名词,就是用户认的东西。哪些有自己的列表页,哪些只在详情里出现。新功能是哪个对象上的事;要不要引入新对象,这是大决定,写出理由。
|
||||
- **动作**:按钮和菜单上的词,就是用户认的动作。新功能能不能用已有的动词说出来。
|
||||
- **出发方向**:用户是先找到对象再做事,还是先选动作再找对象。
|
||||
- **节奏**:一次处理一件还是批量;马上完成还是进后台任务。
|
||||
- **承载**:详情开新页面还是侧面板;编辑用弹窗还是行内。
|
||||
- **反馈**:成功后是原地高亮、角落提示还是跳转。
|
||||
|
||||
这些习惯默认沿用。打破某一条时,说出来自这个任务的理由。
|
||||
|
||||
再看两三家同类产品:这个功能长在哪里、要几步、系统替用户做了什么、用户最后拿到什么,各写一行。能上网就真的打开看;凭记忆写的,标明是记忆。只看这一个功能,不拆整个产品。
|
||||
|
||||
### 三种立场
|
||||
|
||||
每个方案先选一种立场,再在立场里把流程想完整:
|
||||
|
||||
| 立场 | 用户做什么 | 适合 |
|
||||
| --- | --- | --- |
|
||||
| 顺手 | 在原来的地方多做一件事:行内、选中后、对象的操作菜单里,不新增页面和对象 | 简单、依附于现有对象、做完就走 |
|
||||
| 自动 | 系统替用户做一个他本来要自己做的判断或整理,用户只看结果、改例外、撤销 | 规则明确、错了能撤回、每次做法都一样 |
|
||||
| 专用 | 给这件事一个自己的地方:新页面、新面板或新工作区,相关信息和状态摊开 | 高频、步骤多、要连续处理或对照 |
|
||||
|
||||
三种立场是备选,不是配额。先问每种立场在这个任务里能不能写出一个站得住的做法,写得出几个做几个;凑不出就少做一个并说明理由,不把一种立场做成两个方案,也不把一种立场硬套成一个方案。
|
||||
|
||||
“自动”要说得出系统替用户判断了什么。把已有数据拼在一起、换个地方显示、少点一次,都不算自动。只有现有数据真的能支撑这个判断时才选它;数据和接口做不到,就不做这个方案。
|
||||
|
||||
立场只决定入口、流程的形态和承载方式;对象、动作的位置、组件和反馈方式,按上一节读出的习惯沿用。
|
||||
|
||||
### 方案卡
|
||||
|
||||
改流程和加新功能用这张卡代替设计方向里的方向卡,每项一两行:
|
||||
|
||||
| 字段 | 写什么 |
|
||||
| --- | --- |
|
||||
| 立场 | 顺手、自动或专用 |
|
||||
| 入口 | 用户在哪里、看着什么时碰到它 |
|
||||
| 步骤 | 从意图到结果,一行一步,旁边并排现状的步骤或理想路径 |
|
||||
| 修了什么 | 对照“从现象找到原因”的表,修的是哪一条 |
|
||||
| 拿到什么 | 这个方案交付的产出物和“先定产出物”里的样例一致;有增减时,写明多了什么、少了什么、为什么 |
|
||||
| 值得 | 用用户的话写一句改善:“现在不用再……”或“现在能……”。写不出来,这个方案不值得做 |
|
||||
| 出错时 | 失败后用户在哪里,什么被保留了 |
|
||||
| 赌注 | 押的是用户的哪种习惯或场景 |
|
||||
| 放弃什么 | 它做不好的场景 |
|
||||
|
||||
差异检验先看一样,再比两样。先看每个方案的产出物有没有达到样例,差一截就先补,否则几个方案都只是在给一份没用的东西找入口。再比入口是否不同、步数或系统替用户做的事是否不同;两个方案这两样都一样,就是同一个方案换了皮,换一种立场重做。
|
||||
|
||||
### 放进对比页
|
||||
|
||||
候选直接在项目里实现,不另写一套静态页:用独立路由或查询参数切换,例如在原地址后加 `?variant=b`,每个方案都用真实的组件和数据。对比页用本地地址作为候选,现状作为基线放在第一位,交付时开发服务器开着,见 [风格对比页](style-explorer.md)。
|
||||
|
||||
用户要求先看方案、选定后再动代码时,不改业务路由和组件,在任务目录里做可操作的小样,用现有的设计变量和真实数据,见 [风格对比页](style-explorer.md) 的 `"interactive": true`。
|
||||
|
||||
对比页里每个候选只有一个画面,选三个方案差别最大的那一刻,通常是入口或关键的一步,不选结果页,结果页在各种做法下往往长得一样。功能的结果是一样东西时,另外把产出物样例原样放进对比页的说明,让用户看到他最后拿到的是什么。`traits` 写立场、入口和步数,让用户在卡片上就能比较做法;字体和色板三个方案填同一组现有的值。
|
||||
|
||||
### 评审
|
||||
|
||||
不打视觉分,不跑评分循环。交付对比页时推荐给用户的下一步,是下面这一轮方案评审,不是 SKILL.md 步骤 4 的视觉评审和多方向横向评审。给用户之前,自己按任务那句话把每个方案走一遍,记下卡住、走错和犹豫,写进对比页的说明。用户同意后,有隔离执行者时按 [视觉评审协议](visual-review.md) 的“评审改流程和加新功能的方案”评一轮。
|
||||
|
||||
选定并实现后,再走一遍,在同一张表里加一行,和现状并排写进交付说明。各列都没有改善时,先查是不是只改了皮,回到“从现象找到原因”;改善体现在入口更容易找到、更少出错或更容易恢复时,用卡住、走错、犹豫次数的变化说明;只减少出错、没有减少步骤和记忆负担的改动,写明它防的是哪种错,归为防错,不当作结构上的改法。视觉上只按“给已有界面评审”看一轮,润色了一整页以上才做。
|
||||
|
||||
计数表只量路程,不量结果。另写一行:拿到产出物的人能不能不加工直接用;不能,先修产出物,再谈步数。
|
||||
|
||||
## 各类改动怎样评审
|
||||
|
||||
| 改动 | 交给用户挑选之前 | 选定并做完之后 | 打分 |
|
||||
| --- | --- | --- | --- |
|
||||
| 整理 | 不派评审者,主 Agent 和基线做前后对比 | 润色了一整页以上时,按 [视觉评审协议](visual-review.md) 的“给已有界面评审”看一轮,标准是现有规范 | 不打分 |
|
||||
| 升级 | 不派评审者 | 按“给已有界面评审”看一轮,标准是选定的新规范 | 不打分 |
|
||||
| 重塑 | 按 SKILL.md 步骤 4 推荐一轮视觉评审,由用户决定 | 按步骤 4 的评分循环,目标 9 分 | 打分 |
|
||||
| 改流程、加新功能 | 主 Agent 自己走一遍;推荐一轮“评审改流程和加新功能的方案”,由用户决定 | 再走一遍,在计数表里加一行;视觉按“给已有界面评审”,润色一整页以上才做 | 不打分 |
|
||||
|
||||
三档放在同一个对比页里给用户挑时,挑选前都不派评审者;用户选了哪一档,之后按哪一档那一行做。
|
||||
|
||||
## 这时读哪些参考
|
||||
|
||||
改流程和加新功能时,先用这几处:[设计方向](design-direction.md) 的“产品界面:先定对象和工作区骨架”,[交互与状态](interaction-and-state.md) 的“先走通用户任务”“动作层级”“按范围表达状态”和“模型在交互上的默认做法”,[布局与视口](layout-and-viewport.md) 的“选择承载方式”。
|
||||
|
||||
[视觉语言](visual-language.md) 的“细节”和“模型默认审美自查”、设计方向的差异检验、三处基本动效,留到方案选定、开始精修时再用。太早拿出来,注意力会被圆角和线框占满。例外是方案里第一次出现的东西,例如新的输入框、面板或浮层:交给用户之前就按“细节”检查一遍,用户挑方案时看的就是画面。
|
||||
|
||||
## 从源头修改
|
||||
|
||||
改样式之前按顺序检查:
|
||||
|
||||
1. 项目里是否已有同样含义的组件、变量或样式;
|
||||
2. 页面是不是用错了组件;
|
||||
3. 父布局给的尺寸、对齐或定位是否有错;
|
||||
4. 组件默认样式或状态逻辑是否有错;
|
||||
5. 已有的组件变体够不够用;
|
||||
6. 是否真有稳定重复的职责,需要一个新的共享实现。
|
||||
|
||||
前几步没查完,不新增组件、变体或样式。
|
||||
|
||||
- **修共享,不覆盖实例。** 不用 `!important`,不用页面专属选择器侵入组件内部,不用任意数值绕过变量。
|
||||
- **变体要有稳定含义。** 新变体按用途命名,例如 `destructive`、`compact`,不按页面或视觉结果命名,例如 `orders-blue`;至少有两个使用位置,或者属于组件的公共状态。
|
||||
- **先查使用位置。** 改共享组件、变量或样式前,搜出所有使用位置;每种不同的用法各抽一处验证。
|
||||
- **接管后删旧的。** 新实现接管后,迁移所有使用位置,删掉旧组件、旧样式和失效的选择器。做不完时写清剩下哪些位置,不长期保留两套实现。
|
||||
- **收敛视觉值。** 把十几种灰映射到正文、次要文字、边框、表面这类少数语义角色:先改变量,再批量替换,最后删掉孤立的值。
|
||||
- **动效先沿用。** 先用项目已有的时长和缓动;项目没有时才按 [动效](motion.md) 确定,并收成变量。
|
||||
- **展示内容先按需加载。** 文档页、组件目录这类一页里放很多真实组件的页面,首屏以下的示例和样张在滚动到附近时再加载,别让每一页都背上所有示例的脚本。项目有性能预算时,先这样压下去,剩下确实属于新功能的增量再上调基线,并写明原因。
|
||||
- **不顺手改业务逻辑。** 逻辑确实造成了界面问题,例如重复请求、状态不同步,单独指出并说明改动范围。
|
||||
|
||||
## 验证
|
||||
|
||||
- 和基线在同一视口、同样数据和状态下做前后对比截图。
|
||||
- 被改动的共享组件,在其他使用位置各看一处。
|
||||
- 优化 UI 润色了一整页以上时,按 [视觉评审协议](visual-review.md) 的“给已有界面评审”看一轮,标准是规范体检的结论:规范是好的就按现有规范,重新建立了规范就按选定的新规范。
|
||||
- 改流程和加新功能,走一遍主要任务,和走查时的四个数并排对比;保存类操作要重新读取确认。
|
||||
- 运行项目已有的类型检查、测试和构建;不为一次视觉修改新建测试体系。
|
||||
@@ -0,0 +1,69 @@
|
||||
# 配图
|
||||
|
||||
生成图最常见的失败不是画得不好,而是画得太满:细节越丰富越像图库照片,放进版面后没有重点,也和页面没有关系。好的配图要先承担意思,再承担气氛,最后和页面接成一个整体。
|
||||
|
||||
## 先让图承担意思
|
||||
|
||||
生成之前先写一句:这张图要让人一眼看懂什么?答不上来就先别生成。“一张好看的使用场景”不是答案;“产品把一堆混乱变成了哪几个清楚的结果”才是。
|
||||
|
||||
用对比把信息编进图里。整体统一成一种低调的材质或颜色,比如白模、单色、失焦或剪影,只让承载信息的少数几处有颜色、光或细节。例如白色仓库模型里只有出了异常的两排货架亮成橙色,灰度地图上只有正在配送的路线是彩色。所有地方都一样精细,就等于没有重点;第一版通常就是这样失败的。
|
||||
|
||||
选能和页面“接上”的主体:
|
||||
|
||||
- 能跨过两块色面分界的单个物件。
|
||||
- 能延伸出画面的线:线缆、轨道、河流、胶片、纸带。
|
||||
- 能在上面标注的空间:模型、地图、剖面、平面图。
|
||||
|
||||
这样的主体,之后能和页面里的线、标注和区块连成一体;一张自给自足的风景照做不到。
|
||||
|
||||
## 写提示词
|
||||
|
||||
按固定顺序写,每项一两句:
|
||||
|
||||
1. **主体与表现形式**:什么东西,用什么体裁呈现(照片、插画、模型或图解),体裁从页面的方向推出。
|
||||
2. **视角与镜头**:机位的高度和角度;焦段和景深。
|
||||
3. **构图**:主体在画面里的位置和占比,四周留多少边距,哪一块留空给文字。要整件物体完整入画,就写明“完整地放在画面内,四周留出宽松边距”,否则常被裁掉一角,之后没法排版。
|
||||
4. **背景**:写出页面实际使用的十六进制色值,或者要求透明背景。
|
||||
5. **材质与光**:主光方向、辅光、质感。同一页的多张图用同一个光向。
|
||||
6. **重点**:用什么对比让哪几处成为焦点,其余部分怎样退后。
|
||||
7. **排除**:不要文字、标志、界面、屏幕内容;透明物件写明“地面不要阴影”。
|
||||
|
||||
文字、界面和数据一律不让生图模型画:会画错,改不了,也不能被选中、翻译和读屏。它们放在页面上,用真实元素压在图上,或用引线连到图上。
|
||||
|
||||
## 生成后放进页面再判断
|
||||
|
||||
在真实页面、真实尺寸里看,不在图片查看器里判断。单独看很好的图,放进版面后常常主体太满、朝向不对、留白在错的位置。
|
||||
|
||||
不对就改提示词重新生成,并写清要改什么,例如“除了三处重点,其余全部改成白色”“把整个模型收进画面”。不要围着一张不合适的图改版式。同一批里挑最能排版的一张,不挑细节最多的那张。
|
||||
|
||||
## 让图和页面接成一体
|
||||
|
||||
**背景融合。** 不透明的图,从图的四角实际取色,页面背景用同一个值;四条边都淡出到这个颜色,不加框,也不放进圆角卡片。只淡出一侧时,其余三边在宽屏上会露出矩形接缝。淡出可以用 CSS 遮罩,也可以在导出时直接把边缘渐隐成页面色值,后者不增加样式,适合样式体积受限的页面。生成图的底色常常不均匀,靠边缘会偏色,要在实际显示尺寸下检查接缝。
|
||||
|
||||
**透明图。** 先放在最终背景上检查边缘、白边和残留的棋盘格;检查透明通道要看像素,不能看文件后缀。阴影不烘焙在图里,由页面按实际背景加投影,这样换了背景也对。
|
||||
|
||||
**跨越结构。** 透明物件放在两个色块或两个区块的分界线上,让它同时属于两边。
|
||||
|
||||
**图和代码接力。** 生成图负责代码做不出来的材质和光,例如金属、玻璃、模型、光影。代码负责需要精确、可编辑、会延续的部分,例如从图里延伸出去的线、路径和标注。如果图里有一段本该延续到页面的部分,就把它裁掉,改用矢量接着画。这样它能准确穿过版面,带上真实文字,并在不同宽度下重新走线。
|
||||
|
||||
**标注。** 在图上标注用细引线加文字,不用带底色和阴影的气泡卡片。引线端点要对准图里的真实位置:图在版面里用固定的尺寸和位置,按图片像素换算坐标。窄屏上隐藏标注,或改成图下的列表,不让它们漂移到错误的位置。
|
||||
|
||||
**窄屏裁切。** 不把整张大场景等比缩小到看不清。从原图裁出焦点区域另存一张给窄屏用,或用对象定位对准焦点。桌面和手机两种裁切,都要保住承载信息的那几处。
|
||||
|
||||
**一张图用多次。** 结尾或其他区块可以用同一张图的局部特写,比再生成一张光线和风格对不上的图更统一。
|
||||
|
||||
## 让一张画当舞台
|
||||
|
||||
另一种用法正好相反:图不讲信息,只给气氛,界面保持黑白克制,全页只用一种有笔触的画当唯一的颜色来源,例如油画、水粉、水彩或版画。
|
||||
|
||||
- **画当舞台,界面在上面演示。** 把真实界面的简化版放在画上,画从四周露出来。滚动时画固定不动,只换前面的界面内容,和“滚动叙事”接得上。
|
||||
- **大胆裁切。** 只留画的一角或一个局部,接近抽象,不和界面抢。题材不必和产品字面相关,但气质要对得上:快、静、暖或者松弛。
|
||||
- **松和紧互相衬托。** 画越松,界面越要精确、干净;两者一起出现,彼此都更好看。
|
||||
- **用得少。** 一张画贯穿全页,或者只在两三处出现,例如价格卡的顶端和页脚;不要每个区块配一张。同一页的画统一画种和笔触。
|
||||
- **演示用简化过的真实界面。** 次要内容虚化或换成骨架,只把要讲的那一处做清楚;数字对比直接用界面元素画出来,例如两条长短不同的进度条,而不是另做一张图表。
|
||||
|
||||
## 文件与说明
|
||||
|
||||
- 按显示尺寸的 2 倍导出,不直接内嵌原始大图。不透明的图用 JPEG 或 WebP,透明的用 WebP 或 PNG;单张控制在几百 KB 以内。内嵌成 data URL 后体积会再大约三分之一。
|
||||
- 保留提示词、原图和处理后的版本,写清每张图的用途和处理方式。
|
||||
- 生成图不冒充真实产品、真实场所或真实人物。虚构产品的小样,在页脚注明图片由 AI 生成。
|
||||
@@ -0,0 +1,111 @@
|
||||
# 交互与状态
|
||||
|
||||
## 先走通用户任务
|
||||
|
||||
用一条主要路径说明:从哪里进入、识别哪个对象、作出什么决定、执行哪个动作、看到什么结果。只展开当前范围相关的分支,不为一个小表单设计整个产品流程。
|
||||
|
||||
浏览、比较、选择和编辑是不同任务。列表用适合比较的身份、数据或预览;只读详情不默认铺满禁用输入框;用户明确进入编辑后再显示需要的控件。需要连续批量配置的工作表可以持续编辑,但必须有一致的对象与保存语义。
|
||||
|
||||
同一对象与动作沿用统一名称。品牌表达可以改变外观,不随意改变返回、关闭、保存、删除等熟悉动作的含义。
|
||||
|
||||
## 对象身份
|
||||
|
||||
一个对象靠四样东西认出来,按顺序取,够区分就停:
|
||||
|
||||
1. **视觉身份**:缩略图、头像、封面、Logo 或媒体帧。同一对象在各处用同一裁切和比例;加载失败时用稳定的兜底图,并保留名称。
|
||||
2. **主名称**:用户平时称呼和搜索用的名字。
|
||||
3. **区分字段**:同名或相似对象之间最能区分的一个字段。
|
||||
4. **影响判断的状态**:只在它会改变选择或操作时显示。
|
||||
|
||||
数据库 ID、看不懂的长文件名、所有对象都一样的类型标签、装饰图标、截断后彼此相同的文字,都不能充当身份。
|
||||
|
||||
选择器首先是对象识别器,不是字符串下拉框。选项显示图片、名称和必要的区分字段;收起后仍显示足够的身份,用户不用重新打开就能确认选了谁;搜索覆盖名称和区分字段;已选对象不在当前分页或筛选结果里时,仍然显示。
|
||||
|
||||
把这四样做成一个共享的对象身份组件,列表、表格第一列、选择器、已选结果和详情标题都用它。
|
||||
|
||||
## 列表、表格与详情
|
||||
|
||||
列表只回答三件事:有哪些对象,它们在决策上有什么不同,该打开哪一个。每项只放身份、一个影响判断的状态、比较所需的最少字段、整项点击和“更多”。完整分析、证据、关联对象、编辑和历史放进详情。
|
||||
|
||||
- **表格还是卡片**:要横向比较同一字段(数值、状态、日期、价格)用表格;主要靠图片和内容挑选用卡片或列表;每项主体是长文或完整媒体时不压进表格行。同一类对象在整个产品里只保留一种主要形态,只有用户确实有两种任务时才提供视图切换。
|
||||
- **每一列都要有答案**:它帮助比较、表达状态、需要直接修改,还是可执行操作?答不上来就删,不把详情字段逐列搬进表格。用来识别对象的那一列放在最前,横向滚动时固定;单位写在表头,不在每个单元格里重复。
|
||||
- **一个单元格只做一件事**:识别、比较、状态、编辑或操作中的一种。不在单元格里拼“预览 + 状态 + 数量 + 按钮”的迷你卡片;状态只在固定的一列表达一次;行级动作放固定的操作列,不在内容下面再排一行按钮。
|
||||
- **批量**:只有真的存在批量动作时才显示多选框。筛选变化后不静默操作看不见的已选项:要么清除隐藏的选择,要么写明数量和范围。
|
||||
- **点击**:卡片主体和“查看”按钮不做同一件事;整张卡可点时,卡内的独立控件不触发外层点击。编辑完成后回到原行,原行显示最新状态。
|
||||
|
||||
## 动作层级
|
||||
|
||||
| 层级 | 用途 | 呈现 |
|
||||
| --- | --- | --- |
|
||||
| 主操作 | 完成这个区域的主要任务 | 一个高强调按钮,写成“动作 + 对象”,例如“发送报价” |
|
||||
| 次级操作 | 高频但不是主要任务 | 低强调文字按钮 |
|
||||
| 管理操作 | 重命名、复制、重新处理、可撤销的删除 | 收进“更多”,菜单项写文字 |
|
||||
| 危险操作 | 不可恢复或损失大 | 在菜单里用分隔线隔开,并确认 |
|
||||
|
||||
可撤销的操作直接执行并提供撤销,不再确认;不可撤销的操作确认时写出对象和后果,按钮写动作,例如“删除 3 个文件”,不写“确定”。同一区域出现两个高强调按钮,就重新判断主任务。“处理”“管理”“继续”这类词说不出结果,换成动作加对象。次级操作可以在悬停、聚焦或选中该项后出现,但要预留位置、不挤动内容,触屏上要有等价入口。
|
||||
|
||||
每个可见动作都要有真实结果:数据变化、明确范围的预览、可观察的任务状态、导航,或者复制、下载这类本地结果。没有写入能力就不显示写操作,不用长期禁用的按钮占位;当前状态不允许的菜单项直接隐藏,不留无法解释的灰色图标。
|
||||
|
||||
帮助文案只解释界面推断不出的规则、限制和风险;先修正标题、默认值或结构,不用长说明弥补流程混乱。使用原生或已有可靠控件,保持标签、焦点、键盘操作与可访问名称;校验和重要状态不只用颜色或悬停提示表达。
|
||||
|
||||
## 表单与提交
|
||||
|
||||
只收集当前任务必需且无法可靠带入的信息。系统已知的值合理预填,需要用户判断的值保留可编辑性;数据属于其他对象时说明来源,不能悄悄改写源对象。
|
||||
|
||||
相关字段形成清楚的提交边界。一次用户意图只提交一次,处理中防止重复操作;结果即时生效时不再增加无意义的保存。
|
||||
|
||||
只有字段依赖、错误代价或任务长度确实需要时,才增加步骤、预览、草稿或额外确认。行内编辑适合简单独立字段;复杂关联编辑进入能容纳完整任务的表面。
|
||||
|
||||
校验在能够修正的位置显示原因和修正方式。提交失败保留输入、当前对象和位置;返回上一步或从源数据编辑返回时维持流程连续性。
|
||||
|
||||
## 按范围表达状态
|
||||
|
||||
| 情况 | 界面要表达的内容 |
|
||||
| --- | --- |
|
||||
| 首次加载,尚无内容 | 在最终内容区域提供适量占位或进度,保持结构稳定 |
|
||||
| 刷新或筛选,已有内容 | 保留可用内容并标示正在更新,不把旧结果误当成新查询完成 |
|
||||
| 集合确实为空 | 说明原因,并给适合当前任务的开始动作 |
|
||||
| 搜索或筛选无结果 | 保留查询条件,提供调整或清除条件的方式 |
|
||||
| 局部提交 | 在触发位置表达处理中,保持按钮尺寸,防止重复提交 |
|
||||
| 后台长任务 | 给对象明确的排队、处理中、完成或失败状态及必要进度 |
|
||||
| 加载或提交失败 | 在相应范围解释原因和恢复动作,不要求用户重新寻找对象 |
|
||||
|
||||
同一范围不同时表达互相矛盾的状态。局部请求不清空整页,进度不重复堆叠,等待结果不伪装成空集合。
|
||||
|
||||
占位布局应接近实际内容,不制造额外滚动。已有数据刷新时保持结果、数量与查询条件的对应关系;快速连续操作后只呈现当前意图对应的结果。
|
||||
|
||||
## 模型在交互上的默认做法
|
||||
|
||||
这些做法单看都说得过去,所以模型会不假思索地用。出现时换成右边的做法:
|
||||
|
||||
| 默认做法 | 换成 |
|
||||
| --- | --- |
|
||||
| 首页开头四张指标卡,数字没有比较基准,也引不出动作 | 首页回答“现在该处理什么”,见设计方向的“产品界面” |
|
||||
| 可撤销的操作也弹确认框 | 直接执行并提供撤销;只有不可撤销的操作才确认,见“动作层级” |
|
||||
| 所有反馈都用角落里的浮动提示,包括错误 | 反馈出现在触发的位置;错误留在原处直到解决。浮动提示只用于离开视线的结果,例如已复制、已发送 |
|
||||
| 一加载就整页转圈 | 保留已有内容,只在变化的区域标示更新,见“按范围表达状态”和下文数字 |
|
||||
| 空状态只有一张插图和“暂无数据” | 说明为什么空、下一步做什么,给一个能直接开始的动作;能导入或预填示例就提供 |
|
||||
| 字段一字排开,用占位文字当标签,提交后才一起报错 | 标签常驻在输入框上方,按任务分组;离开字段时校验,提交出错时聚焦到第一个错误并说明怎么改;少数可选字段标“选填”,不给满屏必填项加星号 |
|
||||
| 按钮禁用,却不说为什么;每行末尾一排图标按钮;必要按钮只在悬停时出现 | 按“动作层级”重排:主操作一个,管理操作进“更多”,触屏和键盘都能到达 |
|
||||
| 详情和设置默认用弹窗 | 按任务选择弹窗、侧面板或完整页面,见布局与视口的“选择承载方式” |
|
||||
| 表格所有列等宽、居中 | 按“列表、表格与详情”删掉答不出用途的列;对齐方式见视觉语言的“细节” |
|
||||
| 按回车才搜索,无结果时只写“无结果” | 输入即筛选(请求较重时防抖 150–300ms);无结果时保留查询,给出放宽条件或清除的入口 |
|
||||
| 一两个字段也拆成多步向导 | 只有步骤之间确实依赖时才分步;否则一页完成,并能看到全部范围 |
|
||||
| 保存后跳回列表,看不出刚改的是哪一条 | 回到原位置,把刚改动的对象高亮 1–2 秒 |
|
||||
|
||||
## 有数字的底线
|
||||
|
||||
- **响应**:100ms 内给出按下反馈。300ms 内能完成的操作不显示加载,避免一闪而过;超过 1 秒显示骨架或进度;超过 10 秒显示进度,并允许离开后回来查看。加载指示一旦出现,至少停留 400–500ms。
|
||||
- **对比度**:正文与背景至少 4.5:1;大字(约 24px 以上,或 19px 以上的粗体)、图标和控件边界至少 3:1。
|
||||
- **点击区域**:触屏至少 44×44pt(Android 48dp),相邻目标之间留 8px;桌面指针目标至少 24×24px。
|
||||
- **文字**:手机上的输入框文字不小于 16px,否则 iOS 聚焦时会自动放大页面。正文字号和行高见 [视觉语言](visual-language.md) 的“可读性是交付门槛”;阅读型正文每行中文 25–40 字、英文 45–75 个字符。
|
||||
- **提示停留**:普通提示 4–6 秒,带撤销的 5–10 秒;鼠标悬停在提示上时暂停计时。重要错误不用会自动消失的提示。
|
||||
- **键盘**:Esc 关闭浮层,并把焦点还给打开它的元素;Enter 提交单行表单;弹窗打开时焦点落在第一个输入或主按钮上,Tab 不跑到弹窗外面。
|
||||
|
||||
## 确认与完成
|
||||
|
||||
需要等待结果的操作有可观察的生命周期。提交后保留对象与反馈;同步完成后再呈现成功,失败时保留恢复入口。
|
||||
|
||||
可以关闭表面的后台任务,应已转为可持续观察的任务状态。关闭窗口不是取消请求;没有真实取消能力时,不把关闭或取消描述成已撤回操作。
|
||||
|
||||
关键保存从界面重新读取或重新进入验证。模拟原型可以演示状态,但交付时明确模拟边界,不声称真实数据已写入。
|
||||
@@ -0,0 +1,60 @@
|
||||
# 布局与视口
|
||||
|
||||
## 先定空间责任
|
||||
|
||||
明确首屏重点、内容的伸缩方式、滚动区域与主要操作的位置。任务型界面减少无意义滚动;叙事型页面允许有意的长度与节奏。不能为了单屏把文字和控件缩小到难以使用。
|
||||
|
||||
区分全幅背景、限宽正文、弹性区域和固定规格控件。布局由内容、父容器、比例与上下限共同决定,不把整张设计图按比例缩成网页。
|
||||
|
||||
宽屏可并列需要同时参考的信息,窄屏按任务顺序重排。是否分栏取决于内容关系,不默认所有页面都是左文右图。
|
||||
|
||||
## 内容与伸缩
|
||||
|
||||
- 文字与可变数据采用内容驱动尺寸。固定规格用于图标、点击区域、媒体比例或稳定几何,不用于让当前截图刚好装下。
|
||||
- 让长名称和宽内容能够合理收缩、换行或局部滚动。网页中的 Flex/Grid 子项必要时设置收缩边界,不用全页裁切掩盖溢出。
|
||||
- 图像明确尺寸、裁切与主体安全区;异步内容预留合适空间,避免加载后挤走操作。
|
||||
- 把真实组件或页面放进固定大小的缩略窗口(组件目录、模板墙、样张)时,按内容的自然尺寸缩放进窗口,不要居中后让上下两头一起被裁掉。先保证宽度放得下;太高的内容缩到还能读的比例后顶端对齐,底部渐隐。随容器伸缩的组件在缩略窗口里没有自然宽度,会被挤成一列竖排字,要按真实使用时的宽度渲染再缩放。
|
||||
- 响应式验证覆盖项目支持的代表性宽度,断点由内容失效的位置决定。不同视口允许重排,不强求像素比例一致。
|
||||
|
||||
## 滚动由谁负责
|
||||
|
||||
每个表面只有一个主滚动区:应用外壳固定时,主内容区滚动;弹窗只有正文滚动;表格只在自身容器里横向滚动。确实需要分别浏览的面板才有自己的滚动,并且要有明确的尺寸和任务边界。
|
||||
|
||||
- Flex 和 Grid 的子项要允许收缩(最小宽高设为 0),否则长内容会撑破父容器,出现整页横向滚动。
|
||||
- 不让页面、内容区和弹窗同时滚动;不为溢出随手加 `overflow: auto`;不做没有独立任务边界的多层嵌套滚动。
|
||||
|
||||
## 选择承载方式
|
||||
|
||||
| 任务 | 承载 | 要点 |
|
||||
| --- | --- | --- |
|
||||
| 短而自包含:确认、简短表单、预览加配置 | 弹窗 | 宽度随内容:确认用窄的,表单用中等,预览加配置用宽的或双栏 |
|
||||
| 需要保持底层列表、表格或画布可见 | 侧面板 | 从当前对象出发的查看或编辑;关闭后回到原对象 |
|
||||
| 多步骤、复杂创作、大量媒体,或需要两个以上主滚动区 | 完整页面 | 任务本身就是目的地,不是某页的附属动作 |
|
||||
|
||||
不在弹窗上再叠弹窗,需要第二层完整任务时升级为侧面板或页面。沿用项目已有的表面组件,不为一次需求另建一套。
|
||||
|
||||
弹窗固定分三段:
|
||||
|
||||
- **标题栏**固定在顶部:标题、必要的对象身份和关闭入口。
|
||||
- **正文**占剩余空间,是唯一的滚动区。
|
||||
- **操作栏**固定在底部,跨满弹窗宽度:主操作在右,取消紧挨着它,危险确认单独隔开;计数或校验摘要放在左侧。
|
||||
|
||||
弹窗高度用“内容上限 + 动态视口高度”约束,不用固定高度。纯阅读的弹窗只留关闭入口。关闭或返回后,回到原来的位置和对象。
|
||||
|
||||
## 菜单与浮层
|
||||
|
||||
浮层贴着触发器出现,空间不足时翻转或平移,保留视口安全边距。它要渲染在能脱离裁切的层上,不被表格、弹窗或滚动容器截掉;页面滚动、容器滚动和窗口缩放后重新定位;触发器消失时关闭。长名称按对象身份的规则截断,不撑大浮层;内容超出下拉能清楚承载的范围时,改用弹窗或完整页面。单选选完即关,多选在点“完成”或点击外部时关闭。关闭后不留下遮罩、滚动锁或看不见的点击层。
|
||||
|
||||
## 精准还原
|
||||
|
||||
先记录参考图的逻辑视口或已知像素尺寸、完整范围和固定区域。图像像素不等于逻辑视口;无法确定时说明假设。
|
||||
|
||||
测量主区域比例、共享对齐线、文字尺度、图片裁切与间距关系。先对齐整体几何与字体,再处理边框、阴影和细节;不因还原任务重新探索风格。
|
||||
|
||||
用与参考相当的视口和内容检查差异。参考没有展示的窄屏与状态根据内容约束补充,并区分“参考还原”与“合理延展”。
|
||||
|
||||
## 验证重点
|
||||
|
||||
实际检查长内容、空内容和当前改动相关状态。移动端注意视口变化、输入面板与安全边距,主操作不能被键盘或固定区域遮挡。
|
||||
|
||||
检查页面是否意外横向溢出、滚动是否发生在预期区域、焦点与菜单是否可见,以及缩放或重排后阅读和操作顺序是否仍然清楚。
|
||||
@@ -0,0 +1,45 @@
|
||||
# 素材
|
||||
|
||||
## 把资产作为设计起点
|
||||
|
||||
好的资产可以承担整个页面的情绪、对象识别或信息重心。先问“如果只保留一张图、一个标题和一个动作,哪张图值得占据这个位置”,再决定是否需要更多模块。不要把资产理解为布局完成后填进去的装饰。
|
||||
|
||||
跟随设计北极星决定图像的主体、视角、尺度、光线、颜色、材质和留白。页面的字体、背景、边缘处理与运动从这张图建立呼应;也可以通过一个受控的反差突出主动作。统一是共享视觉逻辑,不是对所有图片套同一个滤镜。
|
||||
|
||||
后台和工具同样需要资产判断:对象缩略图帮助识别,内容封面支持浏览,地图或空间图帮助定位,必要的示意图解释关系。它们可以成为界面最有价值的部分;不以“这是后台”为由默认只有表格和卡片。纯数据任务确实不受益时保留清楚的数据表达,不硬塞气氛图。
|
||||
|
||||
资产要成为结构,而不是装在盒子里的插图:让它跨过版面的分界、占满首屏,或者和文字叠在一起。页面可以是一个完整的世界,而不是“一张页面配一张图”。
|
||||
|
||||
先把关键资产放进真实页面看一轮,再延展其他区域。资产未到位时不能把占位版的审美评审视为最终通过。
|
||||
|
||||
没有合适的现成资产且宿主已有可用的图像生成能力时,主动制作关键素材,并给它明确的视觉任务;不要因为正在写前端代码就省略这一步。已有优秀素材时围绕它设计,不为证明使用了工具而重复生成。没有相关能力时保留清楚的素材简报与待完成项。
|
||||
|
||||
## 先确定表达作用
|
||||
|
||||
判断当前差距来自信息与构图,还是缺少能够表达产品和品牌的素材。不要用更多图片、光效和动画掩盖层级问题,也不要仅因代码容易编写,就用通用渐变和几何形状替代必要的图像。
|
||||
|
||||
素材可以用于展示对象、解释关系、建立品牌或情绪。先确定内容、位置、尺寸比例、裁切、背景和与文字的对比,再选择现有资源、摄影、插画或生成素材。
|
||||
|
||||
素材简报保持可执行:要表达什么、主体是什么、放在哪、宽窄屏如何裁切、哪里承载文字、什么细节不可丢失。若画面已经表达了主题,减少重复解说;叠字区域需有稳定明暗和足够空间,不默认给整张图盖浓重渐变。
|
||||
|
||||
## 图片与可编辑界面
|
||||
|
||||
实际查看输入图片后再引用其特征。复用质量合适且有使用依据的资源;不足时才生成或重绘。保留原始文件及来源,区分复用、裁切、生成和后处理。
|
||||
|
||||
界面文字、按钮、表单、数据标签和常规布局由可编辑元素表达,摄影、插画和特殊视觉放在明确容器中。不把完整截图作为可交互成品。
|
||||
|
||||
将素材需求交给宿主可用的生成工具,遵循工具自身的输入与编辑规则。怎样让生成图承担意思、怎样写提示词、怎样把图和页面接成一体,见 [配图](imagery.md)。
|
||||
|
||||
图像暂缺时预留合适容器并明确缺项,不自动安装工具、切换收费服务或要求用户把密钥粘进对话。替代素材不能冒充真实产品截图。
|
||||
|
||||
同组资产统一主体尺度、视角、光线和边缘质感,避免几张各自漂亮却互不相干的图拼在一起。生成素材不冒充真实现场或产品证据。最终交付保留来源与用途,网页按实际显示尺寸处理文件体积,重要主体在桌面和手机裁切中都必须成立。
|
||||
|
||||
## 生成视频的可选路径
|
||||
|
||||
需要可叠加的循环素材时,先验证首尾衔接、主体边缘和背景处理。单色背景加抠像只是候选技术;透明、反光物体可能丢失边缘或产生色溢,应在最终页面背景上检查。
|
||||
|
||||
需要连续叙事时,可以用前一段的末帧衔接下一段的首帧,或使用一致的关键帧规划。按滚动或手势控制播放进度时,检查寻帧、反向播放、加载和内存占用,不默认把整段视频解码成全部大图。
|
||||
|
||||
玻璃、水晶这类需要折射或透光的物体,先在最终页面的背景色上生成画面,让折射和投影烘焙进素材,再用视频抠像模型去掉背景。直接在绿幕上生成再抠像,折射会带着绿色。预渲染的折射和光照只对应生成时的背景,不应承诺会随任意页面内容产生实时物理效果。
|
||||
|
||||
透明底的图片或视频适合放在两个区块的衔接处,跨越边界,把上下两部分连成一个画面,比放在容器里更有设计感。
|
||||
@@ -0,0 +1,128 @@
|
||||
# 动效
|
||||
|
||||
## 每个界面先做三处动效
|
||||
|
||||
完全不动的界面像一张截图:按下没有回应,切换没有来路,用户只能猜刚才发生了什么。新界面和改动了操作流程的界面,先把这三处做出来,手感写进方向卡的“动效”一栏:
|
||||
|
||||
1. **主操作的反馈。** 按下立刻有回应,完成时交出一个结果:按钮陷下去再弹回,勾号画出来,数字滚到新值。
|
||||
2. **一处状态切换的来路。** 选中、展开、换视图、增删列表项时,让人看见东西从哪里来、到哪里去,见下文“界面过渡”。
|
||||
3. **第一次进入时的一次出场。** 主角先到,其余按阅读顺序跟上,只在首次进入时播放。
|
||||
|
||||
这三处之外再加动效,先问:如果要手写一周才能做出来,还值得做吗?模型让动效变得免费,但滚动淡入、视差、跟随鼠标和装饰性循环最容易变成干扰。每个动效都要承担反馈、引导、连续或品牌表达中的一项。
|
||||
|
||||
| 目的 | 适合考虑的方式 |
|
||||
| --- | --- |
|
||||
| 按下、聚焦、展开或普通状态变化 | 原生能力或已有组件的小范围过渡 |
|
||||
| 视图之间的切换、元素换位置 | 共享元素过渡,见下文“界面过渡” |
|
||||
| 拖拽、手势、空间变化与复杂编排 | 项目已有成熟动效能力 |
|
||||
| 特殊品牌画面、材质、复杂物理效果 | 图片、3D、着色器或预渲染视频,按需求取舍;着色器能做的效果见 [点缀与特效](ornament.md) |
|
||||
| 鼠标、触控或滚动控制叙事进度 | 将输入映射到明确的动画进度,处理反向和快速操作;思路见下文“滚动叙事” |
|
||||
|
||||
代码动画适合需要精确状态控制的界面,生成视频适合预先确定的视觉变化;不能用视频替代需要动态数据与真实操作的控件。
|
||||
|
||||
## 手感
|
||||
|
||||
动效的手感从方向的材质和调性推出,用“动作 + 实物”来命名和选参数(弹簧刚度 / 阻尼):
|
||||
|
||||
| 动作 | 实物参照 | 刚度 / 阻尼 | 适合 |
|
||||
| --- | --- | --- | --- |
|
||||
| 咔嗒 | 磁铁吸合、卡扣、拨动开关 | 350–400 / 10–15 | 按钮、开关、下拉展开 |
|
||||
| 滑入 | 气压闭门器、抽屉阻尼 | 250–300 / 20–25 | 底部面板、抽屉、折叠 |
|
||||
| 流淌 | 倒蜂蜜、雾气弥散 | 150–200 / 30–40 | 页面转场、主视觉出现 |
|
||||
| 砸落 | 锤击、重物落桌 | 400–500 / 5–10 | 确认选择、完成反馈 |
|
||||
| 回弹 | 橡皮球、蹦床 | 300 / 8 | 庆祝提示、儿童与游戏 |
|
||||
| 漂浮 | 氦气球、飘絮 | 100 / 25 | 冥想、梦幻场景、空闲循环 |
|
||||
|
||||
重的材质(石、金属、陶)偏咔嗒和砸落,轻的材质(纸、布、雾)偏漂浮和流淌;高频操作用短促的动作,少见的品牌时刻才用绵长的。喧闹粗粝的方向可以用硬切、80–150ms 的线性跳变和抽帧,作为有意的品牌表达;被禁止的只是没有考虑过的默认缓动。
|
||||
|
||||
过冲幅度跟着面积走。整页、面板这类大面积的东西,过冲不超过 1–2%,否则整块画面会晃;徽章、勾选标记这类小东西可以过冲 10–20%,显得有弹性。表里阻尼偏低的参数只用在小物件上。
|
||||
|
||||
网页可以把弹簧写成缓动曲线:按刚度和阻尼对位移做数值积分,取 20–30 个采样点写进 `linear()`,时长取位移稳定在终点 0.5% 以内的时刻。不支持的浏览器退回相近的 `cubic-bezier`。
|
||||
|
||||
## 呼应:让动作彼此接上
|
||||
|
||||
生硬的动效各动各的:按钮自己闪一下,面板自己滑出来,数字直接换掉。好的动效像一句话:一个动作引起的变化沿同一条路传下去,前一段的终点是下一段的起点。
|
||||
|
||||
- **从被操作的地方出发。** 点“加入购物袋”,商品缩成一个点飞进袋子,袋子上的数字跟着跳一下;删掉一行,它朝删除按钮的方向收走,下面的行补上来。
|
||||
- **一次切换朝一个方向。** 卡片往左滑,标题、序号和底色也往左换;数字往上滚,进度也往上涨。方向一乱,就成了几段互不相干的动画。
|
||||
- **主角先动,其余被带动。** 被影响的东西晚 40–80ms 跟上,幅度更小,像被主角拉过去的。全部同时起跑、同样幅度,看起来只是一起闪了一下。
|
||||
- **形状接力。** 上一个状态里的元素变成下一个状态里的元素:按钮展开成面板,搜索图标拉长成输入框,播放键变成暂停键。用户一眼看出它们是同一个东西。
|
||||
- **动法和画面是同一种材质。** 纸面的方向,切换轻、有一点停顿;硬件的方向,开关有咔嗒到位的感觉;液体和玻璃的方向,用流淌和折射。
|
||||
|
||||
### 巧思从产品里找
|
||||
|
||||
巧思是让一处动效讲出这个产品自己的事,不是加更多花样。从内容里找一个只有它才有的动作:
|
||||
|
||||
- 天气应用切到雨天,雨点落在卡片上;雨停以后,卡片上的水痕慢慢干掉。
|
||||
- 记账应用记下一笔支出,预算条少掉的那一截落进这笔账里。
|
||||
- 音乐播放器换歌,新封面的颜色从唱片中心漫到整个背景。
|
||||
- 完成待办时,勾号用和品牌标志相同的笔画画出来。
|
||||
|
||||
找不到这样的动作,就把三处基本动效做准,不硬造。一页最多一两处巧思,其余动效安静、短促。
|
||||
|
||||
## 界面过渡
|
||||
|
||||
**连续。** 用户点的东西就是下一屏的主角。从列表进入详情,被点的那一项放大成详情,其他项淡出;返回时它缩回原位,页面回到原来的滚动位置。原位已经不在视口里,就先把它滚到可见,再缩回去。
|
||||
|
||||
**方向一致。** 下一项从右边进,上一项从左边进;打开和关闭互为逆过程;侧面板从它所在的那一侧滑入滑出。方向随意的动画比没有动画更让人迷惑。
|
||||
|
||||
**只变形状时不做交叉淡化。** 切换视口、筛选、展开说明、缩放,这类变化里内容还是同一个东西,只是尺寸和位置变了。让外框直接变形,内容立即显示新状态。交叉淡化会让新旧两张缩放不同的画面叠在一起,出现重影。只有内容本身换了,才淡入淡出。
|
||||
|
||||
**离开、加入、留下分开处理。** 被移除的淡出,新加入的淡入,留下的滑到新位置。三者用同一种淡化,列表就会整片闪烁。
|
||||
|
||||
**选中指示用一块会移动的滑块。** 分段控件、标签页、列表当前项,都用同一块底板滑到新位置,而不是旧的熄灭、新的点亮。选项宽度不同时,宽度一起过渡。首次显示、窗口缩放和字体加载完成后直接放到位,不要从 0 宽度长出来。
|
||||
|
||||
**先准备好目的地。** 目的地要加载的内容,例如图片或内嵌页面,先加载或预先渲染好,否则动画会停在一块白屏上。需要来回切换的视图预先渲染并留在页面里,切换时只移动,不重新加载。内容加载完成后再淡入,不先闪一下白底。
|
||||
|
||||
**离开比进入快。** 退出时长约为进入的 60–70%,用加速曲线;进入用减速曲线或弹簧。
|
||||
|
||||
**到头要有反馈。** 已经是第一项或最后一项时,轻轻顶一下(往返 10–15px),而不是毫无反应。
|
||||
|
||||
## 时长和幅度
|
||||
|
||||
| 对象 | 时长 | 要点 |
|
||||
| --- | --- | --- |
|
||||
| 按下、开关、选中指示 | 150–300ms | 按下缩到 0.96–0.98,按下时立即生效(约 80ms),松开再弹回 |
|
||||
| 下拉、气泡、提示 | 180–250ms | 从触发点方向展开,缩放从 0.96 左右开始,不从 0 开始 |
|
||||
| 面板、抽屉、侧栏 | 250–400ms | 按移动距离取值,越远越长 |
|
||||
| 整页或视图切换 | 350–500ms | 超过 500ms 就会让人等 |
|
||||
| 首次进入时依次出现 | 每项间隔 40–80ms | 总长不超过 600ms;只在首次进入时播放,重新渲染和恢复状态时不重播 |
|
||||
|
||||
悬停上浮 2–4px,阴影随之加深。位移超过 20px 的悬停效果会显得轻浮。
|
||||
|
||||
## 看不见的结果要说出来
|
||||
|
||||
一次点击产生了看不见的副作用,例如复制到剪贴板、后台保存、发送,就用一条短暂的提示说出结果本身,例如“已复制:……”,而不是只说“成功”。确认类的时刻,例如选中或完成,可以用一次小幅弹出,只在状态第一次变化时播放,重复点击不重播。
|
||||
|
||||
## 滚动叙事:视差与一镜到底
|
||||
|
||||
滚动当作翻页,它只是翻页;当作摄影机的推拉,页面就能讲故事。品牌、发布和叙事页可以这样做,文档、文章、工具和后台不做;一页最多一段。记忆点先放在用户会做的动作上(点按、拖动、悬停),再考虑滚动;不要把整页做成视差秀。
|
||||
|
||||
- **一镜到底。** 整段是一次不切换的镜头。一个主角从头走到尾,可以是产品、一根线、一个形状或一个字,每一步长成、变成或让出下一个画面:推近看到细节,拉远看到全貌,转过去看到背面,穿过去进入下一个场景。动手前先画分镜:每个镜头占多少滚动距离、画面里有什么、什么在动、文字什么时候出现。
|
||||
- **停下来再说话。** 每个镜头留一段静止,文字在静止时出现;边动边读很累。
|
||||
- **视差要有真实的前后。** 只在画面本来就有远近层次时用:前景的物体、中景、远处的背景,越近动得越多,差别要小。文字不做视差。
|
||||
- **转场也是镜头。** 一个圆从某一点扩开,带出下一节;推进一张图,穿过它到达下一节;背景色在章节之间随阅读慢慢变;一句宣言随滚动逐字亮起。每种手法一页只用一次。
|
||||
- **倒着滚也成立。** 每一帧只由滚动位置决定,往回滚自然倒放,刷新或跳到中间也立刻是对的画面。不接管用户的滚动。
|
||||
- **留出口。** 长段落前给一个跳过入口;减少动态效果时,把每个静止画面按顺序排成普通区块。
|
||||
- **代码做不出来的,交给视频。** 用户想要很强的首屏动画,而效果要靠生成视频或序列帧才做得出来,例如角色转身、产品拆解、镜头穿越场景,可以告诉用户用 [oil-motion](https://github.com/oil-oil/oil-motion) 来做。CSS、SVG、着色器能完成的仍自己做;同一次对话只提一次。
|
||||
|
||||
## 体验与性能
|
||||
|
||||
沿用项目的运动节奏和组件能力。高频操作反馈直接,不让动画延迟业务完成。
|
||||
|
||||
动画要能被打断。连续快速操作时,新动画从当前位置接着走,不跳回起点重播。CSS 过渡会从当前值重新出发,适合高频切换;关键帧动画会从头重播,适合一次性的出场。业务状态立即更新,不等动画结束。
|
||||
|
||||
网页连续动画优先用变换与透明度,只声明需要变化的属性;小尺寸的指示块可以直接过渡宽高。逐帧工作要有停止和清理条件,避免频繁交错读写布局,离屏或无任务时停止播放。整页过渡期间页面通常不响应点击,所以整页过渡要短。
|
||||
|
||||
明显位移、缩放和滚动特效,要为“减少动态效果”提供等价呈现,此时所有状态直接到位。必要信息和主要操作不能依赖自动播放、声音或动画完整结束。
|
||||
|
||||
## 检查动效
|
||||
|
||||
静态截图证明不了动效。三处基本动效和动效记忆点都要留证据:一段 3–5 秒录屏,或开始、中间、结束三帧,并写明触发它的动作。把播放速度调到 10%,可以用浏览器开发者工具的动画面板,或自动化工具里的动画倍速,在过渡进行到一半时截图。看有没有重影、跳动、元素突然出现,或者从奇怪的位置飞来。逐个检查开始、中间、结束和反向;再快速连按几次,确认不会卡在中间状态。最后开启“减少动态效果”再走一遍。只有实际测量后才报告帧率或性能改善。
|
||||
|
||||
## 网页实现提示
|
||||
|
||||
- 浏览器原生的视图过渡适合做“同一对象从一处变到另一处”:给前后两个状态里代表同一对象的元素起同一个过渡名,名字在同一时刻必须唯一。
|
||||
- 只变形状的过渡里,隐藏旧快照、直接显示新状态,就不会出现重影。
|
||||
- 过渡伪元素后面只能跟少数伪类,例如 `:only-child`;写成 `:not(...)` 会让整条规则失效。需要按场景区分规则时,在根元素上加一个属性来切换。
|
||||
- 内嵌页面和图片在过渡里会被截成快照;目的地没加载完就开始过渡,会以空白结束。
|
||||
@@ -0,0 +1,82 @@
|
||||
# 点缀与特效:SVG 和着色器
|
||||
|
||||
这里讲用代码画出来的视觉效果:小到一条手绘下划线、一层颗粒,大到首屏整片流动的渐变、扫过按钮的一道流光、由点阵组成的主视觉。它们能让方向从“配色对了”变成“有生命”;做得粗糙时,就是又一层模型默认的装饰。
|
||||
|
||||
## 先问要不要
|
||||
|
||||
- **从生成引擎里来。** 效果要能说出属于这个方向的理由:方向的关键词是“信号”,点阵和扫描线才成立;是“流体”,流动渐变才成立。删掉它,方向会少一点自己的东西;删掉后没有区别,就不加。
|
||||
- **一个主效果,几处回响。** 一页选一个主效果,其余地方只用它的颜色、形状或节奏小声呼应,比如首屏是流动渐变,按钮的流光就用同一组颜色。几种特效各自为政,页面就成了效果展示。
|
||||
- **不碰内容。** 效果不压在正文、数据和控件上,也不画得像按钮;在它上面的文字,按最差的一帧检查对比度。
|
||||
- **用代码画。** SVG 和着色器体积小,跟着主题色走,放大也清楚。摄影和复杂插画才用图片,见 [素材](media.md)。
|
||||
- **质感先来自材质。** 顶尖的产品界面很少用流光、渐变团和着色器,让人记住的多是写实的材质、准确的结果和手感。特效是给少数方向的,不是提升质感的默认手段。
|
||||
- **产品界面收着用。** 后台和工具的工作区不放持续运动的效果;登录、空状态、完成反馈和品牌页可以放开。
|
||||
|
||||
## SVG 能做的几类
|
||||
|
||||
| 效果 | 做法 | 要注意 |
|
||||
| --- | --- | --- |
|
||||
| 颗粒质感 | `feTurbulence type="fractalNoise"` 生成噪声,`feColorMatrix` 把它染成页面的颜色并控制透明度,写成 data URI 平铺成 `background-image`。细颗粒 `baseFrequency` 约 0.8–0.9、透明度 0.05–0.08;再叠一层约 0.004 的大块起伏,表面就不是均匀色块 | 加 `stitchTiles="stitch"` 避免拼缝。做成固定尺寸的平铺图,不把 `filter` 挂在整页或滚动容器上,否则每次重绘都要重算。只在方向本身有实物材质时加,不当作提升质感的通用补丁 |
|
||||
| 手绘框、粗糙边缘、大号数字的毛边 | `feTurbulence` 接 `feDisplacementMap`,`scale` 约 1.5–3。同类图形各换一个 `seed`,不要一模一样 | 正文不加;`scale` 太大像坏了。滤镜区域默认只向外留 10%,位移、光晕和投影会被裁掉,给 `filter` 设足够的 `x`、`y`、`width`、`height` |
|
||||
| 沿弧线或环形排字 | `<textPath>` 配 `startOffset="50%"` 和 `text-anchor="middle"` 居中 | 字放在弧的外侧;放在内侧,字距被挤压,字形会相撞。手机换一套 `viewBox` 重新取景,不只是等比缩小 |
|
||||
| 下划线、轨迹、连接两处内容的引线 | `path` 加 `stroke-linecap="round"`;缩放时线宽不变用 `vector-effect="non-scaling-stroke"`;描线动画设 `pathLength="1"` 和 `stroke-dasharray="1"`,让 `stroke-dashoffset` 从 1 走到 0 | 引线两端要真的指向内容。让光点沿线流动:用一段短虚线加长间隔,动画 `stroke-dashoffset`,再叠一份模糊的副本当光晕 |
|
||||
|
||||
颜色统一用 `currentColor` 或设计变量,让效果跟着主题和深色模式变。
|
||||
|
||||
## 着色器能做什么
|
||||
|
||||
| 效果 | 核心做法 |
|
||||
| --- | --- |
|
||||
| 流动渐变 | 3–5 个颜色点沿各自的慢速轨迹移动,按距离混合;要更像液体,用噪声扭曲坐标再取噪声(domain warping),纹路会像大理石或油彩那样流 |
|
||||
| 极光和光带 | 在一个方向上拉长的噪声,叠加在深色底上,边缘用渐变遮罩收掉 |
|
||||
| 虹彩和镭射 | 用余弦调色板 `a + b·cos(2π(c·t + d))` 按角度或指针位置取色,四组参数就能调出整条连续的色带 |
|
||||
| 流光 | 一条两边柔化的亮带,按 20°–30° 斜角扫过形状,只在形状内部可见 |
|
||||
| 点阵和半调 | 把画面切成网格,每格按下面的亮度画一个圆点;动起来可以是波纹、指针附近的隆起,或者从点阵慢慢显出一张图 |
|
||||
| 像素和抖动 | 用 Bayer 矩阵做有序抖动,把颜色压成少数几种,得到一位色屏幕和早期游戏的质感 |
|
||||
| 跟随指针 | 指针附近的亮斑、扭曲或点阵隆起;指针位置先做惯性平滑,再传给着色器 |
|
||||
|
||||
不少效果不需要 WebGL:边框上转圈的流光,用 `@property` 注册一个角度变量,驱动 `conic-gradient`,再用 `mask` 只露出边框;文字上的流光,用 `background-clip: text` 配移动的渐变;跟随指针的卡片光斑,把指针坐标写进 CSS 变量,驱动 `radial-gradient`。只有连续变化的噪声、逐像素计算或大量点阵时,才用着色器。
|
||||
|
||||
## 做得高级的关键
|
||||
|
||||
**渐变**
|
||||
|
||||
- **在 OKLab 或 OKLCH 里混色。** 在 sRGB 里混两种对比色,中间会经过发灰发脏的一段;在 OKLab 里混,中间段仍然饱满。CSS 可以写 `linear-gradient(in oklch, …)`,着色器里先转到 OKLab 再插值。
|
||||
- **加抖动消色带。** 大面积平滑渐变在 8 位显示上会出现一圈圈色带,暗部最明显。输出前叠一层正负半个色阶的噪声,色带就消失了;把噪声加大到 3–8%,就是颗粒渐变的质感。
|
||||
- **配色从品牌来。** 紫蓝色的柔光团是最典型的模型默认。从设计变量里取两三个色相,挑一组不常见的搭配,再加结构:光带、点阵、颗粒,而不是再加一团光。
|
||||
- **光晕不要糊成白块。** 几层光叠加后会超过 1,直接截断会变成一片死白。用 `1 - exp(-x)` 这类曲线把亮部压回来,高光才有层次。
|
||||
|
||||
**流光**
|
||||
|
||||
- **亮带要窄、边要软。** 宽度约为元素的 15–25%,两侧用 `smoothstep` 柔化。
|
||||
- **扫过要快,间隔要长。** 单次 600–900ms、缓入缓出;在悬停、出现时播放,或每隔 4 秒以上扫一次。一刻不停地扫,就显得廉价。
|
||||
- **用提亮的混合方式。** `screen` 或 `plus-lighter` 让流光提亮底色,而不是刷上一层白。浅色底上几乎看不见流光,所以它更适合深色、饱和色或金属质感的表面。
|
||||
- **只在一个地方扫。** 通常是主按钮或一张主卡片。每张卡都在闪,就没有重点。
|
||||
|
||||
**点阵**
|
||||
|
||||
- **圆点面积跟明暗走。** 半径按亮度的平方根变化,面积才和明暗成正比,画面读起来才像原图。
|
||||
- **边缘要抗锯齿。** 用 `fwidth` 和 `smoothstep` 在一个像素的宽度内过渡(WebGL2 直接可用)。硬边的点动起来会闪烁、爬行。
|
||||
- **防摩尔纹。** 网格尺寸取设备像素的整数倍,或把网格旋转 15°、45°;彩色半调的每个通道用不同角度。
|
||||
- **没亮的点也画出来。** 屏幕式的点阵,熄灭的点保留很淡的一层,亮的点加一圈柔光,才像真的屏幕。
|
||||
- **格子大小决定读法。** 每格 6–12 CSS 像素读起来是质感,16 像素以上读起来是图形。
|
||||
|
||||
**动起来**
|
||||
|
||||
- **速度跟角色走。** 背景的循环慢到十秒以上,读正文时注意不到;交互反馈要在 150–300ms 内响应。
|
||||
- **指针要有惯性。** 每帧让当前位置向目标靠近一小步(例如 `current += (target - current) * 0.1`),效果才像有重量,不会一抖一抖地跟着鼠标。
|
||||
- **可以跟滚动绑定。** 把滚动进度作为参数传进去,让效果随阅读推进变化,比独立循环更有叙事感。
|
||||
|
||||
## 成本与兜底
|
||||
|
||||
- **分辨率按效果选。** 模糊的颜色场按 1 倍或一半分辨率渲染,再用 CSS 放大;点阵、像素和细线要按实际设备像素渲染,否则会糊。
|
||||
- **不引入 3D 引擎。** 只画一个全屏四边形时,直接写 WebGL 或用几 KB 的小工具库,不为它加载 three.js 这类完整引擎。
|
||||
- **看不见就停。** 滚出视口时用 `IntersectionObserver` 暂停,标签页隐藏时停掉 `requestAnimationFrame`;`prefers-reduced-motion` 时只画一帧静止画面。
|
||||
- **先写兜底。** 拿不到 WebGL、上下文丢失(`webglcontextlost`)或设备太弱时,显示同样配色的 CSS 渐变或静态图,页面照常可读。
|
||||
|
||||
## 验收
|
||||
|
||||
- 截几个不同时刻的画面,不只看第一帧;效果上的文字按最差的一帧检查对比度。
|
||||
- 100% 和 200% 下看点阵、颗粒和边缘;缩略图里看不出颗粒是正常的。
|
||||
- 浅色、深色和减少动效三种设置各看一次,再看一次兜底画面。
|
||||
- 在目标手机上看滚动是否掉帧、机身是否发热。
|
||||
- 装饰性 SVG 和画布加 `aria-hidden="true"`;承担意思的效果,例如由点阵拼出的标题,另给读屏能读到的真实文字。
|
||||
@@ -0,0 +1,82 @@
|
||||
# 风格对比页
|
||||
|
||||
把几个候选放在同一页里并排比较,由用户挑选。用户可以并排或单张查看、切换桌面和手机、按实际尺寸看、筛选、选定并写备注。对比页的外壳只负责比较和挑选,不带风格倾向,也不作为候选的参考。
|
||||
|
||||
先保持核心内容、主要动作和目标用户一致,再改变构图、信息组织、字体气质、色彩关系、密度与图像语言。数量按请求和有效差异决定,不复制整个产品来凑方案,也不把内置外壳的风格套给候选。
|
||||
|
||||
对比页是视觉探索工具。HTML 小样默认不执行脚本和提交,标记为可操作的小样和本机地址候选除外(见“准备输入”);需要真实数据、登录或后端的交互验收,仍在项目里进行。
|
||||
|
||||
## 准备输入
|
||||
|
||||
一轮只生成一个对比页,候选能跑就用真实页面:单独写的 HTML 小样直接嵌入,项目里的方案用本地地址。只有拿不到可运行的页面时才用截图。每个方案一个画面:存量项目选入口或差别最大的那一步,整站方案只放代表页,其他页面写进说明。手机版只在手机是主要设备时另出,不手写汇总页。
|
||||
|
||||
在任务目录放置 `manifest.json` 和候选文件。HTML 候选为带 `head` 的文档,CSS 写在页面里。图片、字体和视频用相对路径引用任务目录里的文件,例如 `img/hero.jpg`,生成器会把它们内嵌进对比页;不要自己把图片转成 base64 写进候选,文件会变得很大、难以修改。不引用网络资源和任务目录以外的文件,链接只使用本页锚点。可以保留代码供独立打开,但比较页不执行其脚本。
|
||||
|
||||
候选必须在不运行脚本时也能显示设计内容。依赖脚本生成界面的页面,先取得静态 HTML 或实际截图;不能把只有空挂载节点的应用入口交给对比页。
|
||||
|
||||
候选内部不使用 iframe、object 或 embed 等嵌套文档;先将需要的内容转为静态页面或截图。另存的现有页面常带 `srcset` 或 `<picture><source>` 响应式图片,生成器会拒绝:改成一张用相对路径引用的 `<img>`,或直接用截图作为图片候选。
|
||||
|
||||
图片候选支持 PNG、JPEG、WebP。实际查看图片并记录它代表的页面与状态,避免拿不同任务或不同状态的图比较风格。源文件必须位于 manifest 同目录或子目录。
|
||||
|
||||
manifest 使用固定字段,结构如下。尖括号内是占位说明,全部换成本轮方向卡里的实际内容;不要沿用占位文字作为风格:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"project": "<项目名称>",
|
||||
"brief": "<所有候选共同表达的内容与主要任务>",
|
||||
"round": "<轮次,例如 01>",
|
||||
"candidates": [
|
||||
{
|
||||
"id": "<小写唯一标识>",
|
||||
"name": "<方向名称>",
|
||||
"concept": "<北极星:一句能指导取舍的体验意图>",
|
||||
"typography": "<实际使用的字族、字重与尺度关系>",
|
||||
"palette": ["<十六进制色值>", "<十六进制色值>", "<十六进制色值>"],
|
||||
"traits": ["<可观察特征>", "<可观察特征>"],
|
||||
"kind": "html",
|
||||
"source": "<候选文件的相对路径>"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
自己写的可运行小样(单个 HTML、脚本内联),在 manifest 里给候选加 `"interactive": true`。单张查看时就能直接操作,对比页仍是可离线移动的单个文件,不需要服务器;卡片左下角标注“可操作”。这类候选只能运行内联脚本,不能联网或加载外部文件;浏览器存储换成页面关闭即清空的内存版本;`alert`、`confirm` 这类系统弹窗不会出现,需要确认时用页面内的对话框。
|
||||
|
||||
已有仓库里要运行起来的应用(例如 React、Vue 项目),把 `kind` 设为 `url`,用 `url` 字段代替 `source`,指向本机开发服务器上的页面,例如 `http://localhost:5173/orders?variant=b`。这类候选在对比页里实时运行,脚本和表单照常工作,切换手机视口时应用会真实重排;卡片左下角标注“本地运行”。地址只能是 localhost、127.0.0.1 或 ::1;页面空白时,确认开发服务器已启动,且没有用响应头禁止被嵌入。这类候选要靠开发服务器才能看,所以:
|
||||
|
||||
- 在 manifest 顶层写下启动方式,就是你刚才自己用过的那条命令:`"serve": {"command": "pnpm dev", "cwd": "<项目的绝对路径>", "url": "http://localhost:3456"}`。
|
||||
- 交付时让开发服务器开着,用户打开对比页看到的就是真实页面。
|
||||
- 服务没在运行时,对比页会提示用户在对话里说“打开对比页”,并附上这条命令。
|
||||
|
||||
不默认另存截图版。用户要长期留档或发给别人时,再用 [工具](tools.md) 里的截图工具另截一份。
|
||||
|
||||
给当前版本加 `"baseline": true`,它会排在第一位,编号显示为“现状”,其余方向从 01 开始编号。每轮最多一个基线。改动存量页面时,都把现状放进来,让每个方向都和它对照。
|
||||
|
||||
`id` 使用唯一的小写字母、数字、连字符或下划线。`typography` 描述实际使用的文字关系,`palette` 使用十六进制颜色,`traits` 简述可观察差异。图片候选将 `kind` 改为 `image`、`source` 指向对应图像。
|
||||
|
||||
## 生成和打开
|
||||
|
||||
确认 Python 不低于 3.10,将脚本与输入输出参数解析为绝对路径后运行 [生成器](../scripts/build_explorer.py):
|
||||
|
||||
```text
|
||||
<python> <skill>/scripts/build_explorer.py <任务目录>/manifest.json --output <任务目录>/style-explorer.html
|
||||
```
|
||||
|
||||
macOS/Linux 通常使用 `python3`,Windows 通常使用 `py -3`;执行前确认实际解释器。没有生成器运行能力时保留输入与候选供直接查看,说明尚未组装对比页,不临时重写另一套工具。
|
||||
|
||||
默认拒绝覆盖。确实要更新已有本轮对比页时显式加 `--force`,或使用新的输出文件名。输入错误时保留旧产物;生成器不覆盖源候选,也不允许把运行产物写入 Skill 目录。
|
||||
|
||||
用浏览器打开返回的本地 HTML。HTML 小样和截图已经内嵌,单个文件移到哪里都能看;本地地址候选要开发服务器开着。模板原文件只有空状态,不是用户最终对比页。
|
||||
|
||||
用户之后说“打开对比页”时:读对比页旁边的 manifest 里的 `serve`,在 `cwd` 里后台运行 `command`,等 `url` 能访问了,再用浏览器打开对比页。端口被占用时先确认占着它的是不是同一个项目。
|
||||
|
||||
## 查看与记录选择
|
||||
|
||||
默认使用相同逻辑视口展示 HTML 候选:桌面 1280×900,手机 390×844。卡片等比缩放以便同屏比较;判断文字大小与细节时进入单张查看,使用“实际尺寸 100%”检查真实排版;适应窗口仍可能缩小,不能据此宣称可读性通过。切换手机视口后确认真实重排,而非仅看缩小后的桌面。
|
||||
|
||||
点“选择”时会复制一句简短的文字,例如“<轮次>:选 03 <方向名>”,有备注时再附一行。用户粘贴复制的结果或直接说出选择后,Agent 按轮次和编号对照当前 manifest,确认对应本轮,再把选择与备注更新到项目设计说明,继续实现。浏览器本地保存不代表已经写回项目文件,也不会自动向 Agent 发送消息。
|
||||
|
||||
## 检查实际结果
|
||||
|
||||
核对候选:每张画面和 manifest 里写的页面、状态、视口一致,中文字体真实渲染出来了。对比页本身的并排、单张、筛选、复制和离线打开由 Skill 自带的回归测试保证,任务里不验证、不读模板源码,也不在用户自己的浏览器里清除任何存储。
|
||||
@@ -0,0 +1,55 @@
|
||||
# 工具
|
||||
|
||||
本目录自带的程序按下面的写法调用,不需要读源码;加 `--help` 打印同样的说明。
|
||||
|
||||
宿主有自己的浏览器工具时,查看页面、走查流程照常用。要交出下面这几样标准证据时,优先用这里的截图工具:它开一个独立的临时浏览器,不碰用户自己浏览器里的数据。本机缺 Node 22,或者没有 Chrome、Chromium、Edge 时,退回宿主工具,交出同样的几样东西。不为截图另写 Playwright 或浏览器协议脚本。
|
||||
|
||||
## 截图和录屏
|
||||
|
||||
```text
|
||||
node <skill>/scripts/shoot.mjs <页面地址或 HTML 文件> [选项]
|
||||
```
|
||||
|
||||
本地文件会用一个只监听本机的临时服务器打开。地址里带 `?` 或 `&` 时加引号,例如 `"http://127.0.0.1:3000/?state=done"`;更省事的是用 `--states`。同名文件会直接覆盖,不需要额外参数。
|
||||
|
||||
| 要什么 | 加上 |
|
||||
| --- | --- |
|
||||
| 每个状态各一张,再拼成并排图 | `--states idle,running,done,error --sheet` |
|
||||
| 同时要遮掉全部文字的版本 | 再加 `--mask` |
|
||||
| 200% 截图 | `--zoom 2` |
|
||||
| 手机和桌面各一套 | `--size 390x844,1280x900`(默认只有 390x844) |
|
||||
| 整页长图 | `--full` |
|
||||
| 截图前先操作几步 | `--steps "click .open; wait 300"` |
|
||||
| 主操作录屏,外加开始、中间、结束三帧 | `--record --steps "drag .handle 0 -80; wait 400; click .start" --hold 1500` |
|
||||
|
||||
`--steps` 里的动作用分号隔开:`click 选择器`、`hover 选择器`、`drag 选择器 横向位移 纵向位移`、`type 选择器 文字`、`key 按键`、`scroll 纵向位移`、`wait 毫秒`。
|
||||
|
||||
结果写进 `--out` 指定的目录,默认是 `./shots`:每个状态一张 `<状态>.png`,遮字版是 `<状态>-masked.png`,并排图是 `sheet.png` 和 `sheet-masked.png`,录屏是 `record.mp4` 和 `motion-start/mid/end.jpg`。每张图都会检查控制台错误、横向溢出和没加载出来的图片,命令最后一行给出结论,明细在 `report.json`。录屏需要本机装了 ffmpeg,没有时只留三帧。
|
||||
|
||||
检查报出接口 401、404 这类和画面无关的错误时(例如访客状态下的登录接口),在交付说明里记一行就行,不为了让检查通过去改登录、接口这类业务代码。
|
||||
|
||||
截图前先自己看一眼结果:默认状态是不是已经加载完,遮字版有没有把图形也一起盖掉。证据有错先重截,再交给评审。
|
||||
|
||||
## 写可操作的小样
|
||||
|
||||
小样要能用地址参数直接打开每个状态,例如 `?state=done`,截图工具和评审都靠它。一个组件或一个页面只写一个文件,不要每个状态复制一份。
|
||||
|
||||
颜色、字号、间距、圆角先定成十来个 CSS 变量,后面一律引用变量;同一个组件的样式用原生 CSS 嵌套写在一起。不引入 Tailwind 或 Sass 这类需要构建的工具。
|
||||
|
||||
状态和交互用 Alpine.js 写在 HTML 属性上,不手写一长段切换显示的脚本:
|
||||
|
||||
1. 复制到任务目录:`cp <skill>/assets/vendor/alpine.min.js <任务目录>/vendor/`
|
||||
2. 页面里引用:`<script defer src="vendor/alpine.min.js"></script>`
|
||||
3. 状态集中放在一个 `x-data` 里,初始状态读地址参数:
|
||||
|
||||
```html
|
||||
<main x-data="{ state: new URLSearchParams(location.search).get('state') || 'idle', amount: 40 }">
|
||||
<p x-show="state === 'done'">做好了</p>
|
||||
<input type="range" min="20" max="80" x-model.number="amount">
|
||||
<button @click="state = 'running'">开始</button>
|
||||
</main>
|
||||
```
|
||||
|
||||
放进对比页时,在 manifest 里给这个候选加 `"interactive": true`。生成器会把任务目录里引用到的 CSS 和 JS 文件内联进对比页,不用手动合并。
|
||||
|
||||
存量项目里照用项目自己的技术栈,不引入 Alpine.js。
|
||||
@@ -0,0 +1,202 @@
|
||||
# 视觉语言
|
||||
|
||||
## 从整体到局部
|
||||
|
||||
先查看信息焦点、构图和内容密度,再调整空间、排版、色彩与细节。前一层已经解决问题时,不为显得精修而继续添加样式。
|
||||
|
||||
使用项目有效的视觉变量、组件和参考关系。没有标准时只定义当前需要的文字、颜色与间距角色;不凭空生成完整设计系统。
|
||||
|
||||
每次修改能说明:哪个可见元素、哪种关系存在问题、调整什么、期待改善什么。源码检查可以证明样式不一致,不能单独证明画面更好。
|
||||
|
||||
## 焦点与空间
|
||||
|
||||
- 每个任务区域有可辨认的主信息或主动作,辅助信息通过尺度、字重、对比或位置退后;不用多种强强调同时争夺注意力。
|
||||
- 同类关系保持同类间距,组内更紧、组间更松。先用对齐与留白建立结构,再判断是否需要容器或分隔线。
|
||||
- 留白承担分组、焦点或节奏。工具界面不为追求空旷牺牲必要信息,品牌页面也不强制塞满首屏。
|
||||
- 非对称、越界图形或大尺度文字可以表达选定方向,但需要稳定的局部对齐和清楚阅读顺序;不把有意的非对称自动当成错误。
|
||||
- 父布局负责区域排列,组件负责自身内部空间。同一距离不要同时由多层样式重复增加。
|
||||
- 先把内容直接放在画布上,通过对齐、字号和留白组织。容器只在确有分组、选择、背景对比或操作边界时出现;不要把页面、模块、条目和条目说明逐层套成卡片。
|
||||
- 分隔线和边框不要重复。每条线只做一件事:同一处分隔不叠双线、单线再加一个框;留白已经分开的地方不再补线;不要每个层级都画一遍线。线的粗细对应层级,例如粗线分版面、细线分栏,全页一致。线本身是风格的一部分时照样保留,只删重复的那些。
|
||||
|
||||
## 字体与内容
|
||||
|
||||
- 按标题、正文、辅助信息、标签和数据建立文字角色。角色稳定,层级有足够区别;实际内容长度决定换行和空间。
|
||||
- 检查目标语言的字形、字重与回退字体。英文样例好看不能证明中文排版成立;中英混排检查基线与视觉重量。
|
||||
- 正文行长与行高服务阅读;密集表格按扫描与比较安排,不套用文章排版。需要纵向比较的数字可使用等宽数字。
|
||||
- 重要名称和说明不靠固定高度裁切。确需截断时保留完整内容的可访问入口,避免相近名称截断后无法区分。
|
||||
- 先写具体产品内容再调布局。按钮说明动作结果;价值表达使用真实用途,不用空泛口号、虚构背书或无意义英文填版。
|
||||
|
||||
### 可读性是交付门槛
|
||||
|
||||
字号与行高在真实视口、实际字体和 100% 阅读尺寸下判断,缩略图只能用来判断构图。阅读型页面的中文正文通常从 16–18px 起步,产品界面正文 14–16px,每天长时间使用的高密度工具可以到 13–14px(见设计方向里的密度);正文无单位行高 1.6–1.75;操作文字通常 14–16px。它们是起点,不是跨产品的死规定。不要靠 9–12px 的说明、低对比细字或压缩行高营造“精致”。信息太多先删冗余、调层级或重排。
|
||||
|
||||
多行中文标题通常先用 1.2–1.35 的行高,按真实字形检查。不要照搬英文大写海报的极紧行高,不把标题高度固定为单行,不用负外边距挤压后续内容。展示字确需更紧时,必须看过每个支持视口的实际换行并确认字形不碰撞。
|
||||
|
||||
中文断行要主动控制:`text-wrap: pretty` 或 `balance` 只能减少孤字,不能消除。数字和单位(38 秒、47 分钟)、“不会”这类不可拆的词用不换行包起来;首屏说明、页边批注这类短段落按语义手动断句。行末只剩一两个字,或者词被拆到两行,都算缺陷。
|
||||
|
||||
验收时看长标题、两到三行正文、中英混排、数字单位与字体回退;在窄屏和放大阅读后检查行间、上下区块及按钮内部。文字重叠、截断关键信息或被相邻区域压住属于未完成。先修行高、内容驱动高度和容器伸缩,再考虑减小字号。
|
||||
|
||||
### 示例数据要像真的
|
||||
|
||||
示例数据会暴露模板感,即使视觉层已经很好。要避开:整数指标(1,000 位用户、+10%)、张三李四和示例公司、每条都是“2 小时前”、写着“标题”“描述”的占位字、页面上反复出现的“了解更多”“立即开始”。真实数据是不整齐的:1,238 位用户、+1.83%,时间有“刚刚”“昨天 16:12”“3 月 14 日”;名字有长有短,顺带验证长名字的排版。按钮说出具体动作,例如“保存草稿”“周二发送”。一页上有很多演示(组件目录、模板库、功能展示)时,让它们共用一个具体的虚构场景:同一个产品、同一批人、同一组文件和数字,演示之间互相呼应,比各自编一条“示例标题”可信得多;不要把库名、技术栈当成演示内容。
|
||||
|
||||
## 色彩与表面
|
||||
|
||||
颜色按文字、背景、动作、选中与状态等含义使用。品牌色与语义状态可以共存,但不能只靠颜色区别重要状态。
|
||||
|
||||
边框、阴影、材质和渐变要与方向一致,并帮助分层、表达氛围或建立品牌识别。不要把紫色、圆角、渐变列为一律禁用;问题在于无意义重复、主次混乱和不适合任务。
|
||||
|
||||
避免每张普通卡片都使用不同的强调色、重边框与重阴影。相同层级保持一致重量;内部嵌套轮廓与外部容器协调。
|
||||
|
||||
配色做两个自检:去掉所有发光和模糊后,配色是否还成立;能否说出一个用类似配色的成功产品或出版物。表面层级可以用色温和色相的偏移来区分,不只靠明暗。选完配色再问一句:这组颜色是从这个产品的内容、用户或品牌里推出来的,还是一组放在哪个产品上都不出错的安全色?是后者,就回到产品重新找。
|
||||
|
||||
质感按调性的完成度取用,不混用:
|
||||
- 精致一端:表面的高光、层次柔和的阴影、细微的冷暖交叉色、细线分隔。
|
||||
- 粗粝一端:手工和印刷留下的痕迹,从这个方向的生成引擎里找,不套一组固定的道具。
|
||||
|
||||
核对文字、控件边界、焦点和选中态在实际背景上的可辨认性,尤其是图像、透明和动态背景。遵循项目已有的可访问性目标;基础阅读与操作不能依赖猜测。
|
||||
|
||||
## 模型默认审美自查
|
||||
|
||||
先做一个总测试:把画面给人看,说“这是 AI 做的”,对方会不会马上相信?好的界面应该让人问“这是怎么做出来的”,而不是“这是哪个 AI 做的”。
|
||||
|
||||
没有明确风格依据时,模型容易生成下列默认做法。它们都不是禁令,但每出现一项,都要能说出它怎样服务当前北极星;说不出就换成方向卡里的具体选择:
|
||||
|
||||
- 无来由的渐变、柔光、玻璃拟态和发光描边。
|
||||
- 配色说不出来自这个产品的理由,只是因为它“不会出错”“显得高级”或者“这个行业都这么用”。判断不看是哪种颜色,任何颜色都可以用,只看理由是否来自这个产品。
|
||||
- 中文界面从头到尾只用思源黑体或系统黑体,标题没有自己的字体性格。
|
||||
- 在页面里画假的浏览器窗口、红黄绿三个圆点、写着 9:41 的手机状态栏或整台手机外壳。浏览器和设备本身已经提供这些,产品本身是浏览器或设备模拟器时除外。
|
||||
- 所有内容都装进同样圆角、同样阴影的卡片,卡片里再套卡片。
|
||||
- 首屏固定为左右分栏:左边大标题,右边说明、按钮或一张图;下方接三列图标特性卡。换了字体和颜色,这个骨架也常常原样保留。
|
||||
- 用表情符号或随手找的图标给每个标题做装饰;最典型的是 2px 描边的通用图标放进浅色底圆角方块,每个标题配一个。
|
||||
- 工具、后台,以及画廊、商店、信息流这类内容页,顶部放超大标题、口号或解释这个页面的段落,把第一排内容推出首屏;用一整块深色圆角卡片装“下一步”动作;每一列、每个区块下面一行解释它是干什么的。
|
||||
- 用单侧色条标重点:引文、提示、答题反馈、当前项、紧急卡片,左边或右边加一条 2–4px 的彩色边线。这是最常见的模型默认之一,“它在表达状态”不能当理由。状态用元素本身表达:状态词加颜色或图标、整块底色、字重、位置和顺序。单侧的线只用来分隔结构,例如侧栏和主区之间、表格的列之间,并且用中性色。
|
||||
- 用星星、闪光、魔法棒、机器人表示 AI 功能。图标应表达实际动作或结果,例如摘要、翻译、转写。
|
||||
- 全页只用一种无衬线字体,标题与正文尺度相差不大,标题没有自己的性格。
|
||||
- 每个区块都有英文眉标、胶囊徽章和一句空泛口号,例如“一站式”“赋能”“开启全新体验”。中文产品用英文做字标、表头和分区名(WORKSPACE、CONTENT OBJECT、FIELD NOTES)也属于这一类。
|
||||
- 界面上写满制作说明:“示例”“待核实”“概念图”“示意图”“按钮未接入”,或者“仅保存在此浏览器”这类解释实现方式的话,挂在字段旁、图片角落或页脚。这些是写给委托人的话,放进交付说明。
|
||||
- 所有间距相同,页面没有松紧节奏,也没有一处视觉重心。
|
||||
- 随意给文字上色、加高亮,或者到处加描述性小字和标签,留出没有用途的空白区域。
|
||||
- 首屏之后接一排三栏功能格:编号或图标、小标题、两行说明,内容和首屏重复。首屏再有辨识度,到这里也会退回模板。
|
||||
- 界面完全不动,或只有悬停变色;有动效时各动各的:每个区块套同一个淡入上移,按钮、面板和数字之间没有关系。
|
||||
- 自己画的下拉、开关、滚动条,比平台原生组件更难用。
|
||||
- 文案抽象,用晦涩的比喻代替具体说明。方向的比喻也会渗进功能:按钮叫“检查采样”,导航画成一圈轨道。比喻只管视觉气质,功能名、按钮和导航用产品本身的说法。
|
||||
|
||||
要求精简时给出具体操作,例如“改成以图片为中心的网格,去掉渐变、发光和多余容器”。只说“像苹果一样极简”几乎不起作用。
|
||||
|
||||
检查时截图整页,逐项对照。出现三项以上,通常说明方向还停在默认模板,应回到方向卡重新确定主构图和主视觉,而不是逐个替换装饰。
|
||||
|
||||
## 控件与图标
|
||||
|
||||
同一界面维持一致的图标语言、光学大小与文字对齐。通用图标优先使用成熟资源;特殊品牌插图单独制作,不为每个标题机械添加图标。
|
||||
|
||||
可点击元素需要可识别的入口及相应反馈。默认、按下、聚焦、选中、禁用与处理中表达不同含义;鼠标悬停不能替代触屏与键盘入口。反馈变化保持点击区域与邻近布局稳定。
|
||||
|
||||
控件是一套语法:圆角、描边、字重、阴影、按下时的位移、选中的表达和状态图标,在按钮、输入、标签、开关和空状态之间用同一套逻辑。方向不只贯穿整页,也贯穿每个控件。按钮和输入框还停在组件库的默认样子,换了主色也认不出是哪个产品,就说明控件还没有进入方向。
|
||||
|
||||
陌生图标配可见文字;纯图标按钮仍需可访问名称。箭头符号有含义:↗ 表示打开新页面或外部链接,提交、检查这类原地动作不用它。静态标签和装饰不要伪装成按钮,行内按钮不要误触外层卡片动作。
|
||||
|
||||
### 图标库也会趋同
|
||||
|
||||
模型的默认是 Lucide 或 Heroicons:24px、2px 描边、圆头端点。库本身没有问题,问题是每个方向都用同一套、同一种摆法。图标和字体一样是方向的一部分,在方向卡里写明用哪一套、什么粗细和端点,或者干脆不用。
|
||||
|
||||
| 方向的气质 | 可以考虑 | 特点 |
|
||||
| --- | --- | --- |
|
||||
| 精致、编辑、奢侈 | Phosphor 的 Thin 或 Light,Iconoir | 细描边,配细字重的衬线或无衬线 |
|
||||
| 工业、仪器、企业工具 | Carbon,Material Symbols 的 Sharp,Radix Icons | 直角端点、网格严格;Radix 专为 15px 的紧凑界面设计 |
|
||||
| 友好、圆润、消费 | Phosphor 的 Fill 或 Duotone,MingCute | 圆角端点,实心或双色 |
|
||||
| 中文产品,需要大量业务图标 | IconPark,Remix Icon | 覆盖面广;IconPark 能调描边粗细、端点和主题 |
|
||||
| 需要连续调粗细和填充 | Material Symbols | 可变字体,填充、字重、光学尺寸都能连续调节 |
|
||||
| 像素、游戏、复古 | Pixelarticons | 像素网格 |
|
||||
| 品牌标志 | Simple Icons | 各品牌标志的矢量形状 |
|
||||
|
||||
用 Iconify 可以按名称取到上面大多数库的单个 SVG。
|
||||
|
||||
- **一个产品只用一套。** 需要补缺时,挑描边、端点和圆角一致的,统一成相同的画布尺寸和描边宽度。
|
||||
- **粗细跟字重走。** 图标描边接近同尺寸正文的笔画粗细:细字配 1–1.5px,粗黑体配 2px 以上或实心。图标尺寸约为字号的 1–1.25 倍,按视觉中心和文字对齐,不按外框。
|
||||
- **能不用就不用。** 编辑类方向常用字符本身:→ ↗ ※ §、编号、缩写。只在能加快识别的地方用图标。
|
||||
- **品牌时刻手绘。** 核心动作、空状态这类少数位置,可以用方向自己的线条语言画专属 SVG;通用动作仍用库。
|
||||
- **小样内联。** 自包含小样只内联用到的 SVG,不引用图标字体或 CDN。存量项目沿用已有的库,换库属于改版。
|
||||
- **核对许可。** 多数是 MIT、Apache 或 ISC;SF Symbols 只能用在 Apple 平台的界面里,不能用于网页;需要署名的库按要求署名。
|
||||
|
||||
## 细节
|
||||
|
||||
好效果常常体现在模型不会主动做的小事上。它们单看都很小,加在一起就是“做出来的”和“生成出来的”之间的差别。结构和主视觉定下来以后,逐项过一遍;单个细节单独修,不为修一个细节改动整体结构。
|
||||
|
||||
粗粝一端的方向同样讲究细节,只是讲究的方式不同:套印错位、毛边和颗粒要有意为之、方向和幅度一致,不能和随手没对齐混在一起;用代码画这些质感的做法见 [点缀与特效](ornament.md)。下面的形状与表面偏向精致一端,粗粝方向按自己的质感取舍;文字、对齐、状态和资源两端都适用。
|
||||
|
||||
**形状与表面**
|
||||
|
||||
- 嵌套圆角要同心:内层圆角约等于外层圆角减去两者之间的内边距,内层不大于外层。
|
||||
- 阴影用两层叠成:一层贴近、较实的接触阴影,一层远而淡的环境阴影。阴影颜色带一点背景的色相,不用纯黑;同一层级的阴影保持一致。
|
||||
- 高分屏上的细分隔线用 0.5px 或半透明色;边框和阴影不同时给重的。
|
||||
- 压在图像上的文字,靠图像本身的安静区域和局部遮罩保证对比,不给整张图盖浓重渐变。
|
||||
|
||||
**文字**
|
||||
|
||||
- 中文正文字距保持 0。展示字号可以收紧,字越大越可以收,但要看实际字形不碰撞;全大写的英文小标签加 5–10% 字距。
|
||||
- 中文用全角标点,引号用“”。行首不出现句号、逗号和右引号,行尾不出现左引号和左括号,可以用 `line-break: strict`;浏览器支持时用 `text-spacing-trim` 做标点挤压。
|
||||
- 中文和英文、数字之间的间距全页一致:支持时用 `text-autospace`,否则统一手写空格,不要一处有一处没有。
|
||||
- 数字用等宽数字;单位比数字小一级或更淡;金额、时间、百分比的格式全页统一,例如 1,238、+1.83%、16:12。
|
||||
- 标题按语义断行,不留孤字,见上文“可读性是交付门槛”。
|
||||
|
||||
**对齐**
|
||||
|
||||
- 图标和文字按视觉中心对齐,不按外框;圆形、三角形图标需要光学微调。
|
||||
- 大号标题的字形自带左侧空隙,要微调 1–3px,让它看起来和下方正文左对齐。
|
||||
- 数字列右对齐,小数位对齐。
|
||||
- 按钮文字在按钮里视觉居中;中文字形偏上或偏下时微调。
|
||||
|
||||
**状态**
|
||||
|
||||
- 每个可点元素都有悬停、按下、焦点、禁用和处理中状态;状态变化不改变尺寸、不挤动旁边内容,处理中的按钮保持原宽度。
|
||||
- 焦点环、文字选中色、输入光标、复选框和单选框的强调色跟随方向,不留浏览器默认的蓝色。
|
||||
- 深色或有色背景上的滚动条调成协调的颜色;可滚动区域的边缘用淡出提示还有内容。
|
||||
- 空、加载、错误状态和默认状态一样用心,不是事后补的一行灰字。
|
||||
|
||||
**资源**
|
||||
|
||||
- 位图按显示尺寸的 2 倍准备,图标用矢量。
|
||||
- 图片加载前先占好比例,不让布局跳动。
|
||||
- 网页标题、标签页图标和分享图与方向一致。
|
||||
|
||||
在 100% 和 200% 缩放下各看一遍关键区域(无法用浏览器缩放时,用宽高减半、像素比为 2 的视口截图代替);把局部截图放大,检查对齐、边缘和标点。
|
||||
|
||||
## 克制:大胆在结构,克制在细节
|
||||
|
||||
大胆和克制作用在不同层面,并不冲突。结构上大胆:首屏骨架、主视觉和一处结构性突破可以走得很远。细节上克制:其余元素安静下来,让主角成立。一页只有一个主角,可以是一张图、一句巨大的标题或一个图形装置;其余的字号、颜色、容器和动效都为它退后。满屏都在出招,和满屏米色卡片一样,都是没有取舍。
|
||||
|
||||
克制要落成预算,和调性里“克制”的翻译一致:
|
||||
|
||||
- 一页的字号 4–6 级,字重 2–3 种,字族不超过 2–3 个(展示、正文,可选等宽)。级数少,级差要大。
|
||||
- 强调色约占画面 10% 以内,只用在主动作、选中和需要立刻识别的状态上。
|
||||
- 划线、倾斜、跨界、手写批注这类特别处理,一页一处,放在最能放大北极星的位置。
|
||||
- 先做准三处基本动效,让它们彼此呼应;装饰性的动效一页最多一两处,见 [动效](motion.md)。
|
||||
|
||||
预算是起点。方向明确需要更多时,例如拼贴或喧闹的调性,写出理由即可。
|
||||
|
||||
克制不等于极简。用户选定强烈风格后,减法应让风格更集中,不把它变成通用白底模板。减法删的是装饰和重复,不删帮助判断的信息:工具与决策界面里,对象名称、比较依据、当前选择和下一步动作是主角,删掉它们界面不会更高级,只会失去用途。删减后检查用户是否还能确定当前对象、理解代价、发现主动作和判断结果;不能用更少的文字代替更清楚的结构。
|
||||
|
||||
### 删元素
|
||||
|
||||
逐项判断元素是否帮助识别、判断、操作、反馈,或对选定品牌与情绪有明确贡献。贡献说不清时尝试移除,对照前后效果。优先删重复标题、无语义徽章、多余容器和竞争性装饰。一张合适的图、几句必要文字和一个动作就能承载页面时,让它们成立;不为“完整”追加卖点卡片、数据徽章和重复的行动按钮。
|
||||
|
||||
### 删文字
|
||||
|
||||
模型爱加字:每个区块都是“眉标 + 标题 + 副标题”三件套,内容页顶部还要加一段解释这个页面是干什么的,每个字段下面一行提示,“点击下方按钮开始”这种描述界面的话,“新”徽章、脚注,以及解释图片内容的图注。
|
||||
|
||||
- **逐句问**:删掉后,用户是否仍知道眼前是什么、能做什么?画面、位置或交互已经表达了的,就删。
|
||||
- **用展示代替描述**:能放产品真正交付的东西,例如一份真实的结果或一段真实数据,就不写一段话去形容它。
|
||||
- **一个区块只讲一件事**:一句标题,最多一段说明。需要第三层文字时,通常说明这个区块该拆开或删掉。
|
||||
- **叙事页面的字数预算**:首屏标题约 15 字以内,说明约 40 字以内,按钮 2–6 字,空状态一句话加一个动作。产品界面不按字数砍,按“逐句问”删重复和解释。
|
||||
- **做完后删一轮**:逐区块尝试删掉 30–50% 的文字,只把删了会影响理解的放回来。
|
||||
- **不先加字再调淡**:不先添一堆小字,再用降低对比来补救。设计理由、源文件名、组件术语和装饰性英文留在说明或按需展开处。
|
||||
- **必须留下的**:错误原因、单位、选择范围、陌生动作的标签、风险和后果。
|
||||
|
||||
### 编辑剩下的文字
|
||||
|
||||
文案需要一次主动编辑:用用户会说的话重写空泛口号,保留具体对象、动作和结果;可以继承用户原有的表达。不要把第一轮生成的文案当作不可改变的内容,也不要为了补满版式编造信息。
|
||||
|
||||
模型写的中文常带翻译腔,交付前逐句改掉:用分号把两件事串成一句;“交代当前情况”“带读者回到”“保持内容明确”这类书面套话;标题写成字段名,例如“受控勾选”“禁用等级”。改成一句只讲一件事的短句,标题用人会说的短语,例如“生成中锁定”“回到最新”。
|
||||
|
||||
模型写的文案往往是整页最弱的部分。标题、主张和按钮这类关键文案,优先用用户提供的原话,或交给用户亲自改写;交付时标出哪些仍是模型写的占位文案。
|
||||
@@ -0,0 +1,154 @@
|
||||
# 视觉评审协议
|
||||
|
||||
## 评审者的独立性
|
||||
|
||||
选择能实际看图的隔离执行者,不固定供应商、模型价格或参数规模。遵循用户的模型偏好和预算,不假定更贵必然更懂设计。
|
||||
|
||||
首次评审与后续独立复查均使用新上下文。只传当前版本的必要证据,不继承制作对话,也不将旧评审粘进去。主 Agent 保留历史,用于判断建议是否反复和修改是否有效。
|
||||
|
||||
没有隔离看图能力时,主 Agent 可以直接检查当前画面,但不能称为独立评审。不能看图时只给结构或源码诊断,不根据文件名猜测画面。
|
||||
|
||||
## 交接内容
|
||||
|
||||
| 提供 | 不提供 |
|
||||
| --- | --- |
|
||||
| 用户任务、受众、交付范围和目标设备 | 制作者的论证、已经投入的时间 |
|
||||
| 已确定的方向、品牌与不可改变约束 | 源码、实现细节与技术借口 |
|
||||
| 当前版本截图,注明页面、状态与逻辑视口 | 旧版本截图、旧评分和旧评审结论 |
|
||||
| 已实际查看的必要参考与其用途 | 预设问题清单、希望评审者赞同的答案 |
|
||||
| 需要评估的可见状态或过程证据;三处基本动效(主操作反馈、一处状态切换、首次出场)和动效记忆点,各一段 3–5 秒录屏或开始、中间、结束三帧,并写明触发它的动作 | “必须给到某个分数”等通过暗示 |
|
||||
|
||||
有记忆点时,评审单独回答:这一刻是否让人记住,动作和结果能否一眼对上。有动效证据时,再回答:各处动效是不是同一种手感,是否从被操作的地方出发、朝同一个方向、彼此接得上;指出各动各的、生硬或多余的地方。只有静态截图时,评审记录写明记忆点和动效没有评审。
|
||||
|
||||
新评审者需要理解任务约束,不能只凭一张图自由改题。参考图用于说明方向或质量基线,精准还原任务除外,不作为要求照抄的目标。
|
||||
|
||||
截图文件必须能被评审者访问。交接前确认文件属于当前版本;长页同时提供整体与必要局部,细节图不能替代整体构图。交互只能依据提供的状态与过程证据判断,单张截图不能证明提交或键盘行为正确。
|
||||
|
||||
## 可直接使用的评审任务
|
||||
|
||||
> 根据提供的用户任务、设计方向、约束和当前画面评审设计。先用一两句话说出这个画面想成为什么体裁和风格;再说出它属于什么品类,举出这个品类里两三个风格最鲜明的产品,说出它们在首屏和控件上通常怎么做。画面违背这些共识的地方,判断是有意的偏离,还是没认出品类;同时看画面是不是某个标杆的复制,遮住名字还能不能认出是另一家。再设想这一体裁里顶尖的设计团队会怎样完成同一任务,可以举一两类真实可参照的作品;然后指出当前画面与那个标准之间差距最大的一到三处。先看主构图、阅读顺序、主视觉和内容与操作的关系,再看排版、空间、色彩和细节。留意过度、夸张或一看就是模型生成的做法并扣分,例如首屏左边大标题、右边说明或图片的默认分栏,无来由的渐变与发光,处处圆角卡片,表情符号图标、随意给文字上色、空泛或晦涩的比喻式文案。画面里多个元素同时争当主角、处处都在用力,也算问题;反过来,处处及格却没有一处让人记住,也指出来,并说出最值得集中发力的位置。留意重复的线框:同一处分隔叠了好几条线,留白已经分开的地方又加线,或者每个层级都画一遍线,让画面发碎。承担风格和结构的线不算问题。单侧的彩色边线(引文、提示、反馈、当前项左边的一条色条)也算问题。画面上的制作说明,例如“示例”“待核实”“示意图”“未接入”,也列进可以直接删掉的文字。体裁指产品本身,例如展览落地页;借用的其他媒介只是风格来源,不按“像不像那个媒介”评判,也不为了更像而建议补上更多部件。如果是工具、后台,或画廊、商店、信息流这类让人浏览挑选的页面,再说出一屏能看到几个对象、第一排内容离顶部多远、主动作离当前对象有多远,并和这一体裁里最好的产品比较密度。另外单独列出两类小问题:可以直接删掉的文字,以及对齐、圆角、阴影、标点、状态这类看得出的细节瑕疵;表格和列表要放大看,各列的行线和基线是否对齐。每处问题写清位置、观察、影响和可执行的调整方向,必要时给伪代码或属性关系,不写含糊的散文。敢下判断,给出有主见的建议,不退回安全、容易的做法。区分可观察缺陷与风格偏好;没有明显问题时直接说明,不为凑数提出修改。最后按 10 分制打分,表示当前画面离顶尖水准还有多远。只提供评审,不修改文件,不启动其他评审者。未看到或无法验证的内容明确说明。
|
||||
|
||||
如果有同类的优秀作品截图,更好的做法是把它们和当前画面放在一起,例如四张专业作品加一张当前截图,请评审者按完成度和品味排序并说明差距。有视觉基线的比较比“想象顶尖团队”更稳定。这些作品是基线和情绪板,不是要照抄的目标。
|
||||
|
||||
用“顶尖团队会怎么做”作为参照,是为了拉高标准、让建议有方向,不是要求照抄某个作品。评审只给方向和具体修改点,不自己重做页面;让评审者直接重做通常时间和成本都会翻倍。
|
||||
|
||||
主 Agent 在任务后附实际材料。没有真实材料时不能原样发送模板并让评审者自行想象。
|
||||
|
||||
评审同时核对设计北极星是否在构图、字体、素材和交互中形成呼应,突破是否增强主题,以及是否用冗余文字和层层容器掩盖薄弱的主视觉。阅读尺寸下的文字碰撞、过小正文和错误裁切应直接指出;不要仅凭整体缩略图判定可读性通过。
|
||||
|
||||
## 给已有界面评审
|
||||
|
||||
在已有项目里润色界面时,评审的标准是这个产品自己的设计规范,不是评审者心里的理想风格。规范管“用什么”:哪些颜色、字号、间距、组件;[视觉语言](visual-language.md) 里关于层级、对齐、留白、文字和减法的判断管“怎么用得好”,照常使用。
|
||||
|
||||
什么时候用,由 SKILL.md 步骤 4 决定:润色一整页或多页之后,或者用户要求评审现有界面、没有要求重新设计时。单个元素的小改动不评审;用户要求改版或重新探索时,用上面的正常评审。
|
||||
|
||||
### 先确定规范
|
||||
|
||||
1. **有成文规范时以它为准。** 设计文档、设计变量(颜色、字号、间距、圆角、阴影)、组件库文档或组件目录。
|
||||
2. **没有成文规范时,从现有实现里读出来。** 看设计变量文件和主题配置、共享组件和它们的变体、同类页面的做法,整理一份简短的现状规范:字体和字号层级、每种颜色的用途、间距和圆角、按钮与表单等关键组件的样子、用哪套图标。实现里本来就不一致的,比如三种按钮,以多数用法为准,少数用法记为待统一。
|
||||
3. **规范本身有问题时说出来。** 不在评审里悄悄绕开,单独列出,交给用户决定。
|
||||
|
||||
优化 UI 时做过规范体检、重新建立了规范的,以选定的新规范为准,见 [存量项目](existing-project.md) 的“规范体检”。
|
||||
|
||||
### 交给评审者
|
||||
|
||||
- 当前版本的截图,注明页面、状态和逻辑视口。
|
||||
- 规范:有成文规范时给相关文件或摘录;没有时给整理好的现状规范,再附两三张用了同样组件的其他页面截图,作为一致性的参照。
|
||||
- 不能动的清单:品牌、字体、主色、组件库、全局导航,以及用户指定保留的部分。
|
||||
- 用户任务和受众。
|
||||
|
||||
和正常评审一样,不给源码、制作过程和旧版截图。
|
||||
|
||||
### 评审任务
|
||||
|
||||
> 根据提供的设计规范、不能改动的清单、用户任务和当前画面,评审这个已有界面。先读规范,再看画面。第一部分,逐条列出画面偏离规范的地方:用了规范以外的颜色、字号、间距或圆角,同类组件长得不一样,没用已有组件而是另做了一个。第二部分,在规范范围内指出最影响完成度的三处问题,依据是层级、对齐、留白、文字、状态和减法这些设计判断。每条建议都要能用现有的设计变量和组件做到;确实需要新增变量或组件时,单独列出并说明理由。不建议更换品牌色、字体、组件库或整体布局。另外列出可以直接删掉的文字,以及对不齐、间距不一这类细节瑕疵。每处写清位置、观察和调整方向。只提供评审,不修改文件,不打分,不启动其他评审者。未看到或无法验证的内容明确说明。
|
||||
|
||||
### 处理结果
|
||||
|
||||
- 只做一轮。改完由主 Agent 和基线做前后对比,不为分数复查。
|
||||
- 偏离规范的问题优先改,改在共享的设计变量和组件上,不在单个页面上覆盖。
|
||||
- 需要扩展规范或违背规范的建议,不直接采纳,连同理由交给用户决定。
|
||||
|
||||
## 首稿通常怎样被改好
|
||||
|
||||
首稿即使方向不同,也常常共用同一套模板骨架。交付对比页时推荐一轮独立评审,由用户决定是否执行;执行时它是把方向拉开的主要手段,不能因为方向卡已经不同就跳过某个方向。叙事页面(落地页、品牌页、活动页)最常见、也最有效的几类改法:
|
||||
|
||||
- 删掉装在卡片里的产品界面小组件,把位置让给主视觉。
|
||||
- 把一个元素放大到结构级别,其余元素为它退后。
|
||||
- 用一个主视觉撑起画面。它从方向的生成引擎里来,不套现成的装置。
|
||||
- 砍掉重复的标签和并列的按钮,只留一个主动作。
|
||||
- 让图像参与版面结构,而不是装在盒子里。
|
||||
|
||||
这些改法不要搬到产品界面上。工具、后台和 App 常见的有效改法是:
|
||||
|
||||
- 删掉解释界面的文字:列说明、区块副标题、“卡片顶边对应……”这类说明、和状态标签重复的提示框。
|
||||
- 压缩页面头部,把高度还给工作区;页面标题缩成一行。
|
||||
- 把用户最怕错过的属性放大,例如等待时长、超时、欠费,让它在整页里最先被看到。
|
||||
- 提高密度,直到一屏能看到足够多的对象用于比较。
|
||||
- 同一个状态只表达一次,删掉重复的徽章、色块和提示。
|
||||
|
||||
画廊、商店、信息流这类浏览内容的页面,常见的有效改法是:
|
||||
|
||||
- 删掉顶部的口号和解释页面用途的段落。
|
||||
- 把第一排内容提到首屏,让内容本身成为主角。
|
||||
- 把标题、导航和筛选合进顶部一到两行。
|
||||
- 让缩略图的比例和排列成为版式本身,不再给每张卡片加多余的边框和说明。
|
||||
|
||||
## 反馈格式
|
||||
|
||||
先用短段落说明当前画面如何表达任务与方向。随后按影响列问题,每项包含:
|
||||
|
||||
- 位置:页面、区域、状态或元素。
|
||||
- 观察与证据:实际看到了什么;不能只说“普通”“不高级”。
|
||||
- 影响:妨碍了识别、任务完成、视觉焦点或选定方向中的哪一项。
|
||||
- 建议:可执行的调整,必要时提供布局示意或属性关系;不猜不存在的文件与行号。
|
||||
- 验证:修改后应检查什么,并指出是否只是主观偏好。
|
||||
|
||||
10 分制评分用来驱动循环:主 Agent 以 9 分为完成目标,但不向评审者透露这个目标。分数必须附带具体差距,只有数字的评审不算有效。同一评审模型的前后分数可以比较;不同模型的数值不直接比较,也不把分数当作审美事实。分数高但用户不喜欢时,以用户判断为准。
|
||||
|
||||
## 多个方向时加一次横向评审
|
||||
|
||||
逐个评审只能看出每个方向自身的问题,看不出方向之间的趋同。多个方向都修改完后,再派一个新的评审者,同时看所有方向的整页截图,只回答一个问题:哪些区块在不同方向里本质上是同一个东西(结构、组件或版式相同,只是换了颜色和字体)?逐条列出区块和涉及的方向。被点名的区块,按各自方向的生成引擎重做。
|
||||
|
||||
## 任务走查:让评审者真的去用
|
||||
|
||||
视觉评审看的是截图,看不出流程问题:入口找不到、出错后回不去、第二次用还是一样慢。新界面或改动了操作流程时,在视觉评审之外,再派一个新的执行者做任务走查。它必须能操作真实页面,例如浏览器自动化或可点击的原型,不看代码和制作过程。没有这种能力时,由主 Agent 按同样的方法自己走一遍,并标明不是独立走查。
|
||||
|
||||
交给它:
|
||||
|
||||
- 3–5 个真实任务,用用户的话写,不提界面上的按钮名称。写“把上周没回复的三个客户标成待跟进”,不写“点击筛选按钮,选择未回复”。每个任务写清怎样算完成。
|
||||
- 目标设备、入口地址和需要的测试数据。
|
||||
- 故障场景怎么触发:提交失败、断网、空数据、慢响应。
|
||||
|
||||
它要做的:
|
||||
|
||||
- 每个任务从入口开始,记录每一步看到了什么、为什么选这里。需要猜的地方记为“犹豫”,点错又退回的记为“走错”,走不下去的记为“卡住”。
|
||||
- 每个任务再做一次“第二次使用”:有没有更短的路径,能不能只用键盘完成。
|
||||
- 至少在一个任务里制造失败,看输入是否保留、能否原地恢复。
|
||||
- 在窄屏上把最主要的任务再走一遍。
|
||||
|
||||
输出按严重程度排序:卡住、走错、犹豫、多余的步骤。每条附截图和步骤编号,写清当时预期什么、实际发生了什么。不打审美分,也不提视觉建议。
|
||||
|
||||
所有核心任务都能在没有提示的情况下完成,并且每处犹豫都有对应的修改或保留理由,走查才算通过。任务走查和视觉评分是两条线:一边分数高,不能抵消另一边的问题。
|
||||
|
||||
## 评审改流程和加新功能的方案
|
||||
|
||||
存量项目里改流程和加新功能的方案,比的是做法,不打分,也不问顶尖团队会怎样设计画面。交给评审者:用户的话写成的任务、每个方案的方案卡、各自差别最大的那一帧或本地地址、现状基线。不给制作过程。
|
||||
|
||||
> 根据提供的任务、方案卡和画面,评审这几个存量项目里的方案。先说出这几个方案是不是真的不同的做法:入口和步数都相同、只是画面不同的,指出是哪几个。再对每个方案,按任务从入口走到结果,指出哪一步还在让用户伺候界面,而不是在做决定:找入口、重复填写系统已知的值、为了确认而确认、来回切换页面。然后列出方案里属于补丁的部分:加提示、加引导、加确认、加说明,却没有让步骤、要记住的事或要切换的页面变少。最后指出每个方案最可能失败的场景。每条写清位置、观察和调整方向。只提供评审,不修改文件,不打分,不启动其他评审者。未看到或无法验证的内容明确说明。
|
||||
|
||||
能操作真实页面时,另按上面的“任务走查”走一遍,选定前后各一次,比较步数和卡住、走错、犹豫的次数。
|
||||
|
||||
## 主 Agent 如何处理反馈
|
||||
|
||||
先处理任务阻碍、内容错误与明显视觉层级问题,再处理有依据的精修。将接受的反馈映射到实际实现;用户指定的风格不能被评审者随意替换。
|
||||
|
||||
评审单独列出的“可以直接删掉的文字”默认照删;只有删了会影响理解时才保留,并在记录里写一句理由。
|
||||
|
||||
多个评审对不同方向给出同一条建议时,分别在每个方向里找它自己的落地形式,不用一种实现套到所有方向上。
|
||||
|
||||
建议违反用户约束、仅表达另一种偏好或缺乏证据时,可以不采纳并保留简短理由。为了更像借用的媒介而增加部件、线框或装饰的建议,先对照北极星判断,通常不采纳。一次完成相关改动后集中取证;不每改一个细节就重新派发。
|
||||
|
||||
记录版本、已接受改动、未解决问题与检查结果。保持相当的视口、内容和状态进行前后比较;后续独立评审只看当前版本,前后收益由主 Agent 检查,重大主观取舍由用户判断。
|
||||
|
||||
出现持续换风格、互相推翻或细节调整无可见收益时停止美学循环,保留最符合任务与方向的版本。停止不能掩盖仍然存在的功能缺陷或未经验证的部分。
|
||||
@@ -0,0 +1,413 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build a portable, offline design comparison from a local manifest."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import hashlib
|
||||
from html.parser import HTMLParser
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
|
||||
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
||||
TEMPLATE = SKILL_ROOT / "assets" / "style-explorer.html"
|
||||
MARKER = "/*__OIL_UI_DATA__*/ null"
|
||||
CONNECT_CSP = "connect-src 'none'"
|
||||
PREVIEW_CSP = (
|
||||
"default-src 'none'; style-src 'unsafe-inline'; img-src data:; "
|
||||
"font-src data:; media-src data:; script-src 'none'; "
|
||||
"form-action 'none'; base-uri 'none'; object-src 'none'"
|
||||
)
|
||||
|
||||
|
||||
def text_field(obj: dict, name: str, *, default: str | None = None) -> str:
|
||||
value = obj.get(name, default)
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ValueError(f"{name} 必须是非空字符串")
|
||||
return value.strip()
|
||||
|
||||
|
||||
def text_list(obj: dict, name: str) -> list[str]:
|
||||
values = obj.get(name)
|
||||
if not isinstance(values, list) or not values:
|
||||
raise ValueError(f"{name} 必须是非空字符串数组")
|
||||
if any(not isinstance(v, str) or not v.strip() for v in values):
|
||||
raise ValueError(f"{name} 的每项必须是非空字符串")
|
||||
return [v.strip() for v in values]
|
||||
|
||||
|
||||
class AssetCheck(HTMLParser):
|
||||
"""Reject resource dependencies; the browser sandbox disables behavior."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.styles = []
|
||||
self.in_style = False
|
||||
self.head_position = None
|
||||
|
||||
def handle_starttag(self, tag: str, attrs: list) -> None:
|
||||
attrs = dict(attrs)
|
||||
if tag == "head" and self.head_position is None:
|
||||
self.head_position = (self.getpos(), self.get_starttag_text())
|
||||
if tag in ("iframe", "frame", "object", "embed"):
|
||||
raise ValueError("候选 HTML 不支持嵌套文档;请提供静态内容或截图")
|
||||
if tag == "style":
|
||||
self.in_style = True
|
||||
if attrs.get("style"):
|
||||
self.styles.append(attrs["style"])
|
||||
if tag == "base":
|
||||
raise ValueError("候选 HTML 不应包含 base;资源需要内嵌")
|
||||
if tag == "meta" and attrs.get("http-equiv", "").lower() == "refresh":
|
||||
raise ValueError("候选 HTML 不应自动跳转")
|
||||
for key in ("src", "poster", "data", "href", "xlink:href"):
|
||||
value = attrs.get(key, "") or ""
|
||||
if not value or value.startswith(("#", "data:")):
|
||||
continue
|
||||
raise ValueError(f"候选 HTML 含未内嵌资源 {tag}.{key}: {value}")
|
||||
if attrs.get("srcset"):
|
||||
raise ValueError("候选 HTML 请用内嵌 src 代替 srcset")
|
||||
|
||||
def handle_endtag(self, tag: str) -> None:
|
||||
if tag == "style":
|
||||
self.in_style = False
|
||||
|
||||
def handle_data(self, data: str) -> None:
|
||||
if self.in_style:
|
||||
self.styles.append(data)
|
||||
|
||||
|
||||
CSS_TOKEN = re.compile(
|
||||
r"(?P<comment>/\*.*?\*/)|(?P<string>\"(?:\\.|[^\"\\])*\"|'(?:\\.|[^'\\])*')"
|
||||
r"|(?P<ident>(?:[-_a-zA-Z]|\\(?:[0-9a-fA-F]{1,6}\s?|[^\r\n]))"
|
||||
r"(?:[-_a-zA-Z0-9]|\\(?:[0-9a-fA-F]{1,6}\s?|[^\r\n]))*)"
|
||||
r"|(?P<space>\s+)|(?P<symbol>.)", re.S,
|
||||
)
|
||||
|
||||
|
||||
def css_unescape(value: str) -> str:
|
||||
def replacement(match):
|
||||
if match.group(1):
|
||||
codepoint = int(match.group(1), 16)
|
||||
return chr(codepoint) if 0 < codepoint <= 0x10FFFF else "\ufffd"
|
||||
return match.group(2)
|
||||
return re.sub(r"\\([0-9a-fA-F]{1,6})(?:\s)?|\\([^\r\n])", replacement, value)
|
||||
|
||||
|
||||
def check_css(css: str, label: str) -> None:
|
||||
tokens = [(m.lastgroup, m.group()) for m in CSS_TOKEN.finditer(css)
|
||||
if m.lastgroup not in ("comment", "space")]
|
||||
|
||||
def check_resource(value: str):
|
||||
if not css_unescape(value).strip().lower().startswith(("data:", "#")):
|
||||
raise ValueError(f"{label}: CSS 含未内嵌资源")
|
||||
|
||||
for index, (kind, value) in enumerate(tokens):
|
||||
if value == "@" and index + 1 < len(tokens) and tokens[index + 1][0] == "ident" and css_unescape(tokens[index + 1][1]).lower() == "import":
|
||||
raise ValueError(f"{label}: 请内嵌 CSS,不使用 @import")
|
||||
name = css_unescape(value).lower() if kind == "ident" else ""
|
||||
if name not in ("url", "image-set", "-webkit-image-set", "image", "src"):
|
||||
continue
|
||||
if index + 1 >= len(tokens) or tokens[index + 1][1] != "(":
|
||||
continue
|
||||
depth, body = 1, []
|
||||
for inner_kind, inner_value in tokens[index + 2:]:
|
||||
if inner_kind == "symbol" and inner_value == "(":
|
||||
depth += 1
|
||||
elif inner_kind == "symbol" and inner_value == ")":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
break
|
||||
if name == "url":
|
||||
body.append(inner_value[1:-1] if inner_kind == "string" else inner_value)
|
||||
elif depth == 1 and inner_kind == "string":
|
||||
check_resource(inner_value[1:-1])
|
||||
if name == "url":
|
||||
check_resource("".join(body))
|
||||
|
||||
|
||||
# Sandboxed previews have no storage; give prototypes an in-memory stand-in so their scripts keep running.
|
||||
STORAGE_SHIM = (
|
||||
"<script>(()=>{const mem=()=>{const m=new Map();return{get length(){return m.size},"
|
||||
"key:i=>[...m.keys()][i]??null,getItem:k=>m.has(String(k))?m.get(String(k)):null,"
|
||||
"setItem:(k,v)=>{m.set(String(k),String(v))},removeItem:k=>{m.delete(String(k))},clear:()=>m.clear()}};"
|
||||
"for(const n of['localStorage','sessionStorage']){try{window[n].length}catch{"
|
||||
"Object.defineProperty(window,n,{value:mem(),configurable:true})}}})();</script>"
|
||||
)
|
||||
|
||||
|
||||
def preview_csp(interactive: bool) -> str:
|
||||
# Interactive prototypes may run their own inline scripts, still with no network or external files.
|
||||
# 'unsafe-eval' lets declarative libraries such as Alpine.js evaluate their attribute expressions.
|
||||
return PREVIEW_CSP.replace("script-src 'none'", "script-src 'unsafe-inline' 'unsafe-eval'") if interactive else PREVIEW_CSP
|
||||
|
||||
|
||||
EMBEDDABLE = {
|
||||
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp",
|
||||
".gif": "image/gif", ".avif": "image/avif", ".svg": "image/svg+xml",
|
||||
".woff2": "font/woff2", ".woff": "font/woff", ".ttf": "font/ttf", ".otf": "font/otf",
|
||||
".mp4": "video/mp4", ".webm": "video/webm",
|
||||
}
|
||||
ATTR_REF = re.compile(r"""(?P<lead>\b(?:src|poster|href|xlink:href)\s*=\s*)(?P<q>["'])(?P<ref>[^"'#][^"']*)(?P=q)""")
|
||||
CSS_REF = re.compile(r"""url\(\s*(?P<q>["']?)(?P<ref>[^"')\s][^"')]*)(?P=q)\s*\)""")
|
||||
|
||||
|
||||
def embed_local_files(content: str, base: Path, root: Path, used: set[Path]) -> str:
|
||||
"""Inline images, fonts and videos referenced relative to the candidate, as long as they stay inside the manifest folder."""
|
||||
|
||||
def data_url(ref: str) -> str | None:
|
||||
if re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*:|^//", ref):
|
||||
return None
|
||||
target = (base / ref.split("?")[0].split("#")[0]).resolve()
|
||||
mime = EMBEDDABLE.get(target.suffix.lower())
|
||||
if not mime or not target.is_file() or not target.is_relative_to(root):
|
||||
return None
|
||||
used.add(target)
|
||||
return f"data:{mime};base64," + base64.b64encode(target.read_bytes()).decode("ascii")
|
||||
|
||||
def attr(match):
|
||||
url = data_url(match["ref"])
|
||||
return match.group(0) if url is None else f'{match["lead"]}{match["q"]}{url}{match["q"]}'
|
||||
|
||||
def css(match):
|
||||
url = data_url(match["ref"])
|
||||
return match.group(0) if url is None else f'url("{url}")'
|
||||
|
||||
return CSS_REF.sub(css, ATTR_REF.sub(attr, content))
|
||||
|
||||
|
||||
SCRIPT_SRC = re.compile(r"""<script\b(?P<attrs>[^>]*?)\bsrc\s*=\s*(?P<q>["'])(?P<ref>[^"']+)(?P=q)(?P<rest>[^>]*)>\s*</script>""", re.I)
|
||||
LINK_TAG = re.compile(r"<link\b[^>]*>", re.I)
|
||||
LINK_ATTR = re.compile(r"""\b(?P<name>rel|href)\s*=\s*(?P<q>["'])(?P<value>[^"']*)(?P=q)""", re.I)
|
||||
|
||||
|
||||
def local_file(ref: str, base: Path, root: Path) -> Path | None:
|
||||
if re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*:|^//", ref):
|
||||
return None
|
||||
target = (base / ref.split("?")[0].split("#")[0]).resolve()
|
||||
return target if target.is_file() and target.is_relative_to(root) else None
|
||||
|
||||
|
||||
def inline_local_code(content: str, base: Path, root: Path, used: set[Path], interactive: bool) -> str:
|
||||
"""Inline stylesheets and, for interactive candidates, scripts that live in the manifest folder."""
|
||||
|
||||
def link(match):
|
||||
attrs = {m["name"].lower(): m["value"] for m in LINK_ATTR.finditer(match.group(0))}
|
||||
target = local_file(attrs.get("href", ""), base, root) if "stylesheet" in attrs.get("rel", "").lower() else None
|
||||
if target is None:
|
||||
return match.group(0)
|
||||
used.add(target)
|
||||
css = embed_local_files(target.read_text(encoding="utf-8"), target.parent, root, used)
|
||||
return "<style>" + css.replace("</style", "<\\/style") + "</style>"
|
||||
|
||||
deferred = []
|
||||
|
||||
def script(match):
|
||||
target = local_file(match["ref"], base, root)
|
||||
if target is None:
|
||||
return match.group(0)
|
||||
used.add(target)
|
||||
if not interactive:
|
||||
return ""
|
||||
attrs = re.sub(r"\s+", " ", f'{match["attrs"]} {match["rest"]}').strip()
|
||||
code = target.read_text(encoding="utf-8").replace("</script", "<\\/script")
|
||||
is_module = re.search(r"""type\s*=\s*["']module["']""", attrs, re.I)
|
||||
tag = ('<script type="module">' if is_module else "<script>") + code + "</script>"
|
||||
# Inline scripts ignore defer, so deferred files move to the end of the body to keep their timing.
|
||||
if re.search(r"\bdefer\b", attrs, re.I) and not is_module:
|
||||
deferred.append(tag)
|
||||
return ""
|
||||
return tag
|
||||
|
||||
content = SCRIPT_SRC.sub(script, LINK_TAG.sub(link, content))
|
||||
if deferred:
|
||||
close = content.lower().rfind("</body>")
|
||||
content = content + "".join(deferred) if close < 0 else content[:close] + "".join(deferred) + content[close:]
|
||||
return content
|
||||
|
||||
|
||||
def prepare_html(path: Path, interactive: bool = False, root: Path | None = None, used: set[Path] | None = None) -> str:
|
||||
content = path.read_text(encoding="utf-8")
|
||||
if root is not None:
|
||||
root = root.resolve()
|
||||
used = used if used is not None else set()
|
||||
content = inline_local_code(content, path.parent, root, used, interactive)
|
||||
content = embed_local_files(content, path.parent, root, used)
|
||||
parser = AssetCheck()
|
||||
parser.feed(content)
|
||||
for style in parser.styles:
|
||||
check_css(style, path.name)
|
||||
if parser.head_position is None:
|
||||
raise ValueError(f"{path.name}: 候选 HTML 需要完整的 head 元素")
|
||||
(line, column), start_tag = parser.head_position
|
||||
head_end = sum(len(part) + 1 for part in content.split('\n')[:line - 1]) + column + len(start_tag)
|
||||
meta = f'<meta http-equiv="Content-Security-Policy" content="{preview_csp(interactive)}">'
|
||||
return content[:head_end] + meta + (STORAGE_SHIM if interactive else "") + content[head_end:]
|
||||
|
||||
|
||||
def prepare_image(path: Path) -> str:
|
||||
data = path.read_bytes()
|
||||
if data.startswith(b"\x89PNG\r\n\x1a\n"):
|
||||
mime = "image/png"
|
||||
elif data.startswith(b"\xff\xd8\xff"):
|
||||
mime = "image/jpeg"
|
||||
elif data.startswith(b"RIFF") and data[8:12] == b"WEBP":
|
||||
mime = "image/webp"
|
||||
else:
|
||||
raise ValueError(f"{path.name}: 静态预览支持 PNG、JPEG 和 WebP")
|
||||
return f"data:{mime};base64," + base64.b64encode(data).decode("ascii")
|
||||
|
||||
|
||||
LOOPBACK = {"localhost", "127.0.0.1", "::1"}
|
||||
|
||||
|
||||
def local_url(value: str, identifier: str) -> str:
|
||||
"""Live candidates only point at a dev server on this machine."""
|
||||
try:
|
||||
parts = urlsplit(value)
|
||||
parts.port # Reject malformed ports before generating CSP origins.
|
||||
except ValueError:
|
||||
raise ValueError(f"{identifier}: url 必须是有效的本机 http(s) 地址") from None
|
||||
if parts.scheme not in ("http", "https") or (parts.hostname or "").lower() not in LOOPBACK or parts.username or parts.password:
|
||||
raise ValueError(f"{identifier}: url 只能是本机开发服务器地址,例如 http://localhost:5173/orders")
|
||||
return value
|
||||
|
||||
|
||||
def load_manifest(path: Path) -> tuple[dict, set[Path]]:
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(raw, dict) or raw.get("schemaVersion") != 1:
|
||||
raise ValueError("manifest.schemaVersion 必须为 1")
|
||||
data = {"schemaVersion": 1, "project": text_field(raw, "project"),
|
||||
"brief": text_field(raw, "brief"), "round": text_field(raw, "round", default="01")}
|
||||
if "serve" in raw:
|
||||
serve = raw["serve"]
|
||||
if not isinstance(serve, dict):
|
||||
raise ValueError("serve 必须是对象")
|
||||
if not isinstance(serve.get("command"), str) or not serve["command"].strip():
|
||||
raise ValueError("serve.command 必须是非空字符串")
|
||||
if "cwd" in serve and (not isinstance(serve["cwd"], str) or not Path(serve["cwd"]).is_absolute()):
|
||||
raise ValueError("serve.cwd 必须是绝对路径")
|
||||
if "url" in serve:
|
||||
if not isinstance(serve["url"], str) or not serve["url"].strip():
|
||||
raise ValueError("serve.url 必须是非空的本机 http(s) 地址")
|
||||
local_url(serve["url"], "serve")
|
||||
data["serve"] = serve
|
||||
candidates = raw.get("candidates")
|
||||
if not isinstance(candidates, list) or not candidates:
|
||||
raise ValueError("candidates 至少需要一个候选")
|
||||
seen = set()
|
||||
inputs = {path, TEMPLATE.resolve()}
|
||||
output = []
|
||||
for candidate in candidates:
|
||||
if not isinstance(candidate, dict):
|
||||
raise ValueError("每个候选必须是对象")
|
||||
identifier = text_field(candidate, "id")
|
||||
if not re.fullmatch(r"[a-z0-9][a-z0-9_-]{0,63}", identifier) or identifier in seen:
|
||||
raise ValueError(f"候选 id 无效或重复: {identifier}")
|
||||
seen.add(identifier)
|
||||
kind = candidate.get("kind", "html")
|
||||
if kind not in ("html", "image", "url"):
|
||||
raise ValueError(f"未知候选 kind: {kind}")
|
||||
baseline = candidate.get("baseline", False)
|
||||
interactive = candidate.get("interactive", False)
|
||||
if not isinstance(baseline, bool) or not isinstance(interactive, bool):
|
||||
raise ValueError(f"{identifier}: baseline 和 interactive 必须是 true 或 false")
|
||||
if interactive and kind != "html":
|
||||
raise ValueError(f"{identifier}: interactive 只用于 html 候选")
|
||||
if kind == "url":
|
||||
source = None
|
||||
url = local_url(text_field(candidate, "url"), identifier)
|
||||
else:
|
||||
source = (path.parent / text_field(candidate, "source")).resolve()
|
||||
if not source.is_relative_to(path.parent) or not source.is_file():
|
||||
raise ValueError(f"候选 source 必须是 manifest 目录内可读文件: {identifier}")
|
||||
inputs.add(source)
|
||||
colors = text_list(candidate, "palette")
|
||||
if any(not re.fullmatch(r"#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})", c) for c in colors):
|
||||
raise ValueError(f"{identifier}: palette 需要十六进制颜色")
|
||||
output.append({
|
||||
"id": identifier,
|
||||
**{name: text_field(candidate, name) for name in ("name", "concept", "typography")},
|
||||
"palette": colors, "traits": text_list(candidate, "traits"), "kind": kind,
|
||||
"content": url if kind == "url" else prepare_html(source, interactive, path.parent, inputs) if kind == "html" else prepare_image(source),
|
||||
"sourceLabel": url if kind == "url" else source.name,
|
||||
"baseline": baseline,
|
||||
"interactive": interactive,
|
||||
})
|
||||
if sum(c["baseline"] for c in output) > 1:
|
||||
raise ValueError("最多只能有一个基线候选")
|
||||
# The current version always sits first so every direction is read against it.
|
||||
output.sort(key=lambda c: not c["baseline"])
|
||||
data["candidates"] = output
|
||||
canonical = json.dumps(data, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
data["fingerprint"] = hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:24]
|
||||
return data, inputs
|
||||
|
||||
|
||||
def build(manifest: Path, output: Path, *, force: bool = False) -> dict:
|
||||
manifest, output = manifest.resolve(), output.resolve()
|
||||
data, inputs = load_manifest(manifest)
|
||||
if output in inputs or output.is_relative_to(SKILL_ROOT):
|
||||
raise ValueError("输出不能覆盖输入或写进 Skill 安装目录")
|
||||
if output.exists() and not force:
|
||||
raise FileExistsError("输出已存在;使用新路径,或明确加 --force 更新")
|
||||
template = TEMPLATE.read_text(encoding="utf-8")
|
||||
if template.count(MARKER) != 1:
|
||||
raise ValueError("模板数据入口缺失或重复")
|
||||
if template.count(CONNECT_CSP) != 1:
|
||||
raise ValueError("模板连接策略入口缺失或重复")
|
||||
origins = set()
|
||||
for candidate in data["candidates"]:
|
||||
if candidate["kind"] == "url":
|
||||
parts = urlsplit(candidate["content"])
|
||||
# Browsers reject IPv6 literals in CSP source lists; probe their loopback alias.
|
||||
host = "localhost" if parts.hostname == "::1" else parts.hostname.lower()
|
||||
port = f":{parts.port}" if parts.port is not None else ""
|
||||
origins.add(f"{parts.scheme}://{host}{port}")
|
||||
connect_csp = "connect-src " + (" ".join(sorted(origins)) if origins else "'none'")
|
||||
# A closing script tag in metadata or a nested candidate cannot escape the container.
|
||||
payload = json.dumps(data, ensure_ascii=False).replace("<", "\\u003c").replace("\u2028", "\\u2028").replace("\u2029", "\\u2029")
|
||||
page = template.replace(CONNECT_CSP, connect_csp).replace(MARKER, payload)
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
if force:
|
||||
temp = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=output.parent, delete=False) as handle:
|
||||
temp = Path(handle.name)
|
||||
handle.write(page)
|
||||
temp.replace(output)
|
||||
finally:
|
||||
if temp and temp.exists():
|
||||
temp.unlink()
|
||||
else:
|
||||
with output.open("x", encoding="utf-8") as handle:
|
||||
handle.write(page)
|
||||
if origins and "serve" not in data:
|
||||
print("提醒:本轮包含 url 候选,建议在 manifest 顶层补上 serve 启动方式,方便重新打开对比页。", file=sys.stderr)
|
||||
return {"output": str(output), "candidates": len(data["candidates"]), "fingerprint": data["fingerprint"], "bytes": output.stat().st_size}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if sys.version_info < (3, 10):
|
||||
print("需要 Python 3.10 或更新版本", file=sys.stderr)
|
||||
return 2
|
||||
parser = argparse.ArgumentParser(description="把本地候选与 manifest 组装成独立风格对比 HTML")
|
||||
parser.add_argument("manifest", type=Path)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument("--force", action="store_true", help="明确允许原子更新已有输出")
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
print(json.dumps(build(args.manifest, args.output, force=args.force), ensure_ascii=False))
|
||||
return 0
|
||||
except (OSError, ValueError, TypeError) as exc:
|
||||
print(f"未生成对比页:{exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,327 @@
|
||||
#!/usr/bin/env python3
|
||||
"""检查本 Skill 有没有新版本,有就自动更新。
|
||||
|
||||
由使用 Skill 触发,最多每 10 分钟联网检查一次,网络失败后一小时可重试。
|
||||
只读取 ui.oiloil.org 公开的版本列表,不上传项目内容。
|
||||
发现新版本时用 npx github:oil-oil/oil-cli 原地更新本目录:
|
||||
成功打印一行“已自动更新”,不能自动更新时打印一行提示,条件不变时每天最多提示一次;
|
||||
没有新版本、网络失败或在开发目录(含 .git)里运行时什么也不输出。
|
||||
设置环境变量 OIL_NO_UPDATE_CHECK=1 关闭检查,OIL_NO_AUTO_UPDATE=1 只提示不自动更新。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import NamedTuple
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
API = os.environ.get("OIL_API", "https://ui.oiloil.org").rstrip("/")
|
||||
CLI = "github:oil-oil/oil-cli"
|
||||
DAY = 24 * 3600
|
||||
# 发版后用户下一次使用就能更新;版本列表有服务端缓存,频繁检查的成本很低
|
||||
CHECK_INTERVAL = 10 * 60
|
||||
# 联网检查不能拖慢任务:超时就跳过,下次再查
|
||||
FETCH_TIMEOUT = 2
|
||||
RETRY_AFTER_FAILURE = 3600
|
||||
UPDATE_TIMEOUT = 300
|
||||
|
||||
|
||||
def read_skill() -> tuple[str, str] | None:
|
||||
text = (ROOT / "SKILL.md").read_text(encoding="utf-8")
|
||||
name = re.search(r"^name:\s*\"?([\w-]+)\"?\s*$", text, re.MULTILINE)
|
||||
version = re.search(r"^\s+version:\s*\"?(\d+\.\d+\.\d+)\"?\s*$", text, re.MULTILINE)
|
||||
if not name or not version:
|
||||
return None
|
||||
return name.group(1), version.group(1)
|
||||
|
||||
|
||||
def state_dir() -> Path:
|
||||
if sys.platform == "win32":
|
||||
base = Path(os.environ.get("LOCALAPPDATA") or Path.home() / "AppData" / "Local")
|
||||
else:
|
||||
base = Path(os.environ.get("XDG_STATE_HOME") or Path.home() / ".local" / "state")
|
||||
return base / "oil"
|
||||
|
||||
|
||||
def state_path(name: str) -> Path:
|
||||
"""公开版本缓存仍按 Skill 共享,兼容原来的缓存位置。"""
|
||||
return state_dir() / f"{name}-update.json"
|
||||
|
||||
|
||||
def installation_state_path(name: str) -> Path:
|
||||
key = hashlib.sha256(os.path.normcase(str(ROOT)).encode()).hexdigest()
|
||||
return state_dir() / "installations" / f"{name}-{key}.json"
|
||||
|
||||
|
||||
def config_path() -> Path:
|
||||
if sys.platform == "win32":
|
||||
base = Path(os.environ.get("APPDATA") or Path.home() / "AppData" / "Roaming")
|
||||
else:
|
||||
base = Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config")
|
||||
return base / "oil" / "config.json"
|
||||
|
||||
|
||||
def logged_in() -> bool:
|
||||
if os.environ.get("OIL_TOKEN"):
|
||||
return True
|
||||
try:
|
||||
return bool(json.loads(config_path().read_text(encoding="utf-8")).get("token"))
|
||||
except (OSError, ValueError, AttributeError):
|
||||
return False
|
||||
|
||||
|
||||
def load(path: Path) -> dict:
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
return data if isinstance(data, dict) else {}
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
|
||||
|
||||
def save(path: Path, data: dict) -> None:
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def parse(version: str) -> tuple[int, ...]:
|
||||
return tuple(int(part) for part in version.split("."))
|
||||
|
||||
|
||||
def fetch(name: str) -> dict | None:
|
||||
request = urllib.request.Request(f"{API}/api/store/versions", headers={"User-Agent": "oil-skill-update-check"})
|
||||
with urllib.request.urlopen(request, timeout=FETCH_TIMEOUT) as response:
|
||||
entry = json.load(response).get("skills", {}).get(name)
|
||||
if not isinstance(entry, dict) or not re.fullmatch(r"\d+\.\d+\.\d+", str(entry.get("latest", ""))):
|
||||
return None
|
||||
history = entry.get("history") or []
|
||||
notes = next((h.get("notes", "") for h in history if h.get("version") == entry["latest"]), "")
|
||||
# 免费版的版本信息带公开下载地址,付费版没有
|
||||
return {"latest": entry["latest"], "notes": notes, "free": bool(entry.get("download_url"))}
|
||||
|
||||
|
||||
def headline(notes: str) -> str:
|
||||
for line in notes.splitlines():
|
||||
line = re.sub(r"^[#>*\-\s]+", "", line).strip().rstrip("。.;;,,")
|
||||
if line:
|
||||
return line if len(line) <= 60 else line[:59] + "…"
|
||||
return ""
|
||||
|
||||
|
||||
def file_signature(path: str | Path | None) -> list:
|
||||
if not path:
|
||||
return []
|
||||
try:
|
||||
stat = Path(path).stat()
|
||||
return [str(path), stat.st_mtime_ns, stat.st_size]
|
||||
except OSError:
|
||||
return [str(path)]
|
||||
|
||||
|
||||
def update_context() -> tuple[str, str | None, bool]:
|
||||
"""只存指纹;登录配置变化或补齐依赖时不沿用旧冷却。"""
|
||||
npx = shutil.which("npx")
|
||||
node = shutil.which("node")
|
||||
node_version = ""
|
||||
if node:
|
||||
try:
|
||||
result = subprocess.run([node, "--version"], capture_output=True, text=True, timeout=3)
|
||||
if result.returncode == 0:
|
||||
node_version = result.stdout.strip()
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
pass
|
||||
version = re.fullmatch(r"v?(\d+)\.\d+\.\d+", node_version)
|
||||
ready = bool(npx and version and int(version.group(1)) >= 18)
|
||||
context = [API, file_signature(config_path()), os.environ.get("OIL_TOKEN", ""),
|
||||
file_signature(npx), file_signature(node), node_version,
|
||||
bool(os.environ.get("OIL_NO_AUTO_UPDATE"))]
|
||||
fingerprint = hashlib.sha256(json.dumps(context).encode()).hexdigest()
|
||||
return fingerprint, npx, ready
|
||||
|
||||
|
||||
class UpdateResult(NamedTuple):
|
||||
attempted: bool
|
||||
reason: str
|
||||
|
||||
|
||||
def cli_error(stdout: str, stderr: str) -> str:
|
||||
# npx 的日志可能混在 JSON 前面;不转述 CLI 原始 message,避免泄露信息。
|
||||
for line in reversed((stdout + "\n" + stderr).splitlines()):
|
||||
try:
|
||||
data = json.loads(line)
|
||||
except ValueError:
|
||||
continue
|
||||
if isinstance(data, dict) and isinstance(data.get("error"), str):
|
||||
return data["error"]
|
||||
return ""
|
||||
|
||||
|
||||
def auto_update(name: str, latest: str, free: bool, npx: str | None, ready: bool) -> UpdateResult:
|
||||
if os.environ.get("OIL_NO_AUTO_UPDATE"):
|
||||
return UpdateResult(False, "manual")
|
||||
if not (free or logged_in()):
|
||||
return UpdateResult(False, "unauthorized")
|
||||
if not ready:
|
||||
return UpdateResult(False, "dependencies")
|
||||
# CI=1 让命令行在没登录时直接失败,不会停下来等浏览器确认
|
||||
env = {**os.environ, "CI": "1"}
|
||||
try:
|
||||
result = subprocess.run([npx, "-y", CLI, "update", name, "--path", str(ROOT), "--json"], env=env,
|
||||
stdin=subprocess.DEVNULL, capture_output=True, text=True,
|
||||
timeout=UPDATE_TIMEOUT, check=False)
|
||||
except FileNotFoundError:
|
||||
return UpdateResult(False, "dependencies")
|
||||
except subprocess.TimeoutExpired:
|
||||
return UpdateResult(True, "network")
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return UpdateResult(True, "failed")
|
||||
error = cli_error(result.stdout, result.stderr)
|
||||
if error:
|
||||
return UpdateResult(True, error)
|
||||
# npm 在启动 CLI 前的网络或 Node 缺失错误不是 JSON。
|
||||
if re.search(r"ENOTFOUND|EAI_AGAIN|ECONN\w+|ETIMEDOUT|ERR_SOCKET_TIMEOUT|fetch failed", result.stderr, re.I):
|
||||
return UpdateResult(True, "network")
|
||||
if re.search(r"node[^\n]*(?:not found|No such file)|EBADENGINE", result.stderr, re.I):
|
||||
return UpdateResult(False, "dependencies")
|
||||
skill = read_skill()
|
||||
if result.returncode == 0 and skill and parse(skill[1]) >= parse(latest):
|
||||
return UpdateResult(True, "updated")
|
||||
return UpdateResult(True, "failed")
|
||||
|
||||
|
||||
def english() -> bool:
|
||||
return (os.environ.get("LC_ALL") or os.environ.get("LC_MESSAGES") or os.environ.get("LANG", "")).lower().startswith("en")
|
||||
|
||||
|
||||
def update_command(name: str) -> str:
|
||||
# 双引号方便直接复制,也保护 POSIX shell 中的变量与命令替换。
|
||||
path = str(ROOT)
|
||||
if os.name != "nt":
|
||||
path = re.sub(r'([\\"$`])', r'\\\1', path)
|
||||
return f'npx {CLI} update {name} --path "{path}"'
|
||||
|
||||
|
||||
def product_name(name: str) -> str:
|
||||
if name == "oil-ui-pro":
|
||||
return "Oil UI Pro"
|
||||
if name == "oil-ui":
|
||||
return "Oil UI (open source)" if english() else "Oil UI 开源版"
|
||||
return name
|
||||
|
||||
|
||||
def zh_label(label: str) -> str:
|
||||
# 中文句子里,以汉字结尾的名称后面不加空格。
|
||||
return label if re.search(r"[\u4e00-\u9fff]$", label) else f"{label} "
|
||||
|
||||
|
||||
def notice(name: str, current: str, latest: str, detail: str, reason: str) -> str:
|
||||
command = update_command(name)
|
||||
label = product_name(name)
|
||||
if english():
|
||||
intro = f"{label} {latest} is available (current: {current}){detail}. "
|
||||
if reason == "unauthorized":
|
||||
return intro + f"Authorization has expired or is missing. Run npx {CLI} login, then run {command}."
|
||||
if reason == "inactive":
|
||||
return intro + f"This Skill has no purchase record. Purchase it at https://ui.oiloil.org/pro/ to get updates, then run {command}."
|
||||
if reason == "dependencies":
|
||||
return intro + f"Automatic updates need Node.js 18 or later. Install it, then run {command}."
|
||||
return intro + f"To update, run {command}."
|
||||
intro = f"{zh_label(label)}有新版本 {latest}(当前 {current}){detail}。"
|
||||
if reason == "unauthorized":
|
||||
return intro + f"授权已失效或尚未授权。运行 npx {CLI} login 重新授权,再运行 {command}。"
|
||||
if reason == "inactive":
|
||||
return intro + f"这份 Skill 没有对应的购买记录,购买后才能获得更新。打开 https://ui.oiloil.org/pro/ 购买,再运行 {command}。"
|
||||
if reason == "dependencies":
|
||||
return intro + f"自动更新需要 Node.js 18 以上。安装后运行 {command}。"
|
||||
return intro + f"在终端运行 {command} 即可更新。"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if os.environ.get("OIL_NO_UPDATE_CHECK") or (ROOT / ".git").exists():
|
||||
return 0
|
||||
skill = read_skill()
|
||||
if not skill:
|
||||
return 0
|
||||
name, current = skill
|
||||
path = state_path(name)
|
||||
# 原来的共享文件可能包含失败与提示记录,迁移时只保留公开缓存。
|
||||
cache = {key: value for key, value in load(path).items()
|
||||
if key in {"checked_at", "latest", "notes", "free", "fetch_failed_at"}}
|
||||
install_path = installation_state_path(name)
|
||||
state = load(install_path)
|
||||
now = time.time()
|
||||
|
||||
if now - float(cache.get("checked_at", 0)) >= CHECK_INTERVAL:
|
||||
if now - float(cache.get("fetch_failed_at", 0)) < RETRY_AFTER_FAILURE:
|
||||
return 0
|
||||
try:
|
||||
found = fetch(name)
|
||||
except Exception:
|
||||
found = None
|
||||
if found:
|
||||
cache.update(found)
|
||||
cache["checked_at"] = now
|
||||
cache.pop("fetch_failed_at", None)
|
||||
else:
|
||||
cache["fetch_failed_at"] = now
|
||||
save(path, cache)
|
||||
if not found:
|
||||
return 0
|
||||
|
||||
latest = cache.get("latest")
|
||||
if not isinstance(latest, str) or not re.fullmatch(r"\d+\.\d+\.\d+", latest) or parse(latest) <= parse(current):
|
||||
return 0
|
||||
summary = headline(cache.get("notes", ""))
|
||||
detail = (f": {summary}" if english() else f":{summary}") if summary else ""
|
||||
context, npx, ready = update_context()
|
||||
|
||||
reason = state.get("auto_failed_reason", "failed")
|
||||
cooldown = RETRY_AFTER_FAILURE if reason == "network" else DAY
|
||||
if (state.get("auto_failed_version") != latest or state.get("auto_failed_context") != context
|
||||
or now - float(state.get("auto_failed_at", 0)) >= cooldown):
|
||||
result = auto_update(name, latest, bool(cache.get("free")), npx, ready)
|
||||
reason = result.reason
|
||||
if reason == "updated":
|
||||
save(install_path, {})
|
||||
label = product_name(name)
|
||||
if english():
|
||||
print(f"{label} updated automatically to {latest} (previous: {current}){detail}. Read SKILL.md again before continuing.")
|
||||
else:
|
||||
print(f"{zh_label(label)}已自动更新到 {latest}(原来是 {current}){detail}。请重新读取 SKILL.md 再继续。")
|
||||
return 0
|
||||
if result.attempted:
|
||||
state.update(auto_failed_version=latest, auto_failed_at=now,
|
||||
auto_failed_reason=reason, auto_failed_context=context)
|
||||
else:
|
||||
for key in list(state):
|
||||
if key.startswith("auto_failed_"):
|
||||
state.pop(key)
|
||||
save(install_path, state)
|
||||
|
||||
if reason == "network":
|
||||
return 0
|
||||
|
||||
language = "en" if english() else "zh"
|
||||
if (state.get("notified_version") == latest and state.get("notified_reason") == reason
|
||||
and state.get("notified_context") == context and state.get("notified_language") == language
|
||||
and now - float(state.get("notified_at", 0)) < DAY):
|
||||
return 0
|
||||
state["notified_version"], state["notified_at"] = latest, now
|
||||
state.update(notified_reason=reason, notified_context=context, notified_language=language)
|
||||
save(install_path, state)
|
||||
print(notice(name, current, latest, detail, reason))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,31 @@
|
||||
#!/bin/sh
|
||||
# 加载时先找可用的 Python 3;缺少时只提醒一次,继续原任务。
|
||||
script_dir=${0%/*}
|
||||
if [ -n "$OIL_NO_UPDATE_CHECK" ] || [ -e "$script_dir/../.git" ]; then
|
||||
exit 0
|
||||
fi
|
||||
state_base=${XDG_STATE_HOME:-${LOCALAPPDATA:-$HOME/.local/state}}
|
||||
marker="$state_base/oil/update-python-missing"
|
||||
for interpreter in python3 python; do
|
||||
if command -v "$interpreter" >/dev/null 2>&1 && "$interpreter" -c 'import sys; sys.exit(0 if sys.version_info[0] == 3 else 1)' >/dev/null 2>&1; then
|
||||
rmdir "$marker" 2>/dev/null
|
||||
exec "$interpreter" "$script_dir/check_update.py"
|
||||
fi
|
||||
done
|
||||
# mkdir 的独占创建同时防止连续加载与并发加载重复提醒。
|
||||
missing_python_notice() {
|
||||
case ${LC_ALL:-${LC_MESSAGES:-$LANG}} in
|
||||
en*|EN*) printf '%s\n' 'Automatic updates need Python 3. This update check did not run.' ;;
|
||||
*) printf '%s\n' '自动更新需要 Python 3,本次没有检查更新。' ;;
|
||||
esac
|
||||
}
|
||||
mkdir -p "$state_base/oil" 2>/dev/null
|
||||
if mkdir "$marker" 2>/dev/null; then
|
||||
missing_python_notice
|
||||
elif [ -d "$marker" ]; then
|
||||
printf '%s\n' 'OIL_UPDATE_CHECK_SKIPPED: missing_python'
|
||||
else
|
||||
# 状态目录不可写时,由宿主记住这次提醒。
|
||||
missing_python_notice
|
||||
fi
|
||||
exit 0
|
||||
@@ -0,0 +1,388 @@
|
||||
#!/usr/bin/env node
|
||||
// 给设计小样截图、录屏和做基本检查。只依赖 Node 22+ 和本机的 Chrome / Chromium / Edge。
|
||||
// 用法见 references/tools.md;`node shoot.mjs --help` 打印同样的说明。
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { createServer } from "node:http";
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { basename, dirname, extname, join, resolve } from "node:path";
|
||||
|
||||
const HELP = `用法:node shoot.mjs <页面地址或文件> [选项]
|
||||
|
||||
--out <目录> 输出目录,默认 ./shots
|
||||
--size <宽x高,...> 视口,默认 390x844;可写多个,例如 390x844,1280x900
|
||||
--states <a,b,...> 依次用 ?state=<名字> 打开并各截一张
|
||||
--param <名字> 状态参数名,默认 state
|
||||
--zoom <倍数> 设备像素比,默认 1;2 即 200% 截图
|
||||
--full 截整页,默认只截视口
|
||||
--mask 另截一份遮掉全部文字的版本
|
||||
--sheet 把所有状态拼成一张并排图(配合 --mask 再拼一张遮字版)
|
||||
--steps "<动作>" 截图前先执行的动作,用分号分隔:
|
||||
click <选择器> | hover <选择器> | drag <选择器> <dx> <dy>
|
||||
type <选择器> <文字> | key <按键> | scroll <dy> | wait <毫秒>
|
||||
--record 录下 --steps 的执行过程,输出 record.mp4 和开始、中间、结束三帧
|
||||
--hold <毫秒> 录屏时动作结束后再录多久,默认 1200
|
||||
--wait <毫秒> 页面加载后等多久再截,默认 400
|
||||
|
||||
每张图都会检查控制台错误、横向溢出和加载失败的图片,结果写进 report.json。`;
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
if (!args.length || args.includes("--help") || args.includes("-h")) {
|
||||
console.log(HELP);
|
||||
process.exit(args.length ? 0 : 1);
|
||||
}
|
||||
if (typeof WebSocket !== "function") fail("需要 Node 22 或更新的版本。");
|
||||
|
||||
const opt = { out: "shots", size: "390x844", param: "state", zoom: "1", hold: "1200", wait: "400" };
|
||||
const options = [...HELP.matchAll(/^ (--\S+)/gm)].map((m) => m[1]);
|
||||
const flags = new Set();
|
||||
let target = null;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const a = args[i];
|
||||
if (a === "--force") continue;
|
||||
if (["--full", "--mask", "--sheet", "--record"].includes(a)) flags.add(a.slice(2));
|
||||
else if (a.startsWith("--")) {
|
||||
if (!options.includes(a)) fail(`不认识的选项 ${a}\n可用选项:${options.join(" ")}`);
|
||||
if (i + 1 >= args.length || args[i + 1].startsWith("--")) fail(`${a} 需要一个值`);
|
||||
opt[a.slice(2)] = args[++i];
|
||||
} else target = a;
|
||||
}
|
||||
if (!target) fail("缺少页面地址或文件。");
|
||||
|
||||
const sizes = opt.size.split(",").map((s) => {
|
||||
const m = s.trim().match(/^(\d+)x(\d+)$/);
|
||||
if (!m) fail(`尺寸写成 宽x高,例如 390x844:${s}`);
|
||||
return { w: +m[1], h: +m[2] };
|
||||
});
|
||||
const states = opt.states ? opt.states.split(",").map((s) => s.trim()).filter(Boolean) : [null];
|
||||
const zoom = Number(opt.zoom) || 1;
|
||||
const out = resolve(opt.out);
|
||||
mkdirSync(out, { recursive: true });
|
||||
|
||||
function fail(message) {
|
||||
console.error(`shoot:${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ---------- 本地文件用一个只监听本机的静态服务器打开,模块脚本和 fetch 才能正常工作 ----------
|
||||
const MIME = {
|
||||
".html": "text/html; charset=utf-8", ".js": "text/javascript", ".mjs": "text/javascript", ".css": "text/css",
|
||||
".json": "application/json", ".svg": "image/svg+xml", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
||||
".webp": "image/webp", ".gif": "image/gif", ".avif": "image/avif", ".woff2": "font/woff2", ".woff": "font/woff",
|
||||
".ttf": "font/ttf", ".otf": "font/otf", ".mp4": "video/mp4", ".webm": "video/webm",
|
||||
};
|
||||
let server = null;
|
||||
async function resolveTarget(t) {
|
||||
if (/^https?:\/\//.test(t)) return t;
|
||||
const file = resolve(t);
|
||||
if (!existsSync(file)) fail(`找不到文件:${t}`);
|
||||
const root = statSync(file).isDirectory() ? file : dirname(file);
|
||||
const page = statSync(file).isDirectory() ? "index.html" : basename(file);
|
||||
server = createServer((req, res) => {
|
||||
const path = decodeURIComponent(new URL(req.url, "http://x").pathname);
|
||||
const local = resolve(join(root, path));
|
||||
if (!local.startsWith(root) || !existsSync(local) || statSync(local).isDirectory()) {
|
||||
res.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { "Content-Type": MIME[extname(local).toLowerCase()] || "application/octet-stream" });
|
||||
res.end(readFileSync(local));
|
||||
});
|
||||
await new Promise((ok) => server.listen(0, "127.0.0.1", ok));
|
||||
return `http://127.0.0.1:${server.address().port}/${encodeURIComponent(page)}`;
|
||||
}
|
||||
|
||||
// ---------- 启动一个独立的临时浏览器,不碰用户自己的浏览器数据 ----------
|
||||
function findChrome() {
|
||||
const env = process.env.CHROME_PATH;
|
||||
if (env && existsSync(env)) return env;
|
||||
const candidates = {
|
||||
darwin: [
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
||||
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
||||
],
|
||||
win32: [
|
||||
`${process.env["PROGRAMFILES"]}\\Google\\Chrome\\Application\\chrome.exe`,
|
||||
`${process.env["PROGRAMFILES(X86)"]}\\Google\\Chrome\\Application\\chrome.exe`,
|
||||
`${process.env["PROGRAMFILES(X86)"]}\\Microsoft\\Edge\\Application\\msedge.exe`,
|
||||
],
|
||||
}[process.platform];
|
||||
for (const c of candidates || []) if (c && existsSync(c)) return c;
|
||||
for (const name of ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "microsoft-edge"]) {
|
||||
const r = spawnSync("which", [name], { encoding: "utf8" });
|
||||
if (r.status === 0 && r.stdout.trim()) return r.stdout.trim();
|
||||
}
|
||||
fail("没找到 Chrome、Chromium 或 Edge;安装其一,或用环境变量 CHROME_PATH 指定路径。");
|
||||
}
|
||||
|
||||
const chromePath = findChrome();
|
||||
const profile = mkdtempSync(join(tmpdir(), "oil-shoot-"));
|
||||
const chrome = spawn(chromePath, [
|
||||
"--headless=new", "--remote-debugging-port=0", `--user-data-dir=${profile}`, "--no-first-run",
|
||||
"--no-default-browser-check", "--hide-scrollbars", "--mute-audio", "--disable-extensions", "about:blank",
|
||||
], { stdio: ["ignore", "ignore", "pipe"] });
|
||||
|
||||
let cleaning;
|
||||
function cleanup() {
|
||||
return cleaning ||= (async () => {
|
||||
if (chrome.exitCode === null && chrome.signalCode === null) {
|
||||
await new Promise((ok) => {
|
||||
const timer = setTimeout(() => { chrome.kill("SIGKILL"); ok(); }, 3000);
|
||||
chrome.once("close", () => { clearTimeout(timer); ok(); });
|
||||
chrome.kill();
|
||||
});
|
||||
}
|
||||
server?.close();
|
||||
rmSync(profile, { recursive: true, force: true });
|
||||
})();
|
||||
}
|
||||
// Early startup failures still use process.exit; its handlers must clean up synchronously.
|
||||
process.on("exit", () => {
|
||||
try { chrome.kill(); } catch {}
|
||||
try { server?.close(); } catch {}
|
||||
try { rmSync(profile, { recursive: true, force: true }); } catch {}
|
||||
});
|
||||
process.on("SIGINT", async () => { await cleanup(); process.exit(130); });
|
||||
process.on("SIGTERM", async () => { await cleanup(); process.exit(143); });
|
||||
chrome.on("error", (error) => fail(`浏览器启动失败:${error.message}`));
|
||||
|
||||
const wsUrl = await new Promise((ok) => {
|
||||
let buf = "";
|
||||
const timer = setTimeout(() => fail("浏览器 15 秒内没有启动。"), 15000);
|
||||
chrome.stderr.on("data", (d) => {
|
||||
buf += d;
|
||||
const m = buf.match(/DevTools listening on (ws:\/\/\S+)/);
|
||||
if (m) { clearTimeout(timer); ok(m[1]); }
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- Chrome DevTools 协议 ----------
|
||||
const ws = new WebSocket(wsUrl);
|
||||
await new Promise((ok, no) => { ws.onopen = ok; ws.onerror = () => no(new Error("连接浏览器失败")); });
|
||||
let seq = 0;
|
||||
const pending = new Map();
|
||||
const listeners = [];
|
||||
ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.id && pending.has(msg.id)) {
|
||||
const { ok, no } = pending.get(msg.id);
|
||||
pending.delete(msg.id);
|
||||
msg.error ? no(new Error(msg.error.message)) : ok(msg.result);
|
||||
} else if (msg.method) listeners.forEach((fn) => fn(msg));
|
||||
};
|
||||
const send = (method, params = {}, sessionId) => new Promise((ok, no) => {
|
||||
const id = ++seq;
|
||||
pending.set(id, { ok, no });
|
||||
ws.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }));
|
||||
});
|
||||
|
||||
const { targetId } = await send("Target.createTarget", { url: "about:blank" });
|
||||
const { sessionId } = await send("Target.attachToTarget", { targetId, flatten: true });
|
||||
const cdp = (method, params) => send(method, params, sessionId);
|
||||
await cdp("Page.enable");
|
||||
await cdp("Runtime.enable");
|
||||
await cdp("Log.enable");
|
||||
|
||||
let problems = [];
|
||||
listeners.push((m) => {
|
||||
if (m.sessionId !== sessionId) return;
|
||||
if (m.method === "Runtime.exceptionThrown") problems.push(`脚本错误:${m.params.exceptionDetails?.exception?.description?.split("\n")[0] || m.params.exceptionDetails?.text}`);
|
||||
if (m.method === "Runtime.consoleAPICalled" && m.params.type === "error") problems.push(`控制台错误:${m.params.args.map((a) => a.value ?? a.description ?? "").join(" ").slice(0, 200)}`);
|
||||
if (m.method === "Log.entryAdded" && m.params.entry.level === "error") problems.push(`加载错误:${m.params.entry.text.slice(0, 200)} ${m.params.entry.url || ""}`.trim());
|
||||
});
|
||||
|
||||
const evaluate = async (expression) => {
|
||||
const r = await cdp("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true });
|
||||
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text);
|
||||
return r.result.value;
|
||||
};
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
async function setViewport(w, h, scale) {
|
||||
await cdp("Emulation.setDeviceMetricsOverride", { width: w, height: h, deviceScaleFactor: scale, mobile: w < 600 });
|
||||
await cdp("Emulation.setTouchEmulationEnabled", { enabled: w < 600 });
|
||||
}
|
||||
|
||||
async function open(url) {
|
||||
const loaded = new Promise((ok) => {
|
||||
const fn = (m) => { if (m.sessionId === sessionId && m.method === "Page.loadEventFired") { listeners.splice(listeners.indexOf(fn), 1); ok(); } };
|
||||
listeners.push(fn);
|
||||
});
|
||||
const nav = await cdp("Page.navigate", { url });
|
||||
if (nav.errorText) throw new Error(`打不开 ${url}:${nav.errorText}`);
|
||||
await Promise.race([loaded, sleep(15000)]);
|
||||
await evaluate(`document.fonts ? document.fonts.ready.then(() => true) : true`);
|
||||
await sleep(Number(opt.wait));
|
||||
}
|
||||
|
||||
async function check() {
|
||||
const found = await evaluate(`(() => {
|
||||
const out = [];
|
||||
const doc = document.documentElement;
|
||||
if (doc.scrollWidth > innerWidth + 1) out.push("横向溢出:页面宽 " + doc.scrollWidth + "px,视口 " + innerWidth + "px");
|
||||
for (const img of document.images) if (img.complete && img.naturalWidth === 0) out.push("图片没加载出来:" + (img.getAttribute("src") || "").slice(0, 120));
|
||||
return out;
|
||||
})()`);
|
||||
return [...problems, ...found];
|
||||
}
|
||||
|
||||
async function screenshot(file, full) {
|
||||
let clip;
|
||||
if (full) {
|
||||
const { contentSize } = await cdp("Page.getLayoutMetrics");
|
||||
clip = { x: 0, y: 0, width: Math.ceil(contentSize.width), height: Math.ceil(contentSize.height), scale: 1 };
|
||||
}
|
||||
const { data } = await cdp("Page.captureScreenshot", { format: "png", captureBeyondViewport: !!full, ...(clip ? { clip } : {}) });
|
||||
writeFileSync(file, Buffer.from(data, "base64"));
|
||||
return file;
|
||||
}
|
||||
|
||||
// Keep color intact: SVG icons and CSS decorations may use currentColor.
|
||||
const MASK_CSS = `*,*::before,*::after{text-shadow:none!important;-webkit-text-fill-color:transparent!important;caret-color:transparent!important}
|
||||
::placeholder{color:transparent!important}svg text,svg tspan{fill:transparent!important;stroke:transparent!important}`;
|
||||
const mask = () => evaluate(`(() => { const s = document.createElement("style"); s.id = "oil-mask"; s.textContent = ${JSON.stringify(MASK_CSS)}; document.head.append(s); return true; })()`);
|
||||
|
||||
// ---------- 动作 ----------
|
||||
function tokenize(text) {
|
||||
return [...text.matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)].map((m) => m[1] ?? m[2] ?? m[3]);
|
||||
}
|
||||
const KEYS = { ArrowUp: 38, ArrowDown: 40, ArrowLeft: 37, ArrowRight: 39, Enter: 13, Escape: 27, Tab: 9, " ": 32, Space: 32, Home: 36, End: 35, PageUp: 33, PageDown: 34, Backspace: 8 };
|
||||
async function center(selector) {
|
||||
const box = await evaluate(`(() => { const el = document.querySelector(${JSON.stringify(selector)}); if (!el) return null; el.scrollIntoView({ block: "center", inline: "center" }); const r = el.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; })()`);
|
||||
if (!box) throw new Error(`找不到元素:${selector}`);
|
||||
return box;
|
||||
}
|
||||
const mouse = (type, x, y, extra = {}) => cdp("Input.dispatchMouseEvent", { type, x, y, button: "left", pointerType: "mouse", ...extra });
|
||||
async function runSteps(text) {
|
||||
for (const raw of (text || "").split(";").map((s) => s.trim()).filter(Boolean)) {
|
||||
const [verb, ...rest] = tokenize(raw);
|
||||
if (verb === "wait") await sleep(Number(rest[0]) || 0);
|
||||
else if (verb === "click") { const p = await center(rest[0]); await mouse("mouseMoved", p.x, p.y); await mouse("mousePressed", p.x, p.y, { clickCount: 1 }); await mouse("mouseReleased", p.x, p.y, { clickCount: 1 }); await sleep(120); }
|
||||
else if (verb === "hover") { const p = await center(rest[0]); await mouse("mouseMoved", p.x, p.y); await sleep(200); }
|
||||
else if (verb === "drag") {
|
||||
const p = await center(rest[0]); const dx = Number(rest[1]) || 0; const dy = Number(rest[2]) || 0;
|
||||
await mouse("mouseMoved", p.x, p.y); await mouse("mousePressed", p.x, p.y, { clickCount: 1, buttons: 1 });
|
||||
for (let i = 1; i <= 24; i++) { await mouse("mouseMoved", p.x + (dx * i) / 24, p.y + (dy * i) / 24, { buttons: 1 }); await sleep(16); }
|
||||
await mouse("mouseReleased", p.x + dx, p.y + dy, { clickCount: 1 }); await sleep(150);
|
||||
} else if (verb === "type") {
|
||||
await evaluate(`(() => { const el = document.querySelector(${JSON.stringify(rest[0])}); if (!el) throw new Error("找不到元素:${rest[0]}"); el.focus(); return true; })()`);
|
||||
await cdp("Input.insertText", { text: rest.slice(1).join(" ") }); await sleep(120);
|
||||
} else if (verb === "key") {
|
||||
const key = rest[0] === "Space" ? " " : rest[0]; const code = KEYS[rest[0]] ?? key.toUpperCase().charCodeAt(0);
|
||||
await cdp("Input.dispatchKeyEvent", { type: "keyDown", key, code: rest[0], windowsVirtualKeyCode: code });
|
||||
await cdp("Input.dispatchKeyEvent", { type: "keyUp", key, code: rest[0], windowsVirtualKeyCode: code }); await sleep(80);
|
||||
} else if (verb === "scroll") { await evaluate(`scrollBy(0, ${Number(rest[0]) || 0}), true`); await sleep(200); }
|
||||
else throw new Error(`不认识的动作:${raw}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 并排图:用同一个浏览器把截图排成一张 ----------
|
||||
async function sheet(items, file, w, h) {
|
||||
const cell = Math.min(w, 420);
|
||||
const figures = items.map(({ path, label }) =>
|
||||
`<figure><img src="data:image/png;base64,${readFileSync(path).toString("base64")}"><figcaption>${label}</figcaption></figure>`).join("");
|
||||
const html = `<!doctype html><meta charset="utf-8"><style>body{margin:0;padding:32px;background:#ececea;font:13px -apple-system,"PingFang SC",sans-serif;color:#555}
|
||||
main{display:flex;gap:24px;align-items:flex-start}figure{margin:0;width:${cell}px}img{width:100%;display:block;border-radius:12px;box-shadow:0 1px 3px #0002}
|
||||
figcaption{margin-top:10px}</style><main>${figures}</main>`;
|
||||
const tmp = join(profile, "sheet.html");
|
||||
writeFileSync(tmp, html);
|
||||
const width = items.length * cell + (items.length - 1) * 24 + 64;
|
||||
await setViewport(width, Math.round((cell * h) / w) + 120, 1);
|
||||
await open(`file://${tmp}`);
|
||||
return screenshot(file, true);
|
||||
}
|
||||
|
||||
// ---------- 录屏 ----------
|
||||
async function record(url, w, h) {
|
||||
const frames = [];
|
||||
const dir = join(out, "frames");
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const onFrame = (m) => {
|
||||
if (m.sessionId !== sessionId || m.method !== "Page.screencastFrame") return;
|
||||
const name = join(dir, `f${String(frames.length).padStart(4, "0")}.jpg`);
|
||||
writeFileSync(name, Buffer.from(m.params.data, "base64"));
|
||||
frames.push({ name, t: m.params.metadata.timestamp });
|
||||
cdp("Page.screencastFrameAck", { sessionId: m.params.sessionId }).catch(() => {});
|
||||
};
|
||||
await setViewport(w, h, zoom);
|
||||
await open(url);
|
||||
listeners.push(onFrame);
|
||||
await cdp("Page.startScreencast", { format: "jpeg", quality: 88, everyNthFrame: 1 });
|
||||
await sleep(500);
|
||||
await runSteps(opt.steps);
|
||||
await sleep(Number(opt.hold));
|
||||
const finished = Date.now() / 1000;
|
||||
const issues = await check();
|
||||
await cdp("Page.stopScreencast");
|
||||
listeners.splice(listeners.indexOf(onFrame), 1);
|
||||
if (!frames.length) throw new Error("录屏没有拿到画面");
|
||||
const pick = { start: frames[0], mid: frames[Math.floor(frames.length / 2)], end: frames[frames.length - 1] };
|
||||
for (const [k, f] of Object.entries(pick)) writeFileSync(join(out, `motion-${k}.jpg`), readFileSync(f.name));
|
||||
const ffmpeg = spawnSync("ffmpeg", ["-version"]).status === 0;
|
||||
if (!ffmpeg) return { issues, message: `录屏:没装 ffmpeg,只留了 ${frames.length} 帧和 motion-start/mid/end.jpg` };
|
||||
// Generated basenames are safe for concat's quoting, even when --out contains an apostrophe.
|
||||
// Keep the final still frame through the end of --hold; screencasts only emit changed frames.
|
||||
const list = frames.map((f, i) => `file '${basename(f.name)}'\nduration ${Math.max(0.016, ((frames[i + 1]?.t ?? finished) - f.t)).toFixed(3)}`).join("\n") + `\nfile '${basename(frames.at(-1).name)}'\n`;
|
||||
writeFileSync(join(dir, "list.txt"), list);
|
||||
const r = spawnSync("ffmpeg", ["-y", "-v", "error", "-f", "concat", "-safe", "0", "-i", join(dir, "list.txt"),
|
||||
"-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2,fps=30", "-pix_fmt", "yuv420p", join(out, "record.mp4")], { encoding: "utf8" });
|
||||
if (r.status !== 0) return { issues, message: `录屏:ffmpeg 合成失败(${r.stderr.trim().split("\n").pop()}),帧留在 frames/` };
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
return { issues, message: `录屏:record.mp4(${(finished - frames[0].t).toFixed(1)} 秒)和 motion-start/mid/end.jpg` };
|
||||
}
|
||||
|
||||
// ---------- 主流程 ----------
|
||||
const base = await resolveTarget(target);
|
||||
const withState = (s) => {
|
||||
if (!s) return base;
|
||||
const u = new URL(base);
|
||||
u.searchParams.set(opt.param, s);
|
||||
return u.toString();
|
||||
};
|
||||
const report = [];
|
||||
const lines = [];
|
||||
try {
|
||||
if (flags.has("record")) {
|
||||
const result = await record(withState(states[0]), sizes[0].w, sizes[0].h);
|
||||
lines.push(result.message);
|
||||
report.push({ file: "motion-end.jpg", state: states[0], size: `${sizes[0].w}x${sizes[0].h}`, zoom, issues: result.issues });
|
||||
} else {
|
||||
for (const { w, h } of sizes) {
|
||||
const shots = [], masked = [];
|
||||
for (const s of states) {
|
||||
problems = [];
|
||||
await setViewport(w, h, zoom);
|
||||
await open(withState(s));
|
||||
if (opt.steps) await runSteps(opt.steps);
|
||||
const name = [s || "page", sizes.length > 1 ? `${w}x${h}` : "", zoom !== 1 ? `@${zoom}x` : ""].filter(Boolean).join("-");
|
||||
const file = await screenshot(join(out, `${name}.png`), flags.has("full"));
|
||||
const issues = await check();
|
||||
report.push({ file: basename(file), state: s, size: `${w}x${h}`, zoom, issues });
|
||||
shots.push({ path: file, label: s || "page" });
|
||||
if (flags.has("mask")) {
|
||||
await mask();
|
||||
await sleep(60);
|
||||
masked.push({ path: await screenshot(join(out, `${name}-masked.png`), flags.has("full")), label: s || "page" });
|
||||
}
|
||||
lines.push(`${basename(file)}${issues.length ? " ⚠ " + issues.join(";") : ""}`);
|
||||
}
|
||||
if (flags.has("sheet") && shots.length > 1) {
|
||||
const suffix = sizes.length > 1 ? `-${w}x${h}` : "";
|
||||
lines.push(basename(await sheet(shots, join(out, `sheet${suffix}.png`), w, h)));
|
||||
if (masked.length) lines.push(basename(await sheet(masked, join(out, `sheet${suffix}-masked.png`), w, h)));
|
||||
}
|
||||
}
|
||||
}
|
||||
writeFileSync(join(out, "report.json"), JSON.stringify(report, null, 2));
|
||||
} catch (error) {
|
||||
console.error(`shoot:${error.message}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
console.log(`输出目录:${out}`);
|
||||
for (const l of lines) console.log(`- ${l}`);
|
||||
const total = report.reduce((n, r) => n + r.issues.length, 0);
|
||||
if (report.length) console.log(total ? `发现 ${total} 个问题,详见 report.json` : "检查通过:没有控制台错误、横向溢出或加载失败的图片");
|
||||
ws.close();
|
||||
await cleanup();
|
||||
process.exit(process.exitCode || 0);
|
||||
@@ -0,0 +1,365 @@
|
||||
"""Observable builder contracts. Run with Python's unittest discovery."""
|
||||
|
||||
import base64
|
||||
from contextlib import redirect_stderr
|
||||
import importlib.util
|
||||
from html.parser import HTMLParser
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
spec = importlib.util.spec_from_file_location("build_explorer", ROOT / "scripts" / "build_explorer.py")
|
||||
builder = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(builder)
|
||||
|
||||
|
||||
class ExplorerBuildTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.folder = Path(self.tmp.name)
|
||||
self.manifest = self.folder / "manifest.json"
|
||||
self.output = self.folder / "output" / "explore.html"
|
||||
self.source = self.folder / "sample.html"
|
||||
self.source.write_text('<!doctype html><html><head><style>body{color:#123}</style></head><body>同一内容<script>window.bad=true</script></body></html>', encoding="utf-8")
|
||||
self.data = {"schemaVersion": 1, "project": "项目", "brief": "同一内容比较", "round": "01", "candidates": [{"id": "a", "name": "方向一", "concept": "编辑式", "typography": "宋体与黑体", "palette": ["#112233", "#fff"], "traits": ["大标题"], "kind": "html", "source": "sample.html"}]}
|
||||
self.save()
|
||||
|
||||
def save(self):
|
||||
self.manifest.write_text(json.dumps(self.data, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
def test_portable_single_file_and_html_payload(self):
|
||||
result = builder.build(self.manifest, self.output)
|
||||
page = self.output.read_text(encoding="utf-8")
|
||||
self.assertEqual(result["candidates"], 1)
|
||||
self.assertIn("同一内容", page)
|
||||
self.assertNotIn(str(self.folder), page)
|
||||
self.assertNotIn(builder.MARKER, page)
|
||||
self.assertIn("script-src 'none'", page)
|
||||
|
||||
def test_metadata_cannot_close_script(self):
|
||||
self.data["project"] = '</script><script>window.injected=true</script>'
|
||||
self.save()
|
||||
builder.build(self.manifest, self.output)
|
||||
page = self.output.read_text(encoding="utf-8")
|
||||
self.assertNotIn(self.data["project"], page)
|
||||
self.assertIn('\\u003c/script>', page)
|
||||
|
||||
def test_csp_is_inserted_in_actual_head_not_a_comment(self):
|
||||
self.source.write_text('''<!doctype html>
|
||||
<!-- Skeleton: <head> \u2028 extra text -->
|
||||
<html><head data-note="a > b"></head><body>content</body></html>''', encoding="utf-8")
|
||||
content = builder.prepare_html(self.source)
|
||||
observed = []
|
||||
class Tags(HTMLParser):
|
||||
def handle_starttag(self, tag, attrs):
|
||||
observed.append((tag, dict(attrs)))
|
||||
Tags().feed(content)
|
||||
meta = [attrs for tag, attrs in observed if tag == "meta"]
|
||||
self.assertEqual(meta, [{"http-equiv": "Content-Security-Policy", "content": builder.PREVIEW_CSP}])
|
||||
self.assertEqual([tag for tag, attrs in observed][:3], ["html", "head", "meta"])
|
||||
|
||||
def test_nested_documents_are_rejected_before_replacing_output(self):
|
||||
builder.build(self.manifest, self.output)
|
||||
original = self.output.read_bytes()
|
||||
self.source.write_text('''<html><head></head><body>
|
||||
<iframe srcdoc="<img src='https://example.invalid/a.png'>"></iframe>
|
||||
</body></html>''', encoding="utf-8")
|
||||
with self.assertRaises(ValueError):
|
||||
builder.build(self.manifest, self.output, force=True)
|
||||
self.assertEqual(original, self.output.read_bytes())
|
||||
|
||||
def test_existing_output_is_preserved_and_force_is_explicit(self):
|
||||
builder.build(self.manifest, self.output)
|
||||
original = self.output.read_bytes()
|
||||
self.data["project"] = "新项目"
|
||||
self.save()
|
||||
with self.assertRaises(FileExistsError):
|
||||
builder.build(self.manifest, self.output)
|
||||
self.assertEqual(original, self.output.read_bytes())
|
||||
builder.build(self.manifest, self.output, force=True)
|
||||
self.assertNotEqual(original, self.output.read_bytes())
|
||||
|
||||
def test_invalid_resource_keeps_prior_output_even_with_force(self):
|
||||
builder.build(self.manifest, self.output)
|
||||
original = self.output.read_bytes()
|
||||
self.source.write_text('<html><head></head><body><img src="missing.png"></body></html>', encoding="utf-8")
|
||||
with self.assertRaises(ValueError):
|
||||
builder.build(self.manifest, self.output, force=True)
|
||||
self.assertEqual(original, self.output.read_bytes())
|
||||
|
||||
def test_duplicate_identifier_and_invalid_colors(self):
|
||||
self.data["candidates"].append(self.data["candidates"][0].copy())
|
||||
self.save()
|
||||
with self.assertRaises(ValueError):
|
||||
builder.build(self.manifest, self.output)
|
||||
self.data["candidates"].pop()
|
||||
self.data["candidates"][0]["palette"] = ["url(example)"]
|
||||
self.save()
|
||||
with self.assertRaises(ValueError):
|
||||
builder.build(self.manifest, self.output)
|
||||
self.assertFalse(self.output.exists())
|
||||
|
||||
def test_text_and_preserved_script_are_not_css_resources(self):
|
||||
self.source.write_text('''<html><head><style>
|
||||
/* @import "unused.css"; */
|
||||
p::after {content: "example url(example.png) @import"}
|
||||
</style></head><body><p>Explain @import and url(example.png)</p>
|
||||
<script>const example = "url(unused.png)";</script></body></html>''', encoding="utf-8")
|
||||
builder.build(self.manifest, self.output)
|
||||
self.assertTrue(self.output.is_file())
|
||||
|
||||
def test_css_sources_are_checked_in_styles_and_inline_attributes(self):
|
||||
fragments = [
|
||||
'<style>.hero{background:image-set("https://example.invalid/a.png" 1x)}</style>',
|
||||
'<style>.hero{background:-webkit-image-set("missing.png" 1x)}</style>',
|
||||
'<style>.hero{background:u\\72l(missing.png)}</style>',
|
||||
'<style>@import "missing.css";</style>',
|
||||
]
|
||||
for fragment in fragments:
|
||||
with self.subTest(fragment=fragment):
|
||||
self.source.write_text(f'<html><head>{fragment}</head><body>content</body></html>', encoding="utf-8")
|
||||
with self.assertRaises(ValueError):
|
||||
builder.build(self.manifest, self.output)
|
||||
self.source.write_text('<html><head></head><body style="background:url(missing.png)">content</body></html>', encoding="utf-8")
|
||||
with self.assertRaises(ValueError):
|
||||
builder.build(self.manifest, self.output)
|
||||
|
||||
def test_source_escape_and_overwriting_inputs_are_rejected(self):
|
||||
self.data["candidates"][0]["source"] = "../outside.html"
|
||||
self.save()
|
||||
with self.assertRaises(ValueError):
|
||||
builder.build(self.manifest, self.output)
|
||||
self.data["candidates"][0]["source"] = "sample.html"
|
||||
self.save()
|
||||
with self.assertRaises(ValueError):
|
||||
builder.build(self.manifest, self.source, force=True)
|
||||
with self.assertRaises(ValueError):
|
||||
builder.build(self.manifest, self.manifest, force=True)
|
||||
|
||||
def test_static_image_embedded_and_content_changes_identity(self):
|
||||
image = self.folder / "preview.png"
|
||||
image.write_bytes(base64.b64decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+j7ioAAAAASUVORK5CYII='))
|
||||
self.data["candidates"][0].update(kind="image", source="preview.png")
|
||||
self.save()
|
||||
first = builder.build(self.manifest, self.output)
|
||||
self.assertIn("data:image/png;base64,", self.output.read_text(encoding="utf-8"))
|
||||
self.data["round"] = "02"
|
||||
self.save()
|
||||
second = builder.build(self.manifest, self.output, force=True)
|
||||
self.assertNotEqual(first["fingerprint"], second["fingerprint"])
|
||||
|
||||
def test_relative_local_assets_are_embedded(self):
|
||||
png = base64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=")
|
||||
(self.folder / "pages" / "img").mkdir(parents=True)
|
||||
(self.folder / "pages" / "img" / "dot.png").write_bytes(png)
|
||||
(self.folder / "pages" / "a.html").write_text('<!doctype html><html><head><style>.x{background:url("img/dot.png")}</style></head><body><img src="img/dot.png" alt=""></body></html>', encoding="utf-8")
|
||||
self.data["candidates"][0]["source"] = "pages/a.html"
|
||||
self.save()
|
||||
builder.build(self.manifest, self.output)
|
||||
page = self.output.read_text(encoding="utf-8")
|
||||
self.assertNotIn("img/dot.png", page)
|
||||
self.assertIn("data:image/png;base64,", page)
|
||||
|
||||
def test_local_assets_outside_the_manifest_folder_stay_rejected(self):
|
||||
outside = Path(self.tmp.name).parent / f"{Path(self.tmp.name).name}-outside.png"
|
||||
outside.write_bytes(b"\x89PNG\r\n\x1a\n")
|
||||
self.addCleanup(outside.unlink)
|
||||
self.source.write_text(f'<!doctype html><html><head></head><body><img src="../{outside.name}" alt=""><img src="https://example.com/a.png" alt=""></body></html>', encoding="utf-8")
|
||||
with self.assertRaises(ValueError):
|
||||
builder.build(self.manifest, self.output)
|
||||
|
||||
def test_live_candidates_accept_only_local_dev_servers(self):
|
||||
self.data["candidates"].append({"id": "live", "name": "现状", "concept": "当前版本", "typography": "系统字体", "palette": ["#fff"], "traits": ["现有页面"], "kind": "url", "url": "http://localhost:5173/orders", "baseline": True})
|
||||
self.save()
|
||||
builder.build(self.manifest, self.output)
|
||||
page = self.output.read_text(encoding="utf-8")
|
||||
payload = json.loads(page.split("const DATA = ", 1)[1].split(";\n", 1)[0])
|
||||
self.assertEqual([c["id"] for c in payload["candidates"]], ["live", "a"])
|
||||
for url in ("https://example.com/", "file:///etc/passwd", "http://user:pw@localhost:3000/"):
|
||||
self.data["candidates"][1]["url"] = url
|
||||
self.save()
|
||||
with self.assertRaises(ValueError):
|
||||
builder.build(self.manifest, self.output, force=True)
|
||||
|
||||
def test_serve_accepts_command_and_optional_absolute_cwd_and_local_url(self):
|
||||
for serve in (
|
||||
{"command": "pnpm dev"},
|
||||
{"command": "pnpm dev", "cwd": str(self.folder)},
|
||||
{"command": "pnpm dev", "cwd": str(self.folder), "url": "http://localhost:3456"},
|
||||
{"command": "pnpm dev", "url": "https://127.0.0.1:3456/"},
|
||||
{"command": "pnpm dev", "url": "http://[::1]:3456/"},
|
||||
):
|
||||
with self.subTest(serve=serve):
|
||||
self.data["serve"] = serve
|
||||
self.save()
|
||||
builder.build(self.manifest, self.output, force=True)
|
||||
page = self.output.read_text(encoding="utf-8")
|
||||
payload = json.loads(page.split("const DATA = ", 1)[1].split(";\n", 1)[0])
|
||||
self.assertEqual(payload["serve"], serve)
|
||||
|
||||
def test_serve_requires_a_nonempty_string_command(self):
|
||||
for serve in ({}, {"command": ""}, {"command": " "}, {"command": 123}, {"command": None}):
|
||||
with self.subTest(serve=serve):
|
||||
self.data["serve"] = serve
|
||||
self.save()
|
||||
with self.assertRaisesRegex(ValueError, "serve.command 必须是非空字符串"):
|
||||
builder.build(self.manifest, self.output)
|
||||
for serve in (None, [], "pnpm dev"):
|
||||
with self.subTest(serve=serve):
|
||||
self.data["serve"] = serve
|
||||
self.save()
|
||||
with self.assertRaisesRegex(ValueError, "serve 必须是对象"):
|
||||
builder.build(self.manifest, self.output)
|
||||
self.assertFalse(self.output.exists())
|
||||
|
||||
def test_serve_cwd_must_be_an_absolute_path(self):
|
||||
for cwd in ("project", "./project", "~/project", "", None, 123):
|
||||
with self.subTest(cwd=cwd):
|
||||
self.data["serve"] = {"command": "pnpm dev", "cwd": cwd}
|
||||
self.save()
|
||||
with self.assertRaisesRegex(ValueError, "serve.cwd 必须是绝对路径"):
|
||||
builder.build(self.manifest, self.output)
|
||||
|
||||
def test_serve_url_must_be_local_http_or_https(self):
|
||||
for url in ("https://example.com/", "file:///tmp/index.html", "ftp://localhost/", "http://user:pw@localhost:3456/", "http://localhost:bad/", "http://[::1", "", None, 123):
|
||||
with self.subTest(url=url):
|
||||
self.data["serve"] = {"command": "pnpm dev", "url": url}
|
||||
self.save()
|
||||
with self.assertRaisesRegex(ValueError, "serve.*url.*本机"):
|
||||
builder.build(self.manifest, self.output)
|
||||
|
||||
def test_serve_is_embedded_unchanged_without_script_escape(self):
|
||||
serve = {"command": " printf '</script><script>window.injected=true</script>'\u2028\u2029 ", "cwd": str(self.folder / 'a"$`\\b'), "url": "http://localhost:3456", "note": "保留额外元数据"}
|
||||
self.data["serve"] = serve
|
||||
self.save()
|
||||
builder.build(self.manifest, self.output)
|
||||
page = self.output.read_text(encoding="utf-8")
|
||||
payload = json.loads(page.split("const DATA = ", 1)[1].split(";\n", 1)[0])
|
||||
self.assertEqual(payload["serve"], serve)
|
||||
self.assertNotIn(serve["command"], page)
|
||||
self.assertIn('\\u003c/script>', page)
|
||||
self.assertIn('\\u2028\\u2029', page)
|
||||
|
||||
def test_url_candidates_without_serve_warn_but_still_build(self):
|
||||
self.data["candidates"][0].update(kind="url", url="http://localhost:3456/")
|
||||
self.save()
|
||||
messages = io.StringIO()
|
||||
with redirect_stderr(messages):
|
||||
builder.build(self.manifest, self.output)
|
||||
self.assertTrue(self.output.is_file())
|
||||
self.assertEqual(len(messages.getvalue().splitlines()), 1)
|
||||
self.assertIn("建议在 manifest 顶层补上 serve", messages.getvalue())
|
||||
self.data["serve"] = {"command": "pnpm dev"}
|
||||
self.save()
|
||||
messages = io.StringIO()
|
||||
with redirect_stderr(messages):
|
||||
builder.build(self.manifest, self.output, force=True)
|
||||
self.assertEqual(messages.getvalue(), "")
|
||||
|
||||
def test_shell_connections_allow_only_this_rounds_candidate_origins(self):
|
||||
self.data["serve"] = {"command": "pnpm dev", "url": "http://localhost:9999/"}
|
||||
urls = ["http://localhost:3456/a?note=\"<script>", "http://localhost:3456/b", "https://127.0.0.1:4443/", "http://[::1]:5173/"]
|
||||
self.data["candidates"] = [dict(self.data["candidates"][0], id=f"live{i}", kind="url", url=url) for i, url in enumerate(urls)]
|
||||
self.save()
|
||||
builder.build(self.manifest, self.output)
|
||||
policies = []
|
||||
class Policies(HTMLParser):
|
||||
def handle_starttag(self, tag, attrs):
|
||||
attrs = dict(attrs)
|
||||
if tag == "meta" and attrs.get("http-equiv") == "Content-Security-Policy":
|
||||
policies.append(attrs["content"])
|
||||
Policies().feed(self.output.read_text(encoding="utf-8"))
|
||||
self.assertEqual(policies, ["connect-src http://localhost:3456 http://localhost:5173 https://127.0.0.1:4443"])
|
||||
self.data["candidates"] = [dict(self.data["candidates"][0], kind="html")]
|
||||
self.save()
|
||||
builder.build(self.manifest, self.output, force=True)
|
||||
self.assertIn('content="connect-src \'none\'"', self.output.read_text(encoding="utf-8"))
|
||||
|
||||
def test_interactive_html_runs_inline_scripts_only_when_asked(self):
|
||||
self.assertIn("script-src 'none'", builder.prepare_html(self.source))
|
||||
csp = builder.prepare_html(self.source, interactive=True)
|
||||
self.assertIn("script-src 'unsafe-inline'", csp)
|
||||
self.assertIn("localStorage", csp)
|
||||
self.assertNotIn("localStorage", builder.prepare_html(self.source))
|
||||
self.assertIn("default-src 'none'", csp)
|
||||
self.data["candidates"][0]["interactive"] = True
|
||||
self.save()
|
||||
builder.build(self.manifest, self.output)
|
||||
self.source.write_text('<html><head><script src="https://example.invalid/a.js"></script></head><body></body></html>', encoding="utf-8")
|
||||
with self.assertRaises(ValueError):
|
||||
builder.build(self.manifest, self.output, force=True)
|
||||
self.data["candidates"][0].update(kind="image", interactive=True)
|
||||
self.save()
|
||||
with self.assertRaises(ValueError):
|
||||
builder.build(self.manifest, self.output, force=True)
|
||||
|
||||
def test_local_stylesheets_and_scripts_are_inlined(self):
|
||||
(self.folder / "vendor").mkdir()
|
||||
(self.folder / "vendor" / "lib.js").write_text("window.lib='</script>'", encoding="utf-8")
|
||||
(self.folder / "app.css").write_text("body{color:#123}", encoding="utf-8")
|
||||
self.source.write_text('<!doctype html><html><head><link rel="stylesheet" href="app.css">'
|
||||
'<script defer src="vendor/lib.js"></script></head><body><p>内容</p></body></html>', encoding="utf-8")
|
||||
static = builder.prepare_html(self.source, root=self.folder)
|
||||
self.assertIn("<style>body{color:#123}</style>", static)
|
||||
self.assertNotIn("lib.js", static)
|
||||
self.assertNotIn("window.lib", static)
|
||||
live = builder.prepare_html(self.source, interactive=True, root=self.folder)
|
||||
self.assertIn("'unsafe-eval'", live)
|
||||
self.assertLess(live.index("<p>内容</p>"), live.index("window.lib"))
|
||||
self.assertIn("<\\/script>", live)
|
||||
self.data["candidates"][0]["interactive"] = True
|
||||
self.save()
|
||||
builder.build(self.manifest, self.output)
|
||||
|
||||
def test_only_one_baseline(self):
|
||||
second = dict(self.data["candidates"][0], id="b", baseline=True)
|
||||
self.data["candidates"][0]["baseline"] = True
|
||||
self.data["candidates"].append(second)
|
||||
self.save()
|
||||
with self.assertRaises(ValueError):
|
||||
builder.build(self.manifest, self.output)
|
||||
|
||||
def test_template_keeps_documented_comparison_features(self):
|
||||
builder.build(self.manifest, self.output)
|
||||
page = self.output.read_text(encoding="utf-8")
|
||||
hooks = {
|
||||
"并排与单张": 'data-layout="loupe"',
|
||||
"手机视口": 'data-viewport="mobile"',
|
||||
"筛选": 'id="filter-list"',
|
||||
"设计说明开关": 'id="notes-toggle"',
|
||||
"实际尺寸": '实际尺寸 100%',
|
||||
"选择": "st.chosen",
|
||||
"备注": 'id="notes"',
|
||||
"选择即复制": 'navigator.clipboard',
|
||||
"本地地址候选": "c.kind==='url'",
|
||||
"服务未运行提示": "开发服务器没有运行",
|
||||
"复制启动命令": "button.dataset.copyServe",
|
||||
"现状基线": "c.baseline",
|
||||
"可操作小样": "c.interactive",
|
||||
"按轮次保存": "DATA.fingerprint",
|
||||
"存储不可用提示": "浏览器存储不可用",
|
||||
"展示北极星": "c.concept",
|
||||
"展示色板": "c.palette",
|
||||
}
|
||||
missing = [name for name, hook in hooks.items() if hook not in page]
|
||||
self.assertEqual(missing, [], "模板缺少对比页承诺的功能,见 .github/EXPLORER.md")
|
||||
|
||||
def test_copied_skill_works_from_another_directory(self):
|
||||
copy = self.folder / "relocated"
|
||||
shutil.copytree(ROOT / "scripts", copy / "scripts", ignore=shutil.ignore_patterns("__pycache__"))
|
||||
shutil.copytree(ROOT / "assets", copy / "assets")
|
||||
run = subprocess.run([sys.executable, str(copy / "scripts" / "build_explorer.py"), str(self.manifest), "--output", str(self.output)], cwd=self.folder, capture_output=True, text=True)
|
||||
self.assertEqual(run.returncode, 0, run.stderr)
|
||||
self.assertTrue(self.output.is_file())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,357 @@
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parent.parent / "scripts" / "check_update.py"
|
||||
LAUNCHER = SCRIPT.with_suffix(".sh")
|
||||
SH = shutil.which("sh")
|
||||
|
||||
# 假的 npx:收到 update --path <目录> 时把那里的版本号改成 99.0.0,并记下调用
|
||||
FAKE_NPX = """#!/bin/sh
|
||||
echo "$@" >> "$FAKE_NPX_LOG"
|
||||
[ -n "$FAKE_NPX_STDERR" ] && printf '%s\\n' "$FAKE_NPX_STDERR" >&2
|
||||
[ -n "$FAKE_NPX_JSON" ] && printf '%s\\n' "$FAKE_NPX_JSON"
|
||||
[ -n "$FAKE_NPX_FAIL" ] && exit 1
|
||||
while [ $# -gt 0 ]; do
|
||||
if [ "$1" = "--path" ]; then sed -i.bak 's/version: "0.10.0"/version: "99.0.0"/' "$2/SKILL.md"; fi
|
||||
shift
|
||||
done
|
||||
"""
|
||||
FAKE_NODE = """#!/bin/sh
|
||||
printf '%s\\n' "${FAKE_NODE_VERSION:-v20.0.0}"
|
||||
"""
|
||||
|
||||
|
||||
class Versions(BaseHTTPRequestHandler):
|
||||
payload = {}
|
||||
hits = 0
|
||||
|
||||
def do_GET(self):
|
||||
Versions.hits += 1
|
||||
body = json.dumps(Versions.payload).encode()
|
||||
self.send_response(200 if self.path == "/api/store/versions" else 404)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
|
||||
@unittest.skipIf(sys.platform == "win32", "假 npx 是 shell 脚本")
|
||||
class CheckUpdateTest(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.server = HTTPServer(("127.0.0.1", 0), Versions)
|
||||
threading.Thread(target=cls.server.serve_forever, daemon=True).start()
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.server.shutdown()
|
||||
|
||||
def setUp(self):
|
||||
self.tmp = Path(tempfile.mkdtemp()).resolve()
|
||||
self.skill = self.tmp / "skills" / "oil-ui-pro"
|
||||
(self.skill / "scripts").mkdir(parents=True)
|
||||
shutil.copy(SCRIPT, self.skill / "scripts" / "check_update.py")
|
||||
shutil.copy(LAUNCHER, self.skill / "scripts" / "check_update.sh")
|
||||
(self.skill / "SKILL.md").write_text('---\nname: oil-ui-pro\nmetadata:\n version: "0.10.0"\n---\n', encoding="utf-8")
|
||||
bin_dir = self.tmp / "bin"
|
||||
bin_dir.mkdir()
|
||||
(bin_dir / "npx").write_text(FAKE_NPX, encoding="utf-8")
|
||||
(bin_dir / "npx").chmod(0o755)
|
||||
(bin_dir / "node").write_text(FAKE_NODE, encoding="utf-8")
|
||||
(bin_dir / "node").chmod(0o755)
|
||||
self.bin = bin_dir
|
||||
self.npx_log = self.tmp / "npx.log"
|
||||
Versions.hits = 0
|
||||
self.set_latest("99.0.0", free=False)
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.tmp)
|
||||
|
||||
def set_latest(self, version, free):
|
||||
entry = {"latest": version, "history": [{"version": version, "notes": "- 动效改成三处基本动效。\n- 其他修正"}]}
|
||||
if free:
|
||||
entry["download_url"] = "https://example.com/oil-ui-pro.tar.gz"
|
||||
Versions.payload = {"skills": {"oil-ui-pro": entry}}
|
||||
|
||||
def login(self):
|
||||
config = self.tmp / "config" / "oil"
|
||||
config.mkdir(parents=True, exist_ok=True)
|
||||
(config / "config.json").write_text(json.dumps({"token": "oil_test", "email": "[email protected]"}), encoding="utf-8")
|
||||
|
||||
def run_check(self, api=None, launcher=False, **extra):
|
||||
env = {k: v for k, v in os.environ.items() if not k.startswith("OIL_")}
|
||||
env.update(HOME=str(self.tmp / "home"), APPDATA=str(self.tmp / "config"), LOCALAPPDATA=str(self.tmp / "state"),
|
||||
XDG_STATE_HOME=str(self.tmp / "state"), XDG_CONFIG_HOME=str(self.tmp / "config"),
|
||||
PATH=f"{self.bin}{os.pathsep}{os.environ['PATH']}", FAKE_NPX_LOG=str(self.npx_log),
|
||||
LANG="zh_CN.UTF-8", LC_ALL="", LC_MESSAGES="",
|
||||
OIL_API=api or f"http://127.0.0.1:{self.server.server_port}")
|
||||
env.update(extra)
|
||||
script = self.skill / "scripts" / ("check_update.sh" if launcher else "check_update.py")
|
||||
result = subprocess.run([SH if launcher else sys.executable, str(script)],
|
||||
capture_output=True, text=True, env=env, timeout=30)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(result.stderr, "")
|
||||
return result.stdout
|
||||
|
||||
def install_state(self):
|
||||
paths = list((self.tmp / "state" / "oil" / "installations").glob("*.json"))
|
||||
self.assertEqual(len(paths), 1)
|
||||
return paths[0], json.loads(paths[0].read_text(encoding="utf-8"))
|
||||
|
||||
def assert_no_attempt(self):
|
||||
self.assertEqual(self.npx_calls(), [])
|
||||
_, state = self.install_state()
|
||||
self.assertNotIn("auto_failed_at", state)
|
||||
|
||||
def npx_calls(self):
|
||||
return self.npx_log.read_text(encoding="utf-8").splitlines() if self.npx_log.exists() else []
|
||||
|
||||
def test_logged_in_paid_skill_updates_itself(self):
|
||||
self.login()
|
||||
out = self.run_check()
|
||||
self.assertIn("Oil UI Pro 已自动更新到 99.0.0(原来是 0.10.0):动效改成三处基本动效。", out)
|
||||
self.assertIn(f"update oil-ui-pro --path {self.skill}", self.npx_calls()[0])
|
||||
self.assertIn('version: "99.0.0"', (self.skill / "SKILL.md").read_text(encoding="utf-8"))
|
||||
self.assertEqual(self.run_check(), "")
|
||||
|
||||
def test_free_skill_updates_without_login(self):
|
||||
self.set_latest("99.0.0", free=True)
|
||||
self.assertIn("已自动更新到 99.0.0", self.run_check())
|
||||
|
||||
def test_paid_skill_without_login_only_notifies_once_a_day(self):
|
||||
out = self.run_check()
|
||||
self.assertIn("Oil UI Pro 有新版本 99.0.0(当前 0.10.0):动效改成三处基本动效。", out)
|
||||
self.assertIn("npx github:oil-oil/oil-cli update oil-ui-pro", out)
|
||||
self.assertEqual(self.npx_calls(), [])
|
||||
self.assertEqual(self.run_check(), "")
|
||||
self.assertEqual(Versions.hits, 1)
|
||||
self.assert_no_attempt()
|
||||
|
||||
def test_failed_update_falls_back_to_notice(self):
|
||||
self.login()
|
||||
out = self.run_check(FAKE_NPX_FAIL="1")
|
||||
self.assertIn("有新版本 99.0.0", out)
|
||||
self.assertEqual(len(self.npx_calls()), 1)
|
||||
self.assertEqual(self.run_check(FAKE_NPX_FAIL="1"), "")
|
||||
self.assertEqual(len(self.npx_calls()), 1)
|
||||
|
||||
def test_auto_update_can_be_turned_off(self):
|
||||
self.login()
|
||||
self.assertIn("有新版本 99.0.0", self.run_check(OIL_NO_AUTO_UPDATE="1"))
|
||||
self.assertEqual(self.npx_calls(), [])
|
||||
self.assert_no_attempt()
|
||||
|
||||
def test_cli_errors_have_specific_recovery_commands_in_both_languages(self):
|
||||
self.login()
|
||||
for language in ("zh_CN.UTF-8", "en_US.UTF-8"):
|
||||
for error in ("unauthorized", "inactive"):
|
||||
with self.subTest(language=language, error=error):
|
||||
shutil.rmtree(self.tmp / "state", ignore_errors=True)
|
||||
out = self.run_check(LANG=language, FAKE_NPX_FAIL="1", FAKE_NPX_JSON=json.dumps({
|
||||
"ok": False, "command": "update", "error": error, "message": "DO NOT ECHO THIS",
|
||||
"new_field": "forward-compatible"}))
|
||||
self.assertIn(f'npx github:oil-oil/oil-cli update oil-ui-pro --path "{self.skill}"', out)
|
||||
self.assertNotIn("DO NOT ECHO THIS", out)
|
||||
if error == "unauthorized":
|
||||
self.assertIn("npx github:oil-oil/oil-cli login", out)
|
||||
self.assertIn("Authorization has expired" if language.startswith("en") else "授权已失效", out)
|
||||
else:
|
||||
self.assertIn("https://ui.oiloil.org/pro/", out)
|
||||
self.assertIn("no purchase record" if language.startswith("en") else "没有对应的购买记录", out)
|
||||
_, state = self.install_state()
|
||||
self.assertEqual(state["auto_failed_reason"], error)
|
||||
self.assertEqual(self.run_check(LANG=language, FAKE_NPX_FAIL="1", FAKE_NPX_JSON=json.dumps({"error": error})), "")
|
||||
|
||||
def test_cli_network_errors_are_silent_and_retry_after_an_hour(self):
|
||||
self.login()
|
||||
extra = {"FAKE_NPX_FAIL": "1", "FAKE_NPX_JSON": '{"error":"network"}'}
|
||||
self.assertEqual(self.run_check(**extra), "")
|
||||
self.assertEqual(self.run_check(**extra), "")
|
||||
self.assertEqual(len(self.npx_calls()), 1)
|
||||
path, state = self.install_state()
|
||||
state["auto_failed_at"] -= 3601
|
||||
path.write_text(json.dumps(state), encoding="utf-8")
|
||||
self.assertEqual(self.run_check(**extra), "")
|
||||
self.assertEqual(len(self.npx_calls()), 2)
|
||||
|
||||
def test_npm_network_errors_are_silent_without_json(self):
|
||||
self.login()
|
||||
self.assertEqual(self.run_check(FAKE_NPX_FAIL="1", FAKE_NPX_STDERR="npm error code ENOTFOUND"), "")
|
||||
|
||||
def test_missing_or_old_node_and_missing_npx_do_not_count_as_attempts(self):
|
||||
self.login()
|
||||
for executable in ("npx", "node"):
|
||||
with self.subTest(executable=executable):
|
||||
target = self.bin / executable
|
||||
hidden = self.bin / (executable + "-hidden")
|
||||
target.rename(hidden)
|
||||
out = self.run_check(PATH=str(self.bin))
|
||||
self.assertIn("自动更新需要 Node.js 18 以上", out)
|
||||
self.assertIn(f'--path "{self.skill}"', out)
|
||||
self.assert_no_attempt()
|
||||
hidden.rename(target)
|
||||
out = self.run_check(FAKE_NODE_VERSION="v16.20.0")
|
||||
self.assertIn("自动更新需要 Node.js 18 以上", out)
|
||||
self.assert_no_attempt()
|
||||
self.assertIn("已自动更新", self.run_check())
|
||||
|
||||
def test_login_or_config_change_allows_immediate_retry(self):
|
||||
self.assertIn("login", self.run_check())
|
||||
self.login()
|
||||
self.assertIn("已自动更新", self.run_check())
|
||||
|
||||
def test_reauthorization_and_environment_token_change_clear_failed_cooldown(self):
|
||||
self.login()
|
||||
self.run_check(FAKE_NPX_FAIL="1", FAKE_NPX_JSON='{"error":"unauthorized"}')
|
||||
config = self.tmp / "config" / "oil" / "config.json"
|
||||
config.write_text(json.dumps({"token": "oil_new_token", "email": "[email protected]"}), encoding="utf-8")
|
||||
self.assertIn("已自动更新", self.run_check())
|
||||
self.assertEqual(len(self.npx_calls()), 2)
|
||||
(self.skill / "SKILL.md").write_text('name: oil-ui-pro\nmetadata:\n version: "0.10.0"\n', encoding="utf-8")
|
||||
self.run_check(OIL_TOKEN="oil_first", FAKE_NPX_FAIL="1", FAKE_NPX_JSON='{"error":"unauthorized"}')
|
||||
self.assertIn("已自动更新", self.run_check(OIL_TOKEN="oil_second"))
|
||||
state_text = "".join(p.read_text(encoding="utf-8") for p in (self.tmp / "state").rglob("*.json"))
|
||||
self.assertNotIn("oil_first", state_text)
|
||||
self.assertNotIn("oil_second", state_text)
|
||||
|
||||
def test_repairing_dependency_after_a_failed_attempt_allows_immediate_retry(self):
|
||||
self.login()
|
||||
self.run_check(FAKE_NPX_FAIL="1")
|
||||
self.assertIn("已自动更新", self.run_check(FAKE_NODE_VERSION="v22.0.0"))
|
||||
self.assertEqual(len(self.npx_calls()), 2)
|
||||
|
||||
def test_installations_share_versions_but_not_cooldowns_or_notices(self):
|
||||
self.login()
|
||||
original = self.skill
|
||||
other = self.tmp / "another host" / "oil-ui-pro"
|
||||
shutil.copytree(original, other)
|
||||
self.assertIn("有新版本", self.run_check(FAKE_NPX_FAIL="1"))
|
||||
self.skill = other
|
||||
self.assertIn("已自动更新", self.run_check())
|
||||
self.assertEqual(Versions.hits, 1)
|
||||
self.assertIn('version: "0.10.0"', (original / "SKILL.md").read_text(encoding="utf-8"))
|
||||
self.assertEqual(len(list((self.tmp / "state" / "oil" / "installations").glob("*.json"))), 2)
|
||||
|
||||
def test_legacy_shared_failure_does_not_block_an_installation(self):
|
||||
self.login()
|
||||
cache = self.tmp / "state" / "oil" / "oil-ui-pro-update.json"
|
||||
cache.parent.mkdir(parents=True)
|
||||
cache.write_text(json.dumps({"latest": "99.0.0", "checked_at": 9999999999,
|
||||
"auto_failed_version": "99.0.0", "auto_failed_at": 9999999999,
|
||||
"notified_version": "99.0.0", "notified_at": 9999999999}), encoding="utf-8")
|
||||
self.assertIn("已自动更新", self.run_check())
|
||||
|
||||
def test_checks_versions_again_after_ten_minutes(self):
|
||||
self.run_check(OIL_NO_AUTO_UPDATE="1")
|
||||
self.run_check(OIL_NO_AUTO_UPDATE="1")
|
||||
self.assertEqual(Versions.hits, 1)
|
||||
cache = self.tmp / "state" / "oil" / "oil-ui-pro-update.json"
|
||||
state = json.loads(cache.read_text(encoding="utf-8"))
|
||||
state["checked_at"] -= 601
|
||||
cache.write_text(json.dumps(state), encoding="utf-8")
|
||||
self.run_check(OIL_NO_AUTO_UPDATE="1")
|
||||
self.assertEqual(Versions.hits, 2)
|
||||
|
||||
def test_offline_with_stale_cache_is_silent_and_fetch_retries_later(self):
|
||||
self.run_check(OIL_NO_AUTO_UPDATE="1")
|
||||
cache = self.tmp / "state" / "oil" / "oil-ui-pro-update.json"
|
||||
state = json.loads(cache.read_text(encoding="utf-8"))
|
||||
state["checked_at"] = 0
|
||||
cache.write_text(json.dumps(state), encoding="utf-8")
|
||||
self.assertEqual(self.run_check(api="http://127.0.0.1:9"), "")
|
||||
self.assertEqual(self.run_check(), "")
|
||||
self.assertEqual(Versions.hits, 1)
|
||||
state = json.loads(cache.read_text(encoding="utf-8"))
|
||||
state["fetch_failed_at"] -= 3601
|
||||
cache.write_text(json.dumps(state), encoding="utf-8")
|
||||
self.login()
|
||||
self.assertIn("已自动更新", self.run_check())
|
||||
self.assertEqual(Versions.hits, 2)
|
||||
|
||||
def test_manual_command_targets_absolute_path_and_handles_shell_characters(self):
|
||||
self.skill.rename(self.skill.parent / 'custom space $HOME `false` "quote"')
|
||||
self.skill = self.skill.parent / 'custom space $HOME `false` "quote"'
|
||||
out = self.run_check(OIL_NO_AUTO_UPDATE="1")
|
||||
command = out.split("在终端运行 ", 1)[1].rsplit(" 即可更新。", 1)[0]
|
||||
# 实际复制执行提示;参数只能指向该副本,不能展开变量或命令替换。
|
||||
result = subprocess.run([SH, "-c", command], env={**os.environ, "HOME": str(self.tmp / "home"),
|
||||
"PATH": f"{self.bin}{os.pathsep}{os.environ['PATH']}",
|
||||
"FAKE_NPX_LOG": str(self.npx_log)}, capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn(str(self.skill), self.npx_calls()[0])
|
||||
self.assertIn('version: "99.0.0"', (self.skill / "SKILL.md").read_text(encoding="utf-8"))
|
||||
|
||||
def test_english_success_and_manual_notice(self):
|
||||
out = self.run_check(LANG="en_US.UTF-8", OIL_NO_AUTO_UPDATE="1")
|
||||
self.assertIn("Oil UI Pro 99.0.0 is available (current: 0.10.0)", out)
|
||||
self.assertIn("To update, run npx github:oil-oil/oil-cli", out)
|
||||
self.login()
|
||||
self.assertIn("updated automatically to 99.0.0", self.run_check(LANG="en_US.UTF-8"))
|
||||
|
||||
def shell_path(self):
|
||||
# 不从真实 PATH 找 Python;只保留入口写状态所需的工具。
|
||||
for tool in ("mkdir", "rmdir"):
|
||||
target = self.bin / tool
|
||||
if not target.exists():
|
||||
target.symlink_to(shutil.which(tool))
|
||||
return str(self.bin)
|
||||
|
||||
def test_launcher_runs_when_only_python_is_available(self):
|
||||
(self.bin / "python").symlink_to(sys.executable)
|
||||
out = self.run_check(launcher=True, PATH=self.shell_path(), OIL_NO_AUTO_UPDATE="1")
|
||||
self.assertIn("有新版本", out)
|
||||
self.assertEqual(Versions.hits, 1)
|
||||
|
||||
def test_missing_python_warns_once_and_recovery_resets_the_reminder(self):
|
||||
path = self.shell_path()
|
||||
self.assertEqual(self.run_check(launcher=True, PATH=path), "自动更新需要 Python 3,本次没有检查更新。\n")
|
||||
self.assertEqual(self.run_check(launcher=True, PATH=path), "OIL_UPDATE_CHECK_SKIPPED: missing_python\n")
|
||||
self.assertEqual(Versions.hits, 0)
|
||||
(self.bin / "python").symlink_to(sys.executable)
|
||||
self.assertIn("有新版本", self.run_check(launcher=True, PATH=path, OIL_NO_AUTO_UPDATE="1"))
|
||||
(self.bin / "python").unlink()
|
||||
self.assertIn("Automatic updates need Python 3", self.run_check(launcher=True, PATH=path, LANG="en_US.UTF-8"))
|
||||
|
||||
def test_python2_is_not_used_and_disabled_or_development_launcher_is_silent(self):
|
||||
(self.bin / "python").write_text("#!/bin/sh\nexit 1\n", encoding="utf-8")
|
||||
(self.bin / "python").chmod(0o755)
|
||||
path = self.shell_path()
|
||||
self.assertEqual(self.run_check(launcher=True, PATH=path, OIL_NO_UPDATE_CHECK="1"), "")
|
||||
(self.skill / ".git").mkdir()
|
||||
self.assertEqual(self.run_check(launcher=True, PATH=path), "")
|
||||
(self.skill / ".git").rmdir()
|
||||
self.assertIn("自动更新需要 Python 3", self.run_check(launcher=True, PATH=path))
|
||||
|
||||
def test_silent_when_up_to_date_offline_disabled_or_in_a_checkout(self):
|
||||
self.set_latest("0.0.1", free=False)
|
||||
self.assertEqual(self.run_check(), "")
|
||||
self.set_latest("99.0.0", free=False)
|
||||
self.assertEqual(self.run_check(api="http://127.0.0.1:9", XDG_STATE_HOME=str(self.tmp / "s2")), "")
|
||||
self.assertEqual(self.run_check(OIL_NO_UPDATE_CHECK="1", XDG_STATE_HOME=str(self.tmp / "s3")), "")
|
||||
(self.skill / ".git").mkdir()
|
||||
self.assertEqual(self.run_check(XDG_STATE_HOME=str(self.tmp / "s4")), "")
|
||||
|
||||
|
||||
|
||||
class ChineseLabelTest(unittest.TestCase):
|
||||
def test_no_space_after_chinese_label(self):
|
||||
import importlib.util
|
||||
spec = importlib.util.spec_from_file_location("check_update", SCRIPT)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
self.assertEqual(module.zh_label("Oil UI 开源版") + "有新版本", "Oil UI 开源版有新版本")
|
||||
self.assertEqual(module.zh_label("Oil UI Pro") + "有新版本", "Oil UI Pro 有新版本")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,215 @@
|
||||
"""Exercise the zero-dependency screenshot CLI against a real local browser."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPT = ROOT / "scripts" / "shoot.mjs"
|
||||
NODE = shutil.which("node")
|
||||
|
||||
|
||||
def find_browser():
|
||||
candidates = [os.environ.get("CHROME_PATH")]
|
||||
if sys.platform == "darwin":
|
||||
candidates += [
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
||||
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
||||
]
|
||||
elif sys.platform == "win32":
|
||||
for variable in ("PROGRAMFILES", "PROGRAMFILES(X86)"):
|
||||
root = os.environ.get(variable)
|
||||
if root:
|
||||
candidates += [
|
||||
str(Path(root) / "Google/Chrome/Application/chrome.exe"),
|
||||
str(Path(root) / "Microsoft/Edge/Application/msedge.exe"),
|
||||
]
|
||||
candidates += [shutil.which(name) for name in (
|
||||
"google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "microsoft-edge",
|
||||
)]
|
||||
return next((path for path in candidates if path and Path(path).is_file()), None)
|
||||
|
||||
|
||||
@unittest.skipUnless(NODE, "Node is not installed")
|
||||
class ShootCLITests(unittest.TestCase):
|
||||
def test_help(self):
|
||||
result = subprocess.run([NODE, str(SCRIPT), "--help"], cwd=ROOT,
|
||||
capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("用法", result.stdout)
|
||||
|
||||
def test_missing_target(self):
|
||||
result = subprocess.run([NODE, str(SCRIPT)], cwd=ROOT,
|
||||
capture_output=True, text=True, timeout=10)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
|
||||
def test_unknown_option(self):
|
||||
help_result = subprocess.run([NODE, str(SCRIPT), "--help"], cwd=ROOT,
|
||||
capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(help_result.returncode, 0, help_result.stderr)
|
||||
options = [line.split()[0] for line in help_result.stdout.splitlines()
|
||||
if line.startswith(" --")]
|
||||
for args in (("--xxx",), ("--xxx", "value")):
|
||||
with self.subTest(args=args):
|
||||
result = subprocess.run([NODE, str(SCRIPT), *args], cwd=ROOT,
|
||||
capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 1)
|
||||
self.assertEqual(result.stderr.splitlines(), [
|
||||
"shoot:不认识的选项 --xxx",
|
||||
"可用选项:" + " ".join(options),
|
||||
])
|
||||
self.assertEqual(result.stdout, "")
|
||||
|
||||
def test_missing_option_value(self):
|
||||
for option in ("--out", "--size", "--states", "--param", "--zoom", "--steps", "--hold", "--wait"):
|
||||
for following in ((), ("--force",)):
|
||||
with self.subTest(option=option, following=following):
|
||||
result = subprocess.run([NODE, str(SCRIPT), option, *following], cwd=ROOT,
|
||||
capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 1)
|
||||
self.assertEqual(result.stderr, f"shoot:{option} 需要一个值\n")
|
||||
|
||||
def test_force_is_ignored(self):
|
||||
result = subprocess.run([NODE, str(SCRIPT), "--force"], cwd=ROOT,
|
||||
capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 1)
|
||||
self.assertEqual(result.stderr, "shoot:缺少页面地址或文件。\n")
|
||||
|
||||
|
||||
class ShootBrowserTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
if not NODE:
|
||||
raise unittest.SkipTest("Node 22+ is not installed")
|
||||
version = subprocess.run([NODE, "--version"], capture_output=True, text=True, timeout=10)
|
||||
if version.returncode or int(version.stdout.strip().lstrip("v").split(".")[0]) < 22:
|
||||
raise unittest.SkipTest("Node 22+ is required")
|
||||
cls.browser = find_browser()
|
||||
if not cls.browser:
|
||||
raise unittest.SkipTest("Chrome, Chromium or Edge is not installed")
|
||||
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory(prefix="oil-shoot-test-")
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.folder = Path(self.tmp.name)
|
||||
self.page = self.folder / "sample.html"
|
||||
self.page.write_text('''<!doctype html><html><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" href="data:,">
|
||||
<style>
|
||||
body { margin: 0; padding: 24px; background: #fde68a; font: 20px sans-serif; }
|
||||
body[data-state="b"] { background: #bfdbfe; }
|
||||
button { padding: 16px; }
|
||||
</style></head><body><h1 id="state"></h1><button id="go">切换</button>
|
||||
<script>
|
||||
const state = new URLSearchParams(location.search).get('state') || 'a';
|
||||
function show(value) {
|
||||
document.body.dataset.state = value;
|
||||
document.querySelector('#state').textContent = value;
|
||||
}
|
||||
show(state);
|
||||
document.querySelector('#go').onclick = () => show(document.body.dataset.state === 'a' ? 'b' : 'a');
|
||||
</script></body></html>''', encoding="utf-8")
|
||||
self.env = dict(os.environ, CHROME_PATH=self.browser)
|
||||
self.profile_root = self.folder / "profiles"
|
||||
self.profile_root.mkdir()
|
||||
self.env.update(TMPDIR=str(self.profile_root), TMP=str(self.profile_root), TEMP=str(self.profile_root))
|
||||
|
||||
def shoot(self, output, *args):
|
||||
result = subprocess.run([NODE, str(SCRIPT), str(self.page), "--out", str(output), *args],
|
||||
cwd=ROOT, env=self.env, capture_output=True, text=True, timeout=90)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
return result
|
||||
|
||||
def assert_artifacts(self, output, names):
|
||||
for name in names:
|
||||
with self.subTest(file=name):
|
||||
artifact = output / name
|
||||
self.assertTrue(artifact.is_file(), name)
|
||||
self.assertGreater(artifact.stat().st_size, 0, name)
|
||||
if artifact.suffix == ".png":
|
||||
self.assertTrue(artifact.read_bytes().startswith(b"\x89PNG\r\n\x1a\n"), name)
|
||||
elif artifact.suffix == ".jpg":
|
||||
self.assertTrue(artifact.read_bytes().startswith(b"\xff\xd8"), name)
|
||||
|
||||
def test_states_masks_and_sheets(self):
|
||||
output = self.folder / "shots"
|
||||
self.shoot(output, "--states", "a,b", "--mask", "--sheet")
|
||||
self.assert_artifacts(output, (
|
||||
"a.png", "b.png", "a-masked.png", "b-masked.png",
|
||||
"sheet.png", "sheet-masked.png", "report.json",
|
||||
))
|
||||
report = json.loads((output / "report.json").read_text(encoding="utf-8"))
|
||||
self.assertEqual([entry["state"] for entry in report], ["a", "b"])
|
||||
self.assertEqual([entry["issues"] for entry in report], [[], []])
|
||||
self.assertNotEqual((output / "a.png").read_bytes(), (output / "b.png").read_bytes())
|
||||
self.assertNotEqual((output / "a.png").read_bytes(), (output / "a-masked.png").read_bytes())
|
||||
self.assertEqual(list(self.profile_root.glob("oil-shoot-*")), [], "Temporary browser profiles leaked")
|
||||
|
||||
def test_force_overwrites_existing_output(self):
|
||||
output = self.folder / "shots"
|
||||
output.mkdir()
|
||||
(output / "page.png").write_bytes(b"old screenshot")
|
||||
self.shoot(output, "--force")
|
||||
self.assert_artifacts(output, ("page.png", "report.json"))
|
||||
|
||||
def test_record_steps(self):
|
||||
output = self.folder / "record's output"
|
||||
result = self.shoot(output, "--record", "--steps", "click #go; wait 300", "--hold", "300")
|
||||
self.assert_artifacts(output, ("motion-start.jpg", "motion-mid.jpg", "motion-end.jpg"))
|
||||
self.assertNotEqual((output / "motion-start.jpg").read_bytes(), (output / "motion-end.jpg").read_bytes())
|
||||
report = json.loads((output / "report.json").read_text(encoding="utf-8"))
|
||||
self.assertEqual([entry["issues"] for entry in report], [[]])
|
||||
self.assertEqual(list(self.profile_root.glob("oil-shoot-*")), [], "Temporary browser profiles leaked")
|
||||
if shutil.which("ffmpeg"):
|
||||
self.assertTrue((output / "record.mp4").is_file(), result.stdout + result.stderr)
|
||||
self.assert_artifacts(output, ("record.mp4",))
|
||||
|
||||
def test_mask_preserves_current_color_icons(self):
|
||||
self.page.write_text('''<!doctype html><html><head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" href="data:,"></head><body>
|
||||
<svg width="80" height="80" viewBox="0 0 80 80" style="color:#16a34a">
|
||||
<circle cx="40" cy="40" r="32" fill="currentColor" /></svg>
|
||||
</body></html>''', encoding="utf-8")
|
||||
output = self.folder / "icons"
|
||||
self.shoot(output, "--mask")
|
||||
self.assertEqual((output / "page.png").read_bytes(), (output / "page-masked.png").read_bytes(),
|
||||
"Masking text must preserve icons using currentColor")
|
||||
|
||||
def test_record_preserves_final_hold(self):
|
||||
if not shutil.which("ffmpeg") or not shutil.which("ffprobe"):
|
||||
self.skipTest("ffmpeg and ffprobe are required to check recording duration")
|
||||
output = self.folder / "hold"
|
||||
self.shoot(output, "--record", "--steps", "click #go; wait 300", "--hold", "2000")
|
||||
result = subprocess.run([
|
||||
"ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "json",
|
||||
str(output / "record.mp4"),
|
||||
], capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertGreaterEqual(float(json.loads(result.stdout)["format"]["duration"]), 2.0)
|
||||
|
||||
def test_reports_page_problems(self):
|
||||
self.page.write_text(self.page.read_text(encoding="utf-8").replace("</body>", '''
|
||||
<div style="width:2000px">溢出</div><img src="missing.png">
|
||||
<script>console.error('shoot-test-error'); throw new Error('shoot-test-exception');</script>
|
||||
</body>'''), encoding="utf-8")
|
||||
for args in ((), ("--record", "--hold", "300")):
|
||||
with self.subTest(record=bool(args)):
|
||||
output = self.folder / ("problem-record" if args else "problem-shots")
|
||||
self.shoot(output, "--size", "1280x900", *args)
|
||||
report = json.loads((output / "report.json").read_text(encoding="utf-8"))
|
||||
issues = "\n".join(report[0]["issues"])
|
||||
for expected in ("shoot-test-error", "shoot-test-exception", "横向溢出", "图片没加载出来"):
|
||||
self.assertIn(expected, issues)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user