diff --git a/oil-ui-pro/.gitignore b/oil-ui-pro/.gitignore new file mode 100644 index 0000000..9f2e848 --- /dev/null +++ b/oil-ui-pro/.gitignore @@ -0,0 +1,2 @@ +.DS_Store +__pycache__/ diff --git a/oil-ui-pro/README.en.md b/oil-ui-pro/README.en.md new file mode 100644 index 0000000..d00b297 --- /dev/null +++ b/oil-ui-pro/README.en.md @@ -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 ""`. 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. diff --git a/oil-ui-pro/README.md b/oil-ui-pro/README.md new file mode 100644 index 0000000..3a38066 --- /dev/null +++ b/oil-ui-pro/README.md @@ -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):用生成的视频或序列帧做随滚动、拖动变化的网页动画,比如产品拆解、镜头穿越。想要很强的首屏动画时用它。 diff --git a/oil-ui-pro/SKILL.md b/oil-ui-pro/SKILL.md new file mode 100644 index 0000000..01ae350 --- /dev/null +++ b/oil-ui-pro/SKILL.md @@ -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 不管理密钥、安装工具或发布网站。网络不可用时使用已有参考,并说明来源范围。 diff --git a/oil-ui-pro/assets/style-explorer.html b/oil-ui-pro/assets/style-explorer.html new file mode 100644 index 0000000..497b72b --- /dev/null +++ b/oil-ui-pro/assets/style-explorer.html @@ -0,0 +1,363 @@ + +风格对比 + + +
+
+ +
+ +
+ +
+ + +
+ diff --git a/oil-ui-pro/assets/vendor/alpine.min.js b/oil-ui-pro/assets/vendor/alpine.min.js new file mode 100644 index 0000000..fad4f61 --- /dev/null +++ b/oil-ui-pro/assets/vendor/alpine.min.js @@ -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;efi(n,i,t));for(let n=0;ne.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{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.getOwnPropertyDescriptors(i)).forEach(([s,{value:a,enumerable:c}])=>{if(c===!1||a===void 0||typeof a=="object"&&a!==null&&a.__v_skip)return;let l=o===""?s:`${o}.${s}`;typeof a=="object"&&a!==null&&a._x_interceptor?i[s]=a.initialize(e,l,s,t):r(a)&&a!==i&&!(a instanceof Element)&&n(a,l)})};return n(e)}function Ke(e,t=()=>{}){let r={initialValue:void 0,_x_interceptor:!0,initialize(n,i,o,s){return e(this.initialValue,()=>mi(n,i),a=>Dt(n,i,a),i,o,s)}};return t(r),n=>{if(typeof n=="object"&&n!==null&&n._x_interceptor){let i=r.initialize.bind(r);r.initialize=(o,s,a,c)=>{let l=n.initialize(o,s,a,c);return r.initialValue=l,i(o,s,a,c)}}else r.initialValue=n;return r}}function mi(e,t){return t.split(".").reduce((r,n)=>r[n],e)}function Dt(e,t,r){if(typeof t=="string"&&(t=t.split(".")),t.length===1)e[t[0]]=r;else{if(t.length===0)throw error;return e[t[0]]||(e[t[0]]={}),Dt(e[t[0]],t.slice(1),r)}}var Pr={};function b(e,t){Pr[e]=t}function G(e,t){let r=_i(t);return Object.entries(Pr).forEach(([n,i])=>{Object.defineProperty(e,`$${n}`,{get(){return i(t,r)},enumerable:!1})}),e}function _i(e){let[t,r]=Mt(e),n={interceptor:Ke,...t};return de(e,r),n}function Ir(e,t,r,...n){try{return r(...n)}catch(i){Y(i,e,t)}}function Y(...e){return Lr(...e)}var Lr=gi;function jr(e){Lr=e}function gi(e,t,r=void 0){e=Object.assign(e??{message:"No error message given."},{el:t,expression:r}),console.warn(`Alpine Expression Error: ${e.message} + +${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,(o,s)=>{if(t&&on(o))return s();t=!0,i(o,s)})})}function nn(e){let t=k;Tt((r,n)=>{let i=t(r);return H(i),()=>{}}),e(),Tt(t)}var Ze=0;function sn(e,t){let r=e._x_deferInit;r||(r=e._x_deferInit={pending:0,ownsIgnore:!e._x_ignore,queuedAttributes:new Map},r.ownsIgnore&&(e._x_ignore=!0),Ze++),r.pending++,Promise.resolve(t).catch(n=>{try{Y(n,e)}catch(i){setTimeout(()=>{throw i},0)}}).then(()=>Ai(e,r))}function Ai(e,t){t.pending--,!(t.pending>0)&&(Rr(),!(t.pending>0)&&e._x_deferInit===t&&(delete e._x_deferInit,t.ownsIgnore&&delete e._x_ignore,Ze--,e.isConnected&&(Ti(t),S(e))))}function an(e,t){if(Ze===0)return!1;let r=A(e,n=>n._x_deferInit);return r?(cn(r._x_deferInit,e,t.map(({name:n})=>n)),!0):!1}function cn(e,t,r){let n=e.queuedAttributes.get(t);(!n||n.marker!==t._x_marker)&&(n={marker:t._x_marker,names:new Set},e.queuedAttributes.set(t,n)),r.forEach(i=>n.names.add(i))}function Ti(e){e.queuedAttributes.forEach((t,r)=>{if(!r.isConnected||!r._x_marker||r._x_marker!==t.marker)return;let n=A(r,o=>o._x_deferInit);if(n){cn(n._x_deferInit,r,Array.from(t.names));return}let i=Array.from(t.names).filter(o=>r.hasAttribute(o)).map(o=>({name:o,value:r.getAttribute(o)}));i.length!==0&&ee(r,i).forEach(o=>o())})}F((e,t)=>{Ze!==0&&(!e||e.nodeType!==1||!t||t.nodeType!==1||A(e,r=>r._x_deferInit)&&(t._x_ignore=!0))});function te(e,t,r={},n={}){return e.dispatchEvent(new CustomEvent(t,{detail:r,bubbles:!0,composed:!0,cancelable:!0,...n}))}function T(e,...t){console.warn(`Alpine Warning: ${e}`,...t)}var ln=!1;function fn(){ln&&T("Alpine has already been initialized on this page. Calling Alpine.start() more than once can cause problems."),ln=!0,document.body||T("Unable to initialize. Trying to load Alpine before `` is available. Did you forget to add `defer` in Alpine's `` +3. 状态集中放在一个 `x-data` 里,初始状态读地址参数: + +```html +
+

做好了

+ + +
+``` + +放进对比页时,在 manifest 里给这个候选加 `"interactive": true`。生成器会把任务目录里引用到的 CSS 和 JS 文件内联进对比页,不用手动合并。 + +存量项目里照用项目自己的技术栈,不引入 Alpine.js。 diff --git a/oil-ui-pro/references/visual-language.md b/oil-ui-pro/references/visual-language.md new file mode 100644 index 0000000..28f5181 --- /dev/null +++ b/oil-ui-pro/references/visual-language.md @@ -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% 的文字,只把删了会影响理解的放回来。 +- **不先加字再调淡**:不先添一堆小字,再用降低对比来补救。设计理由、源文件名、组件术语和装饰性英文留在说明或按需展开处。 +- **必须留下的**:错误原因、单位、选择范围、陌生动作的标签、风险和后果。 + +### 编辑剩下的文字 + +文案需要一次主动编辑:用用户会说的话重写空泛口号,保留具体对象、动作和结果;可以继承用户原有的表达。不要把第一轮生成的文案当作不可改变的内容,也不要为了补满版式编造信息。 + +模型写的中文常带翻译腔,交付前逐句改掉:用分号把两件事串成一句;“交代当前情况”“带读者回到”“保持内容明确”这类书面套话;标题写成字段名,例如“受控勾选”“禁用等级”。改成一句只讲一件事的短句,标题用人会说的短语,例如“生成中锁定”“回到最新”。 + +模型写的文案往往是整页最弱的部分。标题、主张和按钮这类关键文案,优先用用户提供的原话,或交给用户亲自改写;交付时标出哪些仍是模型写的占位文案。 diff --git a/oil-ui-pro/references/visual-review.md b/oil-ui-pro/references/visual-review.md new file mode 100644 index 0000000..a33e5e4 --- /dev/null +++ b/oil-ui-pro/references/visual-review.md @@ -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 检查,重大主观取舍由用户判断。 + +出现持续换风格、互相推翻或细节调整无可见收益时停止美学循环,保留最符合任务与方向的版本。停止不能掩盖仍然存在的功能缺陷或未经验证的部分。 diff --git a/oil-ui-pro/scripts/build_explorer.py b/oil-ui-pro/scripts/build_explorer.py new file mode 100644 index 0000000..db5797e --- /dev/null +++ b/oil-ui-pro/scripts/build_explorer.py @@ -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/\*.*?\*/)|(?P\"(?:\\.|[^\"\\])*\"|'(?:\\.|[^'\\])*')" + r"|(?P(?:[-_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\s+)|(?P.)", 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 = ( + "" +) + + +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\b(?:src|poster|href|xlink:href)\s*=\s*)(?P["'])(?P[^"'#][^"']*)(?P=q)""") +CSS_REF = re.compile(r"""url\(\s*(?P["']?)(?P[^"')\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"""[^>]*?)\bsrc\s*=\s*(?P["'])(?P[^"']+)(?P=q)(?P[^>]*)>\s*""", re.I) +LINK_TAG = re.compile(r"]*>", re.I) +LINK_ATTR = re.compile(r"""\b(?Prel|href)\s*=\s*(?P["'])(?P[^"']*)(?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 "" + + 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("' if is_module else "" + # 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("") + 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'' + 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()) diff --git a/oil-ui-pro/scripts/check_update.py b/oil-ui-pro/scripts/check_update.py new file mode 100644 index 0000000..a1b9a28 --- /dev/null +++ b/oil-ui-pro/scripts/check_update.py @@ -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()) diff --git a/oil-ui-pro/scripts/check_update.sh b/oil-ui-pro/scripts/check_update.sh new file mode 100644 index 0000000..2dcb8a5 --- /dev/null +++ b/oil-ui-pro/scripts/check_update.sh @@ -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 diff --git a/oil-ui-pro/scripts/shoot.mjs b/oil-ui-pro/scripts/shoot.mjs new file mode 100644 index 0000000..a5cb180 --- /dev/null +++ b/oil-ui-pro/scripts/shoot.mjs @@ -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 依次用 ?state=<名字> 打开并各截一张 + --param <名字> 状态参数名,默认 state + --zoom <倍数> 设备像素比,默认 1;2 即 200% 截图 + --full 截整页,默认只截视口 + --mask 另截一份遮掉全部文字的版本 + --sheet 把所有状态拼成一张并排图(配合 --mask 再拼一张遮字版) + --steps "<动作>" 截图前先执行的动作,用分号分隔: + click <选择器> | hover <选择器> | drag <选择器> + type <选择器> <文字> | key <按键> | scroll | 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 }) => + `
${label}
`).join(""); + const html = `
${figures}
`; + 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); diff --git a/oil-ui-pro/tests/test_build_explorer.py b/oil-ui-pro/tests/test_build_explorer.py new file mode 100644 index 0000000..aee6882 --- /dev/null +++ b/oil-ui-pro/tests/test_build_explorer.py @@ -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('同一内容', 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"] = '' + 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(''' + + content''', 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(''' + + ''', 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('', 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('''

Explain @import and url(example.png)

+ ''', 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 = [ + '', + '', + '', + '', + ] + for fragment in fragments: + with self.subTest(fragment=fragment): + self.source.write_text(f'{fragment}content', encoding="utf-8") + with self.assertRaises(ValueError): + builder.build(self.manifest, self.output) + self.source.write_text('content', 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('', 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'', 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 ''\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=\"', 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=''", encoding="utf-8") + (self.folder / "app.css").write_text("body{color:#123}", encoding="utf-8") + self.source.write_text('' + '

内容

', encoding="utf-8") + static = builder.prepare_html(self.source, root=self.folder) + self.assertIn("", 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("

内容

"), 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() diff --git a/oil-ui-pro/tests/test_check_update.py b/oil-ui-pro/tests/test_check_update.py new file mode 100644 index 0000000..0a554e1 --- /dev/null +++ b/oil-ui-pro/tests/test_check_update.py @@ -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": "a@example.com"}), 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": "b@example.com"}), 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() diff --git a/oil-ui-pro/tests/test_shoot.py b/oil-ui-pro/tests/test_shoot.py new file mode 100644 index 0000000..7f0d3bc --- /dev/null +++ b/oil-ui-pro/tests/test_shoot.py @@ -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(''' + + +

+''', 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(''' + + + + +''', 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("", ''' +
溢出
+ +'''), 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()