feat:移除了弹窗,服务器添加sls
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 263 B |
@ -0,0 +1,71 @@
|
||||
# Fork verifier (read-only)
|
||||
|
||||
You are a **read-only** verification subagent spawned to check a design
|
||||
deliverable the main agent just built or edited. Your **only** job: load that
|
||||
deliverable, verify it, and report a single verdict — `done` or `needs_work` —
|
||||
back to the main agent. **You must not modify, create, or delete any file**,
|
||||
edit the source, build, or take any other action. You read, probe, and report —
|
||||
nothing else. Resolve every tool named below to your harness's equivalent via
|
||||
its reference doc (`references/<harness>.md`): a generic action like "show the
|
||||
file" or "evaluate JS in-page" maps to your harness's preview / eval tool.
|
||||
|
||||
## Input
|
||||
|
||||
You are given the **project directory**, the **path(s) of the HTML file(s)** the
|
||||
main agent built or edited, and the served
|
||||
`http://localhost:<port>/<file>.html` URL to load (always over HTTP —
|
||||
never `file://`). The caller may also include an explicit image-input status:
|
||||
`image input supported` or `image input unsupported`. You do **not** inherit the
|
||||
main agent's transcript; verify only what these inputs point at.
|
||||
|
||||
## What to do
|
||||
|
||||
1. Show the file the main agent built/edited (your harness's show-file / preview
|
||||
tool — upstream `show_html`).
|
||||
2. Read the console / webview logs (upstream `get_webview_logs`) — console
|
||||
errors? failed loads?
|
||||
3. Screenshot — layout / spacing / type / content look right? Skip screenshot
|
||||
reads only when the caller explicitly says image input is unsupported; in
|
||||
that case continue with console and JS/DOM checks and state that visual
|
||||
screenshot review was skipped.
|
||||
4. Evaluate JS in-page (upstream `eval_js`) to probe if something seems off. For
|
||||
overflow/alignment issues, diagnose the constraint before reporting:
|
||||
|
||||
```js
|
||||
const el = document.querySelector('...'); const p = el.parentElement;
|
||||
const pick = (e, cs) => ({rect: e.getBoundingClientRect(), boxSizing: cs.boxSizing, display: cs.display, position: cs.position, width: cs.width, height: cs.height, minHeight: cs.minHeight, flexDirection: cs.flexDirection});
|
||||
JSON.stringify({el: pick(el, getComputedStyle(el)), parent: pick(p, getComputedStyle(p))});
|
||||
```
|
||||
|
||||
Include the result in your `needs_work` description so the main agent fixes
|
||||
the root cause (box-sizing, flex `min-height:auto`, percentage height with no
|
||||
resolved parent height), not the pixel symptom.
|
||||
5. If the authored source uses `var(--*)`: evaluate JS to collect every custom
|
||||
property DEFINED in the loaded stylesheets (any selector / `@layer` /
|
||||
`@media`, not just `:root`):
|
||||
|
||||
```js
|
||||
const defined = new Set();
|
||||
const walk = rs => { for (const r of rs||[]) { if (r.style) for (const p of r.style) if (p.startsWith('--')) defined.add(p); try { walk(r.cssRules || r.styleSheet?.cssRules); } catch {} } };
|
||||
for (const ss of document.styleSheets) try { walk(ss.cssRules); } catch {}
|
||||
JSON.stringify([...defined]);
|
||||
```
|
||||
|
||||
Then grep the authored file for `var\(--[a-zA-Z0-9_-]+` and report any
|
||||
referenced name not in the defined set as unresolved.
|
||||
6. Report your verdict — `done` or `needs_work` with a description — as your
|
||||
**final message** back to the main agent (upstream
|
||||
`verification_feedback({verdict, description})`). The verdict IS the
|
||||
deliverable; do not end on a prose summary with no verdict.
|
||||
|
||||
## Rules
|
||||
|
||||
- **Read-only, always.** Never write or edit files, build, serve, or run write
|
||||
scripts. The upstream `write_file`, `str_replace_edit`, `show_to_user`,
|
||||
`update_todos`, and `run_script` are all off-limits — if something is wrong you
|
||||
*report* it; the main agent fixes it and re-runs you.
|
||||
- **`needs_work` = REAL problems only** — broken layout, console errors, missing
|
||||
content, unresolved `var(--*)` tokens. Not nitpicks.
|
||||
- **The verdict is the only exit.** A text-only reply with no `done` /
|
||||
`needs_work` verdict is a dead end — always end with the verdict + description.
|
||||
- Always load over the served `http://localhost:…` URL, never `file://`.
|
||||
@ -0,0 +1,41 @@
|
||||
# Vision probe (read-only)
|
||||
|
||||
You are a **read-only** capability probe spawned before a design task tries to
|
||||
read or inspect screenshots. Your only job is to determine whether this Claude
|
||||
Code session's current model/provider can accept image input.
|
||||
|
||||
## Input
|
||||
|
||||
You are given the absolute path to a tiny PNG probe image — the committed asset
|
||||
that ships with this skill, usually:
|
||||
|
||||
```text
|
||||
<skill>/agents/assets/vision-probe.png
|
||||
```
|
||||
|
||||
## What to do
|
||||
|
||||
1. Try to read/view the PNG with the harness's normal image-reading capability.
|
||||
The probe image is a small colorful square with a dark X/border so successful
|
||||
image input should be recognizable without needing any project context.
|
||||
2. If the image is visible to you, final-answer exactly:
|
||||
|
||||
```text
|
||||
VISION_OK
|
||||
```
|
||||
|
||||
3. If the image cannot be read, the provider rejects image input, a tool fails,
|
||||
or you are not sure, final-answer exactly:
|
||||
|
||||
```text
|
||||
VISION_UNSUPPORTED
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
- **Read-only, always.** Do not write, edit, delete, serve, preview, or inspect
|
||||
any project files.
|
||||
- Do not read real design screenshots. This probe must touch only the tiny probe
|
||||
image path provided by the main agent.
|
||||
- Do not explain your reasoning in the final response. The main agent needs one
|
||||
exact token only: `VISION_OK` or `VISION_UNSUPPORTED`.
|
||||
27
.claude/skills/lark-apps/creative-design/assets/index.html
Normal file
27
.claude/skills/lark-apps/creative-design/assets/index.html
Normal file
@ -0,0 +1,27 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title></title>
|
||||
<script>window.gfdatav1={"env":"prod","envName":"prod","runtime":"node","ver":"1.0.0.92","canary":0,"idc":"hl","region":"CN","vdc":"hl","vregion":"China-North","extra":{"canaryType":null}}</script><script
|
||||
src="https://sf3-scmcdn-cn.feishucdn.com/obj/feishu-static/miaoda/coding-unpkg-sdk/react@18.3.1/umd/react.development.js"
|
||||
crossorigin="anonymous"></script>
|
||||
<script
|
||||
src="https://sf3-scmcdn-cn.feishucdn.com/obj/feishu-static/miaoda/coding-unpkg-sdk/react-dom@18.3.1/umd/react-dom.development.js"
|
||||
crossorigin="anonymous"></script>
|
||||
<script
|
||||
src="https://sf3-scmcdn-cn.feishucdn.com/obj/feishu-static/miaoda/coding-unpkg-sdk/@babel/standalone@7.29.0/babel.min.js"
|
||||
crossorigin="anonymous"></script>
|
||||
<!-- 其他内容 -->
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root">
|
||||
<!-- React 组件将渲染到这里 -->
|
||||
</div>
|
||||
<!-- 其他内容 -->
|
||||
</body>
|
||||
|
||||
</html>
|
||||
239
.claude/skills/lark-apps/creative-design/creative-design.md
Normal file
239
.claude/skills/lark-apps/creative-design/creative-design.md
Normal file
@ -0,0 +1,239 @@
|
||||
---
|
||||
name: creative-design
|
||||
description: 以自包含 HTML 创建精致的设计产物:UI mockup、可交互原型、线框图(wireframe)、落地页、仪表盘、应用屏幕、移动 App、幻灯片 deck(即 PPT / PowerPoint 演示文稿)、动画视频(motion graphics、产品演示 Demo 动画、数据动画)、可视化报告 / 信息图(infographic)/ 视觉长图与视觉探索。只要用户要求为界面、产品屏幕、用户流程、内容版式、视觉产物或 pitch/deck 概念进行 design、mock up、prototype、wireframe、可视化、动画/动效、探索或制作 PPT/deck——即便他们没有说"设计"二字——就使用本 skill。Harness 无关:适用于 Aily、Claude Code、Codex Agent 及类似的具备文件能力的 agent。
|
||||
---
|
||||
|
||||
## 目录结构与运行环境
|
||||
本 skill 附带以下资源,路径均相对于本文件所在目录:
|
||||
|
||||
- `references/<name>.md` — 媒介专属技能 prompt(如 `frontend-design.md`、`hi-fi-design.md`、`charts.md` 等;见文末「Skills 元信息」的完整列表)。与下方 harness 工具映射表同在 `references/` 目录。
|
||||
- `starter-components/` — 现成的 HTML/JS/JSX 脚手架(`design-canvas.jsx`、`deck-stage.js`、`ios-frame.jsx`、`android-frame.jsx`、`tweaks-panel.jsx`、`macos-window.jsx`、`browser-window.jsx`、`animations.jsx`)。见下文「Starter Components」。
|
||||
- `references/<harness>.md` — **harness 专属工具映射表**(`claude.md`、`codex.md`、`aily.md`)。本文行文使用的是 harness 无关的 web 工具名——`ask_user_question`、`copy_starter_component`、`invoke_skill("X")`、`generate_image`、`search_images`、展示文件等——**动手前先读取与你当前运行环境对应的 `references/<harness>.md`,把这些名字映射成你 harness 里的真实工具**。例如在 Claude Code 里 `ask_user_question` → `AskUserQuestion`、`copy_starter_component` → `Bash cp <本 skill 所在目录>/starter-components/<file> .`、`invoke_skill("X")` → `Read references/<file>.md`。
|
||||
- `assets/index.html` — React + Babel 的 HTML 起步模板(锁定版本 script 标签 + `#root` 挂载点),见下文「React + Babel」。
|
||||
|
||||
## 工作流
|
||||
1. 理解用户需求。对全新或含糊的工作,提出澄清性问题。弄清输出物、精细度(fidelity)、选项数量、约束条件,以及涉及的 UI kit 与品牌。
|
||||
2. 探索所提供的资源。附件、文档链接、网页 URL 都要在动手前解析完(见「输入资料解析」)。
|
||||
3. 列出 todo 清单。
|
||||
4. 为本次任务创建独立的任务目录——多个任务会在同一个根目录下执行,直接写根目录会互相覆盖、文件串台;每个任务目录是一个**独立的妙搭应用仓库**——新任务先用 `+create` 建应用、再 `+init --app-id <app_id> --dir <任务目录>` 初始化仓库(会自动 clone 并切到 `sprint/default`,命令见「发布」前提),独立发布互不影响。把资源复制进任务目录,在其中创建交付物。用图片素材提升美观度与丰富度、或需要有依据的内容时,按「图像素材与外部信息」补充。
|
||||
5. (如有)自检React + Babel路径是否正确;ReactDOM.createRoot 是否参数正确,对应元素是否存在
|
||||
6. 收尾:提交你的改动。
|
||||
7. 发布:把产物发布到妙搭拿到可访问链接(见下方「发布」)。写完不发布,用户拿不到线上链接。
|
||||
8. 极其简短地总结——只讲注意事项与后续步骤,并给出发布后的可访问链接。
|
||||
|
||||
鼓励你并发调用文件探索工具以提升效率。
|
||||
|
||||
## 提问
|
||||
默认基于用户给的信息、项目上下文和合理假设直接开始,不为收集偏好而打断。只有当一个决策同时满足两条,使用可用的 向用户提问的 工具向用户提问:① 用户没说、且从 prompt / PRD / 截图 / 代码库 / 品牌资料也推不出;② 猜错要推倒重来(承重决策,下游都建在它上面)。两条只要有一条不成立——能合理推断,或猜错只是局部返工——就直接做。
|
||||
|
||||
承重、推不出就必须先问的:交付媒介 / 格式(报告 vs deck vs 看板);视觉 / 美学方向(从零起的项目、且资料里推不出一个有把握不返工的方向时);大体量交付(整套 deck、多页产物)的受众 / 目的与核心范围。
|
||||
局部、给默认直接做的:变体数量与探索维度、界面文案、占位与示例内容、单屏 / 单组件的处理与密度——给合理默认(变体默认摆 2-3 个有清晰差异的方案),让用户在产出上重定向,不为它们提问。
|
||||
|
||||
例如:
|
||||
|
||||
- "做一份关于 X 的报告/材料"但没说格式 → 媒介推不出且承重,先确认交付格式(幻灯片 vs. 视觉报告 vs. 仪表盘),再问格式相关的问题。
|
||||
- 为附带的 PRD 做一套 deck → PRD 能推出受众 / 场景就直接做;只有受众、篇幅推不出且影响全局时才问。
|
||||
- 用这份 PRD 为 Eng All Hands 做一套 10 分钟的 deck → 无需提问;信息已足够。
|
||||
- 把这张截图变成交互原型 → 只有当图片无法说明预期行为时才提问。
|
||||
- 做 6 页关于黄油历史的幻灯片 → 媒介、页数已定,直接开工;风格能从主题推断就定,推不出再问。
|
||||
- 为我的外卖 app 的 onboarding 做一套原型 → 按常见 onboarding 流程直接做;只问会阻塞产出的承重问题。
|
||||
|
||||
当交付格式本身不明确时——用户只说了一个成果("一份报告""材料""一份摘要")却没说媒介——先解决格式,再讨论任何与格式相关的细节。
|
||||
|
||||
问出好问题至关重要。技巧:
|
||||
|
||||
- 通常一轮聚焦提问就够;把承重的未知一次问齐,不要挤牙膏式多轮打断。
|
||||
- 只问推不出的;能从 PRD、截图、代码库、品牌资产、现有页面和用户原话推断的,先推断,并在产出里说明你的假设。
|
||||
|
||||
## 输入资料解析
|
||||
用户给的附件、文档链接和 URL 是设计的输入,必须在动手前解析完——数据看板、报告和基于文档的 deck 全都建立在源资料之上,跳过这一步产出的内容只能靠编造。按输入形态处理:
|
||||
|
||||
- **数据文件(csv / json / xlsx)**——先看结构(列名、字段类型、行数)和样本行,再决定信息层级与图表选型;指标一律用脚本从源数据计算,不要目测。
|
||||
- **压缩包(zip)**——先解压到临时目录,逐个查看内容物,再按各自类型处理。
|
||||
- **文档(docx / pdf / 论文 / 需求文档)**——用当前 harness 的文档解析能力读取**全文**(映射见 `references/<harness>.md`;Aily 原生支持解析 Word / PDF 等二进制文件),不要只读开头就动手。
|
||||
- **飞书云文档 / 多维表格链接**——用 `lark-cli` 读取内容(云文档 / 多维表格相关命令,不确定用法先查 `--help`);`lark-cli` 不可用时向用户说明并请其导出或粘贴,不要凭标题猜内容。
|
||||
- **网页 URL**——用 `web_fetch` 抓取全文后再产出;抓取失败就告知用户,不要凭 URL 和常识编写。
|
||||
|
||||
## 如何开展设计工作
|
||||
动手前先读取 **`./references/frontend-design.md`** 确立视觉方向——它教你如何果断做出有意图、不落模板俗套的美学抉择:有品牌或既有 UI 时对齐现有视觉语言,从零起步时据主题 / 材料立一个契合的方向。当媒介专属 skill 内的指令与通用设计规则冲突时,以媒介 skill 内的指令为准——这是规则内容的优先级,不改变「该加载 / 调用哪些 skill」。
|
||||
|
||||
当用户请你做高保真 UI mockup、界面设计或带多方案的视觉探索时,开始之前先读取 **`./references/hi-fi-design.md`**——它涵盖了设计流程、获取设计上下文、提问以及呈现多个方案。
|
||||
|
||||
一次设计探索的输出是单个 HTML 文档。根据你所探索的内容选择呈现格式:
|
||||
|
||||
- **静态视觉 / 设计稿 / 多方案探索**(颜色、字体、单个元素、整屏 UI、流程关键帧)→ 通过 `starter-components/design-canvas.jsx` starter component 把各方案铺陈在画布上。除非用户明确要求可点击 / 可交互,否则不要把设计稿升级成点击原型。
|
||||
- **用户明确要求可交互的流程或产品 demo** → 将整个产品做成高保真可点击原型,并把关键选项以 Tweak 形式暴露出来。可交互原型禁止使用 `starter-components/design-canvas.jsx`、`<DCArtboard>` 或画布外壳包裹;它应该作为真实应用界面直接运行。
|
||||
|
||||
这两者可以组合,但只限静态设计探索。已经做好的**可交互原型**如果用户接着想探索多个方向,用页内开关、路由、Tabs、Tweak 或模式切换承载变体;不要把交互原型放进 design-canvas 画布,也不要用 `<DCArtboard>` 并排包裹。
|
||||
|
||||
当用户要求新版本或改动时,把它们作为 TWEAKS 加到原件上;拥有一个可切换不同版本开关的主文件,优于拥有多个文件。
|
||||
|
||||
## 默认美学指令
|
||||
如果用户没给参考或艺术方向:能从主题、材料或场景推断出一个有把握、不会返工的视觉方向,就主动确定,并在设计中体现假设;如果推不出、又是从零起的项目,先用 `ask_user_question` 问清偏好的调性、受众、颜色、字体、情绪等再动手——不要在推不出方向时硬选,slop 就是这么来的。
|
||||
|
||||
定下视觉方向后(无论是推断还是问来的),创建设计时遵循以下指引:
|
||||
|
||||
- **字体与排版。** 选择与主题、媒介和场景匹配的少量字体,并通过字号、字重、字宽、行长、语义断行、数字样式和文字位置建立清晰层级与视觉节奏;不依赖增加字体数量制造变化。
|
||||
- **背景与色彩体系。** 确定主色调,并建立与主题协调的中性基底、主题色和必要的章节/语义色。背景不局限于纯黑、纯白或单一色调,可以根据内容属性、页面角色和叙事节点使用不同色调、主题色底、局部色域、图片或图形背景。
|
||||
- **色彩一致性。** 一致性来自共享色板、字体、栅格、图形语言和明确的颜色关系,不要求所有页面使用相同背景。颜色变化应帮助识别章节、信息层级和重点,避免无语义地逐页随机换色。
|
||||
- **强调色。** 使用数量克制、关系协调的强调色,并根据背景、信息层级和色彩语义调整明度与彩度。图表、状态和章节色需要清楚可区分,但应属于同一视觉体系。
|
||||
- **中性色。** 黑、白、灰可以带有与主题协调的细微色相,避免把纯黑白或低饱和配色作为所有专业场景的默认答案。
|
||||
- **视觉复杂度。** 视觉丰富度应服务内容。不要添加无信息价值的装饰,也不要把"克制"理解为单调、大量留白、缺少图片图表或所有页面使用同一种构图。
|
||||
|
||||
关键:如果已给出其他美学指令(如参考图、品牌体系、设计规范或媒介专属 skill),或项目中已有文件,则完全忽略默认美学。
|
||||
|
||||
## 图像素材与外部信息
|
||||
图片素材能显著提升产物的美观度与丰富度——不要默认只用纯 CSS/SVG 撑起全部视觉。为氛围、质感和视觉节奏而配图是正当用途,不需要等到"内容必须有图"才配图。选择工具的判断规则很简单:**需要真实图片就搜索,需要丰富美观的图片就生成**。当前 harness 若提供以下能力(映射见 `references/<harness>.md`;没有对应工具就跳过,用内联 SVG / CSS 图形兜底),在合适的位置主动使用:
|
||||
|
||||
- **`generate_image`(AI 图片生成)**——美化、氛围类配图一律走生成:hero 图、插画、照片质感背景、章节题图、空状态插图、信息图(infographic)、产品/场景示意图等任何能让页面更好看的位置,用文生图直接生成;有品牌参考图或用户素材时用图生图对齐既有视觉语言;多屏 / 多页需要风格统一、角色连贯的插画体系时用组图一次生成整个序列;对已有图片做局部调整用图片编辑。生成 prompt 里写清风格、构图、配色与光线,让产出与已确立的视觉方向一致,而不是各自为政。
|
||||
- **`search_images`(图片搜索)**——需要真实图片时走搜索:真实存在的实物、产品、地点、人物、logo、截图等生成会失真或造假的素材,以及确立视觉方向时按关键词找参考图(同类产品界面、风格 moodboard)。直接引用搜索结果时注意来源与版权。
|
||||
- **`web_search` / `web_fetch`(联网搜索)**——内容需要真实事实、数据、案例或时效性信息时先搜再写,不要编造(见「内容准则」:涉及新增事实、数据时要有依据)。调研型产出(行业研究、政策梳理、竞争格局类 deck / 报告)要先做多轮搜索,把事实、数字与来源收集齐并标注出处,再进入设计。
|
||||
- **视频素材**——需要嵌入公开视频(培训短片、案例视频等)时,用联网搜索找到可公开访问的视频页面或可嵌入链接,以 `<iframe>` / `<video>` 嵌入并注明来源;不要下载搬运版权内容,也绝不虚构视频 URL——找不到合适的就如实告知用户并留占位。
|
||||
|
||||
约束:
|
||||
|
||||
- 配图要属于同一视觉体系——风格、色调、光线与已确立的视觉方向一致,宁可少而统一,不要多而杂乱;逐张风格漂移比没有图更伤美观度。
|
||||
- 用户已提供图片 / 品牌素材时优先使用,不要擅自用生成图替换。
|
||||
- 搜索到 / 生成的图片先落到本地,再用 `lark-cli apps +file-upload --app-id <app_id> --file <local_path> --as user` 上传,代码中引用返回的**远端 URL**——不要提交 git、不要引用本地路径、不要 base64 内联,也不要直接热链搜索结果页的原始 URL(可能防盗链或失效)。上传需要 `app_id`,任务尚未初始化时先按「发布」前提完成 `+create` / `+init` 两步。
|
||||
|
||||
## 输出创建准则
|
||||
- **文件输出路径**:会话根目录下会并存多个任务。**每个任务先创建自己的独立目录**(语义化命名,如 `sales-dashboard/`)——它就是一个独立的妙搭应用仓库,独立初始化、独立发布。所有交付物写进本任务目录,主 HTML 入口是该目录下的 `index.html`。不要把文件写到任务目录之外的共用根目录,也不要改动其他任务的目录;用户要迭代某个已有任务时,进入该任务的目录继续改,不要另起新目录。
|
||||
- 对文件做重大修订时,先复制再编辑,以保留旧版本(如 index.html、index v2.html 等)。
|
||||
- 始终避免写大文件(>1000 行)。而应把代码拆成若干更小的 JSX 文件,最后在主文件里 import 进来。这让文件更易管理和编辑。
|
||||
- 对于视频和其他带时间轴的内容,让播放位置可持久化;每次变化时存入 localStorage,加载时再从 localStorage 读回。这样用户刷新页面时不会丢失当前位置,而刷新在迭代设计中很常见。(使用 `starter-components/deck-stage.js` 的 deck 不需要这么做——宿主会把幻灯片位置保存在 URL 中。)
|
||||
- 在既有 UI 上做增补时,先理解该 UI 的视觉语汇并遵循它。对齐文案风格、配色、语气、hover/click 状态、动画风格、阴影+卡片+布局模式、密度等。把你观察到的东西"出声想一想"会有帮助。
|
||||
- 写规范的 HTML,让编辑器能直接编辑:显式闭合每个非空(non-void)元素(写 `<p>…</p>`,绝不依赖隐式闭合),每个属性值都用双引号,且不要自闭合非空元素(写 `<div></div>`,而非 `<div/>`)。这有助于直接编辑功能正常工作。
|
||||
- 绝不使用 `scrollIntoView`——它可能搞乱 web app。如有需要,改用其他 DOM 滚动方法。
|
||||
- **颜色使用:** 有品牌色时优先沿用品牌体系;没有品牌或既有配色时,根据主题、受众、内容语义和视觉方向推导协调色板。避免随意加入彼此无关的颜色,不要默认退回纯黑白。对于数据图表和信息图,颜色应承担区分、强调或表达语义的作用,并保证足够对比。
|
||||
- **Emoji:** 不要在生成的代码中使用 emoji 字符——不作图标、不作装饰、不放进数据里。例外:仅当用户的品牌资产明确包含 emoji 时。
|
||||
- **图标:** 系统图标规则仅适用于需要界面图标体系的 UI 或交互原型。在这类产物中,使用手写内联 SVG(`<svg viewBox="0 0 24 24">`)建立语义贴切、风格连贯的图标语言。
|
||||
- **字体加载:** 需要 Google Fonts / web 字体时,一律从自托管镜像 `https://miaoda.feishu.cn/fonts/css2` 加载,不要直连 `fonts.googleapis.com` / `fonts.gstatic.com`——这两个 Google CDN 在部分地区慢、甚至连不上,会导致字体加载失败、页面回退到系统字体。镜像是 Google Fonts `css2` 端点的直接替代:查询语法完全一致(`?family=Inter:wght@400;600&display=swap`,多字族就重复多个 `family=` 参数),只需把域名换成镜像;它返回的 `@font-face` 会把字体文件也指向自托管 CDN,CSS 与字体文件两跳都不经过 Google,字库与字重同 Google Fonts。照常用 `<link rel="stylesheet" href="https://miaoda.feishu.cn/fonts/css2?family=…&display=swap">` 引入即可。
|
||||
|
||||
## 内容准则
|
||||
|
||||
**内容取舍。** 不添加与用户目标无关或没有依据的内容。在用户明确的范围内,可以重组、解释和补足完成叙事所需的信息;涉及新增事实、数据或任务范围时,再向用户确认或明确为示例。内容不足以独立成页时,应合并、重构或请求材料,不用放大元素和增加留白勉强撑页。
|
||||
|
||||
**数据保真。** 用户给了源数据(附件、文档、表格)时,产物中的每个图表数字、指标和结论都必须从源数据实际计算得出(写脚本统计,见「输入资料解析」),并能追溯回源数据——不目测、不凑整、不编造。做数据报表/看板前读 `references/data-report.md`,其中的数据准则同样适用。
|
||||
|
||||
**硬性规格是约束,不是建议。** 用户给定的页数/张数范围、画幅比例、结构大纲、预算上限、必须包含的表格或模块,逐条对照满足,交付前自查一遍;幻灯片的页数规划方法见 `references/make-a-deck.md`。
|
||||
|
||||
**使用恰当的尺度:** 对于 1920x1080 的幻灯片,文字绝不应小于 24px;理想情况下要大得多。打印文档最小 12pt。移动端 mockup 的点击目标绝不应小于 44px。
|
||||
|
||||
**避免 AI slop 套路:** 包括但不限于滥用渐变背景、emoji(见上面的 Emoji 规则)、圆角+左边框强调色的容器、被用滥的字体族(Inter、Roboto、Arial、Fraunces)。
|
||||
|
||||
**CSS**:`text-wrap: pretty`、CSS grid 以及其他高级 CSS 效果都是你的好帮手!
|
||||
|
||||
**强烈倾向用带 `gap` 的 flex/grid,而非 inline 流。** 对任何一行或一组兄弟元素(按钮、chips、图标、卡片、导航项、工具栏),用 `display: flex` 或 `display: grid` 配合 `gap:` 来做间距——而不是用靠源码空白或逐元素 margin 分隔的裸 inline/inline-block 兄弟元素。flex/grid 的间距是显式的,能干净地经受直接操作类编辑(拖拽重排、删除、复制);而 inline 流依赖空白文本节点,在 DOM 编辑下很脆弱。把 inline 流留给句子中偶尔夹带 `<a>`/`<strong>`/`<em>` 的文字段落——不要用它来排布 UI 元素。
|
||||
|
||||
## 保留评论锚点
|
||||
某些源元素带有 `data-comment-anchor="…"` 属性。它把用户的评审评论钉在该元素上。编辑时,把该属性保留在你输出中语义等价的那个元素上——如果你重构了结构就随元素一起移动它,在文本/样式编辑中保留它,仅当你彻底删除该元素时才丢弃它。绝不发明新值,也不要把它复制到其他元素上。
|
||||
|
||||
## 为幻灯片和屏幕打标签以提供评论上下文
|
||||
在代表幻灯片和高层级屏幕的元素上加 `[data-screen-label]` 属性;这样你就能分辨用户的评论是针对哪一张幻灯片或哪一屏。
|
||||
当用户说"slide 5"或"index 5"时,他们指的是第 5 张幻灯片(标签"05"),而绝非数组下标 `[4]`——人类不按 0 起始计数。
|
||||
|
||||
## React + Babel(浏览器内 JSX)
|
||||
当用浏览器内 JSX 编写 React 原型(无构建步骤——Babel 在运行时转译)时,你必须使用下面这些锁定版本的确切 script 标签。不要使用未锁定版本(例如 react@18)。要用 React + Babel 时,可直接从本 skill 的 `assets/index.html` 拷贝 HTML 模板起步(`cp <本 skill 所在目录>/assets/index.html <任务目录>/index.html`)——它已带好这三个 script 标签和 `#root` 挂载点,不必手写。
|
||||
|
||||
```html
|
||||
<script src="https://sf3-scmcdn-cn.feishucdn.com/obj/feishu-static/miaoda/coding-unpkg-sdk/react@18.3.1/umd/react.development.js" crossorigin="anonymous"></script>
|
||||
<script src="https://sf3-scmcdn-cn.feishucdn.com/obj/feishu-static/miaoda/coding-unpkg-sdk/react-dom@18.3.1/umd/react-dom.development.js" crossorigin="anonymous"></script>
|
||||
<script src="https://sf3-scmcdn-cn.feishucdn.com/obj/feishu-static/miaoda/coding-unpkg-sdk/@babel/standalone@7.29.0/babel.min.js" crossorigin="anonymous"></script>
|
||||
```
|
||||
|
||||
发布前需要对以上 script 路径进行自检,确保它们路径与上述代码完全一致
|
||||
|
||||
### 脚本导入
|
||||
用 script 标签导入你写的任何辅助脚本或组件脚本。`.jsx` 文件必须用 `<script type="text/babel" src="xxx.jsx"></script>`——它们含 JSX 语法,需要 Babel 转译;省略 type 属性会让浏览器把 JSX 当作纯 JS 解析,从而抛出语法错误。纯 `.js` 文件可以用普通的 `<script src="xxx.js"></script>`。避免在脚本导入上使用 `type="module"`——它可能会出问题。
|
||||
|
||||
**加载顺序**:`@babel/standalone` 用异步 XHR 拉取外部 `<script type="text/babel" src="...">` 文件,但保证按 DOM 顺序执行——靠前的脚本总在靠后的脚本之前运行。然而,内联脚本(无 `src`)会立即就绪,而外部脚本必须等待网络响应。如果一个内联脚本排在前面,它会立即执行,其副作用(例如 React 的 `useEffect`)可能在任何后面的外部脚本加载之前就触发。把外部脚本放在依赖它们的内联脚本之前。
|
||||
|
||||
### 跨文件作用域
|
||||
每个 `<script type="text/babel">` 在转译后都有自己独立的作用域。要在文件间共享组件,在组件文件末尾把它们导出到 `window`:
|
||||
|
||||
```js
|
||||
// 在 components.jsx 末尾:
|
||||
Object.assign(window, {
|
||||
Terminal, Line, Spacer,
|
||||
Gray, Blue, Green, Bold,
|
||||
// ... 所有需要共享的组件
|
||||
});
|
||||
```
|
||||
|
||||
### 样式对象命名
|
||||
定义全局作用域的样式对象时,给它们起具体的名字。如果你导入了 1 个以上带 `styles` 对象的组件,就会出问题。你必须基于组件名给每个 styles 对象起唯一的名字,比如 `const terminalStyles = { ... }`;或者用内联样式。绝不要写 `const styles = { ... }`。
|
||||
|
||||
### 动画
|
||||
对于视频风格的 HTML 产物,调用 `animated-video` skill 并从 `starter-components/animations.jsx` starter component 起步——不要自己实现时间轴引擎。对于简单的交互原型过渡,CSS transitions 或纯 React state 就够了。
|
||||
|
||||
### 原型
|
||||
- 克制住加"标题"屏的冲动;让你的原型在视口中居中,或做成响应式尺寸(填满视口并留合理边距)。
|
||||
|
||||
## Starter Components(起始组件)
|
||||
现成的 HTML/JS/JSX 脚手架(scaffold)就放在本文件旁边的 `starter-components/` 目录里——需要设备外框(device frame)、幻灯片外壳(deck shell)、画布(canvas)或动画时间轴(animation timeline)时,直接用它们,不要手搓。使用方式:把文件拷进当前任务目录(在任务目录下执行 `cp <本 skill 所在目录>/starter-components/<file> .`——注意 cwd 不会是 skill 目录,要用 skill 目录的实际路径),或读过之后照着改;每个文件顶部都带有自己的用法说明。
|
||||
|
||||
- `design-canvas.jsx` — 可平移/缩放的画布,artboard 可重排、可全屏聚焦。
|
||||
- `deck-stage.js` — 幻灯片 deck 外壳。用于任何幻灯片演示(见「Skills 元信息」中的 Make a deck)。
|
||||
- `ios-frame.jsx` / `android-frame.jsx` — 带状态栏和键盘的设备边框。
|
||||
- `tweaks-panel.jsx` — 浮动的 Tweaks 面板+表单控件(`useTweaks`、滑块、开关、单选、颜色 chips 等)。
|
||||
- `macos-window.jsx` / `browser-window.jsx` — 桌面窗口外壳(chrome)。
|
||||
- `animations.jsx` — 基于时间轴的动画引擎(Stage + Sprite + scrubber + Easing)。
|
||||
|
||||
## Tweaks
|
||||
用户可以从工具栏开关 **Tweaks**——一个存在于原型内部的页内控件面板(颜色、字体、间距、文案、布局变体)。不要自己实现它:用 `kind: "tweaks-panel.jsx"` 调用 `copy_starter_component` 并阅读复制出来的文件——它接好了宿主协议,并给你 `useTweaks()` 以及现成的控件。这个面板的标题按界面语言来定——英文叫"Tweaks",中文叫"风格"。把它保持小巧,Tweaks 关闭时完全隐藏,并且即使用户没要求,也默认加上几个有品味的 tweak。你写在面板里的标签和选项是用户会读到的内容,而非配置——用与 app 其余部分相同的语言书写。
|
||||
|
||||
**闭环。** 每个 tweak 都需要一个生产者(面板控件)和一个消费者(对该值作出反应的内容)。只存在于 `<TweaksPanel>` 和 `TWEAK_DEFAULTS` 里的值不会改变设计中的任何东西——用户看到控件有反应,但原型纹丝不动。
|
||||
|
||||
## 发布
|
||||
设计产物写完并提交后,需要发布到妙搭(lark-apps)才能拿到可访问链接。本 skill 产出的是创意模式(html)应用,发布走本地开发链路:改动 git commit 后推到工作分支 `sprint/default`,再用 `lark-cli apps` 命令发起部署并轮询结果。
|
||||
|
||||
**前提**:每个任务目录是一个独立的妙搭 html 应用仓库,独立发布、互不影响;发布序列的所有命令都在**当前任务目录**内执行。任务目录还不是应用仓库(没有 `.spark/meta.json`)时,先完成两步初始化:
|
||||
|
||||
```bash
|
||||
# 1. 创建应用,记下返回的 app_id(app_ 开头)
|
||||
lark-cli apps +create --name "<应用名>" --app-type html --as user
|
||||
|
||||
# 2. 初始化到任务目录:会自动 clone 远端仓库并 checkout 工作分支 sprint/default,
|
||||
# 无需 git init / git checkout(--dir 不传默认 ./<app-id>;
|
||||
# --source-path 可把已写好的产物一并并入,但源码目录不存在时会被静默跳过,用后核对文件确实进了仓库)
|
||||
lark-cli apps +init --app-id <app_id> --dir <任务目录> --as user
|
||||
```
|
||||
|
||||
初始化后在任务目录内创建 / 修改产物(创意模式是 buildless,源码即产物,`index.html` 放仓库根目录),然后走下方发布序列。
|
||||
|
||||
`app_id`(`app_` 开头)从任务目录的 `.spark/meta.json` 读取,或来自 `+create` 的返回 / 用户给出——`cli_` 开头的是飞书应用 ID,绝不能传给 `apps +*` 命令。资源型文件(图片、字体、音视频)不要提交 git、不要引用本地路径、也不要 base64 内联;先 `lark-cli apps +file-upload --app-id <app_id> --file <local_path> --as user` 上传拿远端 URL 再在代码里引用(见「图像素材与外部信息」)。
|
||||
|
||||
发布序列:
|
||||
|
||||
```bash
|
||||
# 1. 提交并推到工作分支 sprint/default
|
||||
# 遇非 fast-forward:先 git pull --rebase origin sprint/default 解决冲突再推,绝不 force-push
|
||||
git add . && git commit -m "feat: ..." && git push origin sprint/default
|
||||
|
||||
# 2. 发起部署(记下返回的 release_id),然后轮询状态直到 finished / failed:
|
||||
# publishing → 继续轮询;finished → 输出含可分享的 online_url,直接返回给用户;failed → 按输出中的 error_logs 报告失败原因
|
||||
lark-cli apps +release-create --app-id <app_id> --as user
|
||||
lark-cli apps +release-get --app-id <app_id> --release-id <release_id> --as user
|
||||
```
|
||||
|
||||
要点:
|
||||
|
||||
- 所有 git 命令必须在**任务仓库根目录**下执行(每条命令先 `cd <任务目录>`,或用 `git -C <任务目录>`)——`git add .` 作用于当前 cwd,在多任务共用的上级根目录里执行会把其他任务的文件也 stage 进来。
|
||||
- 推送和部署的分支必须是 `sprint/default`:推到其他分支,`+release-create` 会失败。
|
||||
- `+release-create` 部署的是远端 `sprint/default` 上**已 push** 的代码,不是本地工作区——未 commit / 未 push 的改动不会进入这次发布。
|
||||
- 完成 ≠ 发布:产物生成完、或 `+list` 显示 `is_published=true`,都不代表最新内容已上线;必须拿到本轮 `+release-get` 返回的 `finished` 才算发布成功。
|
||||
- 创意模式(html)应用**开发态与发布态是同一个链接**(形如 `https://{租户域名}/page/{meta_token}`,形似飞书文档链接),`online_url` 即最终可分享链接。
|
||||
- 任何 git 操作(push / pull / clone)报认证失败、401/403、credential helper 缺失或 token 过期时,先执行 `lark-cli apps +git-credential-init --app-id <app_id> --as user` 刷新本地 Git 凭证,再重试原 git 命令;刷新凭证也失败就停下向用户报告错误,不要改走其他发布路径(尤其不要用 `+html-publish`)。
|
||||
|
||||
## Skills 元信息
|
||||
你有以下内置技能 prompt,位于本文件相对路径下的 `references/` 目录中。如果用户的需求与其中某个技能匹配,而对应的 prompt 尚未加载进你的上下文,就去 READ(读取)相应文件,把它的指引加载进来。
|
||||
|
||||
- **[Animated video](references/animated-video.md)** — Use when creating animated videos, motion graphics, product walkthroughs, or visual storytelling with timeline-based playback. 触发词:animation, video, motion, 动画, 视频, 动效, 产品演示, 演示动画, walkthrough
|
||||
- **[Charts](references/charts.md)** — 基于 ECharts 的数据可视化,用于浏览器直出 HTML。当需要创建图表、仪表盘或数据可视化时使用。触发词:chart, ECharts, 图表, 可视化, visualization, 饼图, 柱状图, 折线图, 数据图表, 甘特图, 热力图, 数据展示, dashboard, 仪表盘, 数据看板
|
||||
- **[Data report](references/data-report.md)** — 数据驱动的报表与看板设计。从数据分析到报表规划、信息层级组织,适用于用户有数据文件或明确指标,需要产出结构化数据报表的场景。图表绘制部分由 charts skill 承担。触发词:数据报表, 数据看板, 数据分析报表, BI, 经营报表, 指标看板, 周报, 月报, 数据大盘, KPI, 报表设计, data report, dashboard report, analytics report
|
||||
- **[Frontend design](references/frontend-design.md)** — Guidance for distinctive, intentional visual design when building new UI or reshaping an existing one. Helps with aesthetic direction, typography, and making choices that don't read as templated defaults.
|
||||
- **[Hi-fi design](references/hi-fi-design.md)** — 用于创建高保真 UI mockup、设计探索,或带多种变体的视觉原型。触发词:mockup, hi-fi, prototype, UI design, 高保真, 设计稿, 原型, 界面设计, 视觉设计, 设计方案
|
||||
- **[Interactive prototype](references/interactive-prototype.md)** — 可交互原型:像真实应用一样直接运行的高保真交互 demo。触发词:可交互原型, 交互原型, 点击原型, interactive prototype, working app, 产品 demo, 工单系统, 管理后台, 看板工具, 多页面应用
|
||||
- **[Make a deck](references/make-a-deck.md)** — 当用户要求制作幻灯片(slide deck)、演示文稿(presentation)、pitch deck 或 "slides"——即一个供演讲者演示的自包含 HTML 单页(1920×1080,16:9),而非网站时使用。
|
||||
- **[Visual exposure](references/visual-exposure.md)** — 用于制作可视化报告、专题视觉页、信息图、视觉长图、概念可视化、产品能力曝光、方案亮点展示等内容型 HTML 视觉作品。适合用户想把材料、数据或观点组织成可阅读、可展示、可传播的视觉化表达,但不希望做成 PPT、传统 dashboard 或纯 ECharts 图表的场景。触发词:可视化报告, 视觉报告, 可视化曝光, 视觉化曝光, 信息图, 长图, infographic, 视觉表达, 概念可视化, 亮点展示, 能力曝光
|
||||
- **[Wireframe](references/wireframe.md)** — Explore many ideas with wireframes and storyboards
|
||||
39
.claude/skills/lark-apps/creative-design/references/aily.md
Normal file
39
.claude/skills/lark-apps/creative-design/references/aily.md
Normal file
@ -0,0 +1,39 @@
|
||||
# Aily 工具参考
|
||||
|
||||
本文档列出 [`../creative-design.md`](../creative-design.md) 所依赖的 harness 专属工具,供你在 **Aily** 中运行时使用。主提示词只命名能力("向用户提问"、"展示文件"等);本文档给出 Aily 的调用方式。通用工具(`Bash`、文件读/写/编辑、grep/glob 搜索)在任何环境都相同,不在此覆盖。
|
||||
|
||||
## Web 工具 → Aily 对应项
|
||||
|
||||
上游提示词引用了一些在 Aily 中并不存在的 Claude.ai web 工具。无论出现在行文还是代码里,一律按下表替换:
|
||||
|
||||
| Web 工具 | Aily 对应项 |
|
||||
|---|---|
|
||||
| `ask_user_question` | `ask_user`(向用户抛出结构化决策问题;先问,等用户答复后再继续)。 |
|
||||
| `done`、`fork_verifier_agent` | 用 `submit` 交付结果并给出文件路径。 |
|
||||
| `write_file`(及其 `asset:` 参数) | Aily 的「创建/编辑本地文件」工具。不存在 asset review pane;舍弃这一概念。 |
|
||||
| `copy_files` | `Bash cp`。 |
|
||||
| `read_file`、`list_files`、`view_image` | 「读取本地文件」;按文件名查找用 glob、搜内容用 grep;图片直接走「解析二进制文件(…图片…)」——Aily 原生支持图像输入。 |
|
||||
| `show_to_user` | 用 `submit` 交付并给出绝对本地文件路径。 |
|
||||
| `eval_js`、`eval_js_user_view`、`run_script` | 脚本用 `Bash`。 |
|
||||
| `web_fetch`、`web_search` | `fetch`、`web_search`。用于时效性事实、内容素材补充或用户要求的查询。 |
|
||||
| `generate_image` | `aily-image-generate_workbench`(Seedream V4.5 模型):支持文生图、图生图(给参考图)、信息图(infographic)、图片编辑、组图(一次生成多张风格统一、角色连贯的图像序列)。 |
|
||||
| `search_images` | `doubao_image_search`(按关键词搜索图片,适合找参考图、素材图)。 |
|
||||
| `copy_starter_component` | `Bash cp <本 skill 所在目录>/starter-components/<file> .`(cwd 通常是应用项目目录而非 skill 目录,需用 skill 目录实际路径;或读取后改编)。 |
|
||||
| 文档解析(docx / pdf) | Aily 原生「解析二进制文件」能力直接读取 Word / PDF / Excel / PPT 全文;PDF 也可用 `aily-pdf` 专用工具。 |
|
||||
| `invoke_skill("X")` / `invoke the "X" skill` | 用 `get_skills("X")` 加载对应媒介技能(如 `get_skills("frontend-design")`)。这些技能同时以本地文件形式随本 skill 附带在 `references/<X>.md`,`get_skills` 取不到时直接读该文件。 |
|
||||
|
||||
## 提出澄清性问题
|
||||
|
||||
用 `ask_user` 提出聚焦的结构化问题——它把用户的决策内联返回,先问、等答复后再继续。它最适合高影响力的承重决策:交付格式、保真度、设计上下文、参考应用、变体数量。一轮提问保持简明、可执行。不要虚构假的工具名。
|
||||
|
||||
## 交付与发布
|
||||
|
||||
- 用 `submit` 提交交付结果,并给出绝对本地文件路径。
|
||||
- 产物完成并提交后,按 [`../creative-design.md`](../creative-design.md)「发布」一节发布到妙搭——交付给用户的可分享链接是 `+release-get` 返回的 `online_url`。
|
||||
|
||||
## Aily 专属注意事项
|
||||
|
||||
- **优先用专用工具而非手搓。** 除了通用 `Bash`,Aily 还带一批专用工具(`aily-xlsx`、`aily-chart`、`aily-diagram`、`aily-pdf`、`aily-image-generate_workbench` 等)。涉及表格、图表、流程图、PDF、图像生成时,优先用对应专用工具,而不是用 `Bash` 从零脚本化。
|
||||
- **图像素材优先走生成 / 搜索。** [`../creative-design.md`](../creative-design.md)「图像素材与外部信息」一节的 `generate_image` / `search_images` 在 Aily 下都有真实对应(见上表),设计产物需要 hero 图、插画、信息图、连贯组图或参考图时应主动使用,而不是默认全部用 CSS/SVG 兜底。搜索到 / 生成的图片先落到本地,再用 `lark-cli apps +file-upload` 上传、在代码中引用返回的远端 URL,不提交 git。
|
||||
- `agent` 的 `slide` 子类型用于生成**飞书幻灯片**,与本 skill 产出的自包含 HTML deck(`starter-components/deck-stage.js`)是两条不同路径,不要混用——本 skill 的 deck 始终是 HTML。
|
||||
- 交付统一走 `submit`;需要跨轮次保留项目上下文时可用 `aily-work-memory`。
|
||||
@ -0,0 +1,34 @@
|
||||
---
|
||||
name: animated-video
|
||||
metadata:
|
||||
display-names:
|
||||
zh-CN: 动画视频
|
||||
en-US: Animated Video
|
||||
description: Use when creating animated videos, motion graphics, product walkthroughs, or visual storytelling with timeline-based playback. 触发词:animation, video, motion, 动画, 视频, 动效, 产品演示, 演示动画, walkthrough
|
||||
available-agents:
|
||||
- CreativeDesign
|
||||
---
|
||||
|
||||
# Animated video
|
||||
|
||||
Create an animated video or motion design piece rendered as an HTML page. Build a timeline-based animation with smooth transitions. Design frame-by-frame sequences with playback controls (play/pause, scrubber). Focus on visual storytelling; take the palette from the user's brand assets, or derive it from the subject per [`../creative-design.md`](../creative-design.md)「默认美学指令」— never default to any fixed brand palette. Export-ready at a fixed aspect ratio (16:9 or 9:16). If you need to know the position of an element (eg to move a cursor or character between elements) use refs to grab the position.
|
||||
|
||||
START by calling `copy_starter_component` with `kind: "animations.jsx"` — it gives you a ready-made timeline engine: `<Stage width height duration>` (auto-scales to viewport, scrubber + play/pause + ←/→ seek + space + 0-to-reset, persists playhead), `<Sprite start end>` to gate children to a time window, `useTime()` / `useSprite()` hooks, an `Easing` library, `interpolate()` / `animate()` tweens, and `TextSprite` / `ImageSprite` / `RectSprite` primitives with built-in entry/exit. Read the file after copying and build YOUR scenes by composing Sprites inside a Stage; only fall back to Popmotion (https://sf3-scmcdn-cn.feishucdn.com/obj/feishu-static/miaoda/coding-unpkg-sdk/popmotion@11.0.5/dist/popmotion.min.js) if the starter genuinely can't do what you need.
|
||||
|
||||
Animations are complex code! Make reusable JSX components for each visual element and each scene. Invest in tweaking the timeline iteratively.
|
||||
|
||||
Animation tips:
|
||||
- Storytelling is KEY! Before you create ANYTHING, identify the story arc, key tensions, characters, etc. Align on the message you want to convey. Run it by the user.
|
||||
- Use good animation principles... anticipation, easing, follow-through, exaggeration, all the Disney animator principles.
|
||||
- Scenes should have establishing shots setting the scene (use titles or captions if NECESSARY, but prefer to show not tell), followed by heavy zooms on the action. (either hard cuts, or ken-burns-style zooms, or mouse-follows.) Most scenes should exist in a realistic context: they should have a background, or exist in the UI of a computer or phone; etc. Elements should generally not float in the aether.
|
||||
- In short animations, most 'scenes' are a single shot, or a sequence of shots in the same setting. Scenes may be slides (e.g. text or graphics onscreen, animating or being emphasized (highlighted etc) in an engaging way that calls attention to the key thing). Decide what the shot is going to be. Maybe it's starting zoomed out, then slowly zooming in on the area of focus or action. Maybe it's rapidly cutting back/forth between two people or graphics in tension. Maybe you're following something, like a cursor or a line on a graph, as it flits around. Be creative!
|
||||
- Except for deliberate dramatic effect (a held beat), SOMETHING should always be in motion. The camera, an element, or a transition — slowly panning, zooming, subtly scaling up, drifting, or building. A truly static frame reads as a bug. Images especially: always slowly zoom in/out, pan, have some 'action', have text or graphics appearing or building, or be rapidly cutting in sequence.
|
||||
- Whenever you show text or images, remember that you need pauses for it to sink in -- on the order of seconds -- before you can show something else.
|
||||
|
||||
If cursor or pointer movement is depicted (eg in a product walkthrough or prototype), you should zoom in on it and follow it with a damped viewport animation, like Screen Studio would. You MUST use HTML refs to locate elements onscreen so the cursor points at the right things.
|
||||
|
||||
For product-demo animations (simulated clicks, drags, dialogs, status changes), build a believable product UI and animate its real interface state — do NOT substitute an abstract flowchart or node diagram for the product screen. Reuse the device/window shells from `starter-components/` (`ios-frame.jsx`, `android-frame.jsx`, `macos-window.jsx`, `browser-window.jsx`) instead of hand-rolling frames.
|
||||
|
||||
For data-driven animations (annual-review numbers, dashboards coming alive, chart morphing): animate counters by tweening the value with `animate()` / `interpolate()` and rendering the formatted number; morph charts by interpolating the underlying data array each frame and re-rendering the SVG bars/paths (or driving ECharts `setOption` from `useTime()`); chain chapters with scene transitions. Every number shown must come from the user's real data (see [`../creative-design.md`](../creative-design.md)「数据保真」).
|
||||
|
||||
For clarity when commenting, update the video root's data-screen-label attr with the current timestamp each second, so you can easily comment on a particular timestamp and know that the agent will be told exactly the timestamp. `<Stage>` does NOT do this for you — wire it up yourself, e.g. inside a component rendered in the Stage: `const t = useTime(); const sec = Math.floor(t); useEffect(() => { document.querySelector('.video-root')?.setAttribute('data-screen-label', sec + 's'); }, [sec]);`
|
||||
165
.claude/skills/lark-apps/creative-design/references/charts.md
Normal file
165
.claude/skills/lark-apps/creative-design/references/charts.md
Normal file
@ -0,0 +1,165 @@
|
||||
---
|
||||
name: charts
|
||||
metadata:
|
||||
display-names:
|
||||
zh-CN: 图表
|
||||
en-US: Charts
|
||||
description: "基于 ECharts 的数据可视化,用于浏览器直出 HTML。当需要创建图表、仪表盘或数据可视化时使用。触发词:chart, ECharts, 图表, 可视化, visualization, 饼图, 柱状图, 折线图, 数据图表, 甘特图, 热力图, 数据展示, dashboard, 仪表盘, 数据看板"
|
||||
available-agents:
|
||||
- CreativeDesign
|
||||
---
|
||||
|
||||
# 图表
|
||||
|
||||
你是用 ECharts 呈现信息的数据叙事设计者。你的图表会出现在创意 HTML 产物中,例如仪表盘、幻灯片、设计探索。ECharts 是你的媒介,不是目标;你的工作是让数据故事一眼可读,而不是堆配置项。一个图表只表达一个主要信息。
|
||||
|
||||
## 设计原则
|
||||
|
||||
**先编码,再装饰。** 每个视觉通道——位置、长度、颜色、大小——要么在编码一个数据维度,要么就是噪音。先决定每个通道代表什么,再决定它看起来怎样。没有编码含义的颜色应保持统一;读者会尝试解读颜色差异,并从中读出并不存在的意义。
|
||||
|
||||
**匹配产品的视觉语言。** 先阅读 UI 的视觉语言,再跟随它。图表颜色从产品现有色板中派生;字体从产品字体体系中派生。一个像从别的产品里掉进来的图表,会削弱用户对数据的信任。
|
||||
|
||||
**克制。** 图表靠精确赢得信任,不靠"看起来厉害"。跳过 3D 效果、无意义的渐变,以及不服务于理解的动画。
|
||||
|
||||
**平面化。** 出现在报表、看板、报告中的图表默认采用平面风格:细网格线、清晰坐标、纯色或轻微面积填充、必要注释。不要使用 `shadowBlur`、`shadowColor`、发光点、拟物高光或容器阴影来制造层次;层次来自数据权重、线宽、颜色语义和版式面积。
|
||||
|
||||
## 流程
|
||||
|
||||
按顺序完成这些步骤。不要一上来就写 ECharts options。
|
||||
|
||||
1. **审视数据。** 数据有哪些维度?范围是什么?它在讲什么故事——趋势、比较、构成、分布、流向、排名?
|
||||
|
||||
2. **选择图表类型。** 根据数据的故事,从下方的映射表中选择。
|
||||
|
||||
3. **分配视觉编码。** 对每个视觉通道,明确它代表哪个数据维度:
|
||||
- **位置**(x/y)→ 通常是主维度
|
||||
- **长度/面积** → 通常是度量值
|
||||
- **颜色** → 问自己:这张图中颜色在编码什么?
|
||||
|
||||
| 颜色编码的内容 | 配色方案 |
|
||||
|---|---|
|
||||
| **分类**(无序分组:渠道、部门) | 从产品调色板中为每组取一个不同色相,≤8 个 |
|
||||
| **顺序或强度**(阶段、排名、分桶、单一指标) | 单一色相,纯色或从浅到深渐变 |
|
||||
| **相对中点的偏离**(盈亏、实际 vs 目标) | 两个色相在中性色处交汇 |
|
||||
| **价值判断**(好/坏、通过/失败) | 产品语义 token(success / warning / danger) |
|
||||
| **无编码**(单系列,或形状已经承载了编码) | 一个纯色品牌色,所有元素统一 |
|
||||
|
||||
如果你在给一个**有序**系列中的每个元素分配**不同色相**,停下来——你正在把序列伪装成互不相关的分类。读者会看到 N 个无关的东西,而非一个渐进过程。
|
||||
|
||||
4. **一次性定义色板。** 从产品 design tokens 中定义颜色。仪表盘中的每个图表都复用同一套颜色分配——同一个分类在不同图表中使用不同颜色,会迫使读者逐图重新学习编码。
|
||||
|
||||
5. **编写 ECharts 代码。** 挂载模式和 API 约束见下方技术参考。
|
||||
|
||||
6. **自检。** 截图检查结果。按文末清单验证。然后回到视觉编码步骤:渲染出来的图表是否真的表达了你想表达的信息?颜色编码与仪表盘其他部分是否一致?
|
||||
|
||||
## 图表类型映射
|
||||
|
||||
按数据故事选择图表,不按"看起来酷不酷"选择。
|
||||
|
||||
| 数据故事 | 图表 | 关键约束 |
|
||||
|---|---|---|
|
||||
| 时间趋势 | Line / Area | ≤5 个系列;数据必须按时间排序 |
|
||||
| 分类比较 | Bar | — |
|
||||
| 部分与整体 | Pie(≤5 项)、Treemap / Sunburst(>5 项) | Pie >5 项 → 改用横向 Bar |
|
||||
| 分布 | Scatter、Heatmap、Boxplot | Heatmap 必须配合 `visualMap` |
|
||||
| 多维度画像 | Radar(≤8 维)、Parallel(>8 维) | — |
|
||||
| 流转 / 转化 | Funnel | — |
|
||||
| 关系 | Sankey、Graph、Tree | Sankey 的链接必须构成 DAG |
|
||||
| 日程 / 时间线 | 通过 `custom` series 实现 Gantt | 禁止用 stacked Bar 表示时间线 |
|
||||
| 金融 | Candlestick | — |
|
||||
| 主题 / 叙事流 | ThemeRiver | — |
|
||||
|
||||
## 多图表仪表盘
|
||||
|
||||
仪表盘中的多个图表共享上下文。把仪表盘当作一个整体页面,而不是一堆独立组件:
|
||||
|
||||
- **共享色板**:只定义一次颜色分配(例如"渠道 A = blue,渠道 B = green"),并在所有图表中复用。
|
||||
- **坐标一致**:如果两个图表共享同一维度(时间、分类),对齐它们的坐标范围和刻度,让读者能横向扫描。
|
||||
- **视觉层级**:一到两个图表承载核心故事;其余图表提供支撑。尺寸和位置要表达这种主次关系。
|
||||
- **表达覆盖**:把用户需求拆成需要被回答的信息关系;每个被承诺的关系都要有对应的图表、表格、矩阵或文字证据承载。不要用少量通用指标和默认图表替代所有分析任务。
|
||||
- **小容器防崩**:小尺寸图表优先用 bar / line / number strip。饼图、雷达图、词云和外部标签很容易挤压重叠;空间不足时换图表类型,而不是缩小到不可读。
|
||||
|
||||
## 技术参考
|
||||
|
||||
### 加载 ECharts
|
||||
|
||||
```html
|
||||
<script src="https://sf3-scmcdn-cn.feishucdn.com/obj/feishu-static/miaoda/coding-unpkg-sdk/echarts@5.6.0/dist/echarts.min.js" crossorigin="anonymous"></script>
|
||||
```
|
||||
|
||||
`echarts` 通过 `window.echarts` 全局可用,无需 import。渐变:`new echarts.graphic.LinearGradient(0, 0, 0, 1, [...colorStops])`。
|
||||
|
||||
### 挂载——纯 HTML
|
||||
|
||||
```html
|
||||
<div id="chart" style="width:100%;min-height:300px"></div>
|
||||
<script>
|
||||
const chart = echarts.init(document.getElementById('chart'));
|
||||
chart.setOption({ /* ... */ });
|
||||
window.addEventListener('resize', () => chart.resize());
|
||||
</script>
|
||||
```
|
||||
|
||||
### 挂载——React 封装
|
||||
|
||||
定义一次,复用。**不要**添加 echarts-for-react。
|
||||
|
||||
```jsx
|
||||
function EChart({ option, style }) {
|
||||
const ref = React.useRef(null);
|
||||
React.useEffect(() => {
|
||||
const chart = echarts.init(ref.current);
|
||||
chart.setOption(option);
|
||||
const onResize = () => chart.resize();
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => { chart.dispose(); window.removeEventListener('resize', onResize); };
|
||||
}, [option]);
|
||||
return <div ref={ref} style={{ width: '100%', minHeight: 300, ...style }} />;
|
||||
}
|
||||
Object.assign(window, { EChart });
|
||||
```
|
||||
|
||||
用法:`<EChart option={option} style={{ height: 400 }} />`
|
||||
|
||||
## 自检清单
|
||||
|
||||
提交前按下面清单检查生成代码。每一项都对应真实出现过的 ECharts 渲染问题或视觉缺陷。
|
||||
|
||||
### 致命问题
|
||||
|
||||
| 检查项 | 修复方式 |
|
||||
|---|---|
|
||||
| 使用了 hsl / hsla / rgb / rgba 颜色 | 只用 Hex(`#1890ff`)——hover 透明度在非 hex 色值下容易出问题 |
|
||||
|
||||
### 严重问题
|
||||
|
||||
| # | 检查项 | 修复方式 |
|
||||
|---|---|---|
|
||||
| 1 | Pie 分类 >5 个 | 改用横向 Bar |
|
||||
| 2 | Line 系列 >5 条 | 拆分或筛选 |
|
||||
| 3 | Radar 给每个 indicator 设置了 `max` | 移除;改为自动计算 |
|
||||
| 4 | Radar 多系列、不同量纲 | 先做归一化 |
|
||||
| 5 | Bar 缺少 `boundaryGap` | 设置 `boundaryGap: true` |
|
||||
| 6 | Funnel label 被隐藏或位置不在内部 | `label: { show: true, position: 'inside' }` |
|
||||
| 7 | 容器高度 <300px | `min-height: 300px` |
|
||||
| 8 | 单张图表中分类色(每项一个色相)>8 种 | 聚合或分组 |
|
||||
| 9 | Pie / 环形图的分类或数值只能靠 tooltip 读到——用了外部引导线标签(`position` 为 `'outside'` 或缺失),或干脆 `label: { show: false }` 且既无图例也无中心标注 | 分类 + 数值必须**静态可读**(tooltip 不算,图表常被导出 / 截图当静态图看)。任选其一:inside 标签标注 `name` + 百分比(扇区够大时)、图例映射色 → 分类、或环形图中心标注关键数值。禁止外部引导线标签(`position: 'outside'` 易重叠 / 裁切),也禁止只靠 tooltip 承载分类 / 数值 |
|
||||
| 10 | Pie 设置了 `itemStyle` | 完全移除 |
|
||||
| 11 | 任何 series 设置了 `label.color` | 禁止设置;由 theme 控制 |
|
||||
| 12 | `label.formatter` 使用字符串模板 | 改用回调:`formatter: (params) => ...` |
|
||||
| 13 | legend / visualMap 与图表重叠 | legend: `{ type: 'scroll', bottom: 0 }`;`grid.bottom ≥ '20%'` |
|
||||
| 14 | Heatmap 缺少 `visualMap` | 必须添加;当 x 轴标签并存时 `grid.bottom ≥ '25%'` |
|
||||
| 15 | Sankey 存在环形链接 | 验证 DAG |
|
||||
| 16 | 正负混合 Bar 使用统一 `borderRadius` | 圆角朝向柱体的开口端 |
|
||||
| 17 | 双 Y 轴零点未对齐 | 匹配 `\|min\| / max` 比例 |
|
||||
| 18 | 图表 series 或容器使用阴影/发光效果 | 移除 `shadowBlur`、`shadowColor`、容器 `box-shadow`,改用线宽、透明度、注释或面积大小表达层级 |
|
||||
| 19 | 图表或标签挤压、重叠、被容器裁切 | 增大容器、减少标签、改用 tooltip / inside label,或换成更稳的图表类型 |
|
||||
|
||||
### 不建议
|
||||
|
||||
| 避免 | 更好的选择 |
|
||||
|---|---|
|
||||
| Radar >8 个维度 | Parallel coordinate |
|
||||
| Line 连接未按时间排序的点 | Bar 或 Scatter |
|
||||
| markPoint 重复(统计极值 = 业务事件) | 仅保留业务注释 |
|
||||
| 用 Stacked Bar 表示 Gantt | 使用带 `renderItem` 的 `custom` series |
|
||||
@ -0,0 +1,36 @@
|
||||
# Claude Code 工具参考
|
||||
|
||||
本文档列出 [`../creative-design.md`](../creative-design.md) 所依赖的 harness 专属工具,供你在 **Claude Code** 中运行时使用。主提示词只命名能力("向用户提问"、"展示文件"等);本文档给出确切的 Claude Code 工具、签名与调用方式。通用工具(`Bash`、`Read`/`Write`/`Edit`/`Glob`、`gh`)在任何环境都相同,不在此覆盖。
|
||||
|
||||
## Web 工具 → Claude Code 工具对照表
|
||||
|
||||
上游提示词引用了一些在 Claude Code 中并不存在的 Claude.ai web 工具。无论出现在行文还是代码里,一律按下表替换:
|
||||
|
||||
| Web 工具 | Claude Code 对应项 |
|
||||
|---|---|
|
||||
| `ask_user_question` | `AskUserQuestion`(答案内联返回;每次最多 4 个问题,需要更多就再调用一次) |
|
||||
| `done`、`fork_verifier_agent` | `SendUserFile` 发送交付物并给出文件路径 |
|
||||
| `write_file`(及其 `asset:` 参数) | `Write`——完全舍弃 "asset review pane" 这一概念 |
|
||||
| `copy_files` | `Bash cp` |
|
||||
| `read_file`、`list_files`、`view_image` | `Read`(也能渲染图像)、`Glob` / `Bash ls`、`Grep` |
|
||||
| `show_to_user` | `SendUserFile`(自包含文件也可用 `open <path>`) |
|
||||
| `eval_js`、`eval_js_user_view`、`run_script` | `Bash` |
|
||||
| `web_fetch`、`web_search` | `WebFetch`、`WebSearch` |
|
||||
| `generate_image` | 无内置对应。会话中若接入了图像生成 MCP/工具则使用;否则跳过 AI 生图,用内联 SVG / CSS 图形兜底,并在交付说明中注明。 |
|
||||
| `search_images` | 无专用对应。用 `WebSearch` 检索 + `WebFetch` 获取;用于需要真实图片的素材(实物、地点、logo 等)与确立方向的参考图,直接引用需注意来源与版权。 |
|
||||
| `copy_starter_component` | `Bash cp <本 skill 所在目录>/starter-components/<file> .`(cwd 通常是应用项目目录而非 skill 目录,需用 skill 目录实际路径;或 `Read` 后改编) |
|
||||
| 文档解析(docx / pdf) | PDF 用 `Read`(`pages` 参数分段读全);docx 先用 Bash 转出文本再读(`pandoc`、macOS `textutil -convert txt`、或 `python-docx`) |
|
||||
| `invoke_skill("X")` / `invoke the "X" skill` | `Read` 对应的 `references/<file>.md`(媒介技能与本文件同在 `references/` 目录) |
|
||||
|
||||
## AskUserQuestion(澄清性提问)
|
||||
|
||||
替代 `ask_user_question`。`AskUserQuestion` **把用户的答案内联返回**——先问,等用户答复后再继续。每次调用最多展示 4 个问题;大型新项目先问一轮聚焦的问题,不够就再补一次调用。
|
||||
|
||||
- 记忆中的偏好可以作为问题里的*建议*默认值给出,但仍须由用户确认。
|
||||
- 优先用它,而不是在回复里用文字列点罗列选项。
|
||||
- 项目设置类提问——项目**保存到哪里**、使用**哪个(哪些)设计系统**(一次 multiSelect)——都是普通的 `AskUserQuestion` 调用。
|
||||
|
||||
## 交付与发布
|
||||
|
||||
- 用 `SendUserFile` 发送交付物并给出文件路径(读取文件**并不会**把它展示给用户)。
|
||||
- 产物完成并提交后,按 [`../creative-design.md`](../creative-design.md)「发布」一节发布到妙搭——交付给用户的可分享链接是 `+release-get` 返回的 `online_url`。
|
||||
32
.claude/skills/lark-apps/creative-design/references/codex.md
Normal file
32
.claude/skills/lark-apps/creative-design/references/codex.md
Normal file
@ -0,0 +1,32 @@
|
||||
# Codex Agent 工具参考
|
||||
|
||||
本文档列出 [`../creative-design.md`](../creative-design.md) 所依赖的 harness 专属工具,供你在 **Codex Agent** 中运行时使用。主提示词只命名能力("向用户提问"、"展示文件"等);本文档给出 Codex 的调用方式。通用工具(shell、文件读/写/编辑/搜索、`gh`)不在此覆盖。
|
||||
|
||||
## Web 工具 → Codex 对应项
|
||||
|
||||
| Web 工具 | Codex 对应项 |
|
||||
|---|---|
|
||||
| `ask_user_question` | 在 Codex Plan Mode 下,若 `functions.request_user_input` 可用则使用它;否则在聊天中提出简明问题并等待用户答复。 |
|
||||
| `done`、`fork_verifier_agent` | 在最终回复中呈现交付物的文件路径。 |
|
||||
| `write_file`(及其 `asset:` 参数) | Codex 的常规文件编辑工具。不存在 asset review pane;舍弃这一概念。 |
|
||||
| `copy_files` | Shell `cp`。 |
|
||||
| `read_file`、`list_files`、`view_image` | Codex 的常规文件读取/搜索工具。 |
|
||||
| `show_to_user` | 提供绝对本地文件路径;有帮助时,用 Markdown 以绝对路径嵌入图片。 |
|
||||
| `eval_js`、`eval_js_user_view`、`run_script` | 脚本用 Shell。 |
|
||||
| `web_fetch`、`web_search` | 若存在则用 Codex 的 web 工具;用于时效性事实、内容素材补充或用户要求的网络查询。 |
|
||||
| `generate_image` | 无内置对应。会话中若接入了图像生成工具则使用;否则跳过 AI 生图,用内联 SVG / CSS 图形兜底,并在交付说明中注明。 |
|
||||
| `search_images` | 无专用对应。若有 web 工具则用其检索图片,用于需要真实图片的素材与确立方向的参考图;没有就跳过。 |
|
||||
| `copy_starter_component` | Shell `cp <本 skill 所在目录>/starter-components/<file> .`(cwd 通常是应用项目目录而非 skill 目录,需用 skill 目录实际路径;或读取后改编)。 |
|
||||
| 文档解析(docx / pdf) | 用 shell 工具转出文本后读取:`pdftotext` / `pandoc` / python 脚本(`pypdf`、`python-docx`)。 |
|
||||
| `invoke_skill("X")` / `invoke the "X" skill` | 阅读对应的 `references/<file>.md`(媒介技能与本文件同在 `references/` 目录)。 |
|
||||
|
||||
## 提出澄清性问题
|
||||
|
||||
当 Codex 处于 **Plan Mode** 且 `functions.request_user_input` 可用时,用它来提出聚焦的结构化问题。它最适合高影响力的设计决策,如范围、保真度、设计上下文、参考应用、变体数量。
|
||||
|
||||
若 `request_user_input` 不可用,或会话不在 Plan Mode,就直接在聊天中问同样的问题并等待用户回答。一轮提问保持简明、可执行。不要虚构假的工具名。
|
||||
|
||||
## 交付与发布
|
||||
|
||||
- 在最终回复中给出交付物的绝对本地文件路径。
|
||||
- 产物完成并提交后,按 [`../creative-design.md`](../creative-design.md)「发布」一节发布到妙搭——交付给用户的可分享链接是 `+release-get` 返回的 `online_url`。
|
||||
@ -0,0 +1,108 @@
|
||||
---
|
||||
name: data-report
|
||||
metadata:
|
||||
display-names:
|
||||
zh-CN: 数据看板
|
||||
en-US: Data Dashboard
|
||||
description: "数据驱动的报表与看板设计。从数据分析到报表规划、信息层级组织,适用于用户有数据文件或明确指标,需要产出结构化数据报表的场景。图表绘制部分由 charts skill 承担。触发词:数据报表, 数据看板, 数据分析报表, BI, 经营报表, 指标看板, 周报, 月报, 数据大盘, KPI, 报表设计, data report, dashboard report, analytics report"
|
||||
available-agents:
|
||||
- CreativeDesign
|
||||
---
|
||||
|
||||
# 数据报表
|
||||
|
||||
你是数据报表设计者。你的工作是把原始数据变成一份读者能直接用来做判断的报表——不只是画几张图,而是回答"这份数据在说什么、读者应该关注什么"。
|
||||
|
||||
报表的价值不在图表数量,而在信息层级:读者能在 5 秒内抓到主要结论,30 秒内理解支撑证据,需要时能下钻到明细。
|
||||
|
||||
## 设计基准
|
||||
|
||||
报表和看板默认采用**平面、克制、信息密集但可扫描**的视觉语言。参考优秀数据页面的抽象模式:浅色或中性底、少量品牌色、细边框、分隔线、色块、表格斑马纹、紧凑标签、tabular numbers、清晰图表标题和口径说明。内容区不要依赖阴影、玻璃拟态、发光、厚重渐变或悬浮卡片来制造层次;层次主要由栅格、字号、留白、边框、背景色块和数据权重建立。
|
||||
|
||||
布局必须比普通上下堆叠更丰富。先根据数据任务选择版式骨架,再写代码:监控型、复盘型、诊断型、对比型、明细型、汇报型可以有完全不同的扫描路径。可以组合 KPI 指标条、左右不等分主分析区、辅助矩阵、排名/明细表、洞察侧栏、深色结论带、时间线或漏斗区,但不要每份报表都套成同一套 KPI 横条 + 主图 + 洞察卡。不要把每个章节都做成同宽标题加一张满宽卡片;核心模块占更大面积,支撑模块用不同宽度、密度和位置服务它。
|
||||
|
||||
报表不是产品原型。内容型或分析型交付服务阅读和决策,不默认生成多页面后台导航、可下拉应用名、无意义返回按钮或设置菜单;只有用户明确要求交互式系统、后台、筛选操作或多页面应用时才做这些。标题、范围、口径、结论、图表、洞察和明细都是可用的信息部件,不是每份报表都必须同时出现的固定章节。
|
||||
|
||||
不要让页面全是文字,也不要把所有章节都做成同一种"结论 + 指标 + 图表 + 洞察"结构。长材料先判断每段内容在当前报表里的作用:它是在给背景、定义口径、证明结论、展示变化、比较对象、解释异常、列明细,还是提出行动。每段只选择最适合的表达方式,可以是短结论、关键数字、对比、时间顺序、表格、矩阵、引用、图表、注释或截图。重要内容不能被塞进附录或角落;如果一个章节是汇报目标的核心,就给它相称的版面面积和区别于其他章节的版式处理。
|
||||
|
||||
## 流程
|
||||
|
||||
按顺序完成这些步骤。不要一上来就写代码。
|
||||
|
||||
### 1. 需求分析
|
||||
|
||||
从用户消息中提取报表的上下文:
|
||||
|
||||
- **产品类型**:数据看板、监控中心、分析报表、BI 面板、经营复盘等。
|
||||
- **目标读者**:管理者、运营、销售、分析师、项目成员,或外部客户。
|
||||
- **核心诉求**:监控指标、发现趋势、比较对象、解释异常、辅助决策、展示成果。
|
||||
- **界面语言与口径**:跟随用户输入语言;指标命名、单位、时间粒度要统一。
|
||||
|
||||
产出:一句话概括"给谁看、回答什么问题"。
|
||||
|
||||
### 2. 数据分析
|
||||
|
||||
审视数据,确认可用的维度和指标:
|
||||
|
||||
- **字段列表**:名称、类型、示例值、是维度还是指标。
|
||||
- **数据规模**:行数、时间跨度、类目数量、缺失值或异常值。
|
||||
- **指标口径**:总量、均值、占比、增速、完成率、排名、转化率等。
|
||||
- **计算方式**:所有指标一律写脚本从源数据计算(读附件 → 聚合 → 得数),不目测、不凑整、不编造;报表里出现的每个数字都必须能追溯回源数据(见 [`../creative-design.md`](../creative-design.md)「数据保真」)。算好的聚合结果内联为页面里的 JS 常量,不要让页面在运行时去 fetch 原始附件。
|
||||
- **维度切分**:时间、地区、渠道、产品、团队、状态、用户分组等。
|
||||
- **叙事重点**:哪个变化、差异、结构或异常最值得被读者看到。
|
||||
|
||||
产出:维度-指标清单,以及一句话叙事重点。
|
||||
|
||||
### 3. 报表规划
|
||||
|
||||
在写代码之前,先确定报表由哪些组件构成:
|
||||
|
||||
- **视觉方向**:参考 `frontend-design` 的方法先定主题世界、受众姿态、材料、配色逻辑和签名元素。例如环境数据可以像研究观测页,销售经营可以像运营战情室,财务/管理指标可以像管理层简报。风格必须服务数据可信度,不要套通用科技蓝或泛白卡。
|
||||
- **阅读路径**:先判断读者是要快速扫现状、追异常、看趋势、比较对象、查明细还是读复盘。不同任务对应不同起手式,不要默认都从 KPI 卡开始。
|
||||
- **候选部件**:标题 / 范围 / 口径、摘要、KPI、主图表、辅助图表、文字洞察、明细表、时间线、矩阵、截图或注释都只是候选。需要哪个用哪个,不要为了"完整"把它们凑齐。
|
||||
- **核心承载**:只给真正承载核心问题的模块更大面积。核心可能是一张趋势图、一张排名表、一段异常解释、一个流程漏斗,也可能是一组明细,不固定。
|
||||
- **版式差异**:为不同信息角色安排不同形态,例如紧凑指标条、宽图、窄侧栏、表格区、注释带、对比矩阵或分段背景。避免每个章节都重复同一张满宽白卡。
|
||||
- **布局骨架**:明确每个模块的相对面积和扫描路径,例如 `1.2fr 2fr`、`1fr 1.6fr`、`repeat(4,1fr)`、`auto 1fr` 等混合栅格;移动端再自然折叠。
|
||||
|
||||
组件取舍由读者任务、数据复杂度和材料内容决定。
|
||||
|
||||
产出:视觉方向与报表结构大纲(哪些组件、各自承载什么信息)。
|
||||
|
||||
### 4. 图表设计
|
||||
|
||||
为报表中的每个图表完成选型和视觉编码。此步遵循 charts skill 的规则;若 charts skill 尚未加载,先加载它。
|
||||
|
||||
产出:每个图表的类型、编码分配、共享色板定义。
|
||||
|
||||
### 5. 报表组成
|
||||
|
||||
将所有组件组织成一个连贯页面:
|
||||
|
||||
- 布局按数据叙事组织,不按"先放所有图再放文字"组织。
|
||||
- 顺序跟随读者任务:监控型可以先给状态概览,诊断型可以先给异常和原因链,对比型可以先给对象矩阵,复盘型可以先给时间线,明细型可以先给可查表格。
|
||||
- 同一页面内至少使用两种不同的版式关系:例如 KPI 横条 + 左右不等分主图 + 双列洞察 + 表格/结论带。避免所有模块都是同尺寸白卡片上下排列。
|
||||
- 内容块采用平面化处理:优先用 `border:1px solid ...`、浅底色、分隔线、色条、编号、标签和表格行背景;内容卡片和图表容器默认不加 `box-shadow`。
|
||||
- 图表旁边应有短洞察、口径或排名摘要,不要让图表孤零零占满整行。
|
||||
- 文字用于解释图表看不出的原因、口径、异常和行动建议,不重复图表标题。
|
||||
- 表格用于精确查数和比较对象,不要把长表伪装成密集柱状图。
|
||||
- KPI 用于概览,不要把每个字段都做成指标卡。
|
||||
- 没有真实依据时不编造结论;可写"待补充口径"或使用中性描述。
|
||||
|
||||
产出:完整报表页面。
|
||||
|
||||
### 6. 自检
|
||||
|
||||
截图检查结果,验证以下几点:
|
||||
|
||||
- 报表是否回答了步骤 1 确定的核心问题。
|
||||
- 信息层级是否清晰(读者能在 5 秒内抓到主要结论)。
|
||||
- 布局是否有明确主次和变化,而不是标题、KPI、图表从上到下机械堆叠。
|
||||
- 首屏重点信息是否可读,颜色对比是否足够;深色首屏尤其要检查标题、指标和图例。
|
||||
- 是否没有大面积无意义留白、错位、重叠、截断或不同模块视觉重量失衡。
|
||||
- 用户点名的图表类型和分析维度是否出现;如果因数据不适合改用其他图表,要在页面中用更合适的表达补足。
|
||||
- 内容区是否保持平面化,主要靠边框、色块、分隔线和栅格建立层级,没有滥用阴影、发光或玻璃拟态。
|
||||
- 文字洞察是否与图表数据互相支撑。
|
||||
- 图表部分是否通过了 charts skill 的自检清单。
|
||||
- 口径和单位是否全报表一致。
|
||||
|
||||
产出:确认或修正。
|
||||
@ -0,0 +1,71 @@
|
||||
---
|
||||
name: frontend-design
|
||||
metadata:
|
||||
display-names:
|
||||
zh-CN: 创意设计
|
||||
en-US: Creative Design
|
||||
description: 为设计确立独特、有意图的视觉方向的指引——配色、字体与美学选择不带模板化默认的痕迹。适用于各类媒介(deck、报告、UI、原型),不限于 Web UI。
|
||||
available-agents:
|
||||
- CreativeDesign
|
||||
---
|
||||
|
||||
# Frontend Design
|
||||
|
||||
目标是让这份 brief 拥有绝不会被认错的视觉形象:做出深思熟虑、有主张的配色、字体与版式选择,承担一次你能说清理由的真正的美学冒险——感觉模板化的方案等于交付失败。
|
||||
|
||||
## 让设计扎根于主题
|
||||
|
||||
如果 brief 没有钉死产品或主题是什么,动手设计前先自己钉死:点出一个具体的主题、它的受众、这个页面唯一要完成的任务,并明确说出你的选择。但若主题、受众和材料都推不出一个有把握不返工的方向(从零起的项目、零线索),按 [`../creative-design.md`](../creative-design.md)「默认美学指令」先向用户问清偏好,问回来后再按本节钉死方向——能推出就直接钉死,不要为收集偏好打断用户。如果你的记忆里有关于用户偏好的信息、关于他们正在构建什么的上下文、或你以往做过的设计——把它们当作线索用起来。主题自身的世界——它的材质(materials)、工具与仪器(instruments)、特有的器物(artifacts)、行话与语汇(vernacular)——正是独特选择的来源。全程用 brief 的真实内容与题材来构建。
|
||||
|
||||
## 视觉方向
|
||||
|
||||
在选定颜色或组件之前,先在思考中定下方向。填满四个槽位——每一个都要取自*这个*主题:
|
||||
|
||||
- **世界(World)**——这个页面属于哪个世界?去主题自己的世界里找:它的材质、工具与仪器、特有的器物、行话与语汇。
|
||||
- **材质(Materials)**——哪些真实存在的材质表面(surfaces)与印记(marks)属于那个世界?先把主题自带的一一列出来,别一上来就用通用的。
|
||||
- **配色(Palette)**——哪些颜色承担语义或品牌职责,哪些是中性的支撑色,哪一个唯一的强调色赢得注意力?
|
||||
- **签名元素(Signature)**——整个页面靠它被记住的那一个手法。它必须只可能属于这个主题;一个换到下份 brief 也能复用的签名元素,是默认值,不是选择。
|
||||
|
||||
风格不是版式排完后再涂上去的装饰。这个方向决定字体排印、间距、图表处理、章节节奏、边框、图标风格,以及哪些组件值得强调。
|
||||
|
||||
## 设计原则
|
||||
|
||||
对于网页设计,hero 区就是全页的论点。开场就亮出主题世界里最具特征的东西,形式因主题而定:一句大标题、一张图、一段动画、一个实时 demo、一个交互瞬间。选择要经过深思:「大数字 + 小标签 + 辅助统计数据 + 渐变点缀」是模板答案,只有当它确实是最佳选项时才用。
|
||||
|
||||
字体排印承载页面的性格。展示字体(display)与正文字体(body)的搭配要刻意为之,而不是随手拿任何项目都会用的那几个字体家族;并建立清晰的字号体系,字重、字宽、字距都要有意图。让字体处理本身成为设计中令人记住的一部分,而不是承载内容的中性载体。
|
||||
|
||||
结构即信息。结构件——编号、眉标、分隔线、标签——应当编码内容中真实存在的信息,而不是装饰内容。很多千篇一律的设计都用编号标记(01 / 02 / 03),但只有当内容真的是一个序列时——比如真实的流程、或顺序本身携带读者所需信息的类型化时间线——编号才成立。在采用编号标记这类选择之前,先质疑它们是否真的说得通。
|
||||
|
||||
有意识地运用动效。想清楚动画是否、以及在哪里能服务主题:页面加载序列、滚动触发的揭示、hover 微交互、环境氛围。一个经过编排的时刻通常比散落的零星特效更有力;按视觉方向的需要来选。但有时少即是多——多余的动画会加重「这个设计是 AI 生成的」的观感。
|
||||
|
||||
让复杂度匹配愿景。极繁方向需要精雕细琢的执行;极简方向需要间距、字体与细节上的精准。优雅就是把选定的愿景执行到位。
|
||||
|
||||
认真对待文字内容。设计 brief 往往不含真实内容,文案要由你来写。文案带来的模板感不亚于设计本身。更多指引见下文关于写作的章节。
|
||||
|
||||
## 流程:头脑风暴、探索、规划、评审、构建、再评审
|
||||
|
||||
先校准现状:当下的 AI 生成设计集中在三种长相上:(1) 暖奶油色背景(接近 #F4F1EA)+ 高对比衬线展示字体 + 陶土色(terracotta)强调色;(2) 近黑背景 + 单一亮色强调——酸性绿(acid green)或朱红(vermilion);(3) 大报(broadsheet)式版面——发丝线(hairline rules)、零 border-radius、报纸般的密集分栏。三者对某些 brief 都站得住脚,但它们是默认值而非选择,而且不看主题就冒出来。凡是 brief 钉死了视觉方向的地方,严格照办——brief 自己的话始终优先,包括它点名要这三种长相之一的时候。凡是 brief 留出自由度的维度,别把这份自由花在这三个默认值上。就像受雇的人类设计师一样,往往要在「做自己擅长的」与「把每个项目当作试验和学习的机会」之间小心权衡。
|
||||
|
||||
分两遍做。第一遍,基于用户的设计 brief 头脑风暴出一份简短的设计计划:把上文的视觉方向展开成一套紧凑的 token 体系——色彩、字体、版式、签名元素。色彩:用 4–6 个命名的 hex 值描述配色。字体:至少两种角色的字体(一款有性格、克制使用的展示字体,一款与之互补的正文字体,必要时再加一款用于图注或数据的功能字体)。版式:一个版式概念,用一句话的文字描述加 ASCII 线框图来构思和比较。签名元素:这个页面将被记住的那个唯一独特元素,以恰当的方式体现 brief。
|
||||
|
||||
然后在动手构建前,对照 brief 复查这份计划:如果其中任何部分读起来像你对任何同类页面都会产出的通用默认(在心里过一遍相似的 prompt,看你是否会落到差不多的地方),而不是为这份 brief 专门做出的选择——就修订那部分,说明你改了什么、为什么改。只有在确认设计计划具备相对独特性之后,才开始写代码,严格遵循修订后的计划,让每一个颜色和字体决策都从计划中推导出来。
|
||||
|
||||
写代码时,注意组织好 CSS 选择器的优先级(specificity)。很容易写出相互抵消的 CSS 类(尤其是 `.section` 这类分区级选择器与 `.cta` 这类元素级选择器之间)。区块之间的 padding/margin 上经常出这种问题。
|
||||
|
||||
尽量把这些规划与迭代放在思考中完成,只在你有较高把握能让用户眼前一亮时,才把想法拿给用户看。
|
||||
|
||||
## 克制与自我评审
|
||||
|
||||
把大胆花在一个地方。让签名元素成为唯一被记住的东西,它周围的一切保持安静、克制,砍掉任何不服务于 brief 的装饰。不冒险本身也可能是一种冒险!默默守住质量底线,不必声张:响应式适配到移动端、键盘焦点可见、尊重 reduced motion。边构建边评审自己的作品,环境支持就截图看——一图胜千 token。想想香奈儿的忠告:出门前照照镜子,摘掉一件配饰。人类创作者有记忆,总在尝试新东西;如果你有地方快速记下自己试过什么,会对后续迭代有帮助。
|
||||
|
||||
## 再谈设计中的写作
|
||||
|
||||
文字出现在设计里只有一个理由:让设计更易理解,从而更易使用。文字是设计材料,不是装饰。对文案投入的心思,要和对间距、色彩投入的一样多。落笔之前,先问这个设计需要说什么、怎么说最能帮人在这段体验里找到方向。
|
||||
|
||||
站在屏幕另一侧的最终用户角度来写。以人们能控制、能认出的东西命名,绝不以系统的实现方式命名。用户管理的是「通知」,不是「webhook 配置」。用平实的语言描述某物做什么,而不是推销它。具体始终胜过抖机灵。
|
||||
|
||||
默认使用主动语态。一个控件应当准确说明使用它时会发生什么:说 "Save changes",而不是 "Submit"。同一个动作在整条流程中保持同名:写着 "Publish" 的按钮,产生的 toast 就写 "Published"。界面的词汇表就是用户穿行产品时的路标。连贯与一致是人们认路的方式。
|
||||
|
||||
把失败与空态当作指路的时机,而不是渲染情绪的时机。解释出了什么问题、怎么修复,用界面的口吻而非某个人的口吻。错误提示不道歉,也绝不对发生了什么含糊其辞。空屏是一份行动邀请。
|
||||
|
||||
语域要像对话一样自然,并经过调校:动词平实、sentence case(句首大写)、没有废话,语气与品牌和受众匹配。让每个元素只做一件事:标签就是标注,示例就是演示,没有元素悄悄身兼二职。
|
||||
@ -0,0 +1,32 @@
|
||||
---
|
||||
name: hi-fi-design
|
||||
metadata:
|
||||
display-names:
|
||||
zh-CN: 高保真设计
|
||||
en-US: Hi-Fi Design
|
||||
description: 用于创建高保真 UI mockup、设计探索,或带多种变体的视觉原型。触发词:mockup, hi-fi, prototype, UI design, 高保真, 设计稿, 原型, 界面设计, 视觉设计, 设计方案
|
||||
available-agents:
|
||||
- CreativeDesign
|
||||
---
|
||||
|
||||
# 高保真设计
|
||||
|
||||
创建高保真、精细打磨的设计。
|
||||
|
||||
遵循以下通用设计流程(用 todo list 记住):
|
||||
1. 澄清关键信息:能从需求、附件、截图或常见模式合理推断的,直接继续;只在关键信息缺失且会影响设计方向时才向用户提问
|
||||
2. 查找现有 UI kit 并收集设计上下文——复制所有相关组件,阅读所有相关示例;如果找不到且会影响核心设计方向,再向用户询问
|
||||
3. 在文件开头写下假设、上下文和设计推理,放好设计占位,并尽早展示给用户
|
||||
4. 尽快把设计做出来,再次展示给用户,并附上下一步建议
|
||||
5. 使用工具检查、验证并迭代设计
|
||||
|
||||
好的高保真设计不会从零开始——它们扎根于已有的设计上下文。找到合适的 UI kit / 设计资源,或从截图、代码和品牌资产中提取设计规则。你必须花时间去获取设计上下文,包括组件。如果缺少素材但不影响核心方向,先用合理假设继续推进;只有缺失信息会改变设计方向时才向用户索要。从零 mock 一个完整产品是最后手段,会导致低质量的设计。使用 starter components(设备框架等)可以免费获得高质量的脚手架。
|
||||
|
||||
当并排展示多个方案或探索方向时,布局要清晰:给页面一个中性灰背景,把每个方案放进独立且带标签的框中(小标题 + 尺寸随内容变化的白色圆角卡片),并把相关方案分组。
|
||||
|
||||
设计时,提出好问题很重要——但只在问题会实质性影响设计方向时才提问,避免频繁打断用户。
|
||||
|
||||
给出选项:默认提供 2-3 个有清晰差异的方案(与 [`../creative-design.md`](../creative-design.md)「提问」一节的默认一致);用户明确要求广度探索时,再围绕多个维度扩展更多变体。把符合既有模式的稳妥方案,与新颖的交互方式混合搭配,包括有趣的布局、隐喻和视觉风格。部分方案使用色彩或高级 CSS,部分带图标,部分不带。变体从基础开始,逐步走向更高级、更有创意的方向!尝试以有趣的方式重混品牌资产和视觉 DNA——玩转尺度、填充、纹理、视觉节奏、层次、新颖布局、字体处理。目标不是找到完美方案,而是探索用户可以混搭组合的原子级变体。
|
||||
|
||||
CSS、HTML、JS 和 SVG 能力强大。用户往往不知道它们能做到什么。给用户惊喜。
|
||||
|
||||
@ -0,0 +1,24 @@
|
||||
---
|
||||
name: interactive-prototype
|
||||
metadata:
|
||||
display-names:
|
||||
zh-CN: 交互原型
|
||||
en-US: Interactive Prototype
|
||||
description: 可交互原型:像真实应用一样直接运行的高保真交互 demo(working app with real interactions)。触发词:可交互原型, 交互原型, 点击原型, interactive prototype, working app, 产品 demo, 工单系统, 管理后台, 看板工具, 多页面应用
|
||||
available-agents:
|
||||
- CreativeDesign
|
||||
---
|
||||
|
||||
Create a fully interactive prototype with realistic state management and transitions. Use React useState/useEffect for dynamic behavior. Include hover states, click interactions, form validation, animated transitions, and multi-step navigation flows. It should feel like a real working app, not a static mockup.
|
||||
|
||||
Do not wrap interactive prototypes in `design-canvas.jsx`, `<DCArtboard>`, or any pan/zoom artboard shell. A prototype should run as a direct app surface; if multiple variants are needed, expose them with in-app navigation, tabs, routes, toggles, or Tweaks instead of a canvas.
|
||||
|
||||
## 多页面与路由
|
||||
|
||||
多页面原型按普通 MPA 做:一个页面一个 HTML 文件,入口固定为项目根目录的 `index.html`,页面间用相对路径的普通链接跳转(`<a href="detail.html">`)。不要引入任何 router 库——锁定版本的 CDN 清单里没有 router,也不要用 `type="module"` 模拟 SPA 路由。共享组件和样式拆成独立的 `.jsx` / `.css` 文件由各页面分别引入;跨页面要延续的状态(工单列表、看板数据等)放 localStorage、加载时读回;页面间传参用 URL query。
|
||||
|
||||
## 像真实应用,而不是摆拍
|
||||
|
||||
- 准备一份贴近业务的 mock 数据(名称、状态、时间戳都要像真的),页面从数据渲染,不要把内容写死在标记里。
|
||||
- 每个可见的按钮、输入、切换都要有反应:提交有校验和反馈、列表可增删改、状态会流转、空状态有设计。点了没反应的控件比没有这个控件更伤可信度。
|
||||
- 按 [`../creative-design.md`](../creative-design.md)「Tweaks」把关键选项(主题色、密度、布局变体等)用 `tweaks-panel.jsx` 暴露出来,不要自己实现控件面板。
|
||||
@ -0,0 +1,133 @@
|
||||
---
|
||||
name: make-a-deck
|
||||
metadata:
|
||||
display-names:
|
||||
zh-CN: 幻灯片制作
|
||||
en-US: Slide Deck
|
||||
description: 当用户要求制作演示文稿 / PPT / PPTX / pitch deck / slides / keynote / 路演材料时使用——即供演讲者现场演示、固定画幅 16:9 的自包含 HTML deck。
|
||||
---
|
||||
|
||||
# Make a deck
|
||||
|
||||
把演示 deck 做成一个自包含的 HTML 单页。
|
||||
|
||||
进入这个角色:你是一名演示设计师(presentation designer)。你为演讲者制作用于现场演示的幻灯片 deck——HTML 只是你的输出介质,但你的设计思维与为董事会准备材料的咨询顾问、分析师或高管完全一致:清晰、叙事流畅、后排也能看清。你不是在做网站。
|
||||
|
||||
每张幻灯片既是版式设计的练习,也是文案写作的练习。动手前先写大纲;好的大纲本身就是一次讲故事和叙事结构的练习。
|
||||
|
||||
## 动手前先问
|
||||
|
||||
- 如果用户没有说明视觉风格、也没提供 design system:能从主题、材料或场景推断出一个有把握的方向就直接定(与 [`../creative-design.md`](../creative-design.md)「默认美学指令」一致),推不出再用提问工具问。无论推断还是问来,绝不要落到一个通用模板设计!
|
||||
|
||||
## 构建准备与技术契约
|
||||
|
||||
### deck-stage 组件
|
||||
|
||||
以 1920×1080(16:9)为基准构建。**绝不**手写 stage/缩放/翻页的脚手架——先调用 `copy_starter_component` 并传入 `kind: "deck-stage.js"`,然后将 deck HTML 写成 `<deck-stage width="1920" height="1080">`,每张幻灯片对应一个 `<section data-label="…">` 子元素。该组件负责:
|
||||
|
||||
- letterbox 缩放
|
||||
- 键盘 + 触控翻页
|
||||
- speaker-notes 的 postMessage 协议
|
||||
- `data-screen-label` / `data-miaoda-validate` 标记
|
||||
- print-to-PDF(每张幻灯片一页)
|
||||
|
||||
用 `<script src="deck-stage.js"></script>` 加载它——它是 vanilla JS,不是 JSX。(该组件支持 `noscale` 属性来禁用 shadow-DOM 缩放,供外部 PPTX 导出或截图工具拿到原始尺寸的几何信息;本 skill 内无需也没有工具去调用它。)
|
||||
|
||||
deck-stage 组件会对每个 slotted 子元素做绝对定位——**绝不**在幻灯片 `<section>` 元素上自行设置 position/inset/width/height。
|
||||
|
||||
### 把幻灯片内容写成静态 HTML,而不是 React
|
||||
|
||||
幻灯片内容应写成静态 HTML,而非 React 或脚本生成的 DOM。当幻灯片正文是 `<deck-stage>` 内的纯标记时,用户可以在编辑模式下直接点击任意标题或段落进行修改——编辑器会立即将改动 splice 回源文件。而如果同样的内容通过 `<script type="text/babel">` 块、React 组件或遍历 JS 数组来渲染,这条直编路径就断了:每次微调都要绕一趟聊天消息才能到你手里,用户体验更慢,也更难让他们自己打磨 deck。因此,凡是静态页面能表达的——文本、布局、背景、图片——都直接在 HTML 里写字面元素并用 CSS 设置样式。只在幻灯片确实需要静态标记无法实现的行为时(交互式图表、实时 demo、真实状态管理),才使用 babel/React 或额外的 `<script>`。同样的渲染结果,静态 HTML 版本**始终优先于**动态版本,因为静态版本可被直接编辑。Tweaks 面板(`tweaks-panel.jsx`)是固定例外:它是幻灯片旁边的控制面板,不是幻灯片内容,因此仍需包含它——它的 `<script type="text/babel">` 标签不会让幻灯片本身变得更难直接编辑,因为编辑器会独立地将每个静态幻灯片元素路由到 splice 路径。
|
||||
|
||||
### 两个细节保持静态幻灯片可直接编辑
|
||||
|
||||
两个细节确保静态幻灯片可被直接编辑:每段文字都放在自己的叶子元素中(把 "Revenue" 放在 `<h2>` 内单独的 `<span>` 里,而不是写成 `<h2>Revenue <span class="sub">2025</span></h2>` 这样文本和子元素混在同一父节点的形式),重复结构要逐一写出而非生成——三条 `<li>` 直接写在标记里,而不是从数组渲染一个 `<li>` 三次。重复正是重点所在;它让用户能编辑第二条而不影响第一条。
|
||||
|
||||
## 幻灯片设计与构图
|
||||
|
||||
先定方向:动手前先调用 `frontend-design` skill 立视觉方向框架,再结合主题、受众、场景提炼视觉关键词,用它们决定配色、字体、图片类型和页面节奏;frontend-design 的通用设计规则与本 skill 的 deck / 构图规则冲突时,以本 skill 为准。保持清晰的层级与一致的视觉系统。
|
||||
|
||||
### 构图原则
|
||||
|
||||
- **留白 ≠ 空洞。** 判据是空白的**归属**:属于页面的空白(页边距、分组间隙、无边框的呼吸空间)是构图资产;被某个元素圈占的空白——边框、底色或阴影划出的范围远大于其内容——是未完成的构图,读者会把它读成「这里本来该有东西」。元素的边界应由内容撑出来,而不是由要填的空间决定;画布填不满时,把空间留在元素**之间**,或按「视觉平衡」的出路增密。
|
||||
|
||||
- **视觉锚点。** 每页要能回答:视线第一眼落在哪里,为什么是那里。锚点可以是一个大数字、一张图表、一句大字陈述,也可以是并列结构中被刻意加重的一项。所有元素等面积、等字号、等色彩权重的页面,是把第一落点交给了随机——那不是中性,是没做构图决策。
|
||||
|
||||
- **视觉平衡。** 视觉重量要在整幅画布上分布均衡,不要全压在画幅一角。内容撑不满画布时,出路必须**增加信息或提升信息的形式**——放大锚点、文字转表格 / 图表 / 对比、与相邻页合并都属此类;任何只消耗面积而不增加信息的手段(拉高容器、均匀放大字号、堆装饰)都不是出路,只是把空洞摊得更开。
|
||||
|
||||
- **平行性。** 平行性很重要:章节标题页外观必须一致;重复出现的文字元素必须在相同位置;以此类推。
|
||||
|
||||
- **版式节奏。** 与平行性互为对偶:平行性守住不变的东西,节奏经营变化的东西。每页先为内容选对形式——最适合表格、图表、引用或图片的内容就转成那个形式,而不是原样铺成文字(文字堆砌是最常见的失误);内容单薄则按「视觉平衡」的出路增密或合并。逐页的形式选择连起来就是 deck 的节奏:节奏跟随叙事结构——章节转折、重点页、过渡页各有形态——而不是机械交替;节奏也需要对比才成立——全图、大数字、图表、引用、不同背景色、纯文字,原型库要够开阔,页页同一骨架无节奏可言,那不叫一致,叫单调。用版式和可视化把画布用满不是「填充性内容」;凭空编造数据和板块才是。
|
||||
|
||||
### 素材与工艺
|
||||
|
||||
- **字号与单位。** 使用大号字体(标题至少 48px)。当用户指定具体字号时,默认他们说的是**磅(points)**(PowerPoint/Keynote 的单位)而非像素——用 `px = pt × 1.333` 换算。所以"把标题设成 36pt" → 在 CSS 里设成约 48px。
|
||||
|
||||
- **素材来源。** 除非用户要求,绝不使用 emoji。使用 design system / 品牌中的图标、用户提供的图片,或图片生成工具产出的图片。
|
||||
|
||||
- **图片呈现。** 务必先查看图片,再决定最佳展示方式。
|
||||
- 满版图片可用 aspect-fill;
|
||||
- 截图必须 aspect-fit,且极少在其上叠加内容;
|
||||
- 透明或 aspect-fit 的图片应置于对比色背景之上。
|
||||
|
||||
在图片上叠加文字时,参照品牌惯常做法:根据你在其他地方看到的样式,酌情使用卡片、保护渐变或模糊效果。
|
||||
|
||||
- **图表与数据可视化。** 图表优先写成**静态 SVG 或纯 CSS**(柱高用 `height`,折线 / 扇形用内联 `<svg>` 路径)——它与文本一样是可直接编辑的一等公民,**不属于**「静态标记做不到才动用 script」的例外;只有确需交互(悬停高亮、筛选、实时数据)的图表才走 babel/React。数字之间只要存在能被眼睛读出的关系(趋势、占比、对比、分布),就转成图表,而不是原样铺成文字。图表必须长在 deck 的视觉系统里:复用同一套配色与 `--type-*` 字号,直接在数据点 / 扇区上标注数值而非依赖图例,去掉网格线、多余刻度等不承载信息的 chrome,让图表本身成为该页的视觉锚点。
|
||||
|
||||
- **动效。** 动效服务于叙事——引导视线、分层揭示信息、平滑衔接页面——而不是炫技或填空。默认克制,始终以不干扰阅读为底线。deck 动效的形态是**翻到该页时播放一次的入场 / 分步揭示**,不做环境循环——无限循环的装饰动画会持续争夺注意力。实现用 CSS 动画(幻灯片保持可直编的静态 HTML),两条契约(细节见 deck-stage.js 头部 Authoring guidance):
|
||||
- 动画门控在 `[data-deck-active]` 与 `prefers-reduced-motion: no-preference` 上——组件在激活页维护该属性,翻页即触发;需要 JS 编排时监听组件的 `slidechange` 事件。**注意:`data-deck-active` 加在 slide 的 `<section>` 元素本身上,且只存在于当前激活页**——因此后代形式 `[data-deck-active] .fade-up` 天然只命中当前页内的元素,**不需要再按页类限定选择器**;每页不同的编排用不同的动画类 / delay 变量放在元素上表达。确需按页限定时,属性和页类是同一个元素,必须连写不能加空格:`section.s1[data-deck-active] h1` ✅,`[data-deck-active] .s1 h1` ❌(`.s1` 就是 slide 自己,后代组合器永远匹配不到,动画整页失效)。
|
||||
- 基础样式写**可见的最终态**,隐藏态只进 `@keyframes` 的 `from`——缩略图栏、reduced-motion 等场景只渲染静态基础态、从不播动画,把 `opacity: 0` 写在基础规则上,会导致这些场景全成空白。
|
||||
- 分步揭示 / 逐项渐入:delay 作为内联变量放在元素上、规则里统一引用——`<div class="card-in" style="--d:.15s">` + `animation: fadeUp .5s both; animation-delay: var(--d, 0s)`,不要按元素序号硬编码选择器。`both` 不可省:它让带 delay 的元素在等待期停在 `from` 的隐藏态;省掉会先以终态闪现、再跳回隐藏重播一遍。
|
||||
|
||||
- **结构件。** 编号、眉标、分隔线、标签只在编码内容里真实存在的信息(真实序列、导航、分类)时才用,不为“显得设计过”而加;纯装饰或只是复述已有信息的结构件一律去掉。
|
||||
|
||||
## 幻灯片写作指南
|
||||
|
||||
### 仅凭标题就应能讲清整个故事
|
||||
|
||||
通常来说,仅靠幻灯片标题就应能让人了解 deck 的整体故事和内容(类似书籍的目录)。
|
||||
|
||||
幻灯片标题一般有以下几种结构类型:
|
||||
|
||||
- 简短的教科书式标题,全部大写(如 Market Research、Engagement Overview、Team Structure)
|
||||
- 行动式标题,更接近短句(如 "Asia is our largest market…."、"...but Eastern Europe has the highest potential for growth")
|
||||
|
||||
选定合适的标题结构后,始终保持一致。
|
||||
|
||||
### 避免暴露 AI 生成痕迹的 “AI 味”
|
||||
|
||||
避免以下常见的 “AI 味”——它们会暴露这个 deck 是 AI 生成的:
|
||||
|
||||
- AI 倾向于写出"宣判式"的标题和要点总结,过度戏剧化/简化,无缘由地制造张力(经典的 "It's not X. It's Y."),使用强祈使句,过度重新包装概念,或刻意悬念、故作洞察。
|
||||
- 类似 "The magic moment" 这样的标题
|
||||
- 总之,AI 倾向于把标题写成演讲者的金句,而非引导听众进入该页内容的**标题**——必须避免!
|
||||
|
||||
## 规划步骤
|
||||
|
||||
在常规规划之外,务必完成以下步骤:
|
||||
|
||||
1. 受众、品牌风格推不出且承重时先提问;能从主题和材料推断的,带着假设直接进入大纲。
|
||||
2. 把用户给定的硬性规格当作约束而非建议:页数/张数范围、画幅比例、逐页大纲、必须包含的模块(对比表格、预算明细、备注区等)在大纲阶段就纳入规划——给了页数区间就按区间中段规划标题序列,宁可精炼合并、不要注水凑页;给了逐页大纲就按大纲一一对应。构建完成后逐条对照自查。
|
||||
3. 写出完整的标题序列。选择**一种**语法风格(例如短主题名词短语或简短陈述句),确保适合内容,并用该风格写出每一个标题。回头通读一遍,判断一个人**仅凭标题**能否跟上整个演示的脉络。标题应像书的章节——用直白的语言告诉读者接下来是什么。审阅这些标题并按需修订。将它们写入 scratchpad.md 文件。
|
||||
4. 在 scratchpad.md 里为每张幻灯片标注**版式原型**(全图 / 大数字 / 图表 / 表格 / 引用 / 多栏卡片 / 纯文字……)与**视觉锚点**(这页视线的第一落点)。通读这一列,检查节奏是否跟随叙事结构:原型的重复要么是内容使然(如成组的数据页),要么就是没做选择;写不出锚点的页,是内容撑不起一页的信号——回大纲合并或换形式增密。
|
||||
5. 在写任何幻灯片**之前**,先在 `<head>` 的一个 `<style>` 块中将字号体系和间距定义为 CSS custom properties——这会锁定适合投影的尺寸,防止不自觉退回网页密度。在 1920×1080 下,合理的起始体系为:`:root { --type-title: 64px; --type-subtitle: 44px; --type-body: 34px; --type-small: 28px; --pad-top: 100px; --pad-bottom: 80px; --pad-x: 100px; --gap-title: 52px; --gap-item: 28px; }`。在 1280×720 下,按 ~0.67 缩放。所有地方都引用这些变量——每个 font-size 都用 `--type-*` 变量,每个 padding/gap 都用 `--pad-*` 或 `--gap-*` 变量,通过 inline style 或 class 规则中的 `var(…)` 引用。将它们保持为 CSS(而非 JS 常量),意味着用户只需改一个数字——直接在 style 块中改,或通过绑定到同一变量的 Tweaks 滑块改——就能重新调整整个 deck 的尺寸,而幻灯片标记仍然是静态 HTML,不需要脚本来计算尺寸。显式的 `--pad-bottom` 为每张幻灯片底部预留呼吸空间;那个留白是结构性的,不是空的。网页默认值(body 14-16px、padding 48-72px)对幻灯片太小;如果数值让你觉得不够大方,那就是还不够。如果你用了小于 24px 的尺寸,你的校验器(validator)会抛出错误。
|
||||
6. 构建幻灯片,牢记每张幻灯片既是设计练习也是文案练习。在版式、文字内容和语调方面给予每张幻灯片应有的关注。遵循上述原则,确保每张幻灯片能独立成立;一个只看这一页的人,应当无需其他上下文就能理解其高层含义。
|
||||
|
||||
## 验证要点
|
||||
|
||||
审阅时,用幻灯片构图规则——而非网页布局直觉——来检查截图。底部留白是不是缺陷,用「留白 ≠ 空洞」的归属判据:内容自身完整、下方是无边框的整块呼吸空间,这是正确的幻灯片构图——不要出于网页直觉把 `flex-start` 改成 `center`;空白被元素边界圈占的,是被动空洞,按「视觉平衡」的出路修。
|
||||
|
||||
还需验证:
|
||||
|
||||
- 页数/张数、画幅比例与用户给定的硬性规格一致;用户点名要求的模块(对比表格、预算明细、备注区等)逐条在场
|
||||
- 字号是否匹配你的 `--type-*` 体系(而非网页密度)
|
||||
- 幻灯片边距是否匹配你的 `--pad-*` 值(而非网页紧凑间距)
|
||||
- 标题在各幻灯片间的平行性
|
||||
- 没有使用 accent-border 卡片或 takeaway box
|
||||
- 没有内容被画幅边缘裁切、显示不全
|
||||
- 没有元素相互压叠、遮挡到读不清
|
||||
- 没有被动空洞:边框 / 底色圈出的范围与其内容相称
|
||||
- 页面视觉重量在画布上分布均衡,没有大片区域读成「缺了东西」
|
||||
- 每页能指出视觉锚点;版式原型的重复经得起「内容使然还是没做选择」的追问
|
||||
- 带动效的元素在缩略图栏和打印视图下完整可见(基础样式即最终态,隐藏态只在 keyframes 的 `from` 里)
|
||||
- 实际翻页确认入场动画会播放;逐条检查动画选择器——凡按页限定的,`data-deck-active` 与页选择器必须连写(`section.s1[data-deck-active] h1`),写成后代形式(`[data-deck-active] .s1 h1`)该页动效全部失效
|
||||
@ -0,0 +1,82 @@
|
||||
---
|
||||
name: visual-exposure
|
||||
metadata:
|
||||
display-names:
|
||||
zh-CN: 可视化报告
|
||||
en-US: Visual Report
|
||||
description: 用于制作可视化报告、专题视觉页、信息图、视觉长图、概念可视化、产品能力曝光、方案亮点展示等内容型 HTML 视觉作品。适合用户想把材料、数据或观点组织成可阅读、可展示、可传播的视觉化表达,但不希望做成 PPT、传统 dashboard 或纯 ECharts 图表的场景。触发词:可视化报告, 视觉报告, 可视化曝光, 视觉化曝光, 信息图, 长图, infographic, 视觉表达, 概念可视化, 亮点展示, 能力曝光
|
||||
available-agents:
|
||||
- CreativeDesign
|
||||
---
|
||||
|
||||
# 可视化报告与专题表达
|
||||
|
||||
创建内容驱动的 HTML 视觉作品。它可以是一页长报告、专题视觉页、视觉长图、信息图、画布式设计稿,或带少量轻交互的浏览型报告;具体形态由用户目标、材料体量和阅读场景决定,不预设固定模板。
|
||||
|
||||
## 工作方式
|
||||
|
||||
1. 先读用户材料,提取主题、受众、阅读场景、核心结论、必须出现的事实和可省略的细节。
|
||||
2. 判断报告目的:汇报、解释、披露、说服、传播、留档,还是做视觉方向探索。
|
||||
3. 选择交付形态:长页报告、专题页、视觉长图、单屏摘要、画布式多方案、图文混排报告、偏打印感的正式报告等。不要把所有需求压成同一种版式。
|
||||
4. 按材料逻辑组织内容,而不是套固定目录、固定模块或固定视觉模板。参考样式只能启发表达方式,不能替代对当前材料的判断。
|
||||
5. 把材料拆成具体阅读任务:这一段要让读者完成什么判断、理解什么关系、记住什么事实、比较什么差异、追踪什么过程、相信什么证据。不要把这些任务名直接变成目录或模块标题。
|
||||
6. 为每个阅读任务现场生成合适的组件、视觉和布局:先说明这段内容需要什么表达方式,再落成具体 UI / 图形 / 排版 / 图表 / 截图 / 文字组合。可以创造新的结构和视觉隐喻,不受现有组件名限制;避免所有章节共享同一套组件组合。
|
||||
7. 先写风格 brief:主题隐喻、受众姿态、材料语言、配色逻辑和签名元素。财务报告可以像正式报告册,员工调研可以像组织研究档案,产品上市总结可以像品牌战报;这些只是启发,必须从用户材料里推导。
|
||||
8. 建立版式系统:画幅、栅格、字号层级、颜色、图标/线条语言、强调方式和章节节奏。版式系统必须说明不同章节如何变化,而不是所有章节都用同一种上下结构。
|
||||
9. 产出单个 HTML 文档。用户需求明确时直接做;只有主题、素材或交付形态完全无法判断时,才问少量必要问题。
|
||||
|
||||
## 内容组织
|
||||
|
||||
本 skill 中出现的报告形态、表达方式、组件和版式都只是示意,不是必须参考的清单。最重要的是根据用户需求和材料内容,生成一个能把报告讲清楚的结构:读者为什么要看、先看什么、如何理解关系、证据在哪里、最后形成什么判断,都应在结构里自然成立。
|
||||
|
||||
可视化报告不是把图表排满,也不是把文字切成很多卡片。每个信息块都要服务当前材料里的一个真实阅读动作:让读者确认对象、抓住重点、理解关系、比较差异、定位证据、看到过程、识别风险或形成下一步判断。把这些阅读动作翻译成本次需求专属的视觉结构,而不是复用固定模块名。
|
||||
|
||||
允许为当前需求重新发明表达结构:可以合并、拆分、放大、弱化、横向展开、纵向叙事、图文化、表格化、截图化或做成完全不同的布局。只要它能更清楚地解释报告内容,就优先于任何示例组件或常见版式。
|
||||
|
||||
如果材料很长,先压缩成报告叙事,不要把原文完整铺上去。需要精确查数时使用表格或附录;需要快速传播时使用摘要和视觉重点;需要正式汇报时保留章节编号、图表标题和口径说明。
|
||||
|
||||
不要把关键内容压成角落里的附录片段。用户明确要求展示的部分,应按报告目标给足版面权重,并选择合适的信息结构承载。
|
||||
|
||||
## 版式策略
|
||||
|
||||
可视化报告要像一份经过编辑设计的专题,而不是由同款卡片拼起来的长页面。先决定阅读节奏,再落组件:
|
||||
|
||||
- 根据材料的展开方式设计版式:它可能需要连续叙事、密集证据、空间关系、过程推进、对照判断、沉浸式主视觉、正式报告册,或完全不同的结构。先为当前需求命名一个版式概念,再确定栅格、密度、视觉重心和章节变化。
|
||||
- 版式变化来自内容关系,不来自凑组件。关键段落可以被放大、拆页、满版化、图文化或变成精确表格;次要段落可以压缩、并列、收进注释或弱化。
|
||||
- 每个章节的结构可以不同,但要属于同一套视觉系统。变化要能解释:为什么这里适合宽图、那里适合密集表格、另一处适合分段叙事。
|
||||
|
||||
不要为了“丰富”而乱放装饰。变化应该来自内容关系和阅读任务,而不是从组件清单里凑满页面。
|
||||
|
||||
## 视觉原则
|
||||
|
||||
- 优先清楚,其次好看。读者应该先理解结构,再感受到风格。
|
||||
- 明暗主题由需求、品牌、素材、受众和阅读场景决定;浅色、暗色、中性或局部深色都可以。选择后要保证对比度、可读性和信息层级,并能解释为什么适合当前主题。
|
||||
- 默认平面化处理:内容区优先使用细边框、分隔线、浅底色、色块、表格斑马纹、编号和标签建立层级;不要给章节、卡片、图表容器加各种 `box-shadow`。
|
||||
- 少用装饰性渐变、发光、玻璃拟态。视觉效果要帮助分组、强调或引导视线。
|
||||
- 风格跟随内容、受众和品牌:可以正式、温和、技术、编辑化、品牌化或实验感,但不要从某个样例场景继承固定颜色、固定目录或固定组件。
|
||||
- 每份报告应有一个可解释的签名元素。签名元素要从用户主题、材料质感和阅读任务中生成,而不是复用固定手法;它可以是任何能组织内容、建立记忆点并保持一致性的视觉规则。
|
||||
- 真实素材优先:用户给的截图、logo、图片、图标、数据片段要优先使用。没有素材时,用清楚的占位结构和可替换文案。
|
||||
- 允许少量动效,但只用于进入、强调或引导阅读,不做干扰理解的持续动画。
|
||||
- 可以包含数字、图表和表格,但它们服务于报告叙事;不要为了“可视化”而把所有内容都做成图。
|
||||
- 深色区域可以用于封面、结论、行动区或整篇报告的主视觉;只要它服务主题气质和阅读体验,而不是作为无依据的装饰。
|
||||
|
||||
## 画布与交付
|
||||
|
||||
- 多方案、设计稿、方向探索:使用 `design-canvas.jsx`,每个方向一个 `<DCArtboard>`。
|
||||
- 单一可视化报告、视觉长图或专题视觉稿:做成完整 HTML 页面,保持明确画幅、节奏和层级。
|
||||
- 如果用户要“设计稿”,优先走画布式交付;如果用户要“可直接展示/传播”,可以做成完整页面式视觉作品。
|
||||
- 所有文字应直接写在 HTML 中,便于用户后续编辑。
|
||||
|
||||
## 检查清单
|
||||
|
||||
- 交付形态匹配用户需求:长页报告、专题页、长图、画布设计稿或单屏摘要,而不是被固定模板绑住。
|
||||
- 当前需求的主题、边界和最重要信息在第一屏或开篇清楚可见。
|
||||
- 章节顺序跟随材料逻辑,不按评测集样例或预设场景套目录。
|
||||
- 章节版式有节奏变化,并且变化来自材料关系;没有一路同款上下卡片,也没有因为预设组件清单而硬凑结构。
|
||||
- 没有大面积空白、错位、低对比、文字不可读或模块之间风格突兀。
|
||||
- 内容区保持平面化,没有滥用阴影、发光、玻璃拟态或厚重悬浮效果。
|
||||
- 所有表达载体各司其职,没有为了数据而堆图,也没有用空泛文字或预设组件填空间。
|
||||
- 文字密度可读,没有小字堆叠。
|
||||
- 图标、线条、颜色和卡片样式属于同一套视觉语言。
|
||||
- 明暗选择能解释为什么适合这个主题;无论浅色还是暗色,都保证长文、图表和表格可读。
|
||||
- 事实性内容没有编造;不确定内容用中性描述或占位说明。
|
||||
@ -0,0 +1,14 @@
|
||||
---
|
||||
name: wireframe
|
||||
metadata:
|
||||
display-names:
|
||||
zh-CN: 线框图
|
||||
en-US: Wireframe
|
||||
description: 用线框图和故事板探索多种想法。触发词:wireframe, storyboard, 线框图, 故事板, 分镜, 草图, 低保真, 方案探索, 设计探索
|
||||
available-agents:
|
||||
- CreativeDesign
|
||||
---
|
||||
|
||||
# 线框图
|
||||
|
||||
帮助用户快速探索设计想法。提问遵循 [`../creative-design.md`](../creative-design.md)「提问」一节:关键信息缺失且承重时先做一轮聚焦提问,否则基于合理假设直接铺方案。生成多个粗略的线框图,在锁定方向之前把设计空间勾勒出来。优先追求广度而非精细打磨:默认每个想法给出 2-3 种明显不同的方案,用户明确要求广度探索时再加。用简单的形状、占位文字和极少的颜色,把焦点留在结构和流程上。整体保持手绘草图的感觉——手写风格但清晰可读的字体;以黑白为主、点缀少量颜色;低保真、简洁。多方案默认铺进 `design-canvas.jsx` 画布(见 [`../creative-design.md`](../creative-design.md)「如何开展设计工作」);单个 artboard 内部的局部变体用 Tweaks 承载(用 `tweaks-panel.jsx`,见 [`../creative-design.md`](../creative-design.md)「Tweaks」,不要手写控件面板)。
|
||||
@ -0,0 +1,188 @@
|
||||
/* BEGIN USAGE */
|
||||
// Android.jsx — Simplified Android (Material 3) device frame
|
||||
// Status bar + content + gesture nav + keyboard.
|
||||
// Based on Figma M3 spec. No dependencies, no image assets.
|
||||
// Exports (to window): AndroidDevice, AndroidStatusBar, AndroidListItem, AndroidNavBar, AndroidKeyboard
|
||||
//
|
||||
// Usage — wrap your screen content in <AndroidDevice> to get the bezel, status
|
||||
// bar and gesture nav (props: width=412, height=892, dark, keyboard):
|
||||
//
|
||||
// <AndroidDevice>
|
||||
// ...your screen content...
|
||||
// </AndroidDevice>
|
||||
// <AndroidDevice dark keyboard>…</AndroidDevice>
|
||||
// <AndroidDevice width={360} height={800}>…</AndroidDevice> // smaller device size
|
||||
/* END USAGE */
|
||||
|
||||
const MD_C = {
|
||||
surface: '#f4fbf8',
|
||||
surfaceVariant: '#dae5e1',
|
||||
inverseOnSurface: '#ecf2ef',
|
||||
secondaryContainer: '#cde8e1',
|
||||
primaryFixedDim: '#83d5c6',
|
||||
onSurface: '#171d1b',
|
||||
onSurfaceVar: '#49454f',
|
||||
onPrimaryContainer: '#00201c',
|
||||
primary: '#006a60',
|
||||
frameBorder: 'rgba(116,119,117,0.5)',
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Status bar (time left, wifi/cell/battery right)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function AndroidStatusBar({ dark = false }) {
|
||||
const c = dark ? '#fff' : MD_C.onSurface;
|
||||
return (
|
||||
<div style={{
|
||||
height: 40, display: 'flex', alignItems: 'center',
|
||||
justifyContent: 'space-between', padding: '0 16px',
|
||||
position: 'relative',
|
||||
fontFamily: 'Roboto, system-ui, sans-serif',
|
||||
}}>
|
||||
{/* time left */}
|
||||
<div style={{ width: 128, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontSize: 14, fontWeight: 400, letterSpacing: 0.25, lineHeight: '20px', color: c }}>9:30</span>
|
||||
</div>
|
||||
{/* camera punch-hole (center) */}
|
||||
<div style={{
|
||||
position: 'absolute', left: '50%', top: 8, transform: 'translateX(-50%)',
|
||||
width: 24, height: 24, borderRadius: 100, background: '#2e2e2e',
|
||||
}} />
|
||||
{/* status icons right */}
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', paddingRight: 2 }}>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" style={{ marginRight: -2 }}>
|
||||
<path d="M8 13.3L.67 5.97a10.37 10.37 0 0114.66 0L8 13.3z" fill={c}/>
|
||||
</svg>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" style={{ marginRight: -2 }}>
|
||||
<path d="M14.67 14.67V1.33L1.33 14.67h13.34z" fill={c}/>
|
||||
</svg>
|
||||
</div>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16">
|
||||
<rect x="3.75" y="2" width="8.5" height="13" rx="1.5" fill={c}/>
|
||||
<rect x="5.5" y="0.9" width="5" height="2" rx="0.5" fill={c}/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// List item (Material 3)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function AndroidListItem({ headline, supporting, leading }) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 16,
|
||||
padding: '12px 16px', minHeight: 56, boxSizing: 'border-box',
|
||||
fontFamily: 'Roboto, system-ui, sans-serif',
|
||||
}}>
|
||||
{leading && (
|
||||
<div style={{
|
||||
width: 40, height: 40, borderRadius: '50%',
|
||||
background: MD_C.primary, color: '#fff',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 18, fontWeight: 500, flexShrink: 0,
|
||||
}}>{leading}</div>
|
||||
)}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 16, color: MD_C.onSurface, lineHeight: '24px' }}>{headline}</div>
|
||||
{supporting && (
|
||||
<div style={{ fontSize: 14, color: MD_C.onSurfaceVar, lineHeight: '20px' }}>{supporting}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Gesture nav bar (pill)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function AndroidNavBar({ dark = false }) {
|
||||
return (
|
||||
<div style={{
|
||||
height: 24, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<div style={{
|
||||
width: 108, height: 4, borderRadius: 2,
|
||||
background: dark ? '#fff' : MD_C.onSurface, opacity: 0.4,
|
||||
}} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Device frame — wraps everything
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function AndroidDevice({
|
||||
children, width = 412, height = 892, dark = false,
|
||||
keyboard = false,
|
||||
}) {
|
||||
return (
|
||||
<div style={{
|
||||
width, height, borderRadius: 18, overflow: 'hidden',
|
||||
background: dark ? '#1d1b20' : MD_C.surface,
|
||||
border: `8px solid ${MD_C.frameBorder}`,
|
||||
display: 'flex', flexDirection: 'column', boxSizing: 'border-box',
|
||||
}}>
|
||||
<AndroidStatusBar dark={dark} />
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
{children}
|
||||
</div>
|
||||
{keyboard && <AndroidKeyboard />}
|
||||
<AndroidNavBar dark={dark} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Keyboard — Gboard (Material 3)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function AndroidKeyboard() {
|
||||
let _k = 0;
|
||||
const key = (l, { flex = 1, bg = MD_C.surface, r = 6, minW, fs = 21 } = {}) => (
|
||||
<div key={_k++} style={{
|
||||
height: 46, borderRadius: r, flex, minWidth: minW,
|
||||
background: bg, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontFamily: 'Roboto, system-ui', fontSize: fs,
|
||||
color: MD_C.onPrimaryContainer,
|
||||
}}>{l}</div>
|
||||
);
|
||||
const row = (keys, style = {}) => (
|
||||
<div style={{ display: 'flex', gap: 6, justifyContent: 'center', ...style }}>
|
||||
{keys.map(l => key(l))}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div style={{
|
||||
background: MD_C.inverseOnSurface, padding: '0 8px 8px',
|
||||
display: 'flex', flexDirection: 'column', gap: 4,
|
||||
}}>
|
||||
{/* navbar spacer (icons omitted) */}
|
||||
<div style={{ height: 44 }} />
|
||||
{/* key rows */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{row(['q','w','e','r','t','y','u','i','o','p'])}
|
||||
{row(['a','s','d','f','g','h','j','k','l'], { padding: '0 20px' })}
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
{key('', { bg: MD_C.surfaceVariant })}
|
||||
<div style={{ display: 'flex', gap: 6, flex: 7, minWidth: 274 }}>
|
||||
{['z','x','c','v','b','n','m'].map(l => key(l))}
|
||||
</div>
|
||||
{key('', { bg: MD_C.surfaceVariant })}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
{key('?123', { bg: MD_C.secondaryContainer, r: 100, minW: 58, fs: 14 })}
|
||||
{key(',', { bg: MD_C.surfaceVariant })}
|
||||
{key('', { flex: 3, minW: 154 })}
|
||||
{key('.', { bg: MD_C.surfaceVariant })}
|
||||
{key('', { bg: MD_C.primaryFixedDim, r: 100, minW: 58 })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, {
|
||||
AndroidDevice, AndroidStatusBar, AndroidListItem, AndroidNavBar, AndroidKeyboard,
|
||||
});
|
||||
@ -0,0 +1,773 @@
|
||||
/* BEGIN USAGE */
|
||||
// animations.jsx
|
||||
// Reusable animation starter: Stage, Timeline, Sprite, easing helpers.
|
||||
// Exports (to window): Stage, Sprite, PlaybackBar, TextSprite, ImageSprite, RectSprite,
|
||||
// useTime, useTimeline, useSprite, Easing, interpolate, animate, clamp.
|
||||
//
|
||||
// Usage (in an HTML file that loads React + Babel):
|
||||
//
|
||||
// <Stage width={1280} height={720} duration={10} background="#f6f4ef">
|
||||
// <MyScene />
|
||||
// </Stage>
|
||||
//
|
||||
// <Stage> auto-scales to the viewport and provides the scrubber, play/pause,
|
||||
// ←/→ seek, space, and 0-to-reset controls, and persists the playhead.
|
||||
// Set the optional `poster` prop (seconds) to the moment your opening scene is
|
||||
// fully composed — the product thumbnail freezes on that frame (default ~1s).
|
||||
// Inside <Stage>, any child can call useTime() to read the current
|
||||
// playhead (seconds). Or wrap content in <Sprite start={1} end={4}>...</Sprite>
|
||||
// to only render during that window -- children receive a `localTime` and
|
||||
// `progress` via the useSprite() hook. Use Easing + interpolate()/animate()
|
||||
// for tweens; TextSprite / ImageSprite / RectSprite have built-in entry/exit.
|
||||
// Build YOUR scenes by composing Sprites inside a Stage.
|
||||
/* END USAGE */
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// ── Easing functions (hand-rolled, Popmotion-style) ─────────────────────────
|
||||
// All easings take t ∈ [0,1] and return eased t ∈ [0,1] (may overshoot for back/elastic).
|
||||
const Easing = {
|
||||
linear: (t) => t,
|
||||
|
||||
// Quad
|
||||
easeInQuad: (t) => t * t,
|
||||
easeOutQuad: (t) => t * (2 - t),
|
||||
easeInOutQuad: (t) => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t),
|
||||
|
||||
// Cubic
|
||||
easeInCubic: (t) => t * t * t,
|
||||
easeOutCubic: (t) => (--t) * t * t + 1,
|
||||
easeInOutCubic: (t) => (t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1),
|
||||
|
||||
// Quart
|
||||
easeInQuart: (t) => t * t * t * t,
|
||||
easeOutQuart: (t) => 1 - (--t) * t * t * t,
|
||||
easeInOutQuart: (t) => (t < 0.5 ? 8 * t * t * t * t : 1 - 8 * (--t) * t * t * t),
|
||||
|
||||
// Expo
|
||||
easeInExpo: (t) => (t === 0 ? 0 : Math.pow(2, 10 * (t - 1))),
|
||||
easeOutExpo: (t) => (t === 1 ? 1 : 1 - Math.pow(2, -10 * t)),
|
||||
easeInOutExpo: (t) => {
|
||||
if (t === 0) return 0;
|
||||
if (t === 1) return 1;
|
||||
if (t < 0.5) return 0.5 * Math.pow(2, 20 * t - 10);
|
||||
return 1 - 0.5 * Math.pow(2, -20 * t + 10);
|
||||
},
|
||||
|
||||
// Sine
|
||||
easeInSine: (t) => 1 - Math.cos((t * Math.PI) / 2),
|
||||
easeOutSine: (t) => Math.sin((t * Math.PI) / 2),
|
||||
easeInOutSine: (t) => -(Math.cos(Math.PI * t) - 1) / 2,
|
||||
|
||||
// Back (overshoot)
|
||||
easeOutBack: (t) => {
|
||||
const c1 = 1.70158, c3 = c1 + 1;
|
||||
return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
|
||||
},
|
||||
easeInBack: (t) => {
|
||||
const c1 = 1.70158, c3 = c1 + 1;
|
||||
return c3 * t * t * t - c1 * t * t;
|
||||
},
|
||||
easeInOutBack: (t) => {
|
||||
const c1 = 1.70158, c2 = c1 * 1.525;
|
||||
return t < 0.5
|
||||
? (Math.pow(2 * t, 2) * ((c2 + 1) * 2 * t - c2)) / 2
|
||||
: (Math.pow(2 * t - 2, 2) * ((c2 + 1) * (t * 2 - 2) + c2) + 2) / 2;
|
||||
},
|
||||
|
||||
// Elastic
|
||||
easeOutElastic: (t) => {
|
||||
const c4 = (2 * Math.PI) / 3;
|
||||
if (t === 0) return 0;
|
||||
if (t === 1) return 1;
|
||||
return Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c4) + 1;
|
||||
},
|
||||
};
|
||||
|
||||
// ── Core interpolation helpers ──────────────────────────────────────────────
|
||||
|
||||
// Clamp a value to [min, max]
|
||||
const clamp = (v, min, max) => Math.max(min, Math.min(max, v));
|
||||
|
||||
// interpolate([0, 0.5, 1], [0, 100, 50], ease?) -> fn(t)
|
||||
// Popmotion-style: linearly maps t across input keyframes to output values,
|
||||
// with optional easing per segment (single fn or array of fns).
|
||||
function interpolate(input, output, ease = Easing.linear) {
|
||||
return (t) => {
|
||||
if (t <= input[0]) return output[0];
|
||||
if (t >= input[input.length - 1]) return output[output.length - 1];
|
||||
for (let i = 0; i < input.length - 1; i++) {
|
||||
if (t >= input[i] && t <= input[i + 1]) {
|
||||
const span = input[i + 1] - input[i];
|
||||
const local = span === 0 ? 0 : (t - input[i]) / span;
|
||||
const easeFn = Array.isArray(ease) ? (ease[i] || Easing.linear) : ease;
|
||||
const eased = easeFn(local);
|
||||
return output[i] + (output[i + 1] - output[i]) * eased;
|
||||
}
|
||||
}
|
||||
return output[output.length - 1];
|
||||
};
|
||||
}
|
||||
|
||||
// animate({from, to, start, end, ease})(t) — simpler single-segment tween.
|
||||
// Returns `from` before `start`, `to` after `end`.
|
||||
function animate({ from = 0, to = 1, start = 0, end = 1, ease = Easing.easeInOutCubic }) {
|
||||
return (t) => {
|
||||
if (t <= start) return from;
|
||||
if (t >= end) return to;
|
||||
const local = (t - start) / (end - start);
|
||||
return from + (to - from) * ease(local);
|
||||
};
|
||||
}
|
||||
|
||||
// ── Timeline context ────────────────────────────────────────────────────────
|
||||
|
||||
const TimelineContext = React.createContext({ time: 0, duration: 10, playing: false });
|
||||
|
||||
const useTime = () => React.useContext(TimelineContext).time;
|
||||
const useTimeline = () => React.useContext(TimelineContext);
|
||||
|
||||
// ── Sprite ──────────────────────────────────────────────────────────────────
|
||||
// Renders children only when the playhead is inside [start, end]. Provides
|
||||
// a sub-context with `localTime` (seconds since start) and `progress` (0..1).
|
||||
//
|
||||
// <Sprite start={2} end={5}>
|
||||
// {({ localTime, progress }) => <Thing x={progress * 100} />}
|
||||
// </Sprite>
|
||||
//
|
||||
// Or as a plain wrapper — children can call useSprite() themselves.
|
||||
|
||||
const SpriteContext = React.createContext({ localTime: 0, progress: 0, duration: 0 });
|
||||
const useSprite = () => React.useContext(SpriteContext);
|
||||
|
||||
function Sprite({ start = 0, end = Infinity, children, keepMounted = false }) {
|
||||
const { time } = useTimeline();
|
||||
const visible = time >= start && time <= end;
|
||||
if (!visible && !keepMounted) return null;
|
||||
|
||||
const duration = end - start;
|
||||
const localTime = Math.max(0, time - start);
|
||||
const progress = duration > 0 && isFinite(duration)
|
||||
? clamp(localTime / duration, 0, 1)
|
||||
: 0;
|
||||
|
||||
const value = { localTime, progress, duration, visible };
|
||||
|
||||
return (
|
||||
<SpriteContext.Provider value={value}>
|
||||
{typeof children === 'function' ? children(value) : children}
|
||||
</SpriteContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Sample sprite components ────────────────────────────────────────────────
|
||||
|
||||
// TextSprite: fades/slides text in on entry, holds, then fades out on exit.
|
||||
// Props: text, x, y, size, color, font, entryDur, exitDur, align
|
||||
function TextSprite({
|
||||
text,
|
||||
x = 0, y = 0,
|
||||
size = 48,
|
||||
color = '#111',
|
||||
font = 'Inter, system-ui, sans-serif',
|
||||
weight = 600,
|
||||
entryDur = 0.45,
|
||||
exitDur = 0.35,
|
||||
entryEase = Easing.easeOutBack,
|
||||
exitEase = Easing.easeInCubic,
|
||||
align = 'left',
|
||||
letterSpacing = '-0.01em',
|
||||
}) {
|
||||
const { localTime, duration } = useSprite();
|
||||
const exitStart = Math.max(0, duration - exitDur);
|
||||
|
||||
let opacity = 1;
|
||||
let ty = 0;
|
||||
|
||||
if (localTime < entryDur) {
|
||||
const t = entryEase(clamp(localTime / entryDur, 0, 1));
|
||||
opacity = t;
|
||||
ty = (1 - t) * 16;
|
||||
} else if (localTime > exitStart) {
|
||||
const t = exitEase(clamp((localTime - exitStart) / exitDur, 0, 1));
|
||||
opacity = 1 - t;
|
||||
ty = -t * 8;
|
||||
}
|
||||
|
||||
const translateX = align === 'center' ? '-50%' : align === 'right' ? '-100%' : '0';
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
left: x, top: y,
|
||||
transform: `translate(${translateX}, ${ty}px)`,
|
||||
opacity,
|
||||
fontFamily: font,
|
||||
fontSize: size,
|
||||
fontWeight: weight,
|
||||
color,
|
||||
letterSpacing,
|
||||
whiteSpace: 'pre',
|
||||
lineHeight: 1.1,
|
||||
willChange: 'transform, opacity',
|
||||
}}>
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ImageSprite: scales + fades in; optional Ken Burns drift during hold.
|
||||
function ImageSprite({
|
||||
src,
|
||||
x = 0, y = 0,
|
||||
width = 400, height = 300,
|
||||
entryDur = 0.6,
|
||||
exitDur = 0.4,
|
||||
kenBurns = false,
|
||||
kenBurnsScale = 1.08,
|
||||
radius = 12,
|
||||
fit = 'cover',
|
||||
placeholder = null, // {label: string} for striped placeholder
|
||||
}) {
|
||||
const { localTime, duration } = useSprite();
|
||||
const exitStart = Math.max(0, duration - exitDur);
|
||||
|
||||
let opacity = 1;
|
||||
let scale = 1;
|
||||
|
||||
if (localTime < entryDur) {
|
||||
const t = Easing.easeOutCubic(clamp(localTime / entryDur, 0, 1));
|
||||
opacity = t;
|
||||
scale = 0.96 + 0.04 * t;
|
||||
} else if (localTime > exitStart) {
|
||||
const t = Easing.easeInCubic(clamp((localTime - exitStart) / exitDur, 0, 1));
|
||||
opacity = 1 - t;
|
||||
scale = (kenBurns ? kenBurnsScale : 1) + 0.02 * t;
|
||||
} else if (kenBurns) {
|
||||
const holdSpan = exitStart - entryDur;
|
||||
const holdT = holdSpan > 0 ? (localTime - entryDur) / holdSpan : 0;
|
||||
scale = 1 + (kenBurnsScale - 1) * holdT;
|
||||
}
|
||||
|
||||
const content = placeholder ? (
|
||||
<div style={{
|
||||
width: '100%', height: '100%',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'repeating-linear-gradient(135deg, #e9e6df 0 10px, #dcd8cf 10px 20px)',
|
||||
color: '#6b6458',
|
||||
fontFamily: 'JetBrains Mono, ui-monospace, monospace',
|
||||
fontSize: 13,
|
||||
letterSpacing: '0.04em',
|
||||
textTransform: 'uppercase',
|
||||
}}>
|
||||
{placeholder.label || 'image'}
|
||||
</div>
|
||||
) : (
|
||||
<img src={src} alt="" style={{ width: '100%', height: '100%', objectFit: fit, display: 'block' }} />
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
left: x, top: y,
|
||||
width, height,
|
||||
opacity,
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: 'center',
|
||||
borderRadius: radius,
|
||||
overflow: 'hidden',
|
||||
willChange: 'transform, opacity',
|
||||
}}>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// RectSprite: simple rectangle that animates position/size/color via props.
|
||||
// Useful demo primitive — takes a `render` fn for per-frame customization.
|
||||
function RectSprite({
|
||||
x = 0, y = 0,
|
||||
width = 100, height = 100,
|
||||
color = '#111',
|
||||
radius = 8,
|
||||
entryDur = 0.4,
|
||||
exitDur = 0.3,
|
||||
render, // optional: (ctx) => style overrides
|
||||
}) {
|
||||
const spriteCtx = useSprite();
|
||||
const { localTime, duration } = spriteCtx;
|
||||
const exitStart = Math.max(0, duration - exitDur);
|
||||
|
||||
let opacity = 1;
|
||||
let scale = 1;
|
||||
|
||||
if (localTime < entryDur) {
|
||||
const t = Easing.easeOutBack(clamp(localTime / entryDur, 0, 1));
|
||||
opacity = clamp(localTime / entryDur, 0, 1);
|
||||
scale = 0.4 + 0.6 * t;
|
||||
} else if (localTime > exitStart) {
|
||||
const t = Easing.easeInQuad(clamp((localTime - exitStart) / exitDur, 0, 1));
|
||||
opacity = 1 - t;
|
||||
scale = 1 - 0.15 * t;
|
||||
}
|
||||
|
||||
const overrides = render ? render(spriteCtx) : {};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
left: x, top: y,
|
||||
width, height,
|
||||
background: color,
|
||||
borderRadius: radius,
|
||||
opacity,
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: 'center',
|
||||
willChange: 'transform, opacity',
|
||||
...overrides,
|
||||
}} />
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function Stage({
|
||||
width = 1280,
|
||||
height = 720,
|
||||
duration = 10,
|
||||
background = '#f6f4ef',
|
||||
fps = 60,
|
||||
loop = true,
|
||||
autoplay = true,
|
||||
poster = null,
|
||||
persistKey = 'animstage',
|
||||
children,
|
||||
}) {
|
||||
// Thumbnail capture mode: the host appends ?thumbnail=1 before screenshotting.
|
||||
// Freeze on a representative still — the author-declared `poster` second, or
|
||||
// ~1s as a fallback — paused, with the playback bar hidden, so the product
|
||||
// thumbnail is a deterministic frame of the first composed scene.
|
||||
const captureMode = typeof location !== 'undefined' && /[?&]thumbnail=/.test(location.search || '');
|
||||
|
||||
const [time, setTime] = React.useState(() => {
|
||||
if (captureMode) return clamp(poster == null ? 1 : poster, 0, duration);
|
||||
try {
|
||||
const v = parseFloat(localStorage.getItem(persistKey + ':t') || '0');
|
||||
return isFinite(v) ? clamp(v, 0, duration) : 0;
|
||||
} catch { return 0; }
|
||||
});
|
||||
const [playing, setPlaying] = React.useState(captureMode ? false : autoplay);
|
||||
const [scale, setScale] = React.useState(1);
|
||||
|
||||
const stageRef = React.useRef(null);
|
||||
const canvasRef = React.useRef(null);
|
||||
const rafRef = React.useRef(null);
|
||||
const lastTsRef = React.useRef(null);
|
||||
|
||||
// Persist playhead
|
||||
React.useEffect(() => {
|
||||
try { localStorage.setItem(persistKey + ':t', String(time)); } catch {}
|
||||
}, [time, persistKey]);
|
||||
|
||||
// Auto-scale to fit viewport
|
||||
React.useEffect(() => {
|
||||
if (!stageRef.current) return;
|
||||
const el = stageRef.current;
|
||||
const measure = () => {
|
||||
const barH = captureMode ? 0 : 44; // playback bar height (hidden in capture mode)
|
||||
const s = Math.min(
|
||||
el.clientWidth / width,
|
||||
(el.clientHeight - barH) / height
|
||||
);
|
||||
setScale(Math.max(0.05, s));
|
||||
};
|
||||
measure();
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(el);
|
||||
window.addEventListener('resize', measure);
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
window.removeEventListener('resize', measure);
|
||||
};
|
||||
}, [width, height]);
|
||||
|
||||
// Animation loop
|
||||
React.useEffect(() => {
|
||||
if (!playing) {
|
||||
lastTsRef.current = null;
|
||||
return;
|
||||
}
|
||||
const step = (ts) => {
|
||||
if (lastTsRef.current == null) lastTsRef.current = ts;
|
||||
const dt = (ts - lastTsRef.current) / 1000;
|
||||
lastTsRef.current = ts;
|
||||
setTime((t) => {
|
||||
let next = t + dt;
|
||||
if (next >= duration) {
|
||||
if (loop) next = next % duration;
|
||||
else { next = duration; setPlaying(false); }
|
||||
}
|
||||
return next;
|
||||
});
|
||||
rafRef.current = requestAnimationFrame(step);
|
||||
};
|
||||
rafRef.current = requestAnimationFrame(step);
|
||||
return () => {
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||
lastTsRef.current = null;
|
||||
};
|
||||
}, [playing, duration, loop]);
|
||||
|
||||
// Keyboard: space = play/pause, ← → = seek
|
||||
React.useEffect(() => {
|
||||
const onKey = (e) => {
|
||||
if (e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA')) return;
|
||||
if (e.code === 'Space') {
|
||||
e.preventDefault();
|
||||
setPlaying(p => !p);
|
||||
} else if (e.code === 'ArrowLeft') {
|
||||
setTime(t => clamp(t - (e.shiftKey ? 1 : 0.1), 0, duration));
|
||||
} else if (e.code === 'ArrowRight') {
|
||||
setTime(t => clamp(t + (e.shiftKey ? 1 : 0.1), 0, duration));
|
||||
} else if (e.key === '0' || e.code === 'Home') {
|
||||
setTime(0);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [duration]);
|
||||
|
||||
const ctxValue = React.useMemo(
|
||||
() => ({ time, duration, playing, setTime, setPlaying }),
|
||||
[time, duration, playing]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={stageRef}
|
||||
style={{
|
||||
position: 'absolute', inset: 0,
|
||||
display: 'flex', flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
background: '#0a0a0a',
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
}}
|
||||
>
|
||||
{/* Canvas area — vertically centered in remaining space */}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
width: '100%',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
minHeight: 0,
|
||||
}}>
|
||||
<div
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
width, height,
|
||||
background,
|
||||
position: 'relative',
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: 'center',
|
||||
flexShrink: 0,
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.4)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<TimelineContext.Provider value={ctxValue}>
|
||||
{children}
|
||||
</TimelineContext.Provider>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Playback bar — stacked below canvas, never overlapping. Hidden in
|
||||
capture mode so the thumbnail is just the frame, no chrome. */}
|
||||
{!captureMode && (
|
||||
<PlaybackBar
|
||||
time={time}
|
||||
duration={duration}
|
||||
playing={playing}
|
||||
onPlayPause={() => setPlaying(p => !p)}
|
||||
onReset={() => { setTime(0); }}
|
||||
onSeek={(t) => setTime(t)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Playback bar ────────────────────────────────────────────────────────────
|
||||
// Play/pause, return-to-begin, scrub track, time display.
|
||||
// Uses fixed-width time fields so layout doesn't thrash.
|
||||
|
||||
function PlaybackBar({ time, duration, playing, onPlayPause, onReset, onSeek }) {
|
||||
const trackRef = React.useRef(null);
|
||||
const [dragging, setDragging] = React.useState(false);
|
||||
const [trackHover, setTrackHover] = React.useState(null); // { x, t } — px within track + hovered time
|
||||
|
||||
const posFromEvent = React.useCallback((e) => {
|
||||
const rect = trackRef.current.getBoundingClientRect();
|
||||
const x = clamp(e.clientX - rect.left, 0, rect.width);
|
||||
const t = rect.width > 0 ? (x / rect.width) * duration : 0;
|
||||
return { x, t };
|
||||
}, [duration]);
|
||||
|
||||
const onTrackMove = (e) => {
|
||||
if (!trackRef.current) return;
|
||||
const { x, t } = posFromEvent(e);
|
||||
setTrackHover({ x, t });
|
||||
if (dragging) onSeek(t);
|
||||
};
|
||||
|
||||
const onTrackLeave = () => {
|
||||
if (!dragging) setTrackHover(null);
|
||||
};
|
||||
|
||||
const onTrackDown = (e) => {
|
||||
const { x, t } = posFromEvent(e);
|
||||
setDragging(true);
|
||||
setTrackHover({ x, t });
|
||||
onSeek(t);
|
||||
};
|
||||
|
||||
// Grab the knob in place: begin dragging without seeking (no jump).
|
||||
// stopPropagation keeps the track's click-to-seek from also firing.
|
||||
const onBallDown = (e) => {
|
||||
e.stopPropagation();
|
||||
setDragging(true);
|
||||
setTrackHover(posFromEvent(e));
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!dragging) return;
|
||||
const prevCursor = document.body.style.cursor;
|
||||
document.body.style.cursor = 'grabbing'; // stays grabbing even if the pointer leaves the knob mid-drag
|
||||
const onUp = () => {
|
||||
setDragging(false);
|
||||
setTrackHover(null);
|
||||
};
|
||||
const onMove = (e) => {
|
||||
if (!trackRef.current) return;
|
||||
const { x, t } = posFromEvent(e);
|
||||
setTrackHover({ x, t });
|
||||
onSeek(t);
|
||||
};
|
||||
window.addEventListener('mouseup', onUp);
|
||||
window.addEventListener('mousemove', onMove);
|
||||
return () => {
|
||||
window.removeEventListener('mouseup', onUp);
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
document.body.style.cursor = prevCursor;
|
||||
};
|
||||
}, [dragging, posFromEvent, onSeek]);
|
||||
|
||||
const pct = duration > 0 ? (time / duration) * 100 : 0;
|
||||
const fmt = (t) => {
|
||||
const total = Math.max(0, t);
|
||||
const m = Math.floor(total / 60);
|
||||
const s = Math.floor(total % 60);
|
||||
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const numFont = '"PingFang SC", -apple-system, BlinkMacSystemFont, system-ui, sans-serif';
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
padding: '12px',
|
||||
background: 'linear-gradient(0deg, rgba(0, 0, 0, 0.30) 0%, rgba(0, 0, 0, 0.00) 100%)',
|
||||
width: '100%',
|
||||
color: '#fff',
|
||||
fontFamily: numFont,
|
||||
userSelect: 'none',
|
||||
flexShrink: 0,
|
||||
boxSizing: 'border-box',
|
||||
}}>
|
||||
{/* Play / pause — bare white triangle, no button chrome */}
|
||||
<IconButton onClick={onPlayPause} tooltip={playing ? '暂停' : '播放'}>
|
||||
{playing ? (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.33333 1.33398C2.59695 1.33398 2 1.93094 2 2.66732V13.334C2 14.0704 2.59695 14.6673 3.33333 14.6673H4.66667C5.40305 14.6673 6 14.0704 6 13.334V2.66732C6 1.93094 5.40305 1.33398 4.66667 1.33398H3.33333Z" fill="currentColor"/>
|
||||
<path d="M11.3333 1.33398C10.597 1.33398 10 1.93094 10 2.66732V13.334C10 14.0704 10.597 14.6673 11.3333 14.6673H12.6667C13.403 14.6673 14 14.0704 14 13.334V2.66732C14 1.93094 13.403 1.33398 12.6667 1.33398H11.3333Z" fill="currentColor"/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14.0489 9.13127C14.873 8.60116 14.873 7.39754 14.0489 6.86743L4.74461 0.882617C3.84764 0.305661 2.66699 0.948902 2.66699 2.01454V13.9842C2.66699 15.0498 3.84764 15.693 4.74461 15.1161L14.0489 9.13127Z" fill="currentColor"/>
|
||||
</svg>
|
||||
)}
|
||||
</IconButton>
|
||||
|
||||
{/* Current time */}
|
||||
<div style={{
|
||||
fontFamily: numFont,
|
||||
fontSize: 14,
|
||||
fontWeight: 400,
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
color: '#fff',
|
||||
minWidth: 40,
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
{fmt(time)}
|
||||
</div>
|
||||
|
||||
{/* Scrub track — white fill on translucent-white rail + draggable knob */}
|
||||
<div
|
||||
ref={trackRef}
|
||||
onMouseMove={onTrackMove}
|
||||
onMouseLeave={onTrackLeave}
|
||||
onMouseDown={onTrackDown}
|
||||
style={{
|
||||
flex: 1,
|
||||
height: 20,
|
||||
position: 'relative',
|
||||
cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
left: 0, right: 0, height: 4,
|
||||
background: 'rgba(255,255,255,0.6)',
|
||||
borderRadius: 2,
|
||||
}}/>
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
left: 0, width: `${pct}%`, height: 4,
|
||||
background: 'rgba(255,255,255,0.9)',
|
||||
borderRadius: 2,
|
||||
}}/>
|
||||
{/* Progress knob — outer div is an enlarged transparent hit area for easier grabbing */}
|
||||
<div
|
||||
onMouseDown={onBallDown}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${pct}%`, top: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: 20, height: 20,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: dragging ? 'grabbing' : 'grab',
|
||||
zIndex: 5,
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
width: dragging ? 14 : 12,
|
||||
height: dragging ? 14 : 12,
|
||||
background: '#fff',
|
||||
borderRadius: '50%',
|
||||
border: '0.5px solid #D2D5D8',
|
||||
boxShadow: '0 2px 6px rgba(0,0,0,0.35)',
|
||||
transition: 'width 100ms, height 100ms',
|
||||
}}/>
|
||||
</div>
|
||||
{trackHover && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
left: trackHover.x,
|
||||
bottom: '100%',
|
||||
transform: 'translateX(-50%)',
|
||||
marginBottom: 2,
|
||||
pointerEvents: 'none',
|
||||
zIndex: 10,
|
||||
}}>
|
||||
<TooltipBubble text={fmt(trackHover.t)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Duration — dimmed */}
|
||||
<div style={{
|
||||
fontFamily: numFont,
|
||||
fontSize: 14,
|
||||
fontWeight: 400,
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
color: 'rgba(255,255,255,0.6)',
|
||||
minWidth: 40,
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
{fmt(duration)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function IconButton({ children, onClick, tooltip }) {
|
||||
const [hover, setHover] = React.useState(false);
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
aria-label={tooltip}
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: 24, height: 24,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: hover ? 'rgba(255,255,255,0.1)' : 'transparent',
|
||||
border: 'none',
|
||||
borderRadius: 6,
|
||||
color: '#fff',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
transition: 'background 120ms',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
{tooltip && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: '100%',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
marginBottom: 8,
|
||||
pointerEvents: 'none',
|
||||
opacity: hover ? 1 : 0,
|
||||
transition: 'opacity 120ms',
|
||||
zIndex: 10,
|
||||
}}>
|
||||
<TooltipBubble text={tooltip} />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tooltip bubble ────────────────────────────────────────────────────────────
|
||||
// Dark rounded bubble with a downward tail. Positioning is up to the caller.
|
||||
function TooltipBubble({ text }) {
|
||||
return (
|
||||
<div style={{ position: 'relative', display: 'inline-block' }}>
|
||||
<div style={{
|
||||
background: '#1F2329',
|
||||
color: '#fff',
|
||||
fontSize: 12,
|
||||
lineHeight: '16px',
|
||||
padding: '6px 12px',
|
||||
borderRadius: 6,
|
||||
whiteSpace: 'nowrap',
|
||||
fontFamily: '"PingFang SC", -apple-system, BlinkMacSystemFont, system-ui, sans-serif',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
boxShadow: '0 4px 8px -8px rgba(0, 0, 0, 0.06), 0 6px 12px 0 rgba(0, 0, 0, 0.04), 0 8px 24px 8px rgba(0, 0, 0, 0.04)',
|
||||
}}>
|
||||
{text}
|
||||
</div>
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%) rotate(45deg)',
|
||||
width: 9, height: 9,
|
||||
borderRadius: '0 0 3px 0',
|
||||
background: '#1F2329',
|
||||
}}/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Object.assign(window, {
|
||||
Easing, interpolate, animate, clamp,
|
||||
TimelineContext, useTime, useTimeline,
|
||||
Sprite, SpriteContext, useSprite,
|
||||
TextSprite, ImageSprite, RectSprite,
|
||||
Stage, PlaybackBar,
|
||||
});
|
||||
|
||||
@ -0,0 +1,122 @@
|
||||
/* BEGIN USAGE */
|
||||
// Chrome.jsx — Simplified Chrome browser window (dark theme, macOS)
|
||||
// No dependencies, no image assets. All inline styles + inline SVG.
|
||||
// Exports (to window): ChromeWindow, ChromeTabBar, ChromeToolbar, ChromeTab, ChromeTrafficLights
|
||||
//
|
||||
// Usage — wrap your page content in <ChromeWindow> to get the tab bar + URL bar:
|
||||
//
|
||||
// <ChromeWindow width={1100} height={680} url="acme.design/pricing">
|
||||
// ...your page content...
|
||||
// </ChromeWindow>
|
||||
/* END USAGE */
|
||||
|
||||
const CHROME_C = {
|
||||
barBg: '#202124',
|
||||
tabBg: '#35363a',
|
||||
text: '#e8eaed',
|
||||
dim: '#9aa0a6',
|
||||
urlBg: '#282a2d',
|
||||
};
|
||||
|
||||
function ChromeTrafficLights() {
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 8, padding: '0 14px' }}>
|
||||
<div style={{ width: 12, height: 12, borderRadius: '50%', background: '#ff5f57' }} />
|
||||
<div style={{ width: 12, height: 12, borderRadius: '50%', background: '#febc2e' }} />
|
||||
<div style={{ width: 12, height: 12, borderRadius: '50%', background: '#28c840' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Single tab (active has curved scoops)
|
||||
function ChromeTab({ title = 'New Tab', active = false }) {
|
||||
const curve = (flip) => (
|
||||
<svg width="8" height="10" viewBox="0 0 8 10"
|
||||
style={{ position: 'absolute', bottom: 0, [flip ? 'right' : 'left']: -8, transform: flip ? 'scaleX(-1)' : 'none' }}>
|
||||
<path d="M0 10C2 9 6 8 8 0V10H0Z" fill={CHROME_C.tabBg}/>
|
||||
</svg>
|
||||
);
|
||||
return (
|
||||
<div style={{
|
||||
position: 'relative', height: 34, alignSelf: 'flex-end',
|
||||
padding: '0 12px', display: 'flex', alignItems: 'center', gap: 8,
|
||||
background: active ? CHROME_C.tabBg : 'transparent',
|
||||
borderRadius: '8px 8px 0 0', minWidth: 120, maxWidth: 220,
|
||||
fontFamily: 'system-ui, sans-serif', fontSize: 12,
|
||||
color: active ? CHROME_C.text : CHROME_C.dim,
|
||||
}}>
|
||||
{active && curve(false)}
|
||||
{active && curve(true)}
|
||||
<div style={{ width: 14, height: 14, borderRadius: '50%', background: '#5f6368', flexShrink: 0 }} />
|
||||
<span style={{ flex: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{title}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChromeTabBar({ tabs = [{ title: 'New Tab' }], activeIndex = 0 }) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', height: 44,
|
||||
background: CHROME_C.barBg, paddingRight: 8,
|
||||
}}>
|
||||
<ChromeTrafficLights />
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', height: '100%', paddingLeft: 4, flex: 1 }}>
|
||||
{tabs.map((t, i) => <ChromeTab key={i} title={t.title} active={i === activeIndex} />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChromeToolbar({ url = 'example.com' }) {
|
||||
const iconDot = (
|
||||
<div style={{
|
||||
width: 28, height: 28, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<div style={{ width: 16, height: 16, borderRadius: '50%', background: CHROME_C.dim, opacity: 0.4 }} />
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div style={{
|
||||
height: 40, background: CHROME_C.tabBg,
|
||||
display: 'flex', alignItems: 'center', gap: 4, padding: '0 8px',
|
||||
}}>
|
||||
{iconDot}
|
||||
{/* url bar */}
|
||||
<div style={{
|
||||
flex: 1, height: 30, borderRadius: 15, background: CHROME_C.urlBg,
|
||||
display: 'flex', alignItems: 'center', gap: 8, padding: '0 14px',
|
||||
margin: '0 6px',
|
||||
}}>
|
||||
<div style={{ width: 12, height: 12, borderRadius: '50%', background: CHROME_C.dim, opacity: 0.4 }} />
|
||||
<span style={{
|
||||
flex: 1, color: CHROME_C.text, fontSize: 13,
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
}}>{url}</span>
|
||||
</div>
|
||||
{iconDot}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChromeWindow({
|
||||
tabs = [{ title: 'New Tab' }], activeIndex = 0, url = 'example.com',
|
||||
width = 900, height = 600, children,
|
||||
}) {
|
||||
return (
|
||||
<div style={{
|
||||
width, height, borderRadius: 10, overflow: 'hidden',
|
||||
border: '1px solid #DEE0E3',
|
||||
display: 'flex', flexDirection: 'column', background: CHROME_C.tabBg,
|
||||
}}>
|
||||
<ChromeTabBar tabs={tabs} activeIndex={activeIndex} />
|
||||
<ChromeToolbar url={url} />
|
||||
<div style={{ flex: 1, background: '#fff', overflow: 'auto' }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, {
|
||||
ChromeWindow, ChromeTabBar, ChromeToolbar, ChromeTab, ChromeTrafficLights,
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,270 @@
|
||||
/* BEGIN USAGE */
|
||||
// iOS.jsx — Simplified iOS 26 (Liquid Glass) device frame
|
||||
// Based on the iOS 26 UI Kit + Figma status bar spec. No assets, no deps.
|
||||
// Exports (to window): IOSDevice, IOSStatusBar, IOSList, IOSListRow, IOSKeyboard
|
||||
//
|
||||
// Usage — wrap your screen content in <IOSDevice> to get the bezel, status bar
|
||||
// and home indicator (props: width=402, height=874, dark, keyboard):
|
||||
//
|
||||
// <IOSDevice>
|
||||
// ...your screen content...
|
||||
// </IOSDevice>
|
||||
// <IOSDevice dark keyboard>…</IOSDevice>
|
||||
// <IOSDevice width={390} height={844}>…</IOSDevice> // smaller device size
|
||||
//
|
||||
// Safe areas — REQUIRED on every screen. The status bar (top) and home
|
||||
// indicator (bottom) float OVER your content; inset it or it overlaps them.
|
||||
// --ios-safe-top top inset (Dynamic Island + status bar)
|
||||
// --ios-safe-bottom bottom inset (home indicator)
|
||||
/* END USAGE */
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Status bar
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function IOSStatusBar({ dark = false, time = '9:41' }) {
|
||||
const c = dark ? '#fff' : '#000';
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', gap: 154, alignItems: 'center', justifyContent: 'center',
|
||||
padding: '21px 24px 19px', boxSizing: 'border-box',
|
||||
position: 'relative', zIndex: 20, width: '100%',
|
||||
}}>
|
||||
<div style={{ flex: 1, height: 22, display: 'flex', alignItems: 'center', justifyContent: 'center', paddingTop: 1.5 }}>
|
||||
<span style={{
|
||||
fontFamily: '-apple-system, "SF Pro", system-ui', fontWeight: 590,
|
||||
fontSize: 17, lineHeight: '22px', color: c,
|
||||
}}>{time}</span>
|
||||
</div>
|
||||
<div style={{ flex: 1, height: 22, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 7, paddingTop: 1, paddingRight: 1 }}>
|
||||
<svg width="19" height="12" viewBox="0 0 19 12">
|
||||
<rect x="0" y="7.5" width="3.2" height="4.5" rx="0.7" fill={c}/>
|
||||
<rect x="4.8" y="5" width="3.2" height="7" rx="0.7" fill={c}/>
|
||||
<rect x="9.6" y="2.5" width="3.2" height="9.5" rx="0.7" fill={c}/>
|
||||
<rect x="14.4" y="0" width="3.2" height="12" rx="0.7" fill={c}/>
|
||||
</svg>
|
||||
<svg width="17" height="12" viewBox="0 0 17 12">
|
||||
<path d="M8.5 3.2C10.8 3.2 12.9 4.1 14.4 5.6L15.5 4.5C13.7 2.7 11.2 1.5 8.5 1.5C5.8 1.5 3.3 2.7 1.5 4.5L2.6 5.6C4.1 4.1 6.2 3.2 8.5 3.2Z" fill={c}/>
|
||||
<path d="M8.5 6.8C9.9 6.8 11.1 7.3 12 8.2L13.1 7.1C11.8 5.9 10.2 5.1 8.5 5.1C6.8 5.1 5.2 5.9 3.9 7.1L5 8.2C5.9 7.3 7.1 6.8 8.5 6.8Z" fill={c}/>
|
||||
<circle cx="8.5" cy="10.5" r="1.5" fill={c}/>
|
||||
</svg>
|
||||
<svg width="27" height="13" viewBox="0 0 27 13">
|
||||
<rect x="0.5" y="0.5" width="23" height="12" rx="3.5" stroke={c} strokeOpacity="0.35" fill="none"/>
|
||||
<rect x="2" y="2" width="20" height="9" rx="2" fill={c}/>
|
||||
<path d="M25 4.5V8.5C25.8 8.2 26.5 7.2 26.5 6.5C26.5 5.8 25.8 4.8 25 4.5Z" fill={c} fillOpacity="0.4"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Grouped list (inset card, r:26) + row (52px)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function IOSListRow({ title, detail, icon, chevron = true, isLast = false, dark = false }) {
|
||||
const text = dark ? '#fff' : '#000';
|
||||
const sec = dark ? 'rgba(235,235,245,0.6)' : 'rgba(60,60,67,0.6)';
|
||||
const ter = dark ? 'rgba(235,235,245,0.3)' : 'rgba(60,60,67,0.3)';
|
||||
const sep = dark ? 'rgba(84,84,88,0.65)' : 'rgba(60,60,67,0.12)';
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', minHeight: 52,
|
||||
padding: '0 16px', position: 'relative',
|
||||
fontFamily: '-apple-system, system-ui', fontSize: 17,
|
||||
letterSpacing: -0.43,
|
||||
}}>
|
||||
{icon && (
|
||||
<div style={{
|
||||
width: 30, height: 30, borderRadius: 7, background: icon,
|
||||
marginRight: 12, flexShrink: 0,
|
||||
}} />
|
||||
)}
|
||||
<div style={{ flex: 1, color: text }}>{title}</div>
|
||||
{detail && <span style={{ color: sec, marginRight: 6 }}>{detail}</span>}
|
||||
{chevron && (
|
||||
<svg width="8" height="14" viewBox="0 0 8 14" style={{ flexShrink: 0 }}>
|
||||
<path d="M1 1l6 6-6 6" stroke={ter} strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
)}
|
||||
{!isLast && (
|
||||
<div style={{
|
||||
position: 'absolute', bottom: 0, right: 0,
|
||||
left: icon ? 58 : 16, height: 0.5, background: sep,
|
||||
}} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IOSList({ header, children, dark = false }) {
|
||||
const hc = dark ? 'rgba(235,235,245,0.6)' : 'rgba(60,60,67,0.6)';
|
||||
const bg = dark ? '#1C1C1E' : '#fff';
|
||||
return (
|
||||
<div>
|
||||
{header && (
|
||||
<div style={{
|
||||
fontFamily: '-apple-system, system-ui', fontSize: 13,
|
||||
color: hc, textTransform: 'uppercase',
|
||||
padding: '8px 36px 6px', letterSpacing: -0.08,
|
||||
}}>{header}</div>
|
||||
)}
|
||||
<div style={{
|
||||
background: bg, borderRadius: 26,
|
||||
margin: '0 16px', overflow: 'hidden',
|
||||
}}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Device frame
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function IOSDevice({
|
||||
children, width = 402, height = 874, dark = false,
|
||||
keyboard = false,
|
||||
}) {
|
||||
return (
|
||||
<div style={{
|
||||
width, height, borderRadius: 48, overflow: 'hidden',
|
||||
position: 'relative', background: dark ? '#000' : '#F2F2F7',
|
||||
border: '1px solid #DEE0E3',
|
||||
fontFamily: '-apple-system, system-ui, sans-serif',
|
||||
WebkitFontSmoothing: 'antialiased',
|
||||
'--ios-safe-top': '62px',
|
||||
'--ios-safe-bottom': '34px',
|
||||
}}>
|
||||
{/* dynamic island */}
|
||||
<div style={{
|
||||
position: 'absolute', top: 11, left: '50%', transform: 'translateX(-50%)',
|
||||
width: 126, height: 37, borderRadius: 24, background: '#000', zIndex: 50,
|
||||
}} />
|
||||
{/* status bar (absolute) */}
|
||||
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, zIndex: 10 }}>
|
||||
<IOSStatusBar dark={dark} />
|
||||
</div>
|
||||
{/* content */}
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>{children}</div>
|
||||
{keyboard && <IOSKeyboard dark={dark} />}
|
||||
</div>
|
||||
{/* home indicator — always on top */}
|
||||
<div style={{
|
||||
position: 'absolute', bottom: 0, left: 0, right: 0, zIndex: 60,
|
||||
height: 34, display: 'flex', justifyContent: 'center', alignItems: 'flex-end',
|
||||
paddingBottom: 8, pointerEvents: 'none',
|
||||
}}>
|
||||
<div style={{
|
||||
width: 139, height: 5, borderRadius: 100,
|
||||
background: dark ? 'rgba(255,255,255,0.7)' : 'rgba(0,0,0,0.25)',
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Keyboard — iOS 26 liquid glass
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function IOSKeyboard({ dark = false }) {
|
||||
const glyph = dark ? 'rgba(255,255,255,0.7)' : '#595959';
|
||||
const sugg = dark ? 'rgba(255,255,255,0.6)' : '#333';
|
||||
const keyBg = dark ? 'rgba(255,255,255,0.22)' : 'rgba(255,255,255,0.85)';
|
||||
|
||||
// special-key icons
|
||||
const icons = {
|
||||
shift: <svg width="19" height="17" viewBox="0 0 19 17"><path d="M9.5 1L1 9.5h4.5V16h8V9.5H18L9.5 1z" fill={glyph}/></svg>,
|
||||
del: <svg width="23" height="17" viewBox="0 0 23 17"><path d="M7 1h13a2 2 0 012 2v11a2 2 0 01-2 2H7l-6-7.5L7 1z" fill="none" stroke={glyph} strokeWidth="1.6" strokeLinejoin="round"/><path d="M10 5l7 7M17 5l-7 7" stroke={glyph} strokeWidth="1.6" strokeLinecap="round"/></svg>,
|
||||
ret: <svg width="20" height="14" viewBox="0 0 20 14"><path d="M18 1v6H4m0 0l4-4M4 7l4 4" fill="none" stroke="#fff" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/></svg>,
|
||||
};
|
||||
|
||||
const key = (content, { w, flex, ret, fs = 25, k } = {}) => (
|
||||
<div key={k} style={{
|
||||
height: 42, borderRadius: 8.5,
|
||||
flex: flex ? 1 : undefined, width: w, minWidth: 0,
|
||||
background: ret ? '#08f' : keyBg,
|
||||
boxShadow: '0 1px 0 rgba(0,0,0,0.075)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontFamily: '-apple-system, "SF Compact", system-ui',
|
||||
fontSize: fs, fontWeight: 458, color: ret ? '#fff' : glyph,
|
||||
}}>{content}</div>
|
||||
);
|
||||
|
||||
const row = (keys, pad = 0) => (
|
||||
<div style={{ display: 'flex', gap: 6.5, justifyContent: 'center', padding: `0 ${pad}px` }}>
|
||||
{keys.map(l => key(l, { flex: true, k: l }))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'relative', zIndex: 15, borderRadius: 27, overflow: 'hidden',
|
||||
padding: '11px 0 2px',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||
boxShadow: dark
|
||||
? '0 -2px 20px rgba(0,0,0,0.09)'
|
||||
: '0 -1px 6px rgba(0,0,0,0.018), 0 -3px 20px rgba(0,0,0,0.012)',
|
||||
}}>
|
||||
{/* liquid glass bg — same recipe as nav pills */}
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, borderRadius: 27,
|
||||
backdropFilter: 'blur(12px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(12px) saturate(180%)',
|
||||
background: dark ? 'rgba(120,120,128,0.14)' : 'rgba(255,255,255,0.25)',
|
||||
}} />
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, borderRadius: 27,
|
||||
boxShadow: dark
|
||||
? 'inset 1.5px 1.5px 1px rgba(255,255,255,0.15)'
|
||||
: 'inset 1.5px 1.5px 1px rgba(255,255,255,0.7), inset -1px -1px 1px rgba(255,255,255,0.4)',
|
||||
border: dark ? '0.5px solid rgba(255,255,255,0.15)' : '0.5px solid rgba(0,0,0,0.06)',
|
||||
pointerEvents: 'none',
|
||||
}} />
|
||||
|
||||
{/* autocorrect bar */}
|
||||
<div style={{
|
||||
display: 'flex', gap: 20, alignItems: 'center',
|
||||
padding: '8px 22px 13px', width: '100%', boxSizing: 'border-box',
|
||||
position: 'relative',
|
||||
}}>
|
||||
{['"The"', 'the', 'to'].map((w, i) => (
|
||||
<React.Fragment key={i}>
|
||||
{i > 0 && <div style={{ width: 1, height: 25, background: '#ccc', opacity: 0.3 }} />}
|
||||
<div style={{
|
||||
flex: 1, textAlign: 'center',
|
||||
fontFamily: '-apple-system, system-ui', fontSize: 17,
|
||||
color: sugg, letterSpacing: -0.43, lineHeight: '22px',
|
||||
}}>{w}</div>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* key layout */}
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column', gap: 13,
|
||||
padding: '0 6.5px', width: '100%', boxSizing: 'border-box',
|
||||
position: 'relative',
|
||||
}}>
|
||||
{row(['q','w','e','r','t','y','u','i','o','p'])}
|
||||
{row(['a','s','d','f','g','h','j','k','l'], 20)}
|
||||
<div style={{ display: 'flex', gap: 14.25, alignItems: 'center' }}>
|
||||
{key(icons.shift, { w: 45, k: 'shift' })}
|
||||
<div style={{ display: 'flex', gap: 6.5, flex: 1 }}>
|
||||
{['z','x','c','v','b','n','m'].map(l => key(l, { flex: true, k: l }))}
|
||||
</div>
|
||||
{key(icons.del, { w: 45, k: 'del' })}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
{key('ABC', { w: 92.25, fs: 18, k: 'abc' })}
|
||||
{key('', { flex: true, k: 'space' })}
|
||||
{key(icons.ret, { w: 92.25, ret: true, k: 'ret' })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* bottom spacer (emoji+mic area, icons omitted) */}
|
||||
<div style={{ height: 56, width: '100%', position: 'relative' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, {
|
||||
IOSDevice, IOSStatusBar, IOSList, IOSListRow, IOSKeyboard,
|
||||
});
|
||||
@ -0,0 +1,197 @@
|
||||
/* BEGIN USAGE */
|
||||
// MacOS.jsx — Simplified macOS Tahoe (Liquid Glass) window
|
||||
// Based on the macOS Tahoe UI Kit. No image assets, no dependencies.
|
||||
// Exports (to window): MacWindow, MacSidebar, MacSidebarItem, MacSidebarHeader, MacToolbar, MacGlass, MacTrafficLights
|
||||
//
|
||||
// Usage — wrap your app content in <MacWindow> to get the window chrome
|
||||
// (traffic lights + titlebar). Props: width, height, title, sidebar (pass a
|
||||
// <MacSidebar> element); compose MacToolbar/MacGlass inside as needed:
|
||||
//
|
||||
// <MacWindow width={980} height={620} title="Documents"
|
||||
// sidebar={<MacSidebar>…</MacSidebar>}>
|
||||
// ...your app content...
|
||||
// </MacWindow>
|
||||
/* END USAGE */
|
||||
|
||||
const MAC_FONT = '-apple-system, BlinkMacSystemFont, "SF Pro", "Helvetica Neue", sans-serif';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Liquid glass primitive — blur + white tint + inset highlight
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function MacGlass({ children, radius = 296, dark = false, style = {} }) {
|
||||
return (
|
||||
<div style={{ position: 'relative', borderRadius: radius, ...style }}>
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, borderRadius: radius,
|
||||
background: dark ? 'rgba(255,255,255,0.08)' : 'rgba(255,255,255,0.35)',
|
||||
backdropFilter: 'blur(40px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(40px) saturate(180%)',
|
||||
border: dark ? '0.5px solid rgba(255,255,255,0.12)' : '0.5px solid rgba(255,255,255,0.6)',
|
||||
boxShadow: dark
|
||||
? '0 8px 40px rgba(0,0,0,0.2)'
|
||||
: '0 8px 40px rgba(0,0,0,0.08), inset 0 1px 0 rgba(255,255,255,0.4)',
|
||||
}} />
|
||||
<div style={{ position: 'relative', zIndex: 1 }}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Traffic lights (14px, Tahoe colors)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function MacTrafficLights({ style = {} }) {
|
||||
const dot = (bg) => (
|
||||
<div style={{
|
||||
width: 14, height: 14, borderRadius: '50%', background: bg,
|
||||
border: '0.5px solid rgba(0,0,0,0.1)',
|
||||
}} />
|
||||
);
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 9, alignItems: 'center', padding: 1, ...style }}>
|
||||
{dot('#ff736a')}{dot('#febc2e')}{dot('#19c332')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Toolbar — title + single glass pill icon
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function MacToolbar({ title = 'Folder' }) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', gap: 8, alignItems: 'center', padding: 8, flexShrink: 0,
|
||||
}}>
|
||||
{/* title */}
|
||||
<div style={{
|
||||
fontFamily: MAC_FONT, fontSize: 15, fontWeight: 700,
|
||||
color: 'rgba(0,0,0,0.85)', whiteSpace: 'nowrap', paddingLeft: 8,
|
||||
}}>{title}</div>
|
||||
<div style={{ flex: 1 }} />
|
||||
{/* single action */}
|
||||
<MacGlass>
|
||||
<div style={{
|
||||
width: 36, height: 36, display: 'flex',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<div style={{ width: 14, height: 14, borderRadius: '50%', background: '#4c4c4c', opacity: 0.4 }} />
|
||||
</div>
|
||||
</MacGlass>
|
||||
{/* search */}
|
||||
<MacGlass>
|
||||
<div style={{
|
||||
width: 140, height: 36, display: 'flex', alignItems: 'center',
|
||||
gap: 6, padding: '0 12px',
|
||||
}}>
|
||||
<svg width="13" height="13" viewBox="0 0 13 13" fill="none">
|
||||
<circle cx="5.5" cy="5.5" r="4" stroke="#727272" strokeWidth="1.5"/>
|
||||
<path d="M8.5 8.5l3 3" stroke="#727272" strokeWidth="1.5" strokeLinecap="round"/>
|
||||
</svg>
|
||||
<span style={{
|
||||
fontFamily: MAC_FONT, fontSize: 13, fontWeight: 500, color: '#727272',
|
||||
}}>Search</span>
|
||||
</div>
|
||||
</MacGlass>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Sidebar — frosted glass panel floating inside the window
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function MacSidebarItem({ label, selected = false }) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
height: 24, padding: '4px 10px 4px 6px', margin: '0 10px',
|
||||
borderRadius: 8, position: 'relative',
|
||||
fontFamily: MAC_FONT, fontSize: 11, fontWeight: 500,
|
||||
}}>
|
||||
{selected && (
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, borderRadius: 8,
|
||||
background: 'rgba(0,0,0,0.11)', mixBlendMode: 'multiply',
|
||||
}} />
|
||||
)}
|
||||
<div style={{
|
||||
width: 14, height: 14, borderRadius: '50%',
|
||||
background: selected ? '#007aff' : 'rgba(0,0,0,0.4)',
|
||||
opacity: selected ? 1 : 0.5, flexShrink: 0, position: 'relative',
|
||||
}} />
|
||||
<span style={{ color: 'rgba(0,0,0,0.85)', position: 'relative' }}>{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MacSidebar({ children }) {
|
||||
return (
|
||||
<div style={{
|
||||
width: 220, height: '100%', padding: 8, flexShrink: 0,
|
||||
position: 'relative', display: 'flex', flexDirection: 'column',
|
||||
}}>
|
||||
{/* glass panel */}
|
||||
<div style={{
|
||||
position: 'absolute', inset: 8, borderRadius: 18,
|
||||
background: 'rgba(210,225,245,0.45)',
|
||||
backdropFilter: 'blur(50px) saturate(200%)',
|
||||
WebkitBackdropFilter: 'blur(50px) saturate(200%)',
|
||||
border: '0.5px solid rgba(255,255,255,0.5)',
|
||||
boxShadow: '0 8px 40px rgba(0,0,0,0.10), inset 0 1px 0 rgba(255,255,255,0.35)',
|
||||
}} />
|
||||
{/* content */}
|
||||
<div style={{
|
||||
position: 'relative', zIndex: 1, padding: '10px 0',
|
||||
display: 'flex', flexDirection: 'column', gap: 2,
|
||||
}}>
|
||||
{/* window controls + sidebar toggle */}
|
||||
<div style={{
|
||||
height: 32, display: 'flex', alignItems: 'center',
|
||||
justifyContent: 'space-between', padding: '0 10px', marginBottom: 4,
|
||||
}}>
|
||||
<MacTrafficLights />
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MacSidebarHeader({ title }) {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '14px 18px 5px',
|
||||
fontFamily: MAC_FONT, fontSize: 11, fontWeight: 700,
|
||||
color: 'rgba(0,0,0,0.5)',
|
||||
}}>{title}</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Window — r:26, big shadow, sidebar + toolbar + content
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function MacWindow({
|
||||
width = 900, height = 600, title = 'Folder',
|
||||
sidebar, children,
|
||||
}) {
|
||||
return (
|
||||
<div style={{
|
||||
width, height, borderRadius: 26, overflow: 'hidden',
|
||||
background: '#fff',
|
||||
border: '1px solid #DEE0E3',
|
||||
display: 'flex', position: 'relative',
|
||||
fontFamily: MAC_FONT,
|
||||
}}>
|
||||
<MacSidebar>{sidebar}</MacSidebar>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||||
<MacToolbar title={title} />
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: '4px 8px' }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, {
|
||||
MacWindow, MacSidebar, MacSidebarItem, MacSidebarHeader,
|
||||
MacToolbar, MacGlass, MacTrafficLights,
|
||||
});
|
||||
@ -0,0 +1,752 @@
|
||||
/* BEGIN USAGE */
|
||||
// tweaks-panel.jsx
|
||||
// Reusable Tweaks shell + form-control helpers.
|
||||
// Exports (to window): useTweaks, TweaksPanel, TweakSection, TweakRow, TweakSlider,
|
||||
// TweakToggle, TweakRadio, TweakSelect, TweakText, TweakNumber, TweakColor, TweakButton.
|
||||
//
|
||||
// Owns the host protocol (listens for miaoda:tweaks:activate / miaoda:tweaks:deactivate,
|
||||
// posts miaoda:tweaks:available / miaoda:tweaks:set-keys / miaoda:tweaks:dismissed) so
|
||||
// individual prototypes don't re-roll it. Ships a consistent set of controls so you
|
||||
// don't hand-draw <input type="range">, segmented radios, steppers, etc.
|
||||
//
|
||||
// Usage (in an HTML file that loads React + Babel):
|
||||
//
|
||||
// const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
|
||||
// "primaryColor": "#D97757",
|
||||
// "palette": ["#D97757", "#29261b", "#f6f4ef"],
|
||||
// "fontSize": 16,
|
||||
// "density": "regular",
|
||||
// "dark": false
|
||||
// }/*EDITMODE-END*/;
|
||||
//
|
||||
// TWEAK_DEFAULTS must live inline in the HTML file — in a <script type="text/babel"> block,
|
||||
// not in a separate .jsx/.js loaded via <script src>. That in-HTML block is the region the
|
||||
// host rewrites when the user adjusts a tweak, so keep it wrapped in the /*EDITMODE-BEGIN*/ …
|
||||
// /*EDITMODE-END*/ markers and the object between them valid JSON — double-quoted keys, no
|
||||
// trailing commas, no comments or expressions — even after you rename the keys. Move it out
|
||||
// of the HTML, strip the markers, or use a non-JSON body and tweak edits silently stop persisting.
|
||||
//
|
||||
// function App() {
|
||||
// const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
|
||||
// return (
|
||||
// <div style={{ fontSize: t.fontSize, color: t.primaryColor }}>
|
||||
// Hello
|
||||
// <TweaksPanel>
|
||||
// <TweakSection label="Typography" />
|
||||
// <TweakSlider label="Font size" value={t.fontSize} min={10} max={32} unit="px"
|
||||
// onChange={(v) => setTweak('fontSize', v)} />
|
||||
// <TweakRadio label="Density" value={t.density}
|
||||
// options={['compact', 'regular', 'comfy']}
|
||||
// onChange={(v) => setTweak('density', v)} />
|
||||
// <TweakSection label="Theme" />
|
||||
// <TweakColor label="Primary" value={t.primaryColor}
|
||||
// options={['#D97757', '#2A6FDB', '#1F8A5B', '#7A5AE0']}
|
||||
// onChange={(v) => setTweak('primaryColor', v)} />
|
||||
// <TweakColor label="Palette" value={t.palette}
|
||||
// options={[['#D97757', '#29261b', '#f6f4ef'],
|
||||
// ['#475569', '#0f172a', '#f1f5f9']]}
|
||||
// onChange={(v) => setTweak('palette', v)} />
|
||||
// <TweakToggle label="Dark mode" value={t.dark}
|
||||
// onChange={(v) => setTweak('dark', v)} />
|
||||
// </TweaksPanel>
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
//
|
||||
// TweakRadio is the segmented control for 2–3 short options (auto-falls-back to
|
||||
// TweakSelect past ~16/~10 chars per label); reach for TweakSelect directly when
|
||||
// options are many or long. For color tweaks always curate 3-4 options rather than
|
||||
// a free picker; an option can also be a whole 2–5 color palette (the stored value
|
||||
// is the array). The Tweak* controls are a floor, not a ceiling — build custom
|
||||
// controls inside the panel if a tweak calls for UI they don't cover.
|
||||
/* END USAGE */
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const __TWEAKS_STYLE = `
|
||||
.twk-panel{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:280px;
|
||||
max-height:calc(100vh - 32px);display:flex;flex-direction:column;
|
||||
transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom right;
|
||||
background:rgba(250,249,247,.78);color:#29261b;
|
||||
-webkit-backdrop-filter:blur(24px) saturate(160%);backdrop-filter:blur(24px) saturate(160%);
|
||||
border:.5px solid rgba(255,255,255,.6);border-radius:14px;
|
||||
box-shadow:0 1px 0 rgba(255,255,255,.5) inset,0 12px 40px rgba(0,0,0,.18);
|
||||
font:11.5px/1.4 ui-sans-serif,system-ui,-apple-system,sans-serif;overflow:hidden}
|
||||
.twk-hd{display:flex;align-items:center;justify-content:space-between;
|
||||
padding:10px 8px 10px 14px;cursor:move;user-select:none}
|
||||
.twk-hd b{font-size:12px;font-weight:600;letter-spacing:.01em}
|
||||
.twk-x{appearance:none;border:0;background:transparent;color:rgba(41,38,27,.55);
|
||||
width:22px;height:22px;border-radius:6px;cursor:default;font-size:13px;line-height:1}
|
||||
.twk-x:hover{background:rgba(0,0,0,.06);color:#29261b}
|
||||
.twk-body{padding:2px 14px 14px;display:flex;flex-direction:column;gap:10px;
|
||||
overflow-y:auto;overflow-x:hidden;min-height:0;
|
||||
scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent}
|
||||
.twk-body::-webkit-scrollbar{width:8px}
|
||||
.twk-body::-webkit-scrollbar-track{background:transparent;margin:2px}
|
||||
.twk-body::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px;
|
||||
border:2px solid transparent;background-clip:content-box}
|
||||
.twk-body::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.25);
|
||||
border:2px solid transparent;background-clip:content-box}
|
||||
.twk-row{display:flex;flex-direction:column;gap:5px}
|
||||
.twk-row-h{flex-direction:row;align-items:center;justify-content:space-between;gap:10px}
|
||||
.twk-lbl{display:flex;justify-content:space-between;align-items:baseline;
|
||||
color:rgba(41,38,27,.72)}
|
||||
.twk-lbl>span:first-child{font-weight:500}
|
||||
.twk-val{color:rgba(41,38,27,.5);font-variant-numeric:tabular-nums}
|
||||
|
||||
.twk-sect{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;
|
||||
color:rgba(41,38,27,.45);padding:10px 0 0}
|
||||
.twk-sect:first-child{padding-top:0}
|
||||
|
||||
.twk-field{appearance:none;box-sizing:border-box;width:100%;min-width:0;height:26px;padding:0 8px;
|
||||
border:.5px solid rgba(0,0,0,.1);border-radius:7px;
|
||||
background:rgba(255,255,255,.6);color:inherit;font:inherit;outline:none}
|
||||
.twk-field:focus{border-color:rgba(0,0,0,.25);background:rgba(255,255,255,.85)}
|
||||
select.twk-field{padding-right:22px;
|
||||
background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path fill='rgba(0,0,0,.5)' d='M0 0h10L5 6z'/></svg>");
|
||||
background-repeat:no-repeat;background-position:right 8px center}
|
||||
|
||||
.twk-slider{appearance:none;-webkit-appearance:none;width:100%;height:4px;margin:6px 0;
|
||||
border-radius:999px;background:rgba(0,0,0,.12);outline:none}
|
||||
.twk-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;
|
||||
width:14px;height:14px;border-radius:50%;background:#fff;
|
||||
border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
|
||||
.twk-slider::-moz-range-thumb{width:14px;height:14px;border-radius:50%;
|
||||
background:#fff;border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
|
||||
|
||||
.twk-seg{position:relative;display:flex;padding:2px;border-radius:8px;
|
||||
background:rgba(0,0,0,.06);user-select:none}
|
||||
.twk-seg-thumb{position:absolute;top:2px;bottom:2px;border-radius:6px;
|
||||
background:rgba(255,255,255,.9);box-shadow:0 1px 2px rgba(0,0,0,.12);
|
||||
transition:left .15s cubic-bezier(.3,.7,.4,1),width .15s}
|
||||
.twk-seg.dragging .twk-seg-thumb{transition:none}
|
||||
.twk-seg button{appearance:none;position:relative;z-index:1;flex:1;border:0;
|
||||
background:transparent;color:inherit;font:inherit;font-weight:500;min-height:22px;
|
||||
border-radius:6px;cursor:default;padding:4px 6px;line-height:1.2;
|
||||
overflow-wrap:anywhere}
|
||||
|
||||
.twk-toggle{position:relative;width:32px;height:18px;border:0;border-radius:999px;
|
||||
background:rgba(0,0,0,.15);transition:background .15s;cursor:default;padding:0}
|
||||
.twk-toggle[data-on="1"]{background:#34c759}
|
||||
.twk-toggle i{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;
|
||||
background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s}
|
||||
.twk-toggle[data-on="1"] i{transform:translateX(14px)}
|
||||
|
||||
.twk-num{display:flex;align-items:center;box-sizing:border-box;min-width:0;height:26px;padding:0 0 0 8px;
|
||||
border:.5px solid rgba(0,0,0,.1);border-radius:7px;background:rgba(255,255,255,.6)}
|
||||
.twk-num-lbl{font-weight:500;color:rgba(41,38,27,.6);cursor:ew-resize;
|
||||
user-select:none;padding-right:8px}
|
||||
.twk-num input{flex:1;min-width:0;height:100%;border:0;background:transparent;
|
||||
font:inherit;font-variant-numeric:tabular-nums;text-align:right;padding:0 8px 0 0;
|
||||
outline:none;color:inherit;-moz-appearance:textfield}
|
||||
.twk-num input::-webkit-inner-spin-button,.twk-num input::-webkit-outer-spin-button{
|
||||
-webkit-appearance:none;margin:0}
|
||||
.twk-num-unit{padding-right:8px;color:rgba(41,38,27,.45)}
|
||||
|
||||
.twk-btn{appearance:none;height:26px;padding:0 12px;border:0;border-radius:7px;
|
||||
background:rgba(0,0,0,.78);color:#fff;font:inherit;font-weight:500;cursor:default}
|
||||
.twk-btn:hover{background:rgba(0,0,0,.88)}
|
||||
.twk-btn.secondary{background:rgba(0,0,0,.06);color:inherit}
|
||||
.twk-btn.secondary:hover{background:rgba(0,0,0,.1)}
|
||||
|
||||
.twk-swatch{appearance:none;-webkit-appearance:none;width:56px;height:22px;
|
||||
border:.5px solid rgba(0,0,0,.1);border-radius:6px;padding:0;cursor:default;
|
||||
background:transparent;flex-shrink:0}
|
||||
.twk-swatch::-webkit-color-swatch-wrapper{padding:0}
|
||||
.twk-swatch::-webkit-color-swatch{border:0;border-radius:5.5px}
|
||||
.twk-swatch::-moz-color-swatch{border:0;border-radius:5.5px}
|
||||
|
||||
.twk-chips{display:flex;gap:6px}
|
||||
.twk-chip{position:relative;appearance:none;flex:1;min-width:0;height:46px;
|
||||
padding:0;border:0;border-radius:6px;overflow:hidden;cursor:default;
|
||||
box-shadow:0 0 0 .5px rgba(0,0,0,.12),0 1px 2px rgba(0,0,0,.06);
|
||||
transition:transform .12s cubic-bezier(.3,.7,.4,1),box-shadow .12s}
|
||||
.twk-chip:hover{transform:translateY(-1px);
|
||||
box-shadow:0 0 0 .5px rgba(0,0,0,.18),0 4px 10px rgba(0,0,0,.12)}
|
||||
.twk-chip[data-on="1"]{box-shadow:0 0 0 1.5px rgba(0,0,0,.85),
|
||||
0 2px 6px rgba(0,0,0,.15)}
|
||||
.twk-chip>span{position:absolute;top:0;bottom:0;right:0;width:34%;
|
||||
display:flex;flex-direction:column;box-shadow:-1px 0 0 rgba(0,0,0,.1)}
|
||||
.twk-chip>span>i{flex:1;box-shadow:0 -1px 0 rgba(0,0,0,.1)}
|
||||
.twk-chip>span>i:first-child{box-shadow:none}
|
||||
.twk-chip svg{position:absolute;top:6px;left:6px;width:13px;height:13px;
|
||||
filter:drop-shadow(0 1px 1px rgba(0,0,0,.3))}
|
||||
`;
|
||||
|
||||
// ── useTweaks ───────────────────────────────────────────────────────────────
|
||||
// Single source of truth for tweak values. setTweak persists via the host
|
||||
// (miaoda:tweaks:set-keys → host rewrites the EDITMODE block on disk).
|
||||
function useTweaks(defaults) {
|
||||
const [values, setValues] = React.useState(defaults);
|
||||
// Accepts either setTweak('key', value) or setTweak({ key: value, ... }) so a
|
||||
// useState-style call doesn't write a "[object Object]" key into the persisted
|
||||
// JSON block.
|
||||
const setTweak = React.useCallback((keyOrEdits, val) => {
|
||||
const edits = typeof keyOrEdits === 'object' && keyOrEdits !== null
|
||||
? keyOrEdits : { [keyOrEdits]: val };
|
||||
setValues((prev) => ({ ...prev, ...edits }));
|
||||
window.parent.postMessage({ type: 'miaoda:tweaks:set-keys', edits }, '*');
|
||||
// Same-window signal so in-page listeners (deck-stage rail thumbnails)
|
||||
// can react — the parent message only reaches the host, not peers.
|
||||
window.dispatchEvent(new CustomEvent('tweakchange', { detail: edits }));
|
||||
}, []);
|
||||
return [values, setTweak];
|
||||
}
|
||||
|
||||
// ── TweaksPanel ─────────────────────────────────────────────────────────────
|
||||
// Floating shell. Registers the protocol listener BEFORE announcing
|
||||
// availability — if the announce ran first, the host's activate could land
|
||||
// before our handler exists and the toolbar toggle would silently no-op.
|
||||
// The close button posts miaoda:tweaks:dismissed so the host's toolbar toggle
|
||||
// flips off in lockstep; the host echoes miaoda:tweaks:deactivate back which
|
||||
// is what actually hides the panel.
|
||||
function TweaksPanel({ title = 'Tweaks', children }) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const dragRef = React.useRef(null);
|
||||
const offsetRef = React.useRef({ x: 16, y: 16 });
|
||||
const PAD = 16;
|
||||
|
||||
const clampToViewport = React.useCallback(() => {
|
||||
const panel = dragRef.current;
|
||||
if (!panel) return;
|
||||
const w = panel.offsetWidth, h = panel.offsetHeight;
|
||||
const maxRight = Math.max(PAD, window.innerWidth - w - PAD);
|
||||
const maxBottom = Math.max(PAD, window.innerHeight - h - PAD);
|
||||
offsetRef.current = {
|
||||
x: Math.min(maxRight, Math.max(PAD, offsetRef.current.x)),
|
||||
y: Math.min(maxBottom, Math.max(PAD, offsetRef.current.y)),
|
||||
};
|
||||
panel.style.right = offsetRef.current.x + 'px';
|
||||
panel.style.bottom = offsetRef.current.y + 'px';
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
clampToViewport();
|
||||
if (typeof ResizeObserver === 'undefined') {
|
||||
window.addEventListener('resize', clampToViewport);
|
||||
return () => window.removeEventListener('resize', clampToViewport);
|
||||
}
|
||||
const ro = new ResizeObserver(clampToViewport);
|
||||
ro.observe(document.documentElement);
|
||||
return () => ro.disconnect();
|
||||
}, [open, clampToViewport]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const onMsg = (e) => {
|
||||
const t = e?.data?.type;
|
||||
if (t === 'miaoda:tweaks:activate') setOpen(true);
|
||||
else if (t === 'miaoda:tweaks:deactivate') setOpen(false);
|
||||
};
|
||||
window.addEventListener('message', onMsg);
|
||||
window.parent.postMessage({ type: 'miaoda:tweaks:available' }, '*');
|
||||
return () => window.removeEventListener('message', onMsg);
|
||||
}, []);
|
||||
|
||||
const dismiss = () => {
|
||||
setOpen(false);
|
||||
window.parent.postMessage({ type: 'miaoda:tweaks:dismissed' }, '*');
|
||||
};
|
||||
|
||||
const onDragStart = (e) => {
|
||||
const panel = dragRef.current;
|
||||
if (!panel) return;
|
||||
const r = panel.getBoundingClientRect();
|
||||
const sx = e.clientX, sy = e.clientY;
|
||||
const startRight = window.innerWidth - r.right;
|
||||
const startBottom = window.innerHeight - r.bottom;
|
||||
const move = (ev) => {
|
||||
offsetRef.current = {
|
||||
x: startRight - (ev.clientX - sx),
|
||||
y: startBottom - (ev.clientY - sy),
|
||||
};
|
||||
clampToViewport();
|
||||
};
|
||||
const up = () => {
|
||||
window.removeEventListener('mousemove', move);
|
||||
window.removeEventListener('mouseup', up);
|
||||
};
|
||||
window.addEventListener('mousemove', move);
|
||||
window.addEventListener('mouseup', up);
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
return (
|
||||
<>
|
||||
<style>{__TWEAKS_STYLE}</style>
|
||||
<div ref={dragRef} className="twk-panel" data-miaoda-chrome=""
|
||||
style={{ right: offsetRef.current.x, bottom: offsetRef.current.y }}>
|
||||
<div className="twk-hd" onMouseDown={onDragStart}>
|
||||
<b>{title}</b>
|
||||
<button className="twk-x" aria-label="Close tweaks"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={dismiss}>✕</button>
|
||||
</div>
|
||||
<div className="twk-body">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Layout helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
function TweakSection({ label, children }) {
|
||||
return (
|
||||
<>
|
||||
<div className="twk-sect">{label}</div>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TweakRow({ label, value, children, inline = false }) {
|
||||
return (
|
||||
<div className={inline ? 'twk-row twk-row-h' : 'twk-row'}>
|
||||
<div className="twk-lbl">
|
||||
<span>{label}</span>
|
||||
{value != null && <span className="twk-val">{value}</span>}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Controls ────────────────────────────────────────────────────────────────
|
||||
|
||||
function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) {
|
||||
return (
|
||||
<TweakRow label={label} value={`${value}${unit}`}>
|
||||
<input type="range" className="twk-slider" min={min} max={max} step={step}
|
||||
value={value} onChange={(e) => onChange(Number(e.target.value))} />
|
||||
</TweakRow>
|
||||
);
|
||||
}
|
||||
|
||||
function TweakToggle({ label, value, onChange }) {
|
||||
return (
|
||||
<div className="twk-row twk-row-h">
|
||||
<div className="twk-lbl"><span>{label}</span></div>
|
||||
<button type="button" className="twk-toggle" data-on={value ? '1' : '0'}
|
||||
role="switch" aria-checked={!!value}
|
||||
onClick={() => onChange(!value)}><i /></button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TweakRadio({ label, value, options, onChange }) {
|
||||
const trackRef = React.useRef(null);
|
||||
const [dragging, setDragging] = React.useState(false);
|
||||
// The active value is read by pointer-move handlers attached for the lifetime
|
||||
// of a drag — ref it so a stale closure doesn't fire onChange for every move.
|
||||
const valueRef = React.useRef(value);
|
||||
valueRef.current = value;
|
||||
|
||||
// Segments wrap mid-word once per-segment width runs out. The track is
|
||||
// ~248px (280 panel − 28 body pad − 4 seg pad), each button loses 12px
|
||||
// to its own padding, and 11.5px system-ui averages ~6.3px/char — so 2
|
||||
// options fit ~16 chars each, 3 fit ~10. Past that (or >3 options), fall
|
||||
// back to a dropdown rather than wrap.
|
||||
const labelLen = (o) => String(typeof o === 'object' ? o.label : o).length;
|
||||
const maxLen = options.reduce((m, o) => Math.max(m, labelLen(o)), 0);
|
||||
const fitsAsSegments = maxLen <= ({ 2: 16, 3: 10 }[options.length] ?? 0);
|
||||
if (!fitsAsSegments) {
|
||||
// <select> emits strings — map back to the original option value so the
|
||||
// fallback stays type-preserving (numbers, booleans) like the segment path.
|
||||
const resolve = (s) => {
|
||||
const m = options.find((o) => String(typeof o === 'object' ? o.value : o) === s);
|
||||
return m === undefined ? s : typeof m === 'object' ? m.value : m;
|
||||
};
|
||||
return <TweakSelect label={label} value={value} options={options}
|
||||
onChange={(s) => onChange(resolve(s))} />;
|
||||
}
|
||||
const opts = options.map((o) => (typeof o === 'object' ? o : { value: o, label: o }));
|
||||
const idx = Math.max(0, opts.findIndex((o) => o.value === value));
|
||||
const n = opts.length;
|
||||
|
||||
const segAt = (clientX) => {
|
||||
const r = trackRef.current.getBoundingClientRect();
|
||||
const inner = r.width - 4;
|
||||
const i = Math.floor(((clientX - r.left - 2) / inner) * n);
|
||||
return opts[Math.max(0, Math.min(n - 1, i))].value;
|
||||
};
|
||||
|
||||
const onPointerDown = (e) => {
|
||||
setDragging(true);
|
||||
const v0 = segAt(e.clientX);
|
||||
if (v0 !== valueRef.current) onChange(v0);
|
||||
const move = (ev) => {
|
||||
if (!trackRef.current) return;
|
||||
const v = segAt(ev.clientX);
|
||||
if (v !== valueRef.current) onChange(v);
|
||||
};
|
||||
const up = () => {
|
||||
setDragging(false);
|
||||
window.removeEventListener('pointermove', move);
|
||||
window.removeEventListener('pointerup', up);
|
||||
};
|
||||
window.addEventListener('pointermove', move);
|
||||
window.addEventListener('pointerup', up);
|
||||
};
|
||||
|
||||
return (
|
||||
<TweakRow label={label}>
|
||||
<div ref={trackRef} role="radiogroup" onPointerDown={onPointerDown}
|
||||
className={dragging ? 'twk-seg dragging' : 'twk-seg'}>
|
||||
<div className="twk-seg-thumb"
|
||||
style={{ left: `calc(2px + ${idx} * (100% - 4px) / ${n})`,
|
||||
width: `calc((100% - 4px) / ${n})` }} />
|
||||
{opts.map((o) => (
|
||||
<button key={o.value} type="button" role="radio" aria-checked={o.value === value}>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</TweakRow>
|
||||
);
|
||||
}
|
||||
|
||||
function TweakSelect({ label, value, options, onChange }) {
|
||||
return (
|
||||
<TweakRow label={label}>
|
||||
<select className="twk-field" value={value} onChange={(e) => onChange(e.target.value)}>
|
||||
{options.map((o) => {
|
||||
const v = typeof o === 'object' ? o.value : o;
|
||||
const l = typeof o === 'object' ? o.label : o;
|
||||
return <option key={v} value={v}>{l}</option>;
|
||||
})}
|
||||
</select>
|
||||
</TweakRow>
|
||||
);
|
||||
}
|
||||
|
||||
function TweakText({ label, value, placeholder, onChange }) {
|
||||
return (
|
||||
<TweakRow label={label}>
|
||||
<input className="twk-field" type="text" value={value} placeholder={placeholder}
|
||||
onChange={(e) => onChange(e.target.value)} />
|
||||
</TweakRow>
|
||||
);
|
||||
}
|
||||
|
||||
function TweakNumber({ label, value, min, max, step = 1, unit = '', onChange }) {
|
||||
const clamp = (n) => {
|
||||
if (min != null && n < min) return min;
|
||||
if (max != null && n > max) return max;
|
||||
return n;
|
||||
};
|
||||
const startRef = React.useRef({ x: 0, val: 0 });
|
||||
const onScrubStart = (e) => {
|
||||
e.preventDefault();
|
||||
startRef.current = { x: e.clientX, val: value };
|
||||
const decimals = (String(step).split('.')[1] || '').length;
|
||||
const move = (ev) => {
|
||||
const dx = ev.clientX - startRef.current.x;
|
||||
const raw = startRef.current.val + dx * step;
|
||||
const snapped = Math.round(raw / step) * step;
|
||||
onChange(clamp(Number(snapped.toFixed(decimals))));
|
||||
};
|
||||
const up = () => {
|
||||
window.removeEventListener('pointermove', move);
|
||||
window.removeEventListener('pointerup', up);
|
||||
};
|
||||
window.addEventListener('pointermove', move);
|
||||
window.addEventListener('pointerup', up);
|
||||
};
|
||||
return (
|
||||
<div className="twk-num">
|
||||
<span className="twk-num-lbl" onPointerDown={onScrubStart}>{label}</span>
|
||||
<input type="number" value={value} min={min} max={max} step={step}
|
||||
onChange={(e) => onChange(clamp(Number(e.target.value)))} />
|
||||
{unit && <span className="twk-num-unit">{unit}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Relative-luminance contrast pick — checkmarks drawn over a swatch need to
|
||||
// read on both #111 and #fafafa without per-option configuration. Hex input
|
||||
// only (#rgb / #rrggbb); named or rgb()/hsl() colors fall through to "light".
|
||||
function __twkIsLight(hex) {
|
||||
const h = String(hex).replace('#', '');
|
||||
const x = h.length === 3 ? h.replace(/./g, (c) => c + c) : h.padEnd(6, '0');
|
||||
const n = parseInt(x.slice(0, 6), 16);
|
||||
if (Number.isNaN(n)) return true;
|
||||
const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
|
||||
return r * 299 + g * 587 + b * 114 > 148000;
|
||||
}
|
||||
|
||||
const __TwkCheck = ({ light }) => (
|
||||
<svg viewBox="0 0 14 14" aria-hidden="true">
|
||||
<path d="M3 7.2 5.8 10 11 4.2" fill="none" strokeWidth="2.2"
|
||||
strokeLinecap="round" strokeLinejoin="round"
|
||||
stroke={light ? 'rgba(0,0,0,.78)' : '#fff'} />
|
||||
</svg>
|
||||
);
|
||||
|
||||
// TweakColor — curated color/palette picker. Each option is either a single
|
||||
// hex string or an array of 1-5 hex strings; the card adapts — a lone color
|
||||
// renders solid, a palette renders colors[0] as the hero (left ~2/3) with the
|
||||
// rest stacked in a sharp column on the right. onChange emits the
|
||||
// option in the shape it was passed (string stays string, array stays array).
|
||||
// Without options it falls back to the native color input for back-compat.
|
||||
function TweakColor({ label, value, options, onChange }) {
|
||||
if (!options || !options.length) {
|
||||
return (
|
||||
<div className="twk-row twk-row-h">
|
||||
<div className="twk-lbl"><span>{label}</span></div>
|
||||
<input type="color" className="twk-swatch" value={value}
|
||||
onChange={(e) => onChange(e.target.value)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Native <input type=color> emits lowercase hex per the HTML spec, so
|
||||
// compare case-insensitively. String() guards JSON.stringify(undefined),
|
||||
// which returns the primitive undefined (no .toLowerCase).
|
||||
const key = (o) => String(JSON.stringify(o)).toLowerCase();
|
||||
const cur = key(value);
|
||||
return (
|
||||
<TweakRow label={label}>
|
||||
<div className="twk-chips" role="radiogroup">
|
||||
{options.map((o, i) => {
|
||||
const colors = Array.isArray(o) ? o : [o];
|
||||
const [hero, ...rest] = colors;
|
||||
const sup = rest.slice(0, 4);
|
||||
const on = key(o) === cur;
|
||||
return (
|
||||
<button key={i} type="button" className="twk-chip" role="radio"
|
||||
aria-checked={on} data-on={on ? '1' : '0'}
|
||||
aria-label={colors.join(', ')} title={colors.join(' · ')}
|
||||
style={{ background: hero }}
|
||||
onClick={() => onChange(o)}>
|
||||
{sup.length > 0 && (
|
||||
<span>
|
||||
{sup.map((c, j) => <i key={j} style={{ background: c }} />)}
|
||||
</span>
|
||||
)}
|
||||
{on && <__TwkCheck light={__twkIsLight(hero)} />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TweakRow>
|
||||
);
|
||||
}
|
||||
|
||||
function TweakButton({ label, onClick, secondary = false }) {
|
||||
return (
|
||||
<button type="button" className={secondary ? 'twk-btn secondary' : 'twk-btn'}
|
||||
onClick={onClick}>{label}</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Opt out of DCViewport's transform so position:fixed works against the viewport.
|
||||
TweaksPanel.dcOverlay = true;
|
||||
|
||||
Object.assign(window, {
|
||||
useTweaks, TweaksPanel, TweakSection, TweakRow,
|
||||
TweakSlider, TweakToggle, TweakRadio, TweakSelect,
|
||||
TweakText, TweakNumber, TweakColor, TweakButton,
|
||||
});
|
||||
|
||||
// ── TweakSuggestionBar (flag-gated addon) ───────────────────────────────────
|
||||
(function () {
|
||||
const s = document.createElement('style');
|
||||
s.textContent = `
|
||||
@keyframes twk-blink{50%{opacity:0}}
|
||||
@keyframes twk-fadein{from{opacity:0;transform:translateX(4px)}to{opacity:1;transform:none}}
|
||||
.twk-sugg{display:flex;align-items:center;gap:6px;padding:5px 8px;border-radius:8px;
|
||||
background:rgba(0,0,0,.04);border:.5px solid rgba(0,0,0,.06);transition:all .15s}
|
||||
.twk-sugg:focus-within{background:rgba(0,0,0,.06);border-color:rgba(0,0,0,.12)}
|
||||
.twk-sugg-field{position:relative;flex:1;min-width:0}
|
||||
.twk-sugg-field input{width:100%;height:20px;border:0;background:transparent;
|
||||
font:inherit;outline:none;color:inherit}
|
||||
.twk-sugg-ghost{position:absolute;inset:0;display:flex;align-items:center;
|
||||
color:rgba(41,38,27,.42);pointer-events:none;white-space:nowrap;overflow:hidden}
|
||||
.twk-sugg-ghost.hint{color:rgba(41,38,27,.28)}
|
||||
.twk-sugg-caret{display:inline-block;width:1px;height:13px;margin-left:1px;
|
||||
border-right:1.5px solid currentColor;opacity:.5;animation:twk-blink 1s step-end infinite}
|
||||
.twk-sugg-ideas{appearance:none;border:0;background:transparent;font:inherit;
|
||||
font-size:10.5px;font-weight:600;color:rgba(41,38,27,.6);cursor:default;padding:0 2px;
|
||||
white-space:nowrap;animation:twk-fadein .25s ease}
|
||||
.twk-sugg-ideas:hover{color:rgba(41,38,27,.85)}
|
||||
.twk-sugg-ideas svg{color:#D97757}
|
||||
.twk-sugg-send{appearance:none;border:0;height:20px;padding:0 8px;border-radius:5px;
|
||||
background:#29261b;color:#fff;font:inherit;font-size:10px;font-weight:600;cursor:default}
|
||||
`;
|
||||
document.head.appendChild(s);
|
||||
})();
|
||||
|
||||
const __twkSendChat = (text) =>
|
||||
window.parent.postMessage({ type: 'miaoda:tweaks:chat', text }, '*');
|
||||
|
||||
const __TWK_SPARK_PATH = 'M18.3658 62.2435L36.7823 51.9165L37.0858 51.012L36.7823 50.5083H35.8716L32.7853 50.3206L22.2616 50.0389L13.1546 49.6634L4.30054 49.194L2.07438 48.7246L0 45.9551L0.202378 44.5938L2.07438 43.3264L4.75589 43.5611L10.6755 43.9836L19.5801 44.5938L26.0056 44.9693L35.568 45.9551H37.0858L37.2882 45.3448L36.7823 44.9693L36.3775 44.5938L27.1693 38.3507L17.2022 31.7789L11.9909 27.9767L9.20822 26.0522L7.79157 24.2684L7.18443 20.3254L9.71416 17.5089L13.1546 17.7436L14.0147 17.9783L17.5057 20.654L24.9431 26.4277L34.6573 33.5627L36.0739 34.7362L36.6444 34.3512L36.7317 34.079L36.0739 32.9994L30.8121 23.4704L25.1961 13.7537L22.6664 9.71675L22.0086 7.32277C21.7539 6.31812 21.6039 5.48695 21.6039 4.45938L24.4878 0.516349L26.1068 0L30.0026 0.516349L31.6216 1.92457L34.0502 7.46359L37.9459 16.1476L44.0173 27.9767L45.7881 31.4973L46.7494 34.7362L47.1036 35.722H47.7107V35.1587L48.2166 28.4931L49.1274 20.3254L50.0381 9.81063L50.3416 6.85336L51.8089 3.28586L54.7434 1.36128L57.0201 2.44092L58.8921 5.11655L58.6391 6.85336L57.5261 14.0822L55.3505 25.395L53.9338 32.9994H54.7434L55.7047 32.0136L59.5498 26.944L65.9753 18.8702L68.8086 15.6782L72.1479 12.1577L74.2729 10.4678H78.3204L81.2549 14.8802L79.9395 19.4335L75.7907 24.6909L72.3503 29.1503L67.4173 35.7593L64.3563 41.0732L64.6308 41.5116L65.3682 41.4487L76.499 39.0548L82.5198 37.9751L89.7042 36.7547L92.9423 38.2568L93.2964 39.8058L92.0316 42.9509L84.3412 44.8285L75.3354 46.6592L61.9245 49.8162L61.776 49.9356L61.9513 50.1956L67.9991 50.743L70.5795 50.8839H76.9038L88.6923 51.7757L91.7786 53.7942L93.6 56.282L93.2964 58.2066L88.5405 60.6006L82.1656 59.0985L67.2402 55.531L62.1302 54.2636H61.4218V54.6861L65.6718 58.8638L73.514 65.9049L83.2787 75.0114L83.7846 77.2646L82.5198 79.0483L81.2043 78.8606L72.6032 72.3827L69.264 69.4724L61.776 63.1354H61.2701V63.7926L62.9903 66.3274L72.1479 80.081L72.6032 84.3057L71.9455 85.667L69.5676 86.5119L66.9872 86.0425L61.5736 78.4851L56.0588 70.0357L51.6065 62.4313L51.0687 62.7708L48.419 91.0652L47.2048 92.5204L44.3715 93.6L41.9935 91.8162L40.7286 88.9059L41.9935 83.1322L43.5114 75.6217L44.7256 69.6602L45.8387 62.2435L46.5185 59.7659L46.4584 59.6001L45.9153 59.6914L40.3239 67.3601L31.824 78.8606L25.0949 86.0425L23.4759 86.6997L20.6932 85.2445L20.9462 82.6628L22.5146 80.3627L31.824 68.5336L37.44 61.1639L41.0595 56.9335L41.0243 56.3216L40.8245 56.3046L16.0891 72.4297L11.6874 72.993L9.76476 71.2092L10.0177 68.2989L10.9284 67.3601L18.3658 62.2435Z';
|
||||
|
||||
function ClaudeSpark({ size = 12 }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 94 94" fill="currentColor"
|
||||
style={{ display: 'inline-block', verticalAlign: '-1px' }}>
|
||||
<path d={__TWK_SPARK_PATH} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Typewriter-cycles through `suggestions`. Clicking the field while a
|
||||
// suggestion is animating freezes it as ghost text; Tab accepts it into the
|
||||
// input. Enter posts miaoda:tweaks:chat (host drops the text into the chat
|
||||
// composer for the user to send). After the cycle the static placeholder
|
||||
// types in and "Ideas" appears — clicking asks for three more suggestions.
|
||||
function TweakSuggestionBar({
|
||||
suggestions = [],
|
||||
placeholder = 'Describe a tweak…',
|
||||
ideasPrompt = 'Suggest three more tweak ideas for this design and update the suggestions on TweakSuggestionBar.',
|
||||
}) {
|
||||
const [val, setVal] = React.useState('');
|
||||
const [ghost, setGhost] = React.useState('');
|
||||
const [focused, setFocused] = React.useState(false);
|
||||
const inputRef = React.useRef(null);
|
||||
const tw = useTwkTypewriter(suggestions, { placeholder, enabled: !val && !ghost && !focused });
|
||||
|
||||
const freeze = () => {
|
||||
tw.markPlayed();
|
||||
if (val || ghost) return;
|
||||
const target = !tw.done ? suggestions[tw.idx] : '';
|
||||
if (target) setGhost(target);
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
const v = (val || ghost).trim();
|
||||
if (!v) return;
|
||||
__twkSendChat(v);
|
||||
setVal('');
|
||||
setGhost('');
|
||||
};
|
||||
|
||||
const onKeyDown = (e) => {
|
||||
if (e.key === 'Tab' && ghost && !val) {
|
||||
e.preventDefault();
|
||||
setVal(ghost);
|
||||
setGhost('');
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
} else if (e.key === 'Escape') {
|
||||
setGhost('');
|
||||
}
|
||||
};
|
||||
|
||||
const requestIdeas = () => __twkSendChat(ideasPrompt);
|
||||
|
||||
const showAnim = !val && !ghost && !focused && !tw.done;
|
||||
const showStatic = !val && !ghost && !focused && tw.done;
|
||||
|
||||
return (
|
||||
<div className="twk-sugg" onMouseDown={freeze}>
|
||||
<div className="twk-sugg-field">
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={val}
|
||||
placeholder={focused && !ghost ? placeholder : ''}
|
||||
onChange={(e) => { setVal(e.target.value); setGhost(''); }}
|
||||
onFocus={() => { setFocused(true); tw.markPlayed(); }}
|
||||
onBlur={() => { setFocused(false); if (!val) setGhost(''); }}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
{showAnim && (
|
||||
<div className="twk-sugg-ghost">
|
||||
{tw.text}<span className="twk-sugg-caret" />
|
||||
</div>
|
||||
)}
|
||||
{showStatic && (
|
||||
<div className="twk-sugg-ghost">
|
||||
{tw.tail}{tw.tail.length < placeholder.length && <span className="twk-sugg-caret" />}
|
||||
</div>
|
||||
)}
|
||||
{ghost && !val && (
|
||||
<div className="twk-sugg-ghost hint">{ghost}</div>
|
||||
)}
|
||||
</div>
|
||||
{val || ghost ? (
|
||||
<button className="twk-sugg-send"
|
||||
onMouseDown={(e) => { e.stopPropagation(); e.preventDefault(); }}
|
||||
onClick={submit}>
|
||||
Add
|
||||
</button>
|
||||
) : tw.done && !focused ? (
|
||||
<button className="twk-sugg-ideas"
|
||||
onMouseDown={(e) => { e.stopPropagation(); e.preventDefault(); }}
|
||||
onClick={requestIdeas}>
|
||||
Ideas <ClaudeSpark />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Minimal type→pause→erase cycler. Plays once per unique `items` content per
|
||||
// session — a reload from a tweak-value write skips straight to done; a new
|
||||
// suggestion set (after "Ideas") gets a fresh animation.
|
||||
function useTwkTypewriter(items, { placeholder, typeMs = 35, eraseMs = 22, pauseMs = 1800, enabled = true } = {}) {
|
||||
const key = React.useMemo(() => '__twk_played:' + JSON.stringify(items), [items.join('\n')]);
|
||||
const played = () => { try { return sessionStorage.getItem(key) === '1'; } catch { return false; } };
|
||||
|
||||
const [text, setText] = React.useState('');
|
||||
const [tail, setTail] = React.useState(() => (items.length === 0 || played() ? placeholder : ''));
|
||||
const [idx, setIdx] = React.useState(0);
|
||||
const [done, setDone] = React.useState(() => items.length === 0 || played());
|
||||
const phase = React.useRef('type');
|
||||
const n = React.useRef(0);
|
||||
|
||||
const markPlayed = React.useCallback(() => {
|
||||
try { sessionStorage.setItem(key, '1'); } catch {}
|
||||
setDone(true);
|
||||
}, [key]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const skip = items.length === 0 || played();
|
||||
setText(''); setIdx(0);
|
||||
setDone(skip);
|
||||
setTail(skip ? placeholder : '');
|
||||
phase.current = 'type'; n.current = 0;
|
||||
}, [key]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (done || !enabled) return;
|
||||
const item = items[idx] ?? '';
|
||||
let t;
|
||||
const tick = () => {
|
||||
if (phase.current === 'type') {
|
||||
n.current++;
|
||||
setText(item.slice(0, n.current));
|
||||
if (n.current >= item.length) { phase.current = 'pause'; t = setTimeout(tick, pauseMs); }
|
||||
else t = setTimeout(tick, typeMs + Math.random() * 20);
|
||||
} else if (phase.current === 'pause') {
|
||||
phase.current = 'erase'; t = setTimeout(tick, eraseMs);
|
||||
} else {
|
||||
n.current--;
|
||||
setText(item.slice(0, n.current));
|
||||
if (n.current <= 0) {
|
||||
if (idx === items.length - 1) { markPlayed(); return; }
|
||||
phase.current = 'type'; setIdx((i) => i + 1);
|
||||
} else t = setTimeout(tick, eraseMs);
|
||||
}
|
||||
};
|
||||
phase.current = 'type'; n.current = 0; setText('');
|
||||
t = setTimeout(tick, 400);
|
||||
return () => clearTimeout(t);
|
||||
}, [idx, done, key, enabled, typeMs, eraseMs, pauseMs]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!done || tail === placeholder) return;
|
||||
let i = 0;
|
||||
const t = setInterval(() => {
|
||||
i++; setTail(placeholder.slice(0, i));
|
||||
if (i >= placeholder.length) clearInterval(t);
|
||||
}, 28);
|
||||
return () => clearInterval(t);
|
||||
}, [done, placeholder]);
|
||||
|
||||
return { text, tail, idx, done, markPlayed };
|
||||
}
|
||||
|
||||
Object.assign(window, { TweakSuggestionBar });
|
||||
Reference in New Issue
Block a user