diff --git a/docs/design/skin-system/.gitignore b/docs/design/skin-system/.gitignore new file mode 100644 index 00000000..c2658d7d --- /dev/null +++ b/docs/design/skin-system/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/docs/design/skin-system/README.md b/docs/design/skin-system/README.md new file mode 100644 index 00000000..088ede4f --- /dev/null +++ b/docs/design/skin-system/README.md @@ -0,0 +1,118 @@ +# MXU 皮肤系统设计讨论 + +**当前状态:通用皮肤协议仍为设计提案;白饭鲸鱼娘与奶蛙已作为两套可信内置皮肤接入 MXU 设置与任务主视图。** 本目录另保留八场景离线概念 demo,用于讨论协议能力与交互。字段、命名和分级尚未冻结,当前内置接入不等于完整 L0/L1 运行时。 + +建议复用 MXU 已有的 React / Tauri Web 渲染能力,用 HTML、CSS、SVG 表达皮肤,再由 MXU 提供包加载、稳定锚点、布局占位、装饰图层和状态事件。普通作者可以只换颜色;复杂作者可以让角色挤开按钮、让机甲武器伸出面板,或让任务状态变成界面里的故事。 + +这是通用皮肤协议,不是特定皮肤评选。MXU 内置、项目随包附带和用户自行导入都是目标来源;项目附带场景也须通过第三方内容校验,来源不扩大权限。先实现 L0 与宿主契约、再验证内置 L1,第三方场景等隔离、专用安装与恢复门槛完成后开放,具体版本节奏尚未冻结。 + +- [完整协议草案](protocol.md) +- [24 条扩展创意](ideas.md) +- [交互 demo 源码入口](demo/index.html) +- [Manifest 示例](demo/examples/mascot.skin.json)(仅清单草案,不能直接安装) +- [白饭鲸鱼娘与奶蛙:离线入口、预设及素材来源](meme-skins.md) +- [PR Review 核对与本轮处理](review-notes.md) + +## 主程序中切换内置皮肤 + +进入 **设置 → 外观 → 内置皮肤**,可选择默认、蓝色大肥鱼吃白饭或奶蛙,并调整皮肤的减少动效设置。原有的跟随系统 / 浅色 / 深色、强调色、自定义强调色与背景图选项继续独立生效。选择默认会移除角色卡片。 + +两套角色在任务主视图的工具栏前以普通布局卡片显示,设置页提供预览;切换只更新外观,不启动、停止或重置业务任务。主程序只读活动实例的运行事实:运行中优先,其次显示整批成功或失败,其余为待机。没有暂停、取消或百分比进度的事实来源时,不从离线 demo 复制这些状态。 + +角色只展示既有公开素材中的动作,不添加对白、拟声台词或额外剧情。旁边的文字是宿主任务状态和操作提示。 + +桌面端通过现有项目外观配置保存 `skinId` 与 `skinReducedMotion`;WebUI 使用浏览器本地外观存储,保存其他配置时保留后端原始皮肤值,避免覆盖桌面选择。资源来自随前端打包的 `src/assets/skins/`,由宿主 React 组件渲染,不读取作者 HTML、脚本或第三方皮肤包。 + +通用 Manifest 解释器、锚点协商、越框图层、第三方安装与隔离仍是后续工作;本次内置卡片不对这些能力作实现承诺。 + +下面为主程序 WebUI 的受控后端测试截图: + +| 设置中的皮肤选择与预览 | 任务视图中的奶蛙 | +| --- | --- | +| ![内置皮肤设置](images/09-settings-skins.png) | ![任务视图内置奶蛙](images/10-main-naiwa.png) | + +## 离线 Demo 的八个场景 + +下图均为**静态设计示意,非 MXU 实机截图**。它们使用 demo 的场景素材来展示设计方向;动画和交互请运行本目录中的 demo 查看。 + +| 捣蛋团子:角色申请真实占位 | 边界破坏者:装饰越过面板边框 | +| ------------------------------------------------------------------- | ------------------------------------------------------------------------ | +| ![捣蛋团子:角色进入操作栏,真实按钮让出空间](images/01-mascot.png) | ![边界破坏者:机甲与长枪伸出面板,装饰不接收点击](images/02-mecha.png) | +| 微型生态舱:状态驱动生长 | 黑胶工作台:运行与暂停的视觉隐喻 | +| ![微型生态舱:任务进度驱动植物与生态舱的表现](images/03-garden.png) | ![黑胶工作台:运行时唱针落下、唱片旋转,暂停时停转](images/04-vinyl.png) | +| 纸上事务所:材质与完成反馈 | 深海声呐站:状态成为环境氛围 | +| ![纸上事务所:叠纸、折角和完成印章](images/05-paper.png) | ![深海声呐站:运行时扫描与信号闪烁](images/06-sonar.png) | + +前六套概念图同时保留同名 SVG 源文件,可用矢量编辑器修改;PNG 便于 GitHub 直接预览。 + +新增两套网络热梗场景:**白饭鲸鱼娘**使用蓝发、鲸鳍、鲸尾、蓝白女仆装与白饭碗;**奶蛙**采用实际社区原片中的三维黄色大肚形态,保留浅色肚皮、绿色眼睛、圆滑有肉的四肢及深褐手脚掌,主动作是仰头捧腹大笑。离线 demo 的角色互动与模拟任务事实分开,失败和取消时优先保留明确状态。两套均为非官方二创场景,不代表相关品牌或原作的官方皮肤。 + +白饭鲸鱼娘直接采用 [PC2005-cloud/dsh-pet 的既有社区吃白饭动画](https://github.com/PC2005-cloud/dsh-pet/blob/33938a355f422525886c3facd883d7179c58ebf4/dsh-pet/assets/webm/%E5%90%83%E7%99%BD%E9%A5%AD.webm)和同源视频的透明静态帧。原动画转为白底无声 H.264,保留原动作;素材按来源注明的 CC BY-NC-SA 4.0 保留 PC2005-cloud、原型作者上善无形和女仆版设计者 ZipZipPipe 的署名。奶蛙使用本地保存且已移除音轨的社区大笑视频、本地海报与原片第 1 秒的闭口静态截帧。最终两套均使用公开动作素材与原片截帧,不包含本次生成或重绘的角色图。素材来源、处理与权利状态详见 [两套皮肤说明](meme-skins.md);代码许可不覆盖这些媒体,运行时无需访问来源网站。 + +下面是本轮离线 Demo 的浏览器截图,均非 MXU 主程序截图: + +| 白饭鲸鱼娘 | 奶蛙 | +| --- | --- | +| ![白饭鲸鱼娘离线 Demo](images/07-deepseek.png) | ![奶蛙播放原梗大笑动作的离线 Demo](images/08-naiwa.png) | + +[观看奶蛙皮肤的静音动态预览](images/08-naiwa-preview.mp4)。 + +## 本地查看离线 Demo + +下载或检出本 PR 的文件,用浏览器打开 `docs/design/skin-system/demo/index.html` 即可离线体验,不需要安装前端依赖,也不需要运行 MXU、Tauri 或 MaaFramework。GitHub 文件预览本身不会执行 HTML。 + +若浏览器的管理策略禁止 `file://`,可在本目录运行 `python -m http.server 8765`,再打开 `http://localhost:8765/demo/index.html#naiwa`;所有运行素材仍从本机加载。 + +演示支持八种场景切换、勾选模拟任务、开始 / 暂停 / 继续 / 停止取消、模拟失败与重试、宿主状态摘要、场景参数调整、布局边界显示,以及减少动效。页面下方还有 24 条可按类别筛选的扩展创意。 + +可直接打开 [白饭鲸鱼娘](demo/index.html#deepseek) 或 [奶蛙](demo/index.html#naiwa)。新增两套的角色互动持续约 2.4 秒,重复触发仅重置同一互动计时器;暂停和终态会接管角色反馈。奶蛙视频在减少动效、页面隐藏、离开页面或切到其他场景时停止,静态姿态与任务文字仍保留;本 demo 不播放音频。 + +鲸鱼娘原片为 VP9 alpha,随包版本已转为白底 H.264,以避免不支持透明 WebM 的平台出现黑底;减少动效或播放失败时使用同源静态帧。浏览器验证仍不代表三种原生 WebView 都已通过验证。 + +可以导出 `mxu-skin-demo/0.1` JSON 预设,修改 `name`、`accent` 或场景允许范围内的 `intensity` 后导入。此入口只接受有限的内置场景配置,不加载第三方 HTML/CSS/JS,**不等于拟议的 `.mxuskin` 安装器**。 + +## 这轮想讨论什么 + +1. **表达范围**:L0 主题变量 + L1 声明式场景是否足够覆盖第一批皮肤?哪些真实需求必须有 L2 隔离脚本? +2. **宿主控件**:是否接受“保留真实业务控件,公开样式变量、锚点与有限槽位”的边界?完整重排任务卡片应放到哪一级? +3. **布局与越框**:先支持边缘占位和面板 inset 是否足够?哪些区域必须禁止装饰遮挡? +4. **隔离方案**:Tauri 各平台 WebView 与 WebUI 能否共用无原生权限的装饰 renderer?iframe、受控渲染器或其他方案如何取舍? +5. **作者体验**:包结构、能力声明、预览调试和文档怎样做,才能让只会写一点 CSS 的作者也容易入门? +6. **玩法与风格**:欢迎提出新的角色行为、材质、控件隐喻、任务状态反馈,或直接画草图;不局限于本次八种风格。 + +## 生成与可复现检查 + +打开 demo 不需要安装任何依赖。仅在修改文档或运行开发检查时,需要 Node.js 20+: + +```sh +cd docs/design/skin-system +npm ci +npm run check +``` + +- `protocol.md` 是协议正文唯一来源,由生成器输出 `demo/protocol.html`,包含完整目录和相对链接。 +- `ideas.json` 是 24 条创意唯一来源,生成 `ideas.md` 和 `demo/ideas.js`,网页与文档共用稳定编号。 +- 编辑源文件后运行 `npm run generate`,再运行 `npm run check`。不要手工编辑派生文件。 +- 本目录的开发依赖与锁文件只用于设计工件检查,不加入 MXU 主程序依赖。 + +浏览器检查使用 Playwright:安装 Chromium(`npx playwright install chromium`)后运行 `npm run check:browser`。也可设置 `CHROMIUM_PATH` 使用已安装的浏览器;脚本优先使用本机 `/usr/bin/chromium`。设置 `SKIN_BROWSER_OUTPUT` 可保存两套新场景截图。 + +主程序接入另用 `npm run check:integration` 验证,先在仓库根目录安装前端依赖,再在本目录 `npm ci`。此脚本启动真实 Vite/React App,以受控 HTTP/WebSocket 后端替代设备服务;检查设置选择、刷新恢复、任务状态、静音视频、减少动效及五种窗口尺寸。`SKIN_INTEGRATION_OUTPUT` 可指定截图目录。 + +## 当前验证范围 + +检查覆盖派生文件一致性、Manifest 与文档样例、资源链接、DOM/事件、成功/失败/取消/重试、互动超时与清理、媒体播放生命周期、预设输入校验与基础可访问性语义;静态概念图及 SVG 也做过导出检查。浏览器实测仅说明本次测试的浏览器与视口,不能替代跨平台 WebView 测试或生产级隔离验证。 + +离线 demo 通过 37 组文档/DOM/事件检查与 18 项 Chromium 155 浏览器检查。新两套覆盖 1440×1100、850×525、680×420、566×350 与 390×844;其中 680×420、566×350 分别模拟 850×525 窗口在 125%/150% 缩放下的有效 CSS 空间,不能替代原生 WebView 缩放测试。检查包含装饰与真实按钮不相交、无横向溢出、模拟任务反馈及两份原动作视频的独立播放生命周期。受管理浏览器阻止 `file://`,该项明确跳过,本地 HTTP 路径已验证。这些数字不作为主程序内置皮肤接入的测试结果。 + +本轮主程序通过 `pnpm build`;`pnpm test` 为 127 项通过、1 项既有平台跳过、无失败;真实 App 的 Chromium 集成检查 11 项通过。回归包含空实例配置也恢复外观、首次 WebUI 换肤保留原明暗/语言/强调色、浏览器与桌面外观隔离、换肤不改运行任务、原片吃饭动作播放和减少动效,以及五语言操作文案一致性与无对白约束。尚未执行原生 Tauri 三平台实机验证。 + +离线 demo 是独立的可信场景,切换场景会重置模拟任务;主程序的内置皮肤切换保持实际业务任务继续执行。正式 `.mxuskin` 导入、能力协商、实际占位结果回传、保护区裁切与权限隔离仍是待实现的设计内容。 + +静态概念图是设计快照,不用于验证最新 demo 的 CSS 几何或可访问性。 + +本目录的中文文案用于设计讨论与离线原型。主程序的内置皮肤设置与角色反馈文案使用项目现有 i18n 资源。 + +## 更多皮肤想法 + +另整理了 [24 条扩展创意](ideas.md),包括任务小火车、错误框修理工、深夜拉面铺、火箭发射台和逐渐显影的照片。每条列出所需能力、建议层级与操作兜底,方便讨论哪些值得先做成内置皮肤。 diff --git a/docs/design/skin-system/demo/app.js b/docs/design/skin-system/demo/app.js new file mode 100644 index 00000000..6c7966eb --- /dev/null +++ b/docs/design/skin-system/demo/app.js @@ -0,0 +1,773 @@ +'use strict'; +(() => { + const $ = (id) => document.getElementById(id); + const stage = $('stage'); + const mascot = document.querySelector('.mascot-actor'); + const appWrap = document.querySelector('.app-wrap'); + const sceneDefs = { + mascot: { + name: '捣蛋团子', + code: 'SCENE 001 — MISCHIEF', + kicker: 'MAKE SOME ROOM.', + title: '借过,我也要上班。', + heading: '它不只是贴纸。', + description: + '右侧宿主按钮可用 Tab 聚焦、Enter 或空格激活,让团子进入操作栏。按钮与点击范围跟随真实布局移动;角色图片本身不接收输入。', + tags: ['host hotspot', 'reservation', 'state'], + caption: '↳ 小家伙走进来,按钮真的为它让出位置。', + trigger: ['角色互动:让团子挤进来', '角色互动:请团子让一让'], + effect: 'mischief', + range: [80, 164, 132], + rangeLabel: '请求占位宽度', + unit: 'px', + device: '运行概览', + }, + mecha: { + name: '边界破坏者', + code: 'SCENE 002 — OVERFLOW', + kicker: 'BEYOND THE FRAME.', + title: '边框,不是它的边界。', + heading: '框装不下想象力。', + description: + '机甲挂在面板之外的装饰层,长枪穿出卡片边界。装饰不接收鼠标,下面的宿主按钮仍能正常操作。', + tags: ['stage.foreground', 'bleed', 'hitTest:none'], + caption: '↳ 越过的是 UI 面板边框;装饰仍受整个窗口约束。', + trigger: ['能量出鞘', '收起能量'], + effect: 'armed', + range: [80, 115, 100], + rangeLabel: '机甲显示比例', + unit: '%', + device: '运行概览', + }, + garden: { + name: '微型生态舱', + code: 'SCENE 003 — LIVING STATE', + kicker: 'A LITTLE MORE ALIVE.', + title: '任务在跑,世界在生长。', + heading: '把进度养成风景。', + description: + '点击开始任务,生态舱随进度舒展开来。用只读状态驱动表现,也可以拓展为抽芽、花开、昼夜和天气变化。', + tags: ['state.progress', 'CSS variable', 'idle'], + caption: '↳ 运行任务看生态舱生长,也可以切换夜间氛围。', + trigger: ['切换到夜晚', '迎接新一天'], + effect: 'night', + range: [60, 110, 75], + rangeLabel: '初始生长比例', + unit: '%', + device: '生态监测', + }, + vinyl: { + name: '黑胶工作台', + code: 'SCENE 004 — ANALOG CONTROL', + kicker: 'GET INTO YOUR GROOVE.', + title: '让任务,进入节奏。', + heading: '操作可以有仪式感。', + description: + '运行时唱针落下、唱片旋转;暂停时停转。控件仍然是原来的开始与停止,熟悉的操作可以拥有新的视觉隐喻。演示不播放音频。', + tags: ['state.running', 'semantic control', 'CSS animation'], + caption: '↳ 点击开始 / 暂停,唱片和唱针跟随真实的模拟状态。', + trigger: ['试一下落针', '唱针归位'], + effect: 'audition', + range: [20, 80, 50], + rangeLabel: '唱片转速', + unit: 'rpm', + device: 'SIDE A / DAILY MIX', + }, + paper: { + name: '纸上事务所', + code: 'SCENE 005 — MATERIAL FEEDBACK', + kicker: 'LESS WORK. MORE LIFE.', + title: '办完一件,盖一个章。', + heading: '反馈也能有触感。', + description: + '任务变成纸上的清单,完成时盖下印章。折角、叠纸和投影赋予平面材质感;不需要替换业务逻辑,也不需要 3D 引擎。', + tags: ['state.success', 'material', 'transition'], + caption: '↳ 完成模拟任务会自动盖章;右侧可以单独预览效果。', + trigger: ['预览完成印章', '收起印章'], + effect: 'stamped', + range: [0, 16, 5], + rangeLabel: '便签倾斜角度', + unit: '°', + device: 'DESK NOTES', + }, + sonar: { + name: '深海声呐站', + code: 'SCENE 006 — SIGNALS', + kicker: 'EVERY TASK, A SIGNAL.', + title: '安静航行,等待回声。', + heading: '让状态成为环境。', + description: + '声呐在运行时扫描,任务信号依次闪亮。状态不仅是角落的一行文字,也可以成为整个界面的节奏。关闭动效后仍保留文字进度。', + tags: ['state.running', 'event pulse', 'reduced-motion'], + caption: '↳ 声呐只是状态的第二种表达,文字状态始终保留。', + trigger: ['开启扫描预览', '结束扫描预览'], + effect: 'pinging', + range: [20, 90, 50], + rangeLabel: '扫描速度', + unit: '%', + device: 'SIGNAL / 03', + }, + deepseek: { + name: '蓝色大肥鱼吃白饭', + code: 'SCENE 007 — DEEPSEEK', + kicker: 'DEEPSEEK / EATING RICE', + title: '蓝色大肥鱼 · 吃白饭', + heading: '蓝色大肥鱼 · 吃白饭', + description: + '点击“投喂白饭”播放本地吃饭视频,或在任务执行和完成时播放。暂停、失败、取消或减少动效时显示静态图;视频始终静音。', + tags: ['task.progress', 'host hotspot', 'interaction.timeout'], + caption: '↳ 互动仅改变角色显示,不改变任务状态或进度。', + trigger: ['投喂白饭', '再次投喂白饭'], + effect: 'feeding', + range: [60, 140, 100], + rangeLabel: '吃饭播放速度', + unit: '%', + device: '运行概览', + }, + naiwa: { + name: '奶蛙', + code: 'SCENE 008 — NAIWA', + kicker: 'NAIWA / LAUGHING', + title: '奶蛙 · 大笑', + heading: '奶蛙 · 大笑', + description: + '点击“播放大笑”预览本地视频,或在任务完成时播放。失败、取消或减少动效时显示静态图;视频始终静音。', + tags: ['task.state', 'host hotspot', 'reduced-motion'], + caption: '↳ 视频播放与任务状态分开;减少动效时保留静态图和任务信息。', + trigger: ['播放大笑', '再次播放大笑'], + effect: 'laughing', + range: [50, 150, 100], + rangeLabel: '大笑播放速度', + unit: '%', + device: '运行概览', + }, + }; + const state = { + skin: 'mascot', + effect: false, + intensity: 132, + phase: 'idle', + progress: 0, + timer: null, + interactionTimer: null, + interactionRevision: 0, + accent: null, + presetName: null, + announcementKey: '', + }; + const taskInputs = [...document.querySelectorAll('[data-task]')]; + const taskRows = [...document.querySelectorAll('.task-row')]; + const media = window.matchMedia('(prefers-reduced-motion: reduce)'); + const log = (message) => { + $('event-log').textContent = message; + }; + const selected = () => taskInputs.flatMap((input, index) => (input.checked ? [index] : [])); + const isMemeScene = () => ['deepseek', 'naiwa'].includes(state.skin); + let pageActive = true; + const characterPlayback = { + deepseek: { pending: false, revision: 0 }, + naiwa: { pending: false, revision: 0 }, + }; + function syncSkinVideos(reset = false) { + for (const [skin, playback] of Object.entries(characterPlayback)) { + const video = $(`${skin}-motion`); + if (!video) continue; + // Both bundled clips remain silent, including after a browser restores this page. + video.muted = true; + video.defaultMuted = true; + video.loop = true; + video.playsInline = true; + if (state.skin === skin) video.playbackRate = state.intensity / 100; + const performing = + state.skin === skin && + (state.effect || + state.phase === 'success' || + (skin === 'deepseek' && state.phase === 'running')) && + !media.matches && + !$('reduce-motion').checked && + !document.hidden && + pageActive; + if (!performing) { + playback.revision++; + playback.pending = false; + stage.classList.remove(`${skin}-performing`); + if (!video.paused) video.pause(); + if ( + reset || + state.skin !== skin || + ['error', 'cancelled'].includes(state.phase) || + (state.phase === 'idle' && !state.effect) + ) { + if (video.currentTime !== 0) video.currentTime = 0; + } + continue; + } + stage.classList.add(`${skin}-performing`); + // Progress ticks never restart a playing clip or repeat an unresolved play request. + if (!video.paused || playback.pending) continue; + playback.pending = true; + const revision = ++playback.revision; + try { + Promise.resolve(video.play()).then( + () => { + if (revision === playback.revision) playback.pending = false; + else syncSkinVideos(); + }, + () => { + if (revision !== playback.revision) return; + playback.pending = false; + stage.classList.remove(`${skin}-performing`); + }, + ); + } catch { + playback.pending = false; + stage.classList.remove(`${skin}-performing`); + } + } + } + function snippet() { + const n = state.intensity; + const snippets = { + mascot: `"slot": "toolbar.actions",\n"edge": "inline-start",\n"size": ${n}, "maxFraction": 0.4`, + mecha: '"layer": "ornament-front",\n"bleed": { "top": 96, "right": 96 },\n"hitTest": "none"', + garden: 'task.progress\n → CSS --growth\n → 生态舱生长', + vinyl: 'task.runState\n running → 唱针落下\n paused → 唱片停转', + paper: 'task.runState = success\n → setSceneState: stamped\n → 静态模式保留印章', + sonar: 'task.runState\n running → 扫描\n reducedMotion → 静态信号', + deepseek: + 'task.progress → 任务进度\ninteraction.feeding → 2400 ms\ntimeout 只复位互动,不改任务', + naiwa: + 'task.state = success → 捧腹大笑\nerror / cancelled → 立即止笑\nreducedMotion → 静态姿态', + }; + $('scene-snippet').textContent = snippets[state.skin]; + } + function setIntensity(value) { + const def = sceneDefs[state.skin]; + state.intensity = Math.max(def.range[0], Math.min(def.range[1], Number(value))); + $('intensity').value = String(state.intensity); + $('intensity-value').textContent = `${state.intensity} ${def.unit}`; + stage.style.setProperty('--reserve', `${state.intensity}px`); + stage.style.setProperty('--art-scale', state.intensity / 100); + stage.style.setProperty( + '--growth', + state.skin === 'garden' ? Math.min(1.1, state.intensity / 100 + state.progress / 300) : 0.75, + ); + document.querySelector('.record').style.animationDuration = + `${60 / Math.max(1, state.intensity)}s`; + document.querySelector('.sonar-sweep').style.animationDuration = `${9 - state.intensity / 12}s`; + document.querySelector('.paper-art').style.transform = + state.skin === 'paper' ? `rotate(${state.intensity}deg)` : ''; + snippet(); + renderMeme(); + } + function stopClock() { + if (state.timer !== null) { + clearInterval(state.timer); + state.timer = null; + } + } + function stopInteraction() { + if (state.interactionTimer !== null) { + clearTimeout(state.interactionTimer); + state.interactionTimer = null; + } + state.interactionRevision++; + stage.classList.remove('feeding', 'laughing'); + if (isMemeScene()) state.effect = false; + } + function renderMeme() { + syncSkinVideos(); + if (!isMemeScene()) return; + const phases = { + idle: '待机中', + running: '执行中', + paused: '已暂停', + success: '已完成', + error: '执行失败', + cancelled: '已取消', + }; + if ($('meme-state')) + $('meme-state').textContent = state.effect + ? `${phases[state.phase]} · ${state.skin === 'deepseek' ? '投喂中' : '播放大笑'}` + : phases[state.phase]; + if ($('rice-fill')) + $('rice-fill').style.setProperty('--task-progress', String(state.progress / 100)); + if ($('meme-progress-text')) + $('meme-progress-text').textContent = `任务进度 ${state.progress}%`; + } + function syncMotion() { + const reduced = media.matches || $('reduce-motion').checked; + if (media.matches) { + $('reduce-motion').checked = true; + } + $('reduce-motion').disabled = media.matches; + $('reduce-motion').title = media.matches ? '已遵循系统减少动态效果设置' : ''; + stage.classList.toggle('reduce-motion', reduced); + $('motion-state').textContent = reduced ? 'MOTION REDUCED' : 'MOTION ON'; + syncSkinVideos(); + } + function renderTasks() { + if (isMemeScene() && ['success', 'error', 'cancelled'].includes(state.phase)) stopInteraction(); + const indexes = selected(); + const completed = + state.progress === 100 ? indexes.length : Math.floor((state.progress / 100) * indexes.length); + $('selected-count').textContent = String(indexes.length); + $('completed-count').textContent = String(completed); + $('progress-value').textContent = `${state.progress}%`; + $('progress-fill').style.width = `${state.progress}%`; + $('task-progress').setAttribute('aria-valuenow', String(state.progress)); + stage.dataset.runState = state.phase; + const active = state.phase === 'running', + paused = state.phase === 'paused'; + const def = sceneDefs[state.skin]; + const taskOwnsPreview = + (['vinyl', 'sonar', 'paper'].includes(state.skin) && state.phase !== 'idle') || + (isMemeScene() && ['paused', 'success', 'error', 'cancelled'].includes(state.phase)); + $('scene-trigger').disabled = taskOwnsPreview; + $('scene-trigger').replaceChildren( + document.createTextNode( + state.skin === 'paper' && state.phase === 'success' + ? '本次完成印章已生效' + : taskOwnsPreview + ? '任务反馈由当前状态控制' + : def.trigger[state.effect ? 1 : 0], + ), + Object.assign(document.createElement('span'), { textContent: state.effect ? '↶' : '→' }), + ); + $('scene-trigger').setAttribute('aria-pressed', String(state.effect)); + stage.classList.toggle('running', active); + stage.classList.toggle('complete', state.phase === 'success'); + stage.classList.toggle('failed', state.phase === 'error'); + stage.classList.toggle('cancelled', state.phase === 'cancelled'); + const phaseText = { + idle: '待机中', + running: '执行中', + paused: '已暂停', + cancelled: '已取消', + error: '执行失败', + success: '已完成', + }; + $('status-label').textContent = phaseText[state.phase]; + $('task-progress').setAttribute( + 'aria-valuetext', + `${phaseText[state.phase]},进度 ${state.progress}%,已完成 ${completed} 项,共 ${indexes.length} 项`, + ); + $('run-label').textContent = active + ? '暂停任务' + : paused + ? '继续任务' + : state.phase === 'success' + ? '再运行一次' + : state.phase === 'error' + ? '重试任务' + : state.phase === 'cancelled' + ? '重新开始' + : '开始任务'; + $('run-icon').textContent = active ? 'Ⅱ' : '▶'; + $('run-button').disabled = indexes.length === 0; + $('stop-button').disabled = !(active || paused); + $('fail-button').disabled = !(active || paused); + $('task-caption').textContent = + indexes.length === 0 + ? '请至少选择一项任务' + : active + ? '正在模拟执行,请稍候' + : paused + ? '任务已暂停,随时继续' + : state.phase === 'success' + ? '全部完成,辛苦啦!' + : state.phase === 'error' + ? '模拟连接失败;可重试,不计为成功' + : state.phase === 'cancelled' + ? '本次执行已取消,不计为成功' + : '准备就绪,随时出发'; + taskRows.forEach((row, index) => { + const order = indexes.indexOf(index), + done = order >= 0 && order < completed; + const firstUnfinished = order === completed && order >= 0; + const current = firstUnfinished && (active || paused); + const interrupted = state.phase === 'error' || state.phase === 'cancelled'; + row.classList.toggle('done', done); + row.classList.toggle('current', current); + row.classList.toggle('failed', firstUnfinished && state.phase === 'error'); + row.querySelector('.task-state').textContent = + order < 0 + ? '跳过' + : done + ? '✓ 完成' + : current + ? paused + ? 'Ⅱ 暂停' + : '执行中' + : firstUnfinished && interrupted + ? state.phase === 'error' + ? '! 失败' + : '已取消' + : interrupted + ? '未执行' + : '等待'; + taskInputs[index].disabled = active || paused; + }); + if (state.skin === 'garden') + stage.style.setProperty( + '--growth', + Math.min(1.1, state.intensity / 100 + state.progress / 300), + ); + $('speech-bubble').textContent = + state.phase === 'success' + ? '下班啦!一起摸鱼~' + : state.phase === 'error' + ? '出点小状况,再试一次?' + : state.phase === 'cancelled' + ? '收到,今天先停在这里。' + : active + ? '我在认真监督!' + : state.effect + ? '嘿嘿,这里归我啦!' + : '给我腾个位置嘛!'; + renderMeme(); + // A phase change is announced once. Progress ticks update the progressbar, not the live region. + const announcementKey = `${state.phase}:${indexes.length}`; + if (state.announcementKey !== announcementKey) { + state.announcementKey = announcementKey; + $('task-announcement').textContent = + indexes.length === 0 + ? '请至少选择一项模拟任务。' + : `${phaseText[state.phase]}。${ + state.phase === 'success' + ? `已完成 ${completed} 项任务。` + : state.phase === 'error' + ? '模拟连接失败,可使用重试任务按钮从头重试。' + : state.phase === 'cancelled' + ? '用户停止了本次执行,未完成的任务不计为成功。' + : `已选择 ${indexes.length} 项模拟任务。` + }`; + } + } + function selectSkin(key, updateHash = true) { + if (!Object.hasOwn(sceneDefs, key)) return; + stopClock(); + stopInteraction(); + state.skin = key; + state.effect = false; + state.phase = 'idle'; + state.progress = 0; + state.accent = null; + state.presetName = null; + const def = sceneDefs[key]; + if (updateHash && window.location.hash !== `#${key}`) + window.history.replaceState(null, '', `#${key}`); + appWrap.append(mascot); + stage.className = `stage theme-${key}`; + stage.style.removeProperty('--app-accent'); + stage.classList.toggle('show-bounds', $('show-bounds').checked); + syncMotion(); + document + .querySelectorAll('[data-skin]') + .forEach((button) => + button.setAttribute('aria-pressed', String(button.dataset.skin === key)), + ); + const fields = { + 'scene-code': def.code, + 'scene-kicker': def.kicker, + 'scene-title': def.title, + 'inspector-title': def.heading, + 'inspector-description': def.description, + 'scene-caption': def.caption, + 'intensity-label': def.rangeLabel, + 'device-title': def.device, + }; + Object.entries(fields).forEach(([id, value]) => { + $(id).textContent = value; + }); + $('scene-trigger').replaceChildren( + document.createTextNode(def.trigger[0]), + Object.assign(document.createElement('span'), { textContent: '→' }), + ); + $('scene-trigger').setAttribute('aria-pressed', 'false'); + if (key === 'mascot') { + $('scene-trigger').dataset.hostHotspot = 'mascot'; + $('scene-trigger').setAttribute('aria-controls', 'reservation'); + $('hotspot-note').textContent = + '宿主拥有的角色热点:Tab 聚焦,Enter / 空格激活。只改变场景,不启动任务;此 demo 未验证隔离渲染器的命中保护。'; + } else if (isMemeScene()) { + $('scene-trigger').dataset.hostHotspot = key; + $('scene-trigger').setAttribute('aria-controls', 'meme-state'); + $('hotspot-note').textContent = + '宿主按钮支持 Tab、Enter 和空格。互动持续 2.4 秒,重复触发只延长互动;不写任务进度。暂停、失败、取消和完成时由任务状态接管。'; + } else { + delete $('scene-trigger').dataset.hostHotspot; + $('scene-trigger').removeAttribute('aria-controls'); + $('hotspot-note').textContent = + '此按钮由演示宿主管理。任务反馈预览仅在待机时启用;重置可返回待机。运行、失败与取消状态由任务控件决定。'; + } + $('capability-tags').replaceChildren( + ...def.tags.map((tag) => Object.assign(document.createElement('span'), { textContent: tag })), + ); + $('intensity').min = def.range[0]; + $('intensity').max = def.range[1]; + setIntensity(def.range[2]); + renderTasks(); + log(`skin.mounted → ${def.name} · 等待交互`); + } + function toggleEffect() { + const def = sceneDefs[state.skin]; + if (isMemeScene()) { + if (!['idle', 'running'].includes(state.phase)) return; + stopInteraction(); + state.effect = true; + stage.classList.add(def.effect); + const revision = state.interactionRevision; + state.interactionTimer = setTimeout(() => { + if (state.interactionRevision !== revision) return; + stopInteraction(); + renderTasks(); + log('interaction.timeout → rest · 任务状态和进度保持不变'); + }, 2400); + renderTasks(); + log(`interaction.${def.effect} → 2400 ms · 仅角色互动,不改变任务`); + return; + } + state.effect = !state.effect; + stage.classList.toggle(def.effect, state.effect); + if (state.skin === 'mascot') (state.effect ? $('reservation') : appWrap).append(mascot); + $('scene-trigger').replaceChildren( + document.createTextNode(def.trigger[state.effect ? 1 : 0]), + Object.assign(document.createElement('span'), { textContent: state.effect ? '↶' : '→' }), + ); + $('scene-trigger').setAttribute('aria-pressed', String(state.effect)); + renderTasks(); + log( + state.skin === 'mascot' + ? `layout.request → ${state.effect ? state.intensity : 0}px · 宿主最多分配操作栏的 40%` + : `preview.${def.effect} → ${state.effect ? 'on' : 'off'} · ${def.name}`, + ); + } + function startClock() { + stopClock(); + if (['vinyl', 'sonar', 'paper'].includes(state.skin)) { + state.effect = false; + stage.classList.remove(sceneDefs[state.skin].effect); + } + state.phase = 'running'; + renderTasks(); + log('task.status → running · 仅模拟执行,不连接真实设备'); + state.timer = setInterval(() => { + state.progress = Math.min(100, state.progress + 5); + if (state.progress === 100) { + stopClock(); + state.phase = 'success'; + log('task.status → success · 全部完成,皮肤接收成功状态'); + } + renderTasks(); + }, 650); + } + $('run-button').addEventListener('click', () => { + if (!selected().length) return; + if (state.phase === 'running') { + stopClock(); + stopInteraction(); + state.phase = 'paused'; + renderTasks(); + log('task.status → paused · 动画跟随暂停'); + return; + } + if (state.phase !== 'paused') state.progress = 0; + startClock(); + }); + $('stop-button').addEventListener('click', () => { + if (!['running', 'paused'].includes(state.phase)) return; + stopClock(); + state.phase = 'cancelled'; + renderTasks(); + log('task.status → cancelled · 用户停止,不触发成功反馈'); + }); + $('fail-button').addEventListener('click', () => { + if (!['running', 'paused'].includes(state.phase)) return; + stopClock(); + state.phase = 'error'; + renderTasks(); + log('task.status → error · 模拟连接失败,可从头重试'); + }); + taskInputs.forEach((input) => + input.addEventListener('change', () => { + stopClock(); + stopInteraction(); + state.progress = 0; + state.phase = 'idle'; + renderTasks(); + log(`selection.changed → 已选择 ${selected().length} 项任务`); + }), + ); + document + .querySelectorAll('[data-skin]') + .forEach((button) => button.addEventListener('click', () => selectSkin(button.dataset.skin))); + $('scene-trigger').addEventListener('click', toggleEffect); + $('intensity').addEventListener('input', (event) => { + setIntensity(event.target.value); + if (state.skin === 'mascot' && state.effect) + log(`layout.request → ${state.intensity}px · 受可用空间上限约束`); + }); + $('show-bounds').addEventListener('change', (event) => { + stage.classList.toggle('show-bounds', event.target.checked); + log(`debug.bounds → ${event.target.checked ? 'on · 橙色面板 / 绿色布局 / 紫色装饰' : 'off'}`); + }); + $('reduce-motion').addEventListener('change', syncMotion); + if (media.addEventListener) media.addEventListener('change', syncMotion); + $('reset').addEventListener('click', () => { + taskInputs.forEach((input) => { + input.checked = true; + }); + $('show-bounds').checked = false; + $('reduce-motion').checked = media.matches; + selectSkin(state.skin); + $('import-result').textContent = '已恢复当前场景默认配置。'; + }); + function makePreset() { + return { + format: 'mxu-skin-demo/0.1', + name: state.presetName || sceneDefs[state.skin].name, + scene: state.skin, + accent: state.accent || getComputedStyle(stage).getPropertyValue('--app-accent').trim(), + intensity: state.intensity, + effect: state.effect, + }; + } + function validatePreset(data) { + if (!data || typeof data !== 'object' || Array.isArray(data)) + throw new Error('文件内容必须是一个 JSON 对象。'); + const allowed = ['format', 'name', 'scene', 'accent', 'intensity', 'effect']; + if (Object.keys(data).some((key) => !allowed.includes(key))) + throw new Error('含有不支持的字段;此演示器只接受导出的轻量预设。'); + if (data.format !== 'mxu-skin-demo/0.1') + throw new Error('格式不匹配,请使用 mxu-skin-demo/0.1 演示预设。'); + if (typeof data.name !== 'string' || !data.name.trim() || data.name.length > 40) + throw new Error('预设名称必须是 1–40 个字符。'); + if (typeof data.scene !== 'string' || !Object.hasOwn(sceneDefs, data.scene)) + throw new Error('场景必须是八个内置场景之一。'); + if (typeof data.accent !== 'string' || !/^#[0-9a-f]{6}$/i.test(data.accent)) + throw new Error('accent 需要 #RRGGBB 格式的颜色。'); + const [min, max] = sceneDefs[data.scene].range; + if (!Number.isFinite(data.intensity) || data.intensity < min || data.intensity > max) + throw new Error(`此场景的 intensity 必须介于 ${min} 和 ${max}。`); + if (typeof data.effect !== 'boolean') throw new Error('effect 必须是 true 或 false。'); + return data; + } + $('export-preset').addEventListener('click', () => { + const url = URL.createObjectURL( + new Blob([JSON.stringify(makePreset(), null, 2) + '\n'], { type: 'application/json' }), + ); + const link = Object.assign(document.createElement('a'), { + href: url, + download: `mxu-${state.skin}.demo.json`, + }); + document.body.append(link); + link.click(); + link.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + $('import-result').textContent = + '已导出演示预设。可修改 name、accent 和 intensity 后重新导入。'; + }); + $('import-preset').addEventListener('click', () => $('preset-file').click()); + $('preset-file').addEventListener('change', async (event) => { + const file = event.target.files[0]; + if (!file) return; + try { + if (file.size > 16 * 1024) throw new Error('演示预设最大为 16 KB。'); + const data = validatePreset(JSON.parse(await file.text())); + selectSkin(data.scene); + state.accent = data.accent; + state.presetName = data.name.trim(); + stage.style.setProperty('--app-accent', data.accent); + setIntensity(data.intensity); + if (data.effect) toggleEffect(); + $('import-result').textContent = + `已应用「${data.name.trim()}」。只导入演示配置,没有加载 HTML、脚本或外部资源。`; + log(`preset.imported → ${data.name.trim()} · ${data.format}`); + } catch (error) { + $('import-result').textContent = + `导入失败:${error instanceof SyntaxError ? 'JSON 格式有误,请检查逗号与引号。' : error.message}`; + } + event.target.value = ''; + }); + function renderIdeas(filter = '全部') { + const ideas = Array.isArray(window.MXU_IDEAS) ? window.MXU_IDEAS : []; + const cards = ideas + .filter((idea) => filter === '全部' || idea.category === filter) + .map((idea) => { + const card = document.createElement('article'); + card.className = 'idea-card'; + const top = document.createElement('div'); + top.className = 'idea-top'; + const index = ideas.indexOf(idea) + 1; + top.append( + Object.assign(document.createElement('span'), { + textContent: `IDEA ${String(index).padStart(2, '0')} / ${idea.category}`, + }), + Object.assign(document.createElement('span'), { + textContent: idea.level === 'L2' ? 'L2 · 进阶扩展' : 'L1 · 声明式', + }), + ); + const title = Object.assign(document.createElement('h4'), { textContent: idea.title }); + const concept = Object.assign(document.createElement('p'), { textContent: idea.concept }); + const tags = document.createElement('div'); + tags.className = 'idea-tags'; + tags.append( + ...idea.capability.map((tag) => + Object.assign(document.createElement('span'), { textContent: tag }), + ), + ); + const detail = document.createElement('details'); + detail.append( + Object.assign(document.createElement('summary'), { textContent: '正常操作如何保留' }), + Object.assign(document.createElement('p'), { textContent: idea.restraint }), + ); + card.append(top, title, concept, tags, detail); + return card; + }); + $('ideas-grid').replaceChildren(...cards); + document.querySelectorAll('[data-filter]').forEach((button) => { + const active = button.dataset.filter === filter; + button.classList.toggle('active', active); + button.setAttribute('aria-pressed', String(active)); + }); + } + document + .querySelectorAll('[data-filter]') + .forEach((button) => + button.addEventListener('click', () => renderIdeas(button.dataset.filter)), + ); + window.addEventListener('pagehide', () => { + pageActive = false; + stopClock(); + stopInteraction(); + syncSkinVideos(true); + if (state.phase === 'running') { + state.phase = 'paused'; + log('task.status → paused · 离开页面时自动暂停'); + } + renderTasks(); + }); + window.addEventListener('pageshow', () => { + pageActive = true; + syncSkinVideos(); + }); + document.addEventListener('visibilitychange', () => syncSkinVideos()); + const sectionAnchors = new Set(['playground', 'ideas', 'architecture']); + function selectHashScene() { + const key = window.location.hash.slice(1); + // Page navigation anchors must not reset an ongoing task simulation. + if (sectionAnchors.has(key)) return; + selectSkin(Object.hasOwn(sceneDefs, key) ? key : 'mascot', false); + } + window.addEventListener('hashchange', selectHashScene); + selectSkin( + Object.hasOwn(sceneDefs, window.location.hash.slice(1)) + ? window.location.hash.slice(1) + : 'mascot', + false, + ); + renderIdeas(); + // An intentionally narrow, read-only entry point for static contract checks. + window.MXUSkinDemo = { validatePreset, makePreset }; +})(); diff --git a/docs/design/skin-system/demo/assets/deepseek-calm.png b/docs/design/skin-system/demo/assets/deepseek-calm.png new file mode 100644 index 00000000..7c3143f0 Binary files /dev/null and b/docs/design/skin-system/demo/assets/deepseek-calm.png differ diff --git a/docs/design/skin-system/demo/assets/deepseek-eat.mp4 b/docs/design/skin-system/demo/assets/deepseek-eat.mp4 new file mode 100644 index 00000000..37f7401d Binary files /dev/null and b/docs/design/skin-system/demo/assets/deepseek-eat.mp4 differ diff --git a/docs/design/skin-system/demo/assets/deepseek-error.png b/docs/design/skin-system/demo/assets/deepseek-error.png new file mode 100644 index 00000000..93ba13f3 Binary files /dev/null and b/docs/design/skin-system/demo/assets/deepseek-error.png differ diff --git a/docs/design/skin-system/demo/assets/deepseek.png b/docs/design/skin-system/demo/assets/deepseek.png new file mode 100644 index 00000000..d8203351 Binary files /dev/null and b/docs/design/skin-system/demo/assets/deepseek.png differ diff --git a/docs/design/skin-system/demo/assets/mascot.svg b/docs/design/skin-system/demo/assets/mascot.svg new file mode 100644 index 00000000..507e1329 --- /dev/null +++ b/docs/design/skin-system/demo/assets/mascot.svg @@ -0,0 +1,38 @@ + + 挤挤,一只喜欢占座的薄荷小怪兽 + 两只不对称的小角,圆滚滚身体和伸向右边的手,正在把界面按钮挤开。 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/design/skin-system/demo/assets/mecha.svg b/docs/design/skin-system/demo/assets/mecha.svg new file mode 100644 index 00000000..78a71f21 --- /dev/null +++ b/docs/design/skin-system/demo/assets/mecha.svg @@ -0,0 +1,102 @@ + + 白隼,携带青绿色能量长枪的机甲 + 白灰装甲、橘色肩甲与长长的发光武器。武器向右上方突出,适合演示越过面板边框的装饰。 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/design/skin-system/demo/assets/naiwa-calm.png b/docs/design/skin-system/demo/assets/naiwa-calm.png new file mode 100644 index 00000000..acd10430 Binary files /dev/null and b/docs/design/skin-system/demo/assets/naiwa-calm.png differ diff --git a/docs/design/skin-system/demo/assets/naiwa-laugh.mp4 b/docs/design/skin-system/demo/assets/naiwa-laugh.mp4 new file mode 100644 index 00000000..d8ea1656 Binary files /dev/null and b/docs/design/skin-system/demo/assets/naiwa-laugh.mp4 differ diff --git a/docs/design/skin-system/demo/assets/naiwa-still.webp b/docs/design/skin-system/demo/assets/naiwa-still.webp new file mode 100644 index 00000000..cb7395a7 Binary files /dev/null and b/docs/design/skin-system/demo/assets/naiwa-still.webp differ diff --git a/docs/design/skin-system/demo/assets/terrarium.svg b/docs/design/skin-system/demo/assets/terrarium.svg new file mode 100644 index 00000000..31f533fc --- /dev/null +++ b/docs/design/skin-system/demo/assets/terrarium.svg @@ -0,0 +1,53 @@ + + 微光温室 + 玻璃罩中的蓝绿色植物和一颗漂浮的暖橘色小行星。 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/design/skin-system/demo/examples/deepseek.demo.json b/docs/design/skin-system/demo/examples/deepseek.demo.json new file mode 100644 index 00000000..818dd8a8 --- /dev/null +++ b/docs/design/skin-system/demo/examples/deepseek.demo.json @@ -0,0 +1,8 @@ +{ + "format": "mxu-skin-demo/0.1", + "name": "蓝色大肥鱼吃白饭", + "scene": "deepseek", + "accent": "#225fbd", + "intensity": 100, + "effect": false +} diff --git a/docs/design/skin-system/demo/examples/mascot.skin.json b/docs/design/skin-system/demo/examples/mascot.skin.json new file mode 100644 index 00000000..12e8d6dc --- /dev/null +++ b/docs/design/skin-system/demo/examples/mascot.skin.json @@ -0,0 +1,88 @@ +{ + "schemaVersion": "0.1", + "id": "studio.example.little-trouble", + "name": { + "zh-CN": "捣蛋小怪兽", + "en-US": "Little Trouble" + }, + "version": "1.0.0", + "mxu": { + "skinApi": "0.1" + }, + "capabilities": { + "required": ["tokens.v1"], + "optional": ["scene.l1", "reservation.edge.v1", "bleed.stage.v1"] + }, + "fallback": { + "mode": "tokens-only" + }, + "tokens": { + "light": { + "--color-accent": "#7357d8" + }, + "dark": { + "--color-accent": "#b6a4ff" + } + }, + "scene": { + "entry": "scene.html", + "initialInteractionState": "rest", + "interactionStates": ["rest", "mischief"] + }, + "placements": [ + { + "id": "mascot", + "element": "mascot", + "anchor": "toolbar.primary", + "box": "inline-start", + "nudge": { + "inline": -8, + "block": 0 + }, + "layer": "ornament-front", + "bleed": { + "blockStart": 48, + "inlineEnd": 16, + "blockEnd": 12, + "inlineStart": 24 + }, + "hitTest": "none" + } + ], + "reservations": [ + { + "id": "mascot-space", + "slot": "toolbar.actions", + "edge": "inline-start", + "size": 112, + "maxFraction": 0.28, + "when": { + "interaction.state": "mischief" + } + } + ], + "bindings": [ + { + "event": "host.hotspot.activate", + "target": "mascot-poke", + "setInteractionState": "mischief" + }, + { + "event": "interaction.timeout", + "state": "mischief", + "afterMs": 2400, + "setInteractionState": "rest" + } + ], + "hotspots": [ + { + "id": "mascot-poke", + "placement": "mascot", + "label": { + "zh-CN": "让团子挤一下", + "en-US": "Make room for the mascot" + }, + "minTargetSize": 44 + } + ] +} diff --git a/docs/design/skin-system/demo/examples/naiwa.demo.json b/docs/design/skin-system/demo/examples/naiwa.demo.json new file mode 100644 index 00000000..5ad02462 --- /dev/null +++ b/docs/design/skin-system/demo/examples/naiwa.demo.json @@ -0,0 +1,8 @@ +{ + "format": "mxu-skin-demo/0.1", + "name": "奶蛙", + "scene": "naiwa", + "accent": "#667c28", + "intensity": 100, + "effect": false +} diff --git a/docs/design/skin-system/demo/ideas.js b/docs/design/skin-system/demo/ideas.js new file mode 100644 index 00000000..13510fdd --- /dev/null +++ b/docs/design/skin-system/demo/ideas.js @@ -0,0 +1,363 @@ +// Generated from ../ideas.json. Run npm run generate; do not edit this file. +window.MXU_IDEAS = [ + { + "number": 1, + "id": "task-train", + "title": "任务小火车", + "category": "空间", + "concept": "每个任务是一节车厢,正在执行的车厢驶过检票口,完成的车厢亮起车窗。展开车厢就能查看真实任务详情;失败车厢停在维修站,重试后重新接入队列。", + "capability": [ + "队列状态", + "语义插槽", + "状态动画", + "详情动作" + ], + "level": "L1", + "restraint": "车厢顺序与真实队列一致,文字列表始终可切回。" + }, + { + "number": 2, + "id": "launch-pad", + "title": "火箭发射台", + "category": "叙事", + "concept": "连接设备、检查资源、开始任务分别点亮发射台的检查灯。运行时尾焰随任务状态变化,全部完成才发射一枚小火箭;中途停止则平稳熄火,保留本次成果。", + "capability": [ + "状态绑定", + "完成事件", + "多层装饰", + "条件显隐" + ], + "level": "L1", + "restraint": "发射只是完成反馈,不插入倒计时,也不延迟真实启动。" + }, + { + "number": 3, + "id": "ramen-shop", + "title": "深夜拉面铺", + "category": "叙事", + "concept": "任务进入队列时挂上一张订单签,执行时厨师搅动汤锅,完成后把一碗面送到取餐台。暂停时关小火,出错时举起写有原始错误摘要的小牌子。", + "capability": [ + "队列状态", + "角色状态机", + "进度映射", + "错误摘要" + ], + "level": "L1", + "restraint": "订单签使用真实任务名称,出错说明保留原文和日志入口。" + }, + { + "number": 4, + "id": "tiny-mechanic", + "title": "住在错误框里的修理工", + "category": "角色", + "concept": "平时只露出工具箱,任务失败时小修理工探出身来,拿手电指向错误信息。用户点击查看日志,他便拉开旁边的检修盖;重试成功后留下一个小小补丁。", + "capability": [ + "错误事件", + "锚点定位", + "宿主动作", + "状态动画" + ], + "level": "L1", + "restraint": "修理工不自动重试、不猜测原因,所有修复操作由用户触发。" + }, + { + "number": 5, + "id": "sleepy-gatekeeper", + "title": "打盹的设备门卫", + "category": "角色", + "concept": "设备未连接时,门卫裹着毯子在连接卡片旁打盹;连接成功便起身开门,运行时认真值守。断连时亮出明确的断线牌,并把重新连接入口递到手边。", + "capability": [ + "连接状态", + "角色状态机", + "边缘占位", + "宿主动作" + ], + "level": "L1", + "restraint": "文字连接状态和重连按钮一直可见,角色不会挡住点击。" + }, + { + "number": 6, + "id": "magic-library", + "title": "会自己翻页的魔法书", + "category": "叙事", + "concept": "一组任务就是一本书,任务标题排成目录,运行进度映射为书页边缘的光线。完成一个任务,书签移到下一章;展开章节可看日志,失败章节夹上一枚红书签。", + "capability": [ + "语义插槽", + "进度映射", + "状态装饰", + "日志动作" + ], + "level": "L1", + "restraint": "翻页只改变装饰,任务列表和键盘焦点不跟着跳走。" + }, + { + "number": 7, + "id": "miniature-city", + "title": "窗边的微缩城市", + "category": "空间", + "concept": "任务卡片变成一排建筑的立面,运行中的楼层亮灯,完成的楼顶升起小旗。城市只占侧栏,点击建筑展开对应任务;任务多时街区自然换行,不强塞进一张地图。", + "capability": [ + "任务状态", + "响应式布局", + "语义插槽", + "详情动作" + ], + "level": "L1", + "restraint": "建筑保留可读名称和状态图标,窄屏回到紧凑任务列表。" + }, + { + "number": 8, + "id": "journey-map", + "title": "桌上的旅行地图", + "category": "空间", + "concept": "按顺序执行的任务成为一条旅行路线,当前任务旁停着小旅行车,已完成的站点盖上印章。暂停时车子停靠路边,失败站点出现路障,点击路标能查看详情。", + "capability": [ + "队列顺序", + "状态映射", + "SVG 路线", + "详情动作" + ], + "level": "L1", + "restraint": "路线只反映已有队列,不暗示不存在的任务依赖或自动改序。" + }, + { + "number": 9, + "id": "amber-hourglass", + "title": "琥珀沙漏", + "category": "状态", + "concept": "真实进度驱动细沙落下,暂停时沙流立即停止,完成时沙粒凝成一块琥珀。无法得到百分比时,改为少量循环落沙,并直接标注进行中,不伪造剩余时间。", + "capability": [ + "进度映射", + "暂停状态", + "不定进度", + "静态降级" + ], + "level": "L1", + "restraint": "百分比仍用数字呈现,低动效模式用静止水位线替代沙粒。" + }, + { + "number": 10, + "id": "weather-window", + "title": "属于任务的天气窗", + "category": "状态", + "concept": "界面角落是一扇小窗:待机时晴朗,运行时云缓缓移动,暂停时夕阳停在窗框上,失败时窗外下雨。成功后云散开,天气只在这扇窗里变化,不覆盖正文。", + "capability": [ + "状态绑定", + "局部动画", + "多层装饰", + "低动效" + ], + "level": "L1", + "restraint": "天气不代替成功和错误文字,也不通过全屏明暗变化打扰阅读。" + }, + { + "number": 11, + "id": "living-circuit", + "title": "被点亮的电路板", + "category": "状态", + "concept": "连接、资源、任务三个状态区像电路元件,装饰走线在状态满足时亮起。当前任务有柔和的电流脉冲,错误区域旁的指示灯常亮,完成后整条线路稳定发光。", + "capability": [ + "状态绑定", + "锚点定位", + "SVG 走线", + "分层绘制" + ], + "level": "L1", + "restraint": "走线表示状态流转而非真实任务依赖,错误灯不闪烁。" + }, + { + "number": 12, + "id": "print-workshop", + "title": "任务印刷工坊", + "category": "叙事", + "concept": "队列像一摞待印纸张,正在执行的任务经过滚筒,完成后吐出带任务名称的小票。用户展开小票查看结果;失败的纸张停在旁边,保留原始错误和重试入口。", + "capability": [ + "队列状态", + "完成事件", + "结果插槽", + "宿主动作" + ], + "level": "L1", + "restraint": "小票数量受上限约束,历史结果仍在正常列表中查询。" + }, + { + "number": 13, + "id": "photo-developing", + "title": "慢慢显影的照片", + "category": "状态", + "concept": "皮肤自带的插画藏在拍立得相纸里,随着任务进度逐渐显影。完成时露出完整画面和任务完成时间;失败时保留当前显影程度,红色状态签明确指出失败。", + "capability": [ + "进度映射", + "CSS 遮罩", + "完成事件", + "结果标签" + ], + "level": "L1", + "restraint": "只使用皮肤素材,不自动读取屏幕截图,也不把插画当执行结果。" + }, + { + "number": 14, + "id": "balloon-courier", + "title": "气球快递员", + "category": "角色", + "concept": "小快递员把任务名称绑在气球篮上,等待时气球轻轻晃动,执行时沿固定航线移动。任务完成后投递一枚徽章,取消则慢慢落地,失败时降到修整平台。", + "capability": [ + "角色状态机", + "进度映射", + "完成事件", + "固定路径" + ], + "level": "L1", + "restraint": "飞行限制在预留装饰区,任务名称和取消按钮保持静止。" + }, + { + "number": 15, + "id": "mechanical-dials", + "title": "机械仪表驾驶舱", + "category": "空间", + "concept": "任务进度成为机械指针,连接状态是拨杆旁的指示灯,运行按钮嵌进黄铜面板。指针转动和刻度变化对应真实数据;状态切换时,表盘边缘的小铭牌同步更新。", + "capability": [ + "语义插槽", + "进度映射", + "SVG 刻度", + "状态绑定" + ], + "level": "L1", + "restraint": "旋钮外观仍使用标准按钮操作,不要求用户精确拖动才能执行。" + }, + { + "number": 16, + "id": "magnetic-workbench", + "title": "磁吸工具台", + "category": "交互", + "concept": "侧栏里的装饰螺帽和工具会被指针轻轻吸引,离开后弹回原位;选中的任务卡片旁吸附一枚小磁铁,帮助辨认当前项。可拖动的只有装饰,不改变任务顺序。", + "capability": [ + "指针输入", + "弹簧动画", + "局部物理", + "选择状态" + ], + "level": "L2", + "restraint": "触屏和低动效模式取消磁吸,真实控件的位置和点击区域不变。" + }, + { + "number": 17, + "id": "inspection-lens", + "title": "工程师的观察镜", + "category": "交互", + "concept": "指针靠近任务卡片时,旁边的装饰观察镜转向当前项,并放大展示该项的状态图形。按下固定的详情按钮,观察镜展开为摘要面板,形成一个有趣的阅读焦点。", + "capability": [ + "指针输入", + "选中状态", + "摘要绑定", + "宿主动作" + ], + "level": "L2", + "restraint": "镜片不跟随覆盖鼠标,键盘聚焦和点击详情具有同等反馈。" + }, + { + "number": 18, + "id": "sliding-drawers", + "title": "抽屉里的工具箱", + "category": "交互", + "concept": "不常用的设置收进带标签的抽屉,点击拉手才展开正常的设置面板;运行时当前任务的抽屉亮起边灯,日志入口像一卷可拉开的记录纸,收起后保留清晰标签。", + "capability": [ + "宿主折叠状态", + "语义插槽", + "布局占位", + "CSS 过渡" + ], + "level": "L1", + "restraint": "停止、错误和核心任务入口永远不藏进抽屉,折叠由宿主管理焦点。" + }, + { + "number": 19, + "id": "darkroom-flashlight", + "title": "暗室里的探照灯", + "category": "交互", + "concept": "深色背景上的装饰星图平时很淡,指针经过时局部显出线条和彩色尘埃。运行中的任务像一盏固定灯塔,暂停时光束收拢;文字和按钮不受这层明暗变化影响。", + "capability": [ + "指针输入", + "CSS 遮罩", + "状态绑定", + "背景层" + ], + "level": "L2", + "restraint": "灯光仅作用于装饰,触屏和低动效模式显示静态背景。" + }, + { + "number": 20, + "id": "orbital-window", + "title": "空间站舷窗", + "category": "空间", + "concept": "任务区域像空间站工作台,窗外行星和舱内装饰随指针产生轻微视差。运行时远处推进器亮起,完成后有一颗卫星经过;真正的表格和操作区始终稳稳停在前景。", + "capability": [ + "指针视差", + "多层合成", + "状态绑定", + "完成事件" + ], + "level": "L2", + "restraint": "视差限制位移幅度,缩小窗口或开启低动效后立即停止。" + }, + { + "number": 21, + "id": "comic-strip", + "title": "今天的四格漫画", + "category": "叙事", + "concept": "同一套角色插画对应准备、运行、成功和失败四个分镜,状态变化时切换到对应画面。重试成功会解锁一个小彩蛋结尾;漫画旁始终并排显示真实任务状态。", + "capability": [ + "有限状态机", + "条件显隐", + "事件绑定", + "本次会话状态" + ], + "level": "L1", + "restraint": "剧情不改变错误含义、不延迟操作,也不需要记录用户的长期历史。" + }, + { + "number": 22, + "id": "detective-board", + "title": "侦探的案件板", + "category": "叙事", + "concept": "任务卡片像夹在软木板上的档案,连接状态贴着设备标签,失败任务留下醒目的线索卡。点击线索直接展开原始日志,重试成功后档案盖上已解决印章。", + "capability": [ + "语义插槽", + "错误事件", + "日志动作", + "状态装饰" + ], + "level": "L1", + "restraint": "只呈现宿主允许的摘要,不向皮肤暴露完整日志、路径或敏感配置。" + }, + { + "number": 23, + "id": "letters-and-stamps", + "title": "写给自己的完成信", + "category": "状态", + "concept": "每次任务结束生成一张当前会话的装饰信笺,写明任务名称与结果,成功盖圆邮戳,失败盖待处理章。信封展开动画结束前,用户也能立即查看详情或开始下一次任务。", + "capability": [ + "完成事件", + "结果插槽", + "CSS 过渡", + "状态标签" + ], + "level": "L1", + "restraint": "信笺只在本地展示,不自动发信、不保存未经授权的任务信息。" + }, + { + "number": 24, + "id": "pixel-inn", + "title": "任务像素旅馆", + "category": "角色", + "concept": "任务依次入住像素旅馆的房间,执行中的房门亮灯,完成后有小客人拖着箱子离开。空闲时老板擦柜台,失败房间挂上需要帮助的牌子,点击房门查看详情。", + "capability": [ + "队列状态", + "角色状态机", + "精灵动画", + "详情动作" + ], + "level": "L1", + "restraint": "房间数量随窗口调整,任务多时使用正常滚动列表而非无限扩张。" + } +]; diff --git a/docs/design/skin-system/demo/index.html b/docs/design/skin-system/demo/index.html new file mode 100644 index 00000000..8f2bbbd6 --- /dev/null +++ b/docs/design/skin-system/demo/index.html @@ -0,0 +1,466 @@ + + + + + + + MXU Skin Lab · 让界面有自己的性格 + + + + +
+
+
+ INTERFACES WITH CHARACTER界面,也可以有脾气。 +
+
+

+ 不止换肤。
让界面活起来。 +

+
+ ✳ +

+ 让角色参与布局,让故事溢出边框。
用熟悉的 HTML / CSS / SVG,
加一层很薄的 + MXU 皮肤协议。 +

+ 阅读设计提案 ↗ +
+
+
+
+
+

01 / 先玩一下

+

同一组功能,八种场景。新增:大肥鱼和奶蛙。

+ +
+
+ + + + + + + + +
+
+
+
+ LIVE SKIN PREVIEWSCENE 001 — MISCHIEF +
+
+ MAKE SOME ROOM. +

借过,我也要上班。

+
+
+
+
+
+ m✳MXU我的自动化工作台 + +
+
+ +
+
+
+ WORKSPACE / 01 +

今日任务

+
+ 模拟设备已就绪 +
+
+ + + +
+
+ 准备就绪,随时出发0% +
+
+
+
+
+ +
+ +
+
+
+ +
+
+
+
执行状态待机中
+
+ 已完成0 / 3 +
+
+ +

运行或暂停后可模拟失败;停止将记为取消。

+
+

+
+ + + + + +
+
+ ↳ 小家伙走进来,按钮真的为它让出位置。MXU / EXPERIMENTAL +
+
+ +
+
+ HOST EVENTSskin.mounted → 捣蛋团子 · 等待交互MOTION ON +
+
+
+ 你的皮肤,从一个文件开始。导出示例配置,改个名字或颜色,再导回来试试。 +
+
+ +
+

+ 此处仅验证演示预设;完整 .mxuskin 包的安装与隔离见协议。 +

+

+ 新朋友的预设: + 大肥鱼 ↓ + 奶蛙 ↓ + 形象与玩法说明 ↗ +

+
+
+
+
+

02 / 多一点协议,少造一点轮子

+ 阅读全文 ↗ +
+
+

浏览器负责画。
MXU 负责边界。

+

+ 不发明新的绘图语言。保留现有主题变量与 React + 业务控件,给皮肤一套稳定的挂载、占位、分层与状态约定。 +

+
+
+
+ 01 / TOKENS +

从配色开始

+

沿用现有 CSS 变量。只换颜色、圆角和材质,也是一款完整皮肤。

+ CSS custom properties +
+
+ 02 / SCENE +

给表现自由

+

HTML / CSS / SVG 组织角色、背景和动画;复杂媒体用可选适配器。

+ HTML + CSS + SVG +
+
+ 03 / LAYOUT +

让布局配合

+

声明锚点、占位和越框区域,由宿主保护按钮、弹窗与窗口边界。

+ anchor · reserve · bleed +
+
+ 04 / EVENTS +

对任务有反应

+

接收最小只读状态,通过受控事件响应运行、成功与错误。

+ state → visual response +
+
+
+ 先做两层,就能玩得很丰富。 +

+ L0 主题变量 + L1 声明式场景覆盖本页大多数创意;L2 + 隔离脚本留给更复杂的物理、小游戏和外部渲染器。角色位移不等于布局占位,越过面板也不等于越过原生窗口。 +

+
+
+
+
+

03 / 皮肤还能怎么玩?

+

24 个可以继续生长的方向。

+
+
+

把自动化,变成你的小世界。

+
+ +
+
+
+

+ 这些是能力设计提案,不代表 MXU 已实现。L1 / L2 + 表示建议实现层级;部分创意需额外开放宿主槽位与只读状态。所有趣味表现都应提供静态模式,并保留直接的任务操作。 +

+
+
+
+ THE SMALL CONTRACT BEHIND THE MAGIC +

皮肤可以任性。
操作始终可靠。

+
+
+

完整设计包含包结构、字段样例、安全隔离、
能力协商,以及对现有 MXU 的接入建议。

+ 打开 MXU Skin Protocol v0.1 ↗查看仓库内的设计说明与源码 ↗单独下载 Manifest 示例 ↓ +
+
+
+ + + + + diff --git a/docs/design/skin-system/demo/protocol.html b/docs/design/skin-system/demo/protocol.html new file mode 100644 index 00000000..41c2e5b3 --- /dev/null +++ b/docs/design/skin-system/demo/protocol.html @@ -0,0 +1,964 @@ + + + + + + + + MXU Skin Protocol 0.1 草案 + + + + +
+ +
+
+ +

MXU Skin Protocol 0.1 草案

+
+

状态:通用协议设计讨论稿,字段命名、精确 schema 和兼容性承诺尚未冻结。本 PR 另将白饭鲸鱼娘与奶蛙作为可信内置卡片接入主程序设置与任务视图(见 §9.3),不代表完整 L0/L1 运行时已经实现。配套 离线 demo 用于展示八类视觉与交互方向,不是通用皮肤运行时。

+
+

1. 技术选择与职责

+

MXU 已使用 React 19、TypeScript、Vite、Tailwind、Zustand,以及 Tauri 2 / Axum。建议继续使用浏览器的渲染能力:Grid/Flex 处理布局,CSS 变量处理主题,SVG、CSS animation 和 Web Animations API 处理角色与特效。第一版不需要自研绘图引擎、物理碰撞系统或另一套通用布局语言。

+

皮肤描述外观、装饰位置和视觉响应;宿主维护真实控件的语义、可用状态与业务行为。启动、停止、设备选择等继续使用 MXU 的真实 React 控件,保留键盘操作、无障碍语义与既有业务判断。皮肤依赖公开契约,不依赖内部组件的 CSS 类名或 DOM 结构。

+
作者提供                       MXU 宿主                     Web 渲染能力
+skin.json                    包校验、稳定锚点              真实业务控件
+HTML / CSS / SVG / assets  →  布局占位、图层合成         →  隔离装饰层
+                             受控状态与事件                Grid / Flex / CSS / SVG
+
+

这里的 HTML/CSS 自定义主要指装饰场景和公开的样式变量。任意 HTML 接管应用、修改私有业务 DOM,不属于第一版皮肤能力;更高权限的应用插件应有独立契约。

+

2. 能力分级与开放节奏

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
层级作者提供表达能力建议顺序
L0 · Tokens通过宿主注册表校验的 JSON 值明暗模式、强调色,以及逐步开放的外观参数先实现统一注入与兼容,再提供内置 token-only 皮肤
L1 · Scene受限 HTML/CSS/SVG、本地资源、声明式绑定角色动画、装饰越框、布局占位、任务状态联动先以随 MXU 发布且经过审阅的内置场景验证;第三方来源等隔离与安装门槛完成后再开放
L2 · Script独立授权的隔离脚本程序化角色、复杂粒子与可选 WebGL仅预留;关闭 L2 不代表 L1 已安全
+
+

协议仍描述完整目标能力,但不再把“L1 的设计存在”等同于“第一阶段可以安全导入第三方场景”。建议按 L0 与宿主契约 → 内置 L1 → 第三方导入推进;具体版本号和发布时间待维护者决定。WebUI 浏览器模式默认不加载第三方场景,须单独通过其部署模式的验证。

+

2.1 来源与项目打包

+

这是皮肤协议,不是某一套具体皮肤。支持三种来源的目标不变:MXU 内置、各项目随项目包附带、用户自行导入。项目可以在自己的资源包中放置皮肤清单与资源,并提出默认皮肤建议;发现目录与 ProjectInterface 的对接字段尚待讨论,不在本 PR 中私自扩展 PI 协议。

+

来源不扩大权限:项目附带的 HTML/CSS/SVG 也属于第三方内容,必须经过相同的校验和开放门槛。内置包同样遵循清单与资源校验,但早期只用经审阅且随程序发布的内置场景验证运行时。

+

身份按 (来源, 项目标识或用户命名空间, skin.id) 隔离;只用包内 id 不能覆盖其他项目或内置皮肤。项目更新不能静默替换用户已选的另一个来源,也不能通过“内置于项目包”绕过版本锁定。

+

2.2 Token 准入与优先级

+

现有 applyTheme 使用 document.documentElement.style.setProperty 写入变量。L0 必须通过宿主统一的解析、合成、注入通道应用;不能让皮肤插入 :root 样式与内联变量竞争,更不能要求作者使用 !important。皮肤文档内 CSS 只影响装饰自身。

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
类别准入与类型优先级和边界
背景、正文、边框、强调色注册的语义变量;首版可只接受 #RRGGBB,拒绝 CSS 表达式、URL、任意属性名宿主默认 → 皮肤明暗默认 → 用户明确覆盖;宿主最后检查文字/控件对比度
圆角、间距等后续外观项每项单独注册数值范围和单位;未注册则拒绝不改变控件最小点击尺寸或布局保护区;不能把“沿用变量”解释成开放任意 CSS
success / info / warning / error宿主拥有的状态语义,不开放任意重映射当前 success/info 跟随 accent 是既有配色策略,不是状态混淆;适配时必须保留文字/图标与可辨识性,高对比策略优先
背景资源及透明度经包资源索引解析;透明度为有界数值用户现有背景设置继续优先;装饰与背景合成后仍需验证对比度,不能拼接原始文件 URL
明暗模式宿主解析应用设置与系统偏好后下发 data-mxu-theme皮肤不得另行读取 prefers-color-scheme 决定模式,避免应用设为浅色而皮肤随系统变深
高对比、减少动效宿主强制策略;减少动效取系统请求与应用设置的并集覆盖皮肤默认与普通用户外观覆盖,不由皮肤关闭
+
+

现有少数 dark: 样式尚可能使用 Tailwind 默认的系统媒体查询;接入前需统一应用主题属性/变体,不能声称这一点已修复。新增宿主文案须覆盖项目现有五个语言包,不能只验证中文短标签。

+

3. 皮肤包与 Manifest

+

拟议的 .mxuskin 是 ZIP 容器,根目录必须有 skin.json。文件扩展名只用于识别容器;清单决定格式版本。最小皮肤可以只有清单,复杂皮肤再附带场景和资源。

+
little-trouble.mxuskin
+├── skin.json
+├── scene.html
+├── scene.css
+├── preview.webp
+└── assets/
+    └── mascot.svg
+
+

以下清单也保存为 mascot.skin.json。它是协议示例,不是可安装成品;其中的语义锚点、槽位、场景状态及配套资源仍需宿主和皮肤作者实现。

+
{
+  "schemaVersion": "0.1",
+  "id": "studio.example.little-trouble",
+  "name": {
+    "zh-CN": "捣蛋小怪兽",
+    "en-US": "Little Trouble"
+  },
+  "version": "1.0.0",
+  "mxu": {
+    "skinApi": "0.1"
+  },
+  "capabilities": {
+    "required": ["tokens.v1"],
+    "optional": ["scene.l1", "reservation.edge.v1", "bleed.stage.v1"]
+  },
+  "fallback": {
+    "mode": "tokens-only"
+  },
+  "tokens": {
+    "light": {
+      "--color-accent": "#7357d8"
+    },
+    "dark": {
+      "--color-accent": "#b6a4ff"
+    }
+  },
+  "scene": {
+    "entry": "scene.html",
+    "initialInteractionState": "rest",
+    "interactionStates": ["rest", "mischief"]
+  },
+  "placements": [
+    {
+      "id": "mascot",
+      "element": "mascot",
+      "anchor": "toolbar.primary",
+      "box": "inline-start",
+      "nudge": {
+        "inline": -8,
+        "block": 0
+      },
+      "layer": "ornament-front",
+      "bleed": {
+        "blockStart": 48,
+        "inlineEnd": 16,
+        "blockEnd": 12,
+        "inlineStart": 24
+      },
+      "hitTest": "none"
+    }
+  ],
+  "reservations": [
+    {
+      "id": "mascot-space",
+      "slot": "toolbar.actions",
+      "edge": "inline-start",
+      "size": 112,
+      "maxFraction": 0.28,
+      "when": {
+        "interaction.state": "mischief"
+      }
+    }
+  ],
+  "bindings": [
+    {
+      "event": "host.hotspot.activate",
+      "target": "mascot-poke",
+      "setInteractionState": "mischief"
+    },
+    {
+      "event": "interaction.timeout",
+      "state": "mischief",
+      "afterMs": 2400,
+      "setInteractionState": "rest"
+    }
+  ],
+  "hotspots": [
+    {
+      "id": "mascot-poke",
+      "placement": "mascot",
+      "label": {
+        "zh-CN": "让团子挤一下",
+        "en-US": "Make room for the mascot"
+      },
+      "minTargetSize": 44
+    }
+  ]
+}
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
字段拟议语义
schemaVersion / mxu.skinApi清单格式版本与宿主皮肤接口版本;与皮肤自身的 SemVer version 分离。0.x 不假设跨 minor 兼容。
id / name稳定的反向域名式标识与本地化名称。同 ID 更新须显式选择,不能静默覆盖另一来源。
capabilities.required缺少任一必需能力则拒绝启用。
capabilities.optional / fallback缺少可选能力则移除对应表现并按清单降级;本例可保留 tokens。各能力的依赖关系须由宿主注册表定义。
tokens仅接受宿主注册的变量及对应类型;色值不能替换为任意 CSS。用户的明暗模式偏好继续有效。
scene场景入口与有限交互态列表,入口只能引用包内资源;任务态不在这里定义。
placements场景元素绑定到宿主扩展盒,声明小幅 nudge、图层和最大越框量;由宿主执行定位。
reservations向宿主边缘槽位申请布局空间。size 是请求值,maxFraction 是作者上限;宿主还要施加自身上限。
when只对公开字段做相等匹配,不执行 JavaScript 表达式;本例用在占位请求上。
bindings仅将白名单事件映射到瞬时交互态;定时器由宿主管理,不能写任务态。
+
+

hotspots 是宿主生成的真实可聚焦按钮清单,包含稳定 ID、本地化名称、目标装饰和最小目标尺寸;不是允许场景元素自行处理业务点击。44 CSS px 是建议起点,实际还受宿主布局和无障碍策略约束。空间不足或会覆盖保护区时不生成 hotspot,并在宿主皮肤控制区提供等价按钮。

+

每个 placement 使用清单内唯一的 id;hotspots[].placement 引用该 ID,element 则引用场景中的装饰元素,两者不能混用。清单必须验证引用的元素、交互态、placement 与 hotspot 是否存在。缺少任一依赖能力时整组相关装饰/占位/hotspot 降级,不能留下孤立按钮或占位。下面的样式只是表现示例,省略资源引用:

+
<div id="mascot" class="mascot" aria-hidden="true">
+  <div class="mascot-motion"><img src="assets/mascot.svg" alt="" /></div>
+</div>
+
+
/* 均由宿主的可信渲染层投影;皮肤文件本身不运行脚本。 */
+[data-task-state='running'] .mascot-motion {
+  animation: breathe 2.8s ease-in-out infinite;
+}
+[data-interaction-state='mischief'] .mascot {
+  transform: rotate(-8deg);
+}
+[data-task-state='error'] .mascot-motion,
+[data-task-state='cancelled'] .mascot-motion,
+[data-reduced-motion='true'] .mascot-motion {
+  animation: none;
+}
+@keyframes breathe {
+  50% {
+    transform: translateY(-6px);
+  }
+}
+
+

任务态与交互态分别作用于内外包裹层。示例不再用 running → cheer → timeout → idle 共用一个状态变量:2.4 秒结束后只恢复 interaction=rest,持续任务态仍是 running。错误、取消和恢复提示优先于趣味交互。

+

4. 真实占位与装饰越框

+

4.1 Anchor:宿主给出可排布的扩展盒

+

宿主发布语义锚点实例,至少包含 anchorId、instanceId、皮肤 generation、布局 revision、锚点矩形、允许的扩展盒与保护区。只注册当前视图中可见且有效的实例;隐藏 Toolbar、卸载节点和旧视图不能成为可选锚点。CSS 类名与 DOM 查询不是协议。

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
字段首版候选值执行方与限制
boxinline-start / inline-end / block-start / block-end / overlay宿主为特定锚点发布其中的允许子集;作者在盒内用普通 CSS 排布
reservations.edgeinline-start / inline-end / block-start / block-end仅限槽位支持的边;不做任意二维碰撞
placements.layerbackground / ornament-front仅是宿主开放的装饰层;popover/modal/recovery 层不能选择
placements.hitTestnone皮肤装饰不抢事件;交互由独立 hotspots 清单交给宿主
nudge.inline / nudge.blockCSS px 数值,建议各轴限制在 -16 到 16只允许小幅视觉校正;不授予逃离扩展盒/保护区的能力
bleed四个非负逻辑边:blockStart / inlineEnd / blockEnd / inlineStart是最多可以伸出的距离,不是任意 z-index、占位请求或输入权限
+
+

矩形使用宿主视口的 CSS px,不使用设备像素;逻辑边由宿主按方向/书写模式解释。宿主把 getBoundingClientRect() 得到的坐标转换到对应装饰层坐标,处理页面滚动、容器偏移及缩放;作者不能自行混用屏幕坐标。布局与画面都使用同一 revision 的已授予盒,旧数据丢弃。含变换容器的转换方案及几何断言需由实现验证。

+

上述是待评审的最小枚举,不是已经冻结的兼容契约。

+

4.2 Reservation:角色占空间,按钮真的移动

+

角色进入捣蛋状态时,宿主在 toolbar.actions 的逻辑起始边插入占位轨道,让 Grid/Flex 中的真实按钮重新排布;退出后释放空间。控件的 DOM 身份、焦点与业务事件处理器保持不变。

+

只给角色或按钮添加 transform 不会改变兄弟元素布局,因此不能替代真实占位。

+

v0.1 建议只支持边缘槽位和面板 inset,不支持任意多边形障碍、二维碰撞或物理推挤。可用宽度不足时先缩小占位;仍不足时返回零占位,让角色移到备用锚点或隐藏。多个请求按稳定优先级分配,合计不得侵占宿主控件最小宽度。

+

宿主返回实际分配的 acceptedSize 与原因,场景按实际结果摆位,不能按“申请 112 px”绘制而按钮只让出 40 px。用户正按住按钮、拖拽或进行键盘操作时,应延后布局变化,避免控件从手下移走。

+

4.3 Bleed:越过面板边框,仍在应用视口内

+

机甲由宿主装饰合成层绘制,跟随被锚定面板的位置,绕过该卡片及其滚动容器的裁切。此能力依赖先完成统一壳层与 portal/层叠上下文治理,不能只在现有 App 根节点插入一个高 z-index 元素。bleed 声明最大越框范围;最终允许区域取皮肤请求、宿主安全区和视口边界的交集。装饰越框不参与布局,也不会自动推开其他面板。

+

overflow: visible 无法穿过 iframe、WebView 或原生窗口边界。隔离装饰 renderer 应覆盖宿主允许的 stage,不能把大角色塞进一张小卡片 iframe 后期待它自行越框。

+

本提案中的“破框”指应用内面板。伸到操作系统桌面需要额外透明窗口与系统级命中处理,不属于 v0.1。

+

4.4 图层、保护区与命中

+

以下是需要宿主实现的目标合成顺序,不代表现有代码已有这些层:

+
background → content → ornament-front → host-popover → host-modal → emergency-controls
+
+

皮肤只能选择开放的层,不能提交任意全局 z-index。前景装饰默认 pointer-events: none;开始、停止、错误处理、恢复默认皮肤等保护区,以及键盘焦点环,不能被装饰遮挡。

+

保护区还包括原生窗口拖动/关闭区域、右栏 resizer、Schedule/权限弹窗和被打开的菜单。宿主设备统计和任务结果不能因为皮肤构图被隐藏;窄窗口需要另有固定文本摘要。恢复入口应位于不依赖皮肤成功渲染的通路。

+

“戳一下角色”由宿主创建带本地化名称、焦点环及 Enter/Space 行为的真实按钮,并验证其区域不覆盖关键控件。透明区域不应拦截指针,场景文档不得接管全屏鼠标。只有已验证的 host hotspot 事件进入 bindings,任何点击不能绕过启动/停止的既有业务校验。

+

5. 状态快照与瞬时交互分层

+

皮肤仅接收裁剪后的公开视图模型,不读取完整 Zustand store、任务密码、原始日志、文件路径或设备凭据。建议由宿主适配层持续投影只读快照:

+
{ generation, revision, runId,
+  task: { state, progress },
+  preferences: { theme, reducedMotion },
+  layoutRevision }
+
+

revision 在当前 generation 内单调递增;宿主丢弃旧快照和旧 runId 的迟到回调,创建新实例时先发完整快照。相同 revision 的重复消息不重放动画。progress 为 [0,1] 或 null(未知),不能从时间动画伪造进度或剩余时间。

+

5.1 只暴露有事实来源的任务状态

+

当前 TaskRunStatus 是 idle / pending / running / succeeded / failed,实例 TaskStatus 与它也不是同一层级。冻结前须建立实例/任务范围的映射表,不能把当前任务成功误当成整个队列成功。

+
+ + + + + + + + + + + + + + + + + + + + + + + +
皮肤视图状态适配要求
idle / pending / running从当前运行实例、排队/启动事实派生,不能只看按钮文字
success / error对同一 runId 的完成结果映射 succeeded / failed,核对其是单任务还是批次结果
cancelled仅在适配层记录显式停止意图并确认该次运行终止后开放;当前停止清成 null 不能直接当作取消或成功
paused仅有真正暂停/恢复语义及事实来源时开放;当前生产适配器不能因为 demo 有暂停按钮就宣布支持
+
+

cancelled / paused 是目标能力,需能力声明或后续宿主状态机支持,不列为现有 MXU 必有状态。能力缺失时不展示相关皮肤状态;尚无法区分终止原因时显示中性的“已结束”,不能伪报成功。

+

5.2 任务态不被互动计时器改写

+

bindings 只改 interaction.state,不能写任务状态。最终显示由宿主任务快照与临时交互态共同决定;错误/取消/恢复提示优先,趣味动作可被抑制或清除。

+

处理规则建议:快照按 revision 接收;激活 hotspot 进入 mischief 并由宿主启动 2400 ms 的交互定时器;重复激活重置同一个定时器;超时只回到 rest。进入终止态时同时取消旧定时器、恢复 interaction=rest 并释放该交互态申请的占位;切换 generation 或卸载时也清除交互态和占位。即使超时事件与任务快照同帧到达,也不影响任务事实。

+

业务操作仍由真实宿主控件触发。未来如开放 requestAction("task.start"),必须校验实例、动作白名单、业务状态与真实用户手势,并继续经过既有启动流程;不开放通用 invoke、任意 HTTP、文件读写或 store setter。

+

L1 作者不编写消息 SDK:可信宿主读取声明并管理布局/热点,把状态与有限属性单向投影到无作者脚本的渲染树。具体跨文档投影机制须满足第 7 节隔离门槛;不能为了接收状态就默认给不可信内容帧开放 allow-scripts。跨源、无脚本的 iframe 不能自行通过 postMessage 更新 CSS,宿主也不能直接访问其 DOM;可信控制域与内容树如何投影状态、重建是否重置动画,都需要隔离原型证明,不能把“双帧”当作现成 SDK。

+

5.3 趣味反馈的组合与优先级

+

新增的白饭鲸鱼娘与奶蛙场景不需要增加任务状态、执行作者脚本或把角色动作写回业务。它们分别把端碗干饭与捧腹大笑作为表现层,仍消费同一份宿主快照。推荐组合顺序如下:

+

本轮两套皮肤只复用公开素材中的动作,不为角色添加对白、拟声台词或额外剧情。任务状态和互动按钮是宿主界面信息,不借角色口吻表达。

+

下表包含目标协议状态和离线 demo 能力;主程序本轮内置接入仅映射已有事实支持的 idle / running / success / error,不因 demo 存在相应按钮就新增生产 paused、cancelled 或百分比进度。

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
宿主事实角色表现与互动规则
idle可以预览吃饭或大笑;标明这是角色互动,不会启动任务。
running允许叠加短暂互动,但任务文字、进度和停止入口持续保留;互动结束后回到当前运行表现。
paused停止工作循环与持续表演,保留明确的暂停文字;不能靠改动画速度模拟业务暂停。
success只对已确认完成的同一 runId 显示庆祝;单任务成功不能提前触发整批完成。
error / cancelled清理旧互动并优先显示失败或取消;停止捧腹狂笑、吃饭庆祝等容易误读的反馈。
恢复模式或场景不可用宿主恢复入口优先,隐藏角色并释放占位,直接使用默认外观。
+
+

进度表现只读取宿主的已知进度。progress: null 时应显示不确定状态,不能用吃饭次数、大笑次数或动画时长推算百分比、任务数量和剩余时间;也不把这些计数写成角色设定。

+

交互结束、重复激活、切肤和卸载应分别测试。超时只清理其所属场景实例的 interaction,不清空任务、不重新启动任务计时器;新场景先消费当前任务快照。系统减少动效时保留静态角色、明确状态与等价热点按钮,停止摇晃、弹跳和动态视频。音频不是这两套场景的必需能力;若以后加入,须另行定义用户手势、静音偏好、停止和卸载清理,不能让主题切换触发自动播放。

+

本地视频同样由宿主管理播放生命周期,不能因嵌入媒体而要求开放作者脚本或外部网络。减少动效、页面隐藏、场景卸载与终止表演时暂停视频;恢复可见时仅在当前状态仍需要表演的情况下继续。视频加载、格式或播放失败时保留本地静态图与任务文字,不阻断业务操作。媒体资源的 MIME、解码预算和来源记录仍须纳入包校验。

+

6. 宿主的五项职责

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
模块职责
SkinLoader解包、schema 校验、版本协商、资源索引与可回滚启用。安装完成不等于立即替换当前皮肤。
AnchorRegistry发布稳定语义锚点及矩形,用 ResizeObserver、滚动监听与合并后的帧更新同步位置。
LayoutCoordinator分配边缘占位、保护控件最小尺寸、返回实际分配结果,响应缩放、语言和布局变化。
SkinStage管理装饰图层、越框裁切、保护区、hotspot、动效生命周期与受信任恢复入口。
SkinBridge从 Zustand 派生只读状态,验证消息、调度声明式事件并清理卸载资源。
+
+

卸载皮肤时必须清除定时器、观察器、资源 URL、占位与事件订阅。启用与健康标记应由原生侧持久化。下一次启动在任何第三方皮肤求值之前判断安全模式,回退到内置主题;窗口显示和恢复入口不能依赖皮肤初始化完成,同时保留现有自启动隐藏窗口的用户偏好。正常换肤不能中断真实任务。

+

7. 第三方导入的隔离与安装门槛

+

7.1 无作者脚本不等于没有攻击面

+

L1 内容文档不执行作者脚本,可信适配器、业务控件与 hotspot 留在宿主域。HTML/CSS 仍可发起资源请求、导航或造成昂贵渲染,因此只禁用 JS 远远不够。Shadow DOM 仅隔离样式;iframe 标签、CSP nonce/hash 和 origin === "null" 均不能代替权限边界。

+

撤回原稿把“同文档可信 bootstrap + allow-scripts + nonce/hash”作为默认隔离方案的写法。nonce/hash 可以限制脚本执行,但不会分离同一文档中的数据与权限,也不能保护其他可达的本地 API。它并非在任何使用方式下都必然可绕过;本提案不依赖这一争论来宣布隔离成立。

+

主窗 csp: null 意味着没有可继承的主窗策略;子文档仍可施加更严格策略。第三方内容应由独立资源响应提供明确、可测试的 CSP,而非只靠父文档继承或皮肤提供的 meta。若使用多帧,可信控制外壳与无脚本内容帧必须分离;双帧本身也不证明安全。

+

7.2 必须验证的四条权限路径

+
+ + + + + + + + + + + + + + + + + + + + + + + +
路径当前事实与开放前要求
Tauri 插件和原生 IPC主窗有广泛文件/网络能力;ACL 与 window/webview label 和 origin 相关,DOM iframe ID 不是独立 capability 边界。独立 WebView、不同源等都只是候选手段,必须证明其拿不到宿主原生权限。
应用自定义命令除插件 capability 外,还要核验 invoke_handler 中应用命令的授权。不能假设“不给新 WebView 插件权限”就封住全部自定义命令;需显式命令范围/来源校验或对应 AppManifest 配置并实测。
本地 Axum / WebUI API当前 Web 服务路由包含配置写入和任务控制,CORS 允许任意 origin 且该路由栈未见认证层。CORS 不是认证。要避免第三方渲染域访问控制面,需控制 API 的鉴权、授权与请求来源,并切断皮肤网络访问;不能只隐藏 Tauri 全局对象。
资源/导航出口仅可读取包资源索引;阻止 fetch、WebSocket、图片/CSS/字体外联、表单、重定向、弹窗与顶层导航等路径,覆盖 loopback 地址和其他本地服务。资产源与控制 API 分开,皮肤不得收到控制 API 的随机会话凭据。
+
+

这里的 API 风险以应用当前权限为界;“重启为管理员”仍经过操作系统提权流程,不能表述成无条件获得管理员权限。实际 Tauri/Wry 版本与平台对初始化脚本、子帧注入有差异;源码中的 main-frame-only 标志不是跨平台隔离承诺。

+

Windows WebView2、macOS WKWebView、Linux WebKitGTK,以及 WebUI 的目标部署方式,需要分别做最小隔离原型与负向测试。__TAURI__ / __TAURI_INTERNALS__ 不可见可作为检查项,但不是充分证明:还要验证原始 IPC、自定义命令和本地 API 实际被拒绝。不存在这些证据时保持第三方场景关闭。

+

权限边界可对照 Tauri capabilities 文档、Tauri 2.9.5 WebView 源码与平台备注 及 CSP3 安全考虑 核对;文档说明与静态源码检查都不能替代目标平台的负向实测。

+

7.3 专用安装通路

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
边界拟议约束
解包器单独实现皮肤 ZIP 导入,不复用应用升级用的通用 extract_zip,不接收 tar.gz。拒绝绝对路径、..、符号链接、重复/大小写冲突、跨平台保留名/特殊文件和超限条目;校验解码后的路径及实际写入目标。
预算初始建议压缩 20 MiB、实际解压 60 MiB、256 文件、单文件 16 MiB;流式计算实际写入字节,不能信任 ZIP 头中的长度。限制节点、像素解码量与昂贵 CSS 效果。
内容校验实际 MIME;SVG 移除脚本、事件、外链和 foreignObject;用成熟解析器及 allowlist 处理 HTML/CSS,遍历嵌套规则、URL、字体和 @import,不用正则代替消毒器。
内容身份计算实际包/版本内容摘要,完整性校验覆盖保存与读取;摘要只有和可信来源记录绑定才提供来源保证,不把作者自报哈希当签名。
安装/启用暂存校验 → 不执行未批准内容的预览 → 写入不可变版本目录 → 原子更新启用记录。使用独立皮肤目录和版本清单,不用符号链接表达 current。失败保留上一个可用版本。
回滚与清理建议至少保留最近两个已知可用版本;回滚记录与健康标记不放到现有启动清理的临时更新目录。清理必须理解引用关系,不能删除当前/回滚版本。
更新首版不做自动或静默皮肤更新;新版本重新校验、显式启用,用户选择锁定来源和版本,不能借更新增加权限。
+
+

当前应用更新工具支持的归档类型与校验预算不等同于皮肤安装要求;本 PR 不顺带改造应用更新系统。内置和项目附带的资源仍走对应校验,来源不豁免内容约束。

+

7.4 开放第三方场景的验收门槛

+

以下全部完成后再讨论默认启用:隔离域三平台负向实测;原生 IPC/自定义命令/控制 API 权限隔离及网络出口限制;专用解包与资源校验;不依赖皮肤的原生启动恢复;锁定版本、显式更新与回滚。WebUI 另做独立验收。L2 需要重新评审,不是给 L1 随手添加一个 allow-scripts 开关。

+

8. 兼容、降级与动效预算

+

宿主先检查 schema 与必需能力,再检查锚点。当前视图暂时没有某个锚点时,隐藏对应装饰并释放占位。缺少可选场景能力可以降为 L0;未知必需能力必须拒绝。新增字段需在 schema 中明确标为可选,不能静默容忍字段拼写错误。

+

基础方案优先使用 Grid/Flex、CSS 变量、SVG 与经典关键帧。CSS anchor positioning、复杂滤镜、WebGL、Lottie/Rive 等可在有实际需求时作为独立能力与适配器添加,不把协议绑定到某个动画库。

+ +

这些数值是实现起点,不是测得的性能承诺。验证需覆盖五种语言与长标签、850×525 最小窗口、125%/150% 缩放、面板折叠、移动 WebUI、减少动效与运行中换肤。需要真实浏览器/WebView 的静态布局 harness 验证扩展盒、碰撞与焦点/点击保护区;jsdom 无法证明这些几何性质。字体、字重与颜色组合须以可读性和 WCAG 对比度检查验收,不能把示意图的小字号当产品规范。

+

9. 接入现有 MXU 的前提

+

下表以本设计 PR 的基线 a05a112 说明通用皮肤运行时的接入前提。§9.3 单独列出本轮已实现的两套可信内置卡片;不能将该有限接入当作表中全部改造已经完成。

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
现有代码应先完成的工作
src/themes/index.ts、src/index.css保留已有主题行为,以 token registry 统一入口和优先级;核对自定义强调色、背景透明度、主题变体与无障碍策略。
src/App.tsx设置/加载/主页面有互斥布局路径,裁切容器与 stacking context 多处存在。先设计统一 AppShell 和 portal 层策略,明确每个视图支持哪些装饰层;不是加一个根级 Stage 就完成。
Toolbar.tsx三处 JSX 使用位置并非运行时同时挂载三份,但设置页存在隐藏实例。注册锚点必须按可见实例和 ref/id 管理,清理隐藏实例的皮肤订阅,不借用业务全局快捷键监听作桥接。
弹窗、菜单、resizer 与标题栏统一宿主层级,保留权限/Schedule 弹窗、拖动区域、停止操作与恢复入口的保护区;现有 z-50/z-[100]/z-[9999] 和 portal 需逐一映射。
src/stores/appStore.ts、src/stores/types.ts为任务/批次建立只读 VM 适配表,明确状态来源、runId、revision 和终止原因;不能直接枚举尚无事实来源的 paused/cancelled。
src-tauri/src/lib.rs、windowUtils.ts当前窗口起初隐藏,正常显示依赖前端初始化。原生侧需在皮肤求值前处理启用健康标记/安全模式,并提供不等皮肤成功的显示与恢复通路;当前并不存在一个可直接挂钩的 Rust setup show()。
web_server.rs、tauri.conf.json、capabilities/default.json分别验证本地控制 API、资源 origin、CSP、插件与自定义命令权限,不能互相替代。
+
+

9.1 偏好与版本的存储域

+

桌面现有外观配置是按项目保存,WebUI 有独立的 origin-local 外观偏好且不会直接回写覆盖桌面。这些行为应保留兼容,不能新增一个全局 activeSkinId 后默默改变用户的项目偏好。

+

建议明确区分桌面本机默认、用户对特定项目的显式覆盖,以及项目包的推荐值。初次启用项目皮肤需要用户选择“跟随项目推荐”;没有选择时不因项目更新强制换肤。解析优先级为用户项目覆盖 → 用户本机默认 → 已授权跟随的项目推荐 → MXU 内置回退。记录来源与版本,项目身份需稳定且可迁移。

+

WebUI 的选择仍是该浏览器客户端的偏好,不广播到桌面或其他浏览器。已有后端配置 revision/CAS 可复用到其实际负责的设置域,不需要另造一套全局写入机制;皮肤公开状态的 generation/revision 是运行时消息时序,与持久配置 revision 不混用。切项目与多客户端并发策略需在实现前固定。

+

9.2 建议的实施顺序

+
    +
  1. 先补 token 注册/优先级、统一壳层与图层计划、VM 事实映射和恢复设计;内置 L0 可先落地。
  2. +
  3. 实现稳定锚点、扩展盒与占位、宿主 hotspot,在经审阅的内置 L1 上验证各类表现。
  4. +
  5. 提供浏览器/WebView 布局 harness,测试最小窗口、缩放、语言、滚动、折叠、热点与运行中换肤。
  6. +
  7. 完成第 7 节全部门槛后,才开放项目附带/用户导入的第三方场景;WebUI 单独放行。
  8. +
  9. 有不可由 L1 表达的实际需求时,再设计 L2。
  10. +
+

具体版本节奏待讨论,本稿没有将上述步骤变成发行承诺。所有新增宿主界面文案使用既有 i18n 体系。

+

9.3 本轮可信内置皮肤接入

+

主程序设置的外观分区增加默认、蓝色大肥鱼吃白饭、奶蛙三项选择,以及皮肤减少动效偏好。原有明暗模式、强调色、自定义强调色和背景图继续独立生效;内置皮肤选择不通过改写用户强调色实现。

+

skinId 仅接受 default / deepseek / naiwa,未知或旧配置中缺失的值回退为默认;skinReducedMotion 与系统减少动效请求共同约束角色表现。桌面沿用现有项目外观配置,WebUI 沿用本地外观存储及后端值缓存,避免浏览器本地选择回写覆盖桌面偏好。这不是第 9.1 节完整多来源、项目推荐和版本管理方案。

+

BuiltinSkinCompanion 由宿主 React 代码和前端随包资源实现,在主视图两个可见工具栏位置之前使用普通文档流卡片,设置页提供预览。默认皮肤不渲染角色。它不依赖隐藏 Toolbar 实例,不接管业务控件,也不创建任意高 z-index 覆盖层;图片、视频仅负责装饰,互动使用宿主真实按钮。换肤只卸载或更新表现,不改任务 store、启动流程或停止流程。

+

生产任务状态读取活动实例:isRunning 优先映射运行中,再将整批 Succeeded / Failed 映射成功 / 失败,其余中性显示为待机。停止流程清成 null 不被解释成成功或取消;不从单任务状态、动画时间、饭量或笑声计算百分比。角色临时互动只更新组件本地状态,不能覆盖当前任务事实。

+

资源固定来自 src/assets/skins/,不支持作者提供 HTML/脚本、不解释 .mxuskin、不提供第三方安装入口,也不增加原生或网络权限。媒体来源说明见 两套皮肤说明。本轮验证可信内置场景在现有宿主中的选择、持久化和真实布局,通用 token registry、Manifest 绑定、稳定锚点、保护区裁切与隔离门槛继续保留。

+

鲸鱼娘采用固定版本社区吃饭动画,原 VP9 alpha 经解码合成白底、移除音轨并转为 H.264,静态姿态从同源视频抽帧保留透明,本次不重绘角色。格式不受支持或播放失败时保留静态表现;不能以 Chromium 成功播放推断所有原生 WebView 都已通过验证。资产的 CC BY-NC-SA 4.0、署名与修改记录独立于代码许可。

+

10. 离线 Demo 能证明什么

+

八个离线场景分别展示角色占位、机甲越框、生态舱生长、黑胶旋转、完成印章、声呐扫描,以及白饭鲸鱼娘与奶蛙的趣味互动;另提供成功/失败/取消/重试的任务模拟、稳定的宿主状态摘要、进度条语义、宿主按钮热点、布局轮廓、减少动效、有限预设导入导出和 24 条扩展创意。它们用于讨论表现力、协议范围与操作手感;主程序本轮只接入 §9.3 的两套内置皮肤。

+

需要明确区分:

+ +

11. 待讨论与冻结事项

+

正式实现前仍需共同决定(本文候选字段仍需 schema 与原型验证):

+
    +
  1. L1 是否只承担装饰和主题,还是需要受控的业务卡片模板?完整布局替换是否另设能力?
  2. +
  3. 锚点、槽位、公开状态和能力注册表的最小集合,以及缺失能力的依赖降级顺序。
  4. +
  5. 本文新增的最小候选枚举、分层状态与 hotspot 字段是否合适;仍需正式 JSON Schema、完整错误诊断与兼容性测试。
  6. +
  7. 各平台隔离 renderer 的具体实现和原生权限验证方法。
  8. +
  9. 多占位请求冲突、长标签与窄窗口下的布局分配规则。
  10. +
  11. 作者预览、调试工具、静态打包与资源预算如何做得足够简单。
  12. +
  13. 第一批内置皮肤优先验证哪些玩法,哪些效果值得先做成官方可复用组件。
  14. +
+

欢迎在设计 PR 中补充想法、替代方案、示意图或具体皮肤需求;本稿的目标是把讨论变得具体,不是提前冻结实现。

+
+ + + diff --git a/docs/design/skin-system/demo/style.css b/docs/design/skin-system/demo/style.css new file mode 100644 index 00000000..1d974d04 --- /dev/null +++ b/docs/design/skin-system/demo/style.css @@ -0,0 +1,3276 @@ +:root { + --ink: #242722; + --muted: #73776e; + --line: #ddded5; + --page: #f6f6ef; + --orange: #e76b41; + --mint: #aadea6; + font-family: Inter, 'Noto Sans SC', 'Microsoft YaHei', system-ui, sans-serif; + color: var(--ink); + background: var(--page); + font-synthesis: none; +} +* { + box-sizing: border-box; +} +html { + scroll-behavior: smooth; + scroll-padding-top: 30px; +} +body { + margin: 0; +} +button, +input, +a { + -webkit-tap-highlight-color: transparent; +} +button, +input { + font: inherit; +} +button, +a { + touch-action: manipulation; +} +button { + cursor: pointer; +} +a { + color: inherit; + text-decoration: none; +} +button:disabled { + cursor: default; + opacity: 0.42; +} +button:focus-visible, +a:focus-visible, +input:focus-visible { + outline: 3px solid #ec7446; + outline-offset: 4px; +} +button { + color: inherit; +} +img { + max-width: 100%; +} +.site-header { + height: 94px; + margin: 0 4.6%; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--line); +} +.brand { + display: flex; + align-items: center; + gap: 12px; +} +.brand-icon { + display: flex; + align-items: center; + font-size: 36px; + font-weight: 900; + letter-spacing: -6px; + line-height: 1; +} +.brand-icon span { + font-size: 28px; + color: var(--orange); + transform: translateY(-5px); + margin-left: 2px; +} +.brand b { + font-size: 16px; + letter-spacing: -0.5px; +} +.brand b > span { + font-size: 12px; + font-weight: 450; + letter-spacing: 1.5px; + margin-left: 10px; +} +.site-header nav { + display: flex; + gap: 32px; + font-size: 13px; +} +.site-header nav a:hover { + color: var(--orange); +} +.nav-count { + font-size: 10px; + color: var(--muted); + margin-left: 4px; +} +.version { + font: + 10px ui-monospace, + monospace; + letter-spacing: 0.7px; + display: flex; + align-items: center; + gap: 8px; +} +.version i { + width: 6px; + height: 6px; + background: #69a776; + border-radius: 50%; +} +main { + max-width: 1600px; + margin: auto; + padding: 0 4.6%; +} +.intro { + padding: 48px 0 46px; +} +.eyebrow { + font-size: 10px; + font-weight: 650; + letter-spacing: 1.5px; + display: flex; + align-items: center; + gap: 15px; +} +.tiny-rule { + width: 28px; + height: 1px; + background: #a4a79e; +} +.eyebrow > span:last-child { + letter-spacing: 1px; + color: var(--muted); +} +.intro-row { + display: flex; + justify-content: space-between; + align-items: flex-end; + margin-top: 22px; +} +.intro h1 { + font-size: clamp(42px, 4.8vw, 72px); + letter-spacing: -3px; + line-height: 1.22; + margin: 0; + font-weight: 780; +} +.ink-highlight { + position: relative; +} +.ink-highlight svg { + position: absolute; + bottom: -9px; + left: 0; + width: 100%; + height: 20px; + stroke: var(--orange); + stroke-width: 3; + fill: none; +} +.intro-note { + position: relative; + padding: 0 48px 0 65px; +} +.asterisk { + position: absolute; + font-size: 45px; + left: 0; + top: -4px; + color: var(--orange); + line-height: 1; +} +.intro-note p { + font-size: 14px; + line-height: 1.95; + margin: 0 0 20px; + color: #696e62; +} +.intro-note a { + font-size: 12px; + font-weight: 600; + display: flex; + justify-content: space-between; +} +.intro-note a span { + font-size: 20px; +} +.section-heading { + display: flex; + align-items: center; + gap: 20px; + margin-bottom: 20px; +} +.section-heading h2 { + font-size: 18px; + margin: 0; + font-weight: 650; +} +.section-heading h2 span { + font: + 11px ui-monospace, + monospace; + color: var(--orange); + margin-right: 13px; +} +.section-heading p { + font-size: 12px; + color: var(--muted); + margin: 0; +} +.section-heading > a { + margin-left: auto; + font-size: 12px; +} +.text-button { + margin-left: auto; + background: none; + border: 0; + font-size: 12px; + padding: 8px 0; +} +.playground { + scroll-margin-top: 25px; +} +.skin-tabs { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 8px; + margin-bottom: 12px; +} +.skin-tabs button { + padding: 13px 11px; + display: flex; + align-items: center; + gap: 9px; + border: 1px solid var(--line); + background: transparent; + border-radius: 9px; + text-align: left; + position: relative; + transition: + background 0.2s, + border 0.2s; +} +.skin-tabs button:hover { + background: #eceee4; +} +.skin-tabs button[aria-pressed='true'] { + background: #fff; + border-color: #33382f; + box-shadow: 0 2px 0 #34382e; +} +.skin-symbol { + display: grid; + place-items: center; + width: 33px; + height: 38px; + flex-shrink: 0; + border-radius: 7px; + font-size: 25px; + line-height: 1; +} +.mint { + background: #cee7bc; +} +.coral { + background: #f1c5b3; +} +.blue { + background: #cbdaea; +} +.peach { + background: #f0d4ac; +} +.sand { + background: #e9dfc7; +} +.teal { + background: #b5d3c9; +} +.skin-tabs b { + display: block; + font-size: 12px; + white-space: nowrap; +} +.skin-tabs small { + font-size: 9px; + display: block; + margin-top: 5px; + color: var(--muted); +} +.tab-index { + font: + 9px ui-monospace, + monospace; + position: absolute; + right: 7px; + top: 7px; + color: #898e82; +} +.lab-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) 255px; + gap: 13px; +} +.stage { + --scene-bg: #e1e8d5; + --app-bg: #fbfcf6; + --app-text: #303a2f; + --app-sub: #87917d; + --app-line: #e6e9de; + --app-accent: #364f36; + --app-soft: #eff2e8; + --reserve: 132px; + --art-scale: 1; + --growth: 0.75; + background: var(--scene-bg); + position: relative; + min-height: 600px; + border-radius: 13px; + overflow: hidden; + transition: background 0.5s; + color: var(--app-text); + display: flex; + flex-direction: column; + isolation: isolate; +} +.stage:before { + content: ''; + position: absolute; + inset: 0; + background-image: radial-gradient(#65725c26 0.6px, transparent 0.6px); + background-size: 15px 15px; + pointer-events: none; + z-index: -1; +} +.stage-topline { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px 24px; + font: + 9px ui-monospace, + monospace; + letter-spacing: 1px; + opacity: 0.7; +} +.stage-topline > span:first-child { + display: flex; + gap: 7px; + align-items: center; +} +.live-dot { + width: 5px; + height: 5px; + border-radius: 50%; + background: currentColor; +} +.scene-label { + padding: 14px 32px 0; +} +.scene-label > span { + font: + 10px ui-monospace, + monospace; + letter-spacing: 1.5px; + opacity: 0.65; +} +.scene-label h3 { + font-size: 26px; + letter-spacing: -0.8px; + margin: 9px 0 0; + font-weight: 650; +} +.app-wrap { + position: relative; + margin: 37px 35px 47px; + flex: 1; + min-height: 345px; + display: flex; + align-items: center; +} +.app-shell { + position: relative; + z-index: 2; + background: var(--app-bg); + color: var(--app-text); + width: 100%; + border: 1px solid #ffffff80; + border-radius: 12px; + box-shadow: 0 20px 50px #3c453c1a; + transition: + background 0.4s, + color 0.4s; +} +.shell-shadow { + position: absolute; + inset: 20px -10px -12px 15px; + background: #89967c20; + border: 1px solid #ffffff45; + border-radius: 14px; + transform: rotate(-2deg); + z-index: 0; +} +.app-titlebar { + height: 43px; + display: flex; + gap: 9px; + align-items: center; + border-bottom: 1px solid var(--app-line); + padding: 0 15px; + font-size: 11px; +} +.mini-brand { + font-size: 22px; + font-weight: 900; + letter-spacing: -4px; + line-height: 1; +} +.mini-brand span { + font-size: 16px; + color: var(--app-accent); + position: relative; + top: -3px; +} +.app-project { + font-size: 9px; + opacity: 0.5; + margin-left: 6px; +} +.window-dots { + display: flex; + gap: 5px; + margin-left: auto; +} +.window-dots i { + height: 5px; + width: 5px; + background: var(--app-sub); + opacity: 0.5; + border-radius: 50%; +} +.app-body { + display: flex; + min-height: 310px; +} +.mini-sidebar { + width: 44px; + flex-shrink: 0; + border-right: 1px solid var(--app-line); + display: flex; + flex-direction: column; + align-items: center; + padding: 15px 0; + gap: 14px; + font-size: 17px; + color: var(--app-sub); +} +.mini-sidebar > span { + width: 28px; + height: 28px; + display: grid; + place-items: center; +} +.mini-sidebar .sidebar-selected { + background: var(--app-soft); + color: var(--app-accent); + border-radius: 7px; +} +.sidebar-bottom { + margin-top: auto; +} +.task-pane { + padding: 20px 18px 14px; + flex: 1; + min-width: 0; +} +.pane-heading { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 19px; + gap: 5px; +} +.pane-heading small { + font: + 7px ui-monospace, + monospace; + letter-spacing: 1px; + color: var(--app-sub); +} +.pane-heading h4 { + font-size: 17px; + margin: 4px 0 0; + font-weight: 650; +} +.connection { + font-size: 7px; + white-space: nowrap; + color: var(--app-sub); + display: flex; + align-items: center; + gap: 4px; +} +.connection i { + width: 4px; + height: 4px; + border-radius: 50%; + background: #7caa73; +} +.task-list { + display: flex; + flex-direction: column; + gap: 7px; +} +.task-row { + display: flex; + align-items: center; + gap: 9px; + border: 1px solid var(--app-line); + border-radius: 6px; + padding: 10px 9px; + min-height: 51px; + cursor: pointer; + transition: background 0.3s; +} +.task-row input { + appearance: none; + width: 11px; + height: 11px; + border: 1px solid var(--app-sub); + border-radius: 3px; + margin: 0; + display: grid; + place-items: center; + flex-shrink: 0; +} +.task-row input:checked { + background: var(--app-accent); + border-color: var(--app-accent); +} +.task-row input:checked:after { + content: '✓'; + font-size: 9px; + color: var(--app-bg); + line-height: 1; +} +.task-row input:disabled { + opacity: 0.5; +} +.task-glyph { + font-size: 15px; + color: var(--app-sub); +} +.task-copy { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} +.task-copy b { + font-size: 14px; + font-weight: 550; +} +.task-copy small { + font-size: 12px; + color: var(--app-sub); + white-space: nowrap; +} +.task-state { + margin-left: auto; + color: var(--app-sub); + font-size: 12px; + white-space: nowrap; +} +.task-row.current { + background: var(--app-soft); + border-color: var(--app-accent); +} +.task-row.done .task-state { + color: var(--app-accent); +} +.progress-line { + display: flex; + justify-content: space-between; + font-size: 12px; + color: var(--app-sub); + margin: 15px 0 6px; +} +.progress-track { + height: 3px; + border-radius: 3px; + background: var(--app-line); + overflow: hidden; +} +.progress-track > div { + height: 100%; + width: 0; + background: var(--app-accent); + transition: width 0.5s; +} +.action-area { + display: flex; + align-items: center; + min-height: 48px; + position: relative; + padding-top: 12px; +} +.reservation { + width: 0; + flex: 0 0 0px; + align-self: stretch; + transition: flex-basis 0.65s cubic-bezier(0.2, 0.8, 0.2, 1); + position: relative; + min-width: 0; +} +.reservation span { + display: none; + position: absolute; + font: + 7px ui-monospace, + monospace; + white-space: nowrap; + top: 4px; + left: 4px; + color: #2e7454; +} +.theme-mascot.mischief .reservation { + flex-basis: max(0px, min(var(--reserve), 40%, calc(100% - 180px))); +} +.action-buttons { + display: flex; + gap: 6px; + flex: 1; + min-width: 0; +} +.run-button { + display: flex; + align-items: center; + justify-content: center; + gap: 7px; + background: var(--app-accent); + border: 1px solid transparent; + color: var(--app-bg); + height: 34px; + border-radius: 6px; + flex: 1; + min-width: 76px; + font-size: 10px; + white-space: nowrap; +} +.run-button:hover { + filter: brightness(1.12); +} +.run-button #run-icon { + font-size: 9px; +} +.stop-button { + background: transparent; + border: 1px solid var(--app-line); + border-radius: 6px; + padding: 0 13px; + height: 34px; + font-size: 9px; +} +.device-panel { + width: 148px; + flex-shrink: 0; + padding: 22px 13px 12px; + background: var(--app-soft); + border-left: 1px solid var(--app-line); + border-radius: 0 0 11px 0; + display: flex; + flex-direction: column; + position: relative; +} +.device-top { + font-size: 9px; + display: flex; + justify-content: space-between; +} +.device-top > span:last-child { + opacity: 0.4; +} +.device-art { + height: 134px; + display: grid; + place-items: center; + position: relative; + margin: 5px 0 3px; +} +.plain-device { + width: 63px; + height: 93px; + border: 2px solid var(--app-line); + border-radius: 8px; + background: var(--app-bg); + padding: 5px; + transform: rotate(9deg); + box-shadow: 5px 6px 0 #65735310; +} +.device-screen { + height: 65px; + background: var(--app-soft); + border-radius: 3px; + display: grid; + place-items: center; + font-size: 20px; + font-weight: 800; + letter-spacing: -2px; + color: var(--app-sub); +} +.device-base { + width: 10px; + height: 3px; + background: var(--app-line); + display: block; + margin: 7px auto; +} +.device-metrics { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: auto; +} +.device-metrics > div { + display: flex; + align-items: center; + justify-content: space-between; +} +.device-metrics small { + font-size: 13px; + color: var(--app-sub); +} +.device-metrics b { + font-size: 13px; + font-weight: 550; +} +.device-metrics em { + font-size: 13px; + font-style: normal; + color: var(--app-sub); +} +.device-footer { + margin-top: 18px; + padding-top: 9px; + border-top: 1px solid var(--app-line); + font: + 5.8px ui-monospace, + monospace; + letter-spacing: 0.1px; + display: flex; + gap: 4px; + align-items: center; + color: var(--app-sub); +} +.tiny-dot { + width: 3px; + height: 3px; + border-radius: 50%; + background: currentColor; +} +.actor { + position: absolute; + pointer-events: none; + z-index: 5; + display: none; +} +.actor img { + display: block; + width: 100%; + height: 100%; + object-fit: contain; +} +.mascot-actor { + display: block; + bottom: -13px; + left: -10px; + width: 139px; + height: 160px; + transform: translate(-22px, 0) rotate(-9deg); + transition: + transform 0.65s cubic-bezier(0.2, 0.8, 0.2, 1), + left 0.65s; +} +.mascot-actor img { + animation: idle-bob 3.5s ease-in-out infinite; +} +.mischief .mascot-actor { + left: 50px; + transform: translate(0, -2px) rotate(3deg); +} +.speech-bubble { + position: absolute; + top: -25px; + left: 5px; + white-space: nowrap; + background: #fbfff7; + color: #3b4a31; + padding: 9px 12px; + border: 1.5px solid #435a39; + border-radius: 13px 13px 13px 0; + font-size: 9px; + font-weight: 600; + transform: rotate(-5deg); + box-shadow: 2px 3px 0 #435a3915; +} +.scene-bottom { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 0 25px 21px; + font-size: 9px; + opacity: 0.7; +} +.stage-stamp { + font: + 7px ui-monospace, + monospace; + letter-spacing: 1px; + white-space: nowrap; +} +.outline-tag { + display: none; +} +.show-bounds .app-shell { + outline: 2px dashed #ef8b62; + outline-offset: 6px; +} +.show-bounds .action-area { + outline: 1px dashed #5c9d7d; +} +.show-bounds .reservation { + background: #9adcbe60; + outline: 1px dashed #5c9d7d; +} +.show-bounds .reservation span { + display: block; +} +.show-bounds .outline-tag { + display: block; + position: absolute; + top: -10px; + left: 10px; + background: #f09065; + color: #282a23; + z-index: 6; + font: + 7px ui-monospace, + monospace; + padding: 4px 6px; + letter-spacing: 0.7px; +} +.show-bounds .actor { + outline: 1px dashed #b563c3; +} +.inspector { + border: 1px solid var(--line); + border-radius: 12px; + padding: 23px 21px; + display: flex; + flex-direction: column; + background: #fbfbf6; +} +.inspector-title { + display: flex; + justify-content: space-between; + align-items: center; +} +.inspector-title > span:last-child { + font-size: 20px; + color: var(--orange); +} +.inspector-title .eyebrow { + font-size: 8px; + letter-spacing: 1.5px; + color: var(--muted); +} +.inspector h3 { + font-size: 21px; + margin: 22px 0 12px; + letter-spacing: -0.5px; +} +.inspector > p { + font-size: 11px; + line-height: 1.9; + color: var(--muted); + margin: 0; + min-height: 81px; +} +.capability-tags { + display: flex; + flex-wrap: wrap; + gap: 5px; + margin-top: 14px; +} +.capability-tags span { + font: + 8px ui-monospace, + monospace; + border: 1px solid #d9dece; + padding: 5px 7px; + border-radius: 4px; + color: #69765d; +} +.control-block { + margin-top: 26px; +} +.control-label { + font-size: 10px; + font-weight: 600; +} +.scene-trigger { + width: 100%; + margin-top: 10px; + padding: 12px 11px; + display: flex; + justify-content: space-between; + border: 1px solid #353a2f; + background: #e8eddf; + border-radius: 5px; + font-size: 11px; + font-weight: 600; +} +.scene-trigger:hover { + background: #dae5ce; +} +.range-control { + display: flex; + flex-wrap: wrap; + margin-top: 19px; + gap: 10px; + font-size: 10px; + color: var(--muted); +} +.range-control output { + margin-left: auto; + font: + 10px ui-monospace, + monospace; + color: var(--ink); +} +.range-control input { + width: 100%; + height: 3px; + accent-color: #4d6840; + cursor: pointer; + margin: 4px 0 0; +} +.inspection-options { + padding: 17px 0 14px; + border-bottom: 1px solid var(--line); + margin-top: 7px; + display: flex; + flex-direction: column; + gap: 14px; +} +.inspection-options label { + position: relative; + display: flex; + align-items: center; + gap: 6px; + font-size: 10px; + cursor: pointer; +} +.inspection-options input { + position: absolute; + right: 0; + opacity: 0; + width: 26px; + height: 15px; + margin: 0; +} +.toggle-track { + margin-left: auto; + width: 26px; + height: 15px; + background: #daddd1; + border-radius: 15px; + padding: 2px; + position: relative; + pointer-events: none; +} +.toggle-track:after { + content: ''; + display: block; + width: 11px; + height: 11px; + border-radius: 50%; + background: white; + transition: transform 0.2s; +} +.inspection-options input:checked ~ .toggle-track { + background: #567349; +} +.inspection-options input:checked ~ .toggle-track:after { + transform: translateX(11px); +} +.inspection-options input:focus-visible ~ .toggle-track { + outline: 2px solid var(--orange); + outline-offset: 4px; +} +.code-preview { + margin-top: 17px; +} +.code-preview > div { + display: flex; + justify-content: space-between; + font-size: 8px; + color: var(--muted); +} +.code-preview > div span:last-child { + font: + 8px ui-monospace, + monospace; +} +.code-preview pre { + font: + 9px/1.8 ui-monospace, + SFMono-Regular, + monospace; + color: #627750; + white-space: pre-wrap; + margin: 10px 0; + min-height: 66px; +} +.inspector-link { + font-size: 9px; + margin-top: auto; + display: flex; + justify-content: space-between; + border-top: 1px solid var(--line); + padding-top: 14px; +} +.event-console { + margin-top: 10px; + border: 1px solid var(--line); + border-radius: 6px; + display: flex; + align-items: center; + gap: 20px; + padding: 12px 16px; + font: + 9px ui-monospace, + monospace; +} +.event-console > span:first-child { + display: flex; + align-items: center; + gap: 7px; + white-space: nowrap; + font-size: 8px; + color: var(--muted); +} +.event-console i { + height: 5px; + width: 5px; + background: #83a172; + border-radius: 50%; +} +.event-console output { + color: #5c6d52; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.event-console > span:last-child { + margin-left: auto; + font-size: 8px; + color: #8c9385; + white-space: nowrap; +} +.preset-bar { + display: flex; + justify-content: space-between; + align-items: center; + gap: 20px; + flex-wrap: wrap; + padding: 23px 0; + border-bottom: 1px solid var(--line); +} +.preset-bar > div:first-child { + display: flex; + flex-direction: column; + gap: 6px; +} +.preset-bar b { + font-size: 12px; + font-weight: 600; +} +.preset-bar span { + font-size: 10px; + color: var(--muted); +} +.preset-bar > div:nth-child(2) { + display: flex; + gap: 8px; +} +.secondary-button { + background: none; + border: 1px solid #cdd1c4; + border-radius: 5px; + padding: 9px 13px; + font-size: 10px; +} +.secondary-button:hover { + background: #e9ebdf; +} +.preset-bar p { + width: 100%; + font-size: 9px; + color: #929789; + margin: 0; +} +.architecture { + padding-top: 76px; +} +.architecture-intro { + display: flex; + gap: 50px; + align-items: center; + justify-content: space-between; + margin-top: 38px; + margin-bottom: 30px; +} +.architecture-intro h3 { + font-size: 32px; + letter-spacing: -1px; + line-height: 1.5; + margin: 0; +} +.architecture-intro h3 span { + color: #808875; +} +.architecture-intro p { + font-size: 13px; + line-height: 1.9; + max-width: 385px; + color: var(--muted); +} +.architecture-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + border-top: 1px solid var(--line); + border-bottom: 1px solid var(--line); +} +.architecture-grid article { + padding: 27px 23px; + border-right: 1px solid var(--line); +} +.architecture-grid article:first-child { + padding-left: 0; +} +.architecture-grid article:last-child { + border: 0; + padding-right: 0; +} +.architecture-grid article > span { + font: + 9px ui-monospace, + monospace; + color: #8d9684; + letter-spacing: 1px; +} +.architecture-grid h4 { + font-size: 18px; + font-weight: 600; + margin: 20px 0 10px; +} +.architecture-grid p { + font-size: 11px; + line-height: 1.9; + color: var(--muted); + max-width: 210px; +} +.architecture-grid code { + font-size: 9px; + color: #a15d3d; + display: block; + margin-top: 23px; +} +.architecture-note { + margin-top: 20px; + display: flex; + gap: 30px; + align-items: flex-start; +} +.architecture-note b { + font-size: 11px; + white-space: nowrap; + line-height: 1.8; +} +.architecture-note p { + margin: 0; + font-size: 10px; + line-height: 1.9; + color: var(--muted); +} +.ideas { + padding-top: 76px; +} +.ideas-intro { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 20px; + margin: 32px 0 28px; +} +.ideas-intro h3 { + font-size: 26px; + margin: 0; + font-weight: 600; + letter-spacing: -0.8px; +} +.ideas-intro h3 > span { + color: #8b946e; +} +.idea-filters { + display: flex; + gap: 5px; + flex-wrap: wrap; +} +.idea-filters button { + background: transparent; + border: 1px solid transparent; + border-radius: 20px; + padding: 7px 13px; + font-size: 10px; + color: var(--muted); +} +.idea-filters button.active { + background: #30362b; + color: #fff; +} +.idea-filters button:hover:not(.active) { + border-color: #bfc8b5; +} +.ideas-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 13px; +} +.idea-card { + border: 1px solid var(--line); + border-radius: 8px; + padding: 23px 22px; + background: #fafbf5; + transition: + transform 0.2s, + background 0.2s; +} +.idea-card:hover { + transform: translateY(-3px); + background: #eef1e3; +} +.idea-top { + display: flex; + justify-content: space-between; + align-items: center; + font: + 9px ui-monospace, + monospace; + color: #919784; +} +.idea-top span:last-child { + font-size: 8px; + padding: 4px 6px; + border: 1px solid #e1e5d7; + border-radius: 3px; +} +.idea-card h4 { + font-size: 17px; + font-weight: 600; + margin: 23px 0 12px; +} +.idea-card > p { + font-size: 11px; + color: #727969; + line-height: 1.85; + margin: 0; + min-height: 61px; +} +.idea-tags { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 16px; +} +.idea-tags span { + font-size: 8px; + color: #7e8c70; +} +.idea-tags span:before { + content: '#'; + color: #a7b09b; + margin-right: 2px; +} +.idea-card details { + margin-top: 17px; + font-size: 9px; + border-top: 1px solid #e4e7dc; + padding-top: 12px; + color: #859078; +} +.idea-card summary { + cursor: pointer; +} +.idea-card details p { + line-height: 1.8; + margin: 10px 0 0; +} +.ideas-note { + font-size: 10px; + line-height: 1.8; + color: #8a9280; + margin-top: 20px; +} +.closing { + display: flex; + justify-content: space-between; + gap: 30px; + background: #e7ebda; + border-radius: 10px; + padding: 43px 45px; + margin: 65px 0 50px; +} +.closing .eyebrow { + font-size: 8px; + color: #7e886e; +} +.closing h2 { + font-size: 33px; + font-weight: 650; + line-height: 1.5; + letter-spacing: -1px; + margin: 16px 0 0; +} +.closing p { + font-size: 12px; + line-height: 1.9; + color: #727e61; + margin-bottom: 24px; +} +.dark-button { + display: flex; + gap: 40px; + padding: 14px 16px; + background: #303b2b; + color: #f9fbf2; + border-radius: 5px; + font-size: 11px; +} +.download-manifest { + display: block; + font-size: 10px; + color: #7a866a; + margin-top: 14px; + text-align: right; +} +footer { + border-top: 1px solid var(--line); + margin: 0 4.6%; + padding: 23px 0; + display: flex; + justify-content: space-between; + align-items: center; + gap: 20px; + font: + 9px ui-monospace, + monospace; + color: #8c9383; +} +footer p { + font-size: 10px; + margin: 0; +} +footer > span:first-child { + font-size: 11px; + font-weight: 700; + color: #56604a; +} +footer > span:last-child { + font-size: 7px; + letter-spacing: 0.6px; +} +/* Each scene changes presentation; the controls remain the same host-owned elements. */ +.stage:not(.theme-mascot) .mascot-actor { + display: none; +} +.theme-mecha { + --scene-bg: #e4cec0; + --app-bg: #252d31; + --app-text: #e9e5df; + --app-sub: #8f999b; + --app-line: #3b4448; + --app-accent: #ee9b72; + --app-soft: #2c363a; +} +.theme-mecha:before { + background-image: + linear-gradient(#9d796915 1px, transparent 1px), + linear-gradient(90deg, #9d796915 1px, transparent 1px); + background-size: 35px 35px; +} +.theme-mecha .stage-topline, +.theme-mecha .scene-label, +.theme-mecha .scene-bottom { + color: #6e574a; +} +.theme-mecha .app-wrap { + margin-right: 65px; +} +.theme-mecha .app-shell { + border-radius: 5px; + border-color: #555b58; +} +.theme-mecha .mecha-actor { + display: block; + right: -105px; + top: -53px; + width: 355px; + height: 355px; + transform: rotate(-3deg) scale(var(--art-scale)); + transform-origin: 70% 65%; + transition: + transform 0.5s, + filter 0.5s; +} +.mecha-actor img { + position: relative; + filter: drop-shadow(8px 15px 6px #1b29261b); +} +.mecha-halo { + position: absolute; + top: 22%; + left: 22%; + height: 55%; + width: 55%; + border: 1px solid #f3f2d990; + border-radius: 50%; + outline: 1px dashed #fff9; + outline-offset: 15px; +} +.armed .mecha-actor { + transform: translate(-6px, -13px) rotate(5deg) scale(var(--art-scale)); + filter: drop-shadow(0 0 12px #8ef3d099); +} +.theme-mecha .task-pane { + padding-right: 10px; +} +.theme-garden { + --scene-bg: #c6d4de; + --app-bg: #243748; + --app-text: #e8f0ee; + --app-sub: #8ba5b1; + --app-line: #395164; + --app-accent: #addbbb; + --app-soft: #2a4154; +} +.theme-garden .stage-topline, +.theme-garden .scene-label, +.theme-garden .scene-bottom { + color: #506774; +} +.theme-garden .garden-actor { + display: block; + right: -20px; + top: 42px; + width: 205px; + height: 218px; + transform: scale(var(--growth)); + transform-origin: 50% 85%; + transition: + transform 1s, + filter 1s; +} +.theme-garden .plain-device { + display: none; +} +.theme-garden .garden-actor img { + animation: garden-float 6s ease-in-out infinite; +} +.theme-garden.night { + --scene-bg: #203241; +} +.theme-garden.night .stage-topline, +.theme-garden.night .scene-label, +.theme-garden.night .scene-bottom { + color: #b0c9d4; +} +.night .garden-actor { + filter: drop-shadow(0 0 16px #b5e6bf55); +} +.growth-orbit { + position: absolute; + inset: 20% 3%; + border: 1px dashed #c1e7d03b; + border-radius: 50%; + transform: rotate(-24deg); +} +.vinyl-art, +.paper-art, +.sonar-art { + display: none; +} +.theme-vinyl { + --scene-bg: #e7d6b9; + --app-bg: #f5eedf; + --app-text: #50432c; + --app-sub: #a49479; + --app-line: #e2d5bd; + --app-accent: #b05237; + --app-soft: #eee2ca; +} +.theme-vinyl .plain-device { + display: none; +} +.theme-vinyl .vinyl-art { + display: block; + position: relative; + width: 122px; + height: 130px; +} +.record { + width: 118px; + height: 118px; + border-radius: 50%; + position: absolute; + top: 12px; + background: repeating-radial-gradient(#262421 0px, #262421 2px, #3b3833 3px, #262421 4px); + box-shadow: 0 5px 7px #332a2933; + display: grid; + place-items: center; + animation: spin 5s linear infinite; + animation-play-state: paused; +} +.record:after { + content: ''; + position: absolute; + inset: 3px; + background: conic-gradient(transparent, #ffffff15, transparent, #ffffff10, transparent); + border-radius: 50%; +} +.record-label { + width: 42px; + height: 42px; + border-radius: 50%; + background: #c47851; + border: 1px solid #d99469; + display: grid; + place-items: center; + position: relative; +} +.record-label:after { + content: ''; + width: 5px; + height: 5px; + background: #34312a; + border: 2px solid #e8d7af; + position: absolute; + top: 17px; + left: 17px; + border-radius: 50%; +} +.record-label span { + font: + 4px/2 ui-monospace, + monospace; + color: #ffecd1; + transform: translateY(-7px); + text-align: center; +} +.tonearm { + position: absolute; + top: 0; + right: -4px; + width: 7px; + height: 85px; + border-radius: 4px; + background: linear-gradient(90deg, #b1a491, #e1d4b8, #9a8d79); + transform: rotate(-12deg); + transform-origin: 50% 6px; + transition: transform 0.5s; + z-index: 2; + box-shadow: 1px 1px 3px #33271c33; +} +.tonearm:before { + content: ''; + position: absolute; + top: -3px; + left: -4px; + width: 15px; + height: 15px; + background: #6e6354; + border: 4px solid #beb196; + border-radius: 50%; +} +.tonearm:after { + content: ''; + position: absolute; + bottom: -5px; + left: -3px; + width: 12px; + height: 19px; + background: #655848; + border-radius: 3px; + transform: rotate(-8deg); +} +.theme-vinyl.running .record, +.theme-vinyl.audition .record { + animation-play-state: running; +} +.theme-vinyl.running .tonearm, +.theme-vinyl.audition .tonearm { + transform: rotate(26deg); +} +.theme-vinyl .shell-shadow { + transform: rotate(1.5deg); +} +.theme-paper { + --scene-bg: #e7e3d6; + --app-bg: #fffdf4; + --app-text: #4a5045; + --app-sub: #a1a18a; + --app-line: #dedfcb; + --app-accent: #648e70; + --app-soft: #f0efdf; +} +.theme-paper .app-shell { + border-radius: 2px; + box-shadow: + 3px 4px 0 #d3d0bd, + 6px 7px 0 #f6f3e6, + 7px 8px 0 #d3d0bd; +} +.theme-paper .task-row { + border: 0; + border-bottom: 1px dashed #cfd3b9; + border-radius: 0; + background-image: linear-gradient(90deg, #eec1ad66 1px, transparent 1px); + background-position: 28px 0; + background-repeat: no-repeat; +} +.theme-paper .plain-device { + display: none; +} +.theme-paper .paper-art { + display: flex; + align-items: center; + width: 110px; + height: 130px; + background: #f5deb0; + box-shadow: 3px 4px 0 #d7caac55; + transform: rotate(5deg); + position: relative; + padding: 13px; + transition: transform 0.5s; + transform-origin: 10% 5%; + background-image: repeating-linear-gradient(transparent 0, transparent 21px, #d9b57f55 22px); +} +.paper-art > span:not(.paper-clip) { + font: + 8px/2.6 ui-monospace, + monospace; + color: #90744b; +} +.paper-art i { + font-style: normal; + color: #b57c4c; + margin-right: 5px; +} +.paper-clip { + position: absolute; + width: 12px; + height: 30px; + left: 20px; + top: -14px; + border: 2px solid #908d76; + border-radius: 8px; + transform: rotate(-7deg); +} +.paper-art:after { + content: ''; + position: absolute; + bottom: 0; + right: 0; + border: 11px solid transparent; + border-bottom-color: #f0efdf; + border-right-color: #f0efdf; + filter: drop-shadow(-1px -1px 0 #dcc8a2); +} +.paper-stamp { + position: absolute; + bottom: 24px; + left: 20px; + border: 3px double #ba624a; + color: #ba624a; + font: + 900 19px ui-monospace, + monospace; + transform: scale(1.7) rotate(-15deg); + opacity: 0; + transition: + transform 0.4s, + opacity 0.4s; + padding: 2px 4px; +} +.stamped .paper-stamp, +.theme-paper.complete .paper-stamp { + opacity: 0.85; + transform: scale(1) rotate(-15deg); +} +.theme-paper.folded .paper-art { + transform: perspective(250px) rotateY(-28deg) rotate(-4deg); +} +.theme-sonar { + --scene-bg: #183d3d; + --app-bg: #102d32; + --app-text: #d0eee5; + --app-sub: #6c9f9b; + --app-line: #284b4e; + --app-accent: #8cdbb7; + --app-soft: #15363c; +} +.theme-sonar:before { + background-image: radial-gradient( + circle at 75% 50%, + transparent 20%, + #78b6a40e 20.2%, + transparent 20.5%, + transparent 40%, + #78b6a40e 40.2%, + transparent 40.5% + ); + background-size: 100% 100%; +} +.theme-sonar .plain-device { + display: none; +} +.theme-sonar .sonar-art { + display: block; + position: relative; + width: 122px; + height: 122px; + border: 1px solid #65b99966; + border-radius: 50%; + overflow: hidden; + background: repeating-radial-gradient( + transparent 0px, + transparent 19px, + #65b99935 20px, + transparent 21px + ); + box-shadow: 0 0 16px #65b99911 inset; +} +.sonar-cross { + position: absolute; + top: 0; + left: 50%; + height: 100%; + width: 1px; + background: #65b99944; +} +.sonar-cross:after { + content: ''; + position: absolute; + left: -61px; + top: 50%; + width: 122px; + height: 1px; + background: #65b99944; +} +.sonar-sweep { + position: absolute; + inset: 0; + background: conic-gradient(transparent 70%, #a0fbd135); + animation: spin 5s linear infinite; + animation-play-state: paused; +} +.theme-sonar.running .sonar-sweep, +.theme-sonar.pinging .sonar-sweep { + animation-play-state: running; +} +.sonar-dot { + position: absolute; + width: 5px; + height: 5px; + border-radius: 50%; + background: #c7f8ab; + box-shadow: 0 0 9px #a6eaae; + opacity: 0.25; +} +.sonar-dot.d1 { + left: 30px; + top: 40px; +} +.sonar-dot.d2 { + right: 35px; + bottom: 28px; +} +.sonar-dot.d3 { + right: 27px; + top: 30px; +} +.running .sonar-dot, +.pinging .sonar-dot { + animation: ping 2s ease-in-out infinite; +} +.running .d2, +.pinging .d2 { + animation-delay: 0.7s; +} +.running .d3, +.pinging .d3 { + animation-delay: 1.4s; +} +.theme-sonar .stage-topline, +.theme-sonar .scene-label, +.theme-sonar .scene-bottom { + color: #9bc6b8; +} +.theme-sonar .shell-shadow { + background: #8ad7b907; + border-color: #8ad7b922; +} +.running .mascot-actor img { + animation: idle-bob 1.4s ease-in-out infinite; +} +.complete .mascot-actor img { + transform: rotate(5deg); +} +.stage.reduce-motion * { + animation: none !important; + transition: none !important; +} +.stage.reduce-motion .record { + transform: rotate(25deg); +} +.stage.reduce-motion .sonar-dot { + opacity: 1; +} +@keyframes idle-bob { + 0%, + 100% { + transform: translateY(0) rotate(-2deg); + } + 50% { + transform: translateY(-7px) rotate(2deg); + } +} +@keyframes garden-float { + 0%, + 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-6px); + } +} +@keyframes spin { + to { + transform: rotate(360deg); + } +} +@keyframes ping { + 0%, + 100% { + opacity: 0.25; + } + 50% { + opacity: 1; + } +} +@media (min-width: 1500px) { + .stage { + min-height: 650px; + } + .app-wrap { + margin: 46px 70px 58px; + } + .app-body { + min-height: 345px; + } + .task-pane { + padding: 25px; + } + .task-row { + padding: 12px; + } + .device-panel { + width: 180px; + } + .mascot-actor { + width: 160px; + height: 180px; + } + .mischief .mascot-actor { + left: 56px; + } + .theme-mecha .mecha-actor { + width: 400px; + height: 400px; + } + .theme-garden .garden-actor { + right: 0; + width: 220px; + height: 240px; + } +} +@media (max-width: 1190px) { + .lab-grid { + grid-template-columns: minmax(0, 1fr) 230px; + } + .inspector { + padding: 20px 17px; + } + .skin-tabs { + gap: 6px; + } + .skin-tabs button { + padding: 12px 7px; + gap: 7px; + } + .tab-index { + display: none; + } + .skin-symbol { + width: 27px; + height: 33px; + font-size: 22px; + } + .skin-tabs b { + font-size: 11px; + } + .skin-tabs small { + font-size: 8px; + } + .app-wrap { + margin-left: 22px; + margin-right: 22px; + } + .device-panel { + width: 126px; + padding: 20px 9px 12px; + } + .task-pane { + padding-left: 12px; + padding-right: 12px; + } + .mini-sidebar { + width: 35px; + } + .task-row { + gap: 6px; + padding: 9px 7px; + } + .connection { + display: none; + } + .task-copy small { + font-size: 12px; + } + .task-glyph { + display: none; + } + .theme-mecha .app-wrap { + margin-right: 42px; + } + .theme-mecha .mecha-actor { + right: -93px; + width: 315px; + height: 315px; + } + .theme-garden .garden-actor { + width: 172px; + height: 200px; + top: 48px; + right: -15px; + } + .record { + width: 103px; + height: 103px; + } + .vinyl-art { + transform: scale(0.9); + } + .theme-sonar .sonar-art { + width: 105px; + height: 105px; + } + .theme-paper .paper-art { + width: 97px; + height: 122px; + } + .stage-stamp { + display: none; + } + .intro-note { + padding-right: 10px; + } + .mischief .mascot-actor { + left: 30px; + width: 125px; + height: 148px; + } + .stage-topline { + padding: 20px 18px; + font-size: 7px; + } + .device-footer { + font-size: 5px; + } + .task-copy b { + font-size: 14px; + } + .action-buttons { + gap: 4px; + } + .stop-button { + padding: 0 9px; + } + .ideas-grid { + grid-template-columns: repeat(3, 1fr); + } +} +@media (max-width: 980px) { + .lab-grid { + grid-template-columns: 1fr; + } + .stage { + min-height: 590px; + } + .app-wrap { + margin: 37px 60px 50px; + } + .device-panel { + width: 165px; + } + .task-pane { + padding: 20px; + } + .task-copy b { + font-size: 14px; + } + .task-copy small { + font-size: 12px; + } + .task-glyph { + display: block; + } + .connection { + display: flex; + } + .inspector { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px 28px; + padding: 23px; + } + .inspector-title { + grid-column: 1; + } + .inspector h3 { + grid-column: 1; + margin: 0; + } + .inspector > p { + grid-column: 1; + min-height: 0; + } + .capability-tags { + grid-column: 1; + margin-top: 0; + } + .control-block { + grid-column: 2; + grid-row: 1 / span 3; + margin: 0; + } + .inspection-options { + grid-column: 2; + grid-row: 4 / span 2; + margin: 0; + padding-top: 0; + border: 0; + } + .code-preview { + grid-column: 1; + margin-top: 0; + } + .code-preview pre { + min-height: 0; + } + .inspector-link { + grid-column: 1 / -1; + } + .theme-mecha .app-wrap { + margin-right: 85px; + } + .theme-mecha .mecha-actor { + right: -105px; + width: 350px; + height: 350px; + } + .theme-garden .garden-actor { + width: 215px; + height: 235px; + right: -15px; + top: 33px; + } + .mischief .mascot-actor { + left: 44px; + width: 140px; + height: 164px; + } + .theme-vinyl .vinyl-art { + transform: scale(1); + } + .record { + width: 118px; + height: 118px; + } + .theme-sonar .sonar-art { + width: 122px; + height: 122px; + } + .theme-paper .paper-art { + width: 110px; + height: 130px; + } + .intro-note p { + font-size: 12px; + } + .intro h1 { + font-size: 52px; + } + .skin-tabs { + grid-template-columns: repeat(3, 1fr); + gap: 8px; + } + .skin-tabs button { + padding: 11px 13px; + } + .skin-tabs b { + font-size: 12px; + } + .tab-index { + display: block; + } + .skin-tabs small { + font-size: 9px; + } + .skin-symbol { + width: 32px; + height: 35px; + } + .ideas-grid { + grid-template-columns: repeat(2, 1fr); + } + .architecture-grid { + grid-template-columns: repeat(2, 1fr); + } + .architecture-grid article:nth-child(2) { + border-right: 0; + } + .architecture-grid article:nth-child(-n + 2) { + border-bottom: 1px solid var(--line); + } + .architecture-grid article:nth-child(3) { + padding-left: 0; + } + .architecture-grid article:nth-child(4) { + padding-left: 23px; + } + .architecture-note { + gap: 20px; + } + .architecture-intro { + gap: 30px; + } + .architecture-intro p { + max-width: 300px; + } + .closing { + padding: 32px; + gap: 20px; + } + .closing h2 { + font-size: 28px; + } + .dark-button { + gap: 15px; + } + .version { + display: none; + } + footer > span:last-child { + display: none; + } +} +@media (max-width: 600px) { + .site-header { + height: 75px; + } + .brand b { + font-size: 13px; + } + .brand b > span { + display: none; + } + .brand-icon { + font-size: 31px; + } + .site-header nav { + gap: 14px; + font-size: 10px; + } + .nav-count { + display: none; + } + .intro { + padding: 32px 0; + } + .eyebrow { + font-size: 7px; + gap: 8px; + letter-spacing: 0.8px; + } + .tiny-rule { + width: 14px; + } + .intro-row { + display: block; + margin-top: 20px; + } + .intro h1 { + font-size: 45px; + letter-spacing: -2px; + } + .intro-note { + margin-top: 30px; + padding-left: 44px; + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 12px; + padding-right: 0; + } + .asterisk { + font-size: 32px; + top: 2px; + } + .intro-note p { + font-size: 10px; + margin: 0; + } + .intro-note a { + font-size: 9px; + white-space: nowrap; + gap: 10px; + padding-bottom: 2px; + } + .intro-note a span { + font-size: 16px; + } + .section-heading { + gap: 9px; + flex-wrap: wrap; + margin-bottom: 17px; + } + .section-heading h2 { + font-size: 16px; + } + .section-heading h2 span { + font-size: 9px; + margin-right: 7px; + } + .section-heading p { + font-size: 9px; + order: 3; + width: 100%; + } + .text-button { + font-size: 10px; + } + .skin-tabs button { + gap: 6px; + padding: 9px 6px; + } + .skin-tabs b { + font-size: 10px; + } + .skin-tabs small { + font-size: 7px; + } + .skin-symbol { + height: 29px; + width: 24px; + font-size: 20px; + } + .tab-index { + display: none; + } + .stage { + min-height: 555px; + border-radius: 9px; + } + .stage-topline { + padding: 17px 14px; + letter-spacing: 0.5px; + font-size: 6px; + } + .scene-label { + padding: 14px 18px 0; + } + .scene-label > span { + font-size: 8px; + } + .scene-label h3 { + font-size: 23px; + margin-top: 10px; + } + .app-wrap { + margin: 37px 15px 67px; + min-height: 320px; + } + .mini-sidebar { + display: none; + } + .app-body { + min-height: 300px; + } + .device-panel { + display: none; + } + .task-pane { + padding: 17px 14px 14px; + } + .task-row { + padding: 9px 8px; + min-height: 47px; + gap: 9px; + } + .task-copy b { + font-size: 14px; + } + .task-copy small { + font-size: 12px; + } + .task-state { + font-size: 12px; + } + .task-glyph { + display: block; + } + .connection { + font-size: 6px; + } + .pane-heading { + margin-bottom: 14px; + } + .app-titlebar { + height: 37px; + padding: 0 12px; + } + .app-project { + font-size: 7px; + } + .pane-heading h4 { + font-size: 15px; + } + .run-button { + font-size: 9px; + height: 33px; + } + .stop-button { + height: 33px; + font-size: 9px; + } + .mascot-actor { + width: 108px; + height: 120px; + bottom: -31px; + left: 5px; + } + .mischief .mascot-actor { + left: 10px; + width: 119px; + height: 139px; + bottom: -19px; + } + .speech-bubble { + font-size: 7px; + padding: 7px 9px; + top: -11px; + } + .scene-bottom { + padding: 0 16px 18px; + font-size: 8px; + line-height: 1.6; + } + .theme-mecha .app-wrap { + margin-left: 15px; + margin-right: 15px; + margin-top: 65px; + } + .theme-mecha .mecha-actor { + width: 210px; + height: 210px; + top: -98px; + right: -35px; + opacity: 0.97; + } + .theme-mecha .scene-label h3 { + font-size: 21px; + } + .theme-mecha .task-pane { + padding-right: 14px; + } + .theme-garden .garden-actor { + width: 125px; + height: 140px; + right: -5px; + top: -77px; + opacity: 0.98; + } + .theme-garden .scene-label h3 { + max-width: 200px; + font-size: 21px; + } + .theme-garden .app-wrap { + margin-top: 60px; + } + .theme-vinyl .device-panel, + .theme-paper .device-panel, + .theme-sonar .device-panel { + display: flex; + position: absolute; + right: 13px; + top: -66px; + width: 95px; + height: 95px; + border: 0; + background: transparent; + padding: 0; + pointer-events: none; + } + .theme-vinyl .device-panel > :not(.device-art), + .theme-paper .device-panel > :not(.device-art), + .theme-sonar .device-panel > :not(.device-art) { + display: none; + } + .theme-vinyl .device-art, + .theme-paper .device-art, + .theme-sonar .device-art { + transform: scale(0.65); + height: 120px; + width: 120px; + transform-origin: top right; + margin: 0; + } + .theme-vinyl .app-wrap, + .theme-paper .app-wrap, + .theme-sonar .app-wrap { + margin-top: 64px; + } + .theme-vinyl .scene-label h3, + .theme-paper .scene-label h3, + .theme-sonar .scene-label h3 { + font-size: 21px; + } + .inspector { + padding: 20px 18px; + gap: 11px 16px; + } + .inspector h3 { + font-size: 18px; + } + .inspector > p { + font-size: 10px; + line-height: 1.8; + } + .inspector-title .eyebrow { + font-size: 7px; + } + .capability-tags span { + font-size: 7px; + padding: 4px; + } + .scene-trigger { + font-size: 9px; + padding: 10px 8px; + margin-top: 8px; + } + .control-label { + font-size: 9px; + } + .range-control { + font-size: 9px; + margin-top: 16px; + } + .range-control output { + font-size: 9px; + } + .inspection-options label { + font-size: 9px; + } + .code-preview pre { + font-size: 7px; + } + .event-console { + gap: 8px; + padding: 10px; + font-size: 8px; + } + .event-console > span:first-child { + font-size: 6px; + gap: 4px; + } + .event-console > span:last-child { + display: none; + } + .preset-bar { + gap: 15px; + } + .preset-bar b { + font-size: 11px; + } + .preset-bar span { + font-size: 9px; + line-height: 1.7; + } + .preset-bar > div:nth-child(2) { + width: 100%; + } + .secondary-button { + flex: 1; + } + .preset-bar p { + font-size: 8px; + line-height: 1.7; + } + .architecture, + .ideas { + padding-top: 50px; + } + .architecture-intro { + display: block; + margin-top: 25px; + } + .architecture-intro h3 { + font-size: 28px; + } + .architecture-intro p { + font-size: 11px; + margin-top: 20px; + max-width: none; + } + .architecture-grid article { + padding: 22px 14px; + } + .architecture-grid article:nth-child(4) { + padding-left: 14px; + } + .architecture-grid h4 { + font-size: 16px; + margin: 15px 0 10px; + } + .architecture-grid article > span { + font-size: 7px; + } + .architecture-grid p { + font-size: 10px; + } + .architecture-grid code { + font-size: 7px; + margin-top: 18px; + } + .architecture-note { + display: block; + } + .architecture-note p { + margin-top: 8px; + font-size: 9px; + } + .ideas-intro { + margin: 22px 0 20px; + } + .ideas-intro h3 { + font-size: 24px; + line-height: 1.5; + max-width: 230px; + } + .idea-filters { + gap: 3px; + } + .idea-filters button { + padding: 6px 12px; + font-size: 9px; + } + .ideas-grid { + grid-template-columns: 1fr; + gap: 10px; + } + .idea-card { + padding: 20px; + } + .idea-card h4 { + margin-top: 15px; + } + .idea-card > p { + min-height: 0; + } + .idea-card details { + margin-top: 14px; + } + .ideas-note { + font-size: 9px; + } + .closing { + display: block; + margin: 45px 0 30px; + padding: 28px 24px; + } + .closing h2 { + font-size: 27px; + } + .closing p { + font-size: 11px; + margin-top: 22px; + } + .closing .eyebrow { + font-size: 6px; + letter-spacing: 1px; + } + .dark-button { + justify-content: space-between; + font-size: 10px; + } + .download-manifest { + text-align: left; + font-size: 9px; + } + footer { + font-size: 8px; + padding: 18px 0; + gap: 8px; + } + footer > span:first-child { + font-size: 9px; + } + footer p { + font-size: 8px; + } +} +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } + *, + *:before, + *:after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} +/* The actor is moved into the actual granted flex track, so its artwork follows the granted width. */ +.theme-mascot:not(.mischief) .mascot-actor { + left: auto; + right: -8px; + bottom: 88px; + width: 135px; + height: 150px; + transform: rotate(-6deg); +} +.theme-mascot .plain-device { + visibility: hidden; +} +.theme-mascot.mischief .reservation .mascot-actor { + left: 0; + right: auto; + top: auto; + bottom: -5px; + width: 100%; + height: auto; + aspect-ratio: 300/320; + transform: rotate(3deg); +} +.theme-mascot.mischief .reservation .speech-bubble { + left: -9px; + top: -18px; + font-size: 8px; +} +/* Host information keeps its readable size in every skin and viewport. */ +.app-shell .task-row { + min-height: 66px; + gap: 8px; +} +.app-shell .task-row input { + width: 16px; + height: 16px; +} +.app-shell .task-row input:checked:after { + font-size: 12px; +} +.app-shell .task-copy b { + font-size: 14px; + line-height: 1.45; +} +.app-shell .task-copy small { + font-size: 12px; + line-height: 1.5; + white-space: normal; + color: var(--app-text); +} +.app-shell .task-row .task-state { + color: var(--app-text); + font-size: 12px; + font-weight: 600; +} +.app-shell .task-row.failed { + border-style: dashed; + border-width: 2px; +} +.app-shell .progress-line { + font-size: 12px; + line-height: 1.6; + gap: 12px; + color: var(--app-text); +} +.app-shell .progress-track { + height: 6px; +} +.app-shell .run-button, +.app-shell .stop-button { + min-height: 44px; + height: auto; + font-size: 13px; + padding: 8px 12px; +} +.app-shell .run-button { + min-width: 102px; +} +.app-shell .stop-button { + white-space: nowrap; +} +.app-shell .connection { + font-size: 11px; + color: var(--app-text); +} +.app-shell .device-top { + font-size: 12px; + color: var(--app-text); +} +.app-shell .device-footer { + margin-top: auto; + font-size: 9px; + line-height: 1.5; + flex-wrap: wrap; + color: var(--app-text); +} +.host-status { + position: relative; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px 18px; + padding: 16px 18px; + border-top: 1px solid var(--app-line); + border-radius: 0 0 11px 11px; + background: var(--app-bg); + color: var(--app-text); +} +.host-status .device-metrics { + flex: 1; + flex-direction: row; + flex-wrap: wrap; + gap: 12px 24px; + margin: 0; +} +.host-status .device-metrics > div { + justify-content: flex-start; + gap: 8px; +} +.host-status .device-metrics small, +.host-status .device-metrics b, +.host-status .device-metrics em { + font-size: 13px; + line-height: 1.6; + color: var(--app-text); + white-space: nowrap; +} +.host-status .device-metrics b { + font-weight: 700; +} +.failure-button { + min-height: 40px; + border: 1px solid var(--app-text); + border-radius: 6px; + padding: 8px 12px; + background: transparent; + color: var(--app-text); + font-size: 12px; +} +.host-status-note { + flex-basis: 100%; + margin: 0; + font-size: 12px; + line-height: 1.6; +} +.hotspot-note { + font-size: 12px; + line-height: 1.7; + color: #515a47; + margin: 12px 0 0; +} +.scene-trigger { + min-height: 44px; + font-size: 13px; + line-height: 1.6; +} +.scene-trigger[data-host-hotspot='mascot']:focus-visible { + outline: 3px solid #b85629; + outline-offset: 4px; +} +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} +/* The robot occupies decorative space; host metrics live in the stable footer below it. */ +.stage.theme-mecha .app-wrap { + margin-top: 155px; +} +.stage.theme-mecha .mecha-actor { + right: -48px; + top: -200px; + width: 280px; + height: 280px; +} +.theme-mecha .plain-device { + visibility: hidden; +} +.stage.theme-mascot:not(.mischief) .mascot-actor { + top: 80px; + bottom: auto; +} +.stage .device-panel { + padding-bottom: 18px; +} +/* Meme companions receive their own grid column; decoration never covers host controls. */ +.ocean { + background: #c6e8fb; + color: #225fbd; +} +.butter { + background: #f4e78a; + color: #667c28; +} +.skin-tabs b em { + margin-left: 5px; + font: + 8px ui-monospace, + monospace; + color: #596b32; + background: #e4edc4; + border-radius: 3px; + padding: 2px 4px; + vertical-align: middle; +} +.meme-companion { + display: none; +} +.theme-deepseek { + --scene-bg: #dcecf5; + --app-bg: #fbfdff; + --app-text: #233e64; + --app-sub: #546d88; + --app-line: #dce7f2; + --app-accent: #225fbd; + --app-soft: #edf5fc; +} +.theme-naiwa { + --scene-bg: #eeefc8; + --app-bg: #fffff8; + --app-text: #3c4729; + --app-sub: #636c45; + --app-line: #e4e8cc; + --app-accent: #667c28; + --app-soft: #f5f6e5; +} +.stage:is(.theme-deepseek, .theme-naiwa) { + background-image: radial-gradient(ellipse at 100% 20%, #ffffffa0, transparent 65%); + min-height: 650px; +} +.stage:is(.theme-deepseek, .theme-naiwa) .app-wrap { + display: grid; + grid-template-columns: minmax(0, 1fr) 210px; + gap: 16px; + margin: 30px 28px 35px; + align-items: center; +} +.stage:is(.theme-deepseek, .theme-naiwa) .app-shell { + border-color: #ffffff; + box-shadow: 0 18px 45px #263c5e12; + min-width: 0; +} +.stage:is(.theme-deepseek, .theme-naiwa) .device-panel { + display: none; +} +.stage:is(.theme-deepseek, .theme-naiwa) .shell-shadow { + display: none; +} +.stage:is(.theme-deepseek, .theme-naiwa) .meme-companion { + display: flex; + flex-direction: column; + align-items: center; + align-self: stretch; + justify-content: center; + min-width: 0; + padding: 8px 0; + position: relative; + z-index: 2; +} +.theme-naiwa .meme-companion { + mix-blend-mode: multiply; +} +.meme-badge { + color: var(--app-accent); + font: + 9px ui-monospace, + monospace; + letter-spacing: 1px; + display: flex; + align-items: center; + gap: 7px; +} +.meme-spark { + font-size: 20px; +} +.meme-portrait { + width: 100%; + height: 260px; + position: relative; + isolation: isolate; + margin: 13px 0 8px; + pointer-events: none; +} +.meme-orbit { + width: 190px; + height: 190px; + background: #ffffff65; + border: 1px solid #ffffffb0; + border-radius: 50%; + position: absolute; + left: 50%; + top: 44%; + transform: translate(-50%, -50%); + box-shadow: 0 0 0 15px #ffffff20; + z-index: -1; +} +.meme-portrait .actor { + inset: 0 -8px; + width: calc(100% + 16px); + height: 100%; + transform-origin: 50% 90%; +} +.theme-deepseek .deepseek-actor, +.theme-naiwa .naiwa-actor { + display: block; +} +.meme-portrait img, +.meme-portrait video { + width: 100%; + height: 100%; + object-fit: contain; +} +.meme-portrait .deepseek-actor img, +.meme-portrait .deepseek-actor video { + object-fit: cover; +} +.meme-portrait .deepseek-actor { + background: #fff; + border-radius: 12px; + overflow: hidden; +} +.meme-portrait .deepseek-actor .meme-calm-art, +.meme-portrait .deepseek-actor .meme-error-art, +.meme-portrait .deepseek-actor video { + display: none; +} +.stage:is([data-run-state='paused'], [data-run-state='cancelled']) + .deepseek-actor + .meme-active-art { + display: none; +} +.stage:is([data-run-state='paused'], [data-run-state='cancelled']) .deepseek-actor .meme-calm-art { + display: block; +} +.stage[data-run-state='error'] .deepseek-actor .meme-active-art { + display: none; +} +.stage[data-run-state='error'] .deepseek-actor .meme-error-art { + display: block; +} +.deepseek-performing .deepseek-actor .meme-active-art { + display: none; +} +.deepseek-performing .meme-portrait .deepseek-actor video { + display: block; +} +.naiwa-actor video, +.naiwa-actor .meme-laugh-still { + display: none; +} +.naiwa-actor video, +.naiwa-actor .meme-laugh-still { + mix-blend-mode: multiply; +} +.naiwa-performing .naiwa-actor video { + display: block; +} +.naiwa-performing .naiwa-actor .meme-calm-art { + display: none; +} +.theme-naiwa:is(.laughing, .complete):not(.naiwa-performing) .meme-laugh-still { + display: block; +} +.theme-naiwa:is(.laughing, .complete):not(.naiwa-performing) .meme-calm-art { + display: none; +} +.meme-confetti { + position: absolute; + z-index: 6; + color: var(--app-accent); + font-size: 24px; + opacity: 0; +} +.meme-confetti.c1 { + left: 0; + top: 20%; +} +.meme-confetti.c2 { + right: 0; + top: 55%; + font-size: 17px; +} +.stage:is(.feeding, .complete) .meme-confetti { + opacity: 1; +} +.meme-alert { + display: none; + position: absolute; + z-index: 6; + top: 12px; + right: 10px; + border-radius: 50%; + width: 30px; + height: 30px; + background: #fff3e8; + border: 1px solid #bb492b; + color: #aa3d23; + text-align: center; + font: 700 20px/28px system-ui; +} +.failed .meme-alert { + display: block; +} +.meme-copy { + text-align: center; + width: 100%; +} +.meme-state { + display: inline-block; + padding: 5px 9px; + border: 1px solid #ffffff; + border-radius: 20px; + background: #ffffff80; + color: var(--app-text); + font-size: 11px; + font-weight: 600; +} +.meme-meter { + width: 90%; + text-align: center; + margin-top: 12px; +} +.meme-meter-track { + height: 6px; + background: #ffffff80; + border-radius: 10px; + overflow: hidden; +} +#rice-fill { + height: 100%; + background: var(--app-accent); + transform: scaleX(var(--task-progress, 0)); + transform-origin: left; + transition: transform 0.3s; +} +#meme-progress-text { + display: block; + font-size: 10px; + color: var(--app-sub); + margin-top: 8px; + line-height: 1.6; +} +.meme-downloads { + flex-basis: 100%; + font-size: 12px; + color: var(--muted); +} +.meme-downloads a { + margin-left: 12px; + text-decoration: underline; + text-underline-offset: 3px; +} +@media (max-width: 1190px) and (min-width: 981px) { + .stage:is(.theme-deepseek, .theme-naiwa) .app-wrap { + grid-template-columns: minmax(0, 1fr) 165px; + gap: 10px; + margin: 25px 16px 30px; + } + .meme-portrait { + height: 225px; + } + .meme-orbit { + width: 150px; + height: 150px; + } + .stage:is(.theme-deepseek, .theme-naiwa) .mini-sidebar { + display: none; + } +} +@media (max-width: 700px) { + .skin-tabs { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .skin-tabs button { + min-width: 0; + } + .skin-tabs small { + font-size: 10px; + } + .stage:is(.theme-deepseek, .theme-naiwa) .app-wrap { + display: flex; + flex-direction: column-reverse; + gap: 18px; + margin: 20px 15px 30px; + } + .stage:is(.theme-deepseek, .theme-naiwa) .meme-companion { + display: grid; + grid-template-columns: 135px minmax(0, 1fr); + column-gap: 12px; + width: 100%; + padding: 0 8px; + } + .meme-portrait { + grid-column: 1; + grid-row: 1 / 4; + height: 170px; + margin: 0; + } + .meme-orbit { + width: 120px; + height: 120px; + box-shadow: 0 0 0 7px #ffffff20; + } + .meme-badge { + grid-column: 2; + font-size: 7px; + letter-spacing: 0.5px; + justify-content: center; + } + .meme-copy { + grid-column: 2; + } + .meme-state { + font-size: 10px; + } + .meme-meter { + grid-column: 2; + width: 100%; + } + #meme-progress-text { + font-size: 9px; + } + .meme-confetti.c1 { + font-size: 18px; + } + .stage:is(.theme-deepseek, .theme-naiwa) .scene-bottom { + padding: 0 18px 20px; + } +} +@media (max-width: 600px) { + .theme-mascot:not(.mischief) .mascot-actor { + right: -2px; + bottom: auto; + top: -72px; + width: 100px; + height: 112px; + } + .theme-mascot:not(.mischief) .speech-bubble { + display: none; + } + .theme-mascot.mischief .reservation .mascot-actor { + left: 0; + bottom: -8px; + width: 100%; + height: auto; + } + .theme-mascot.mischief .reservation .speech-bubble { + font-size: 7px; + top: -15px; + left: -8px; + } + .stage.theme-mascot:not(.mischief) .mascot-actor { + top: -72px; + } + .stage.theme-mecha .mecha-actor { + width: 180px; + height: 180px; + top: -105px; + right: -25px; + } + .stage.theme-mecha .app-wrap { + margin-top: 95px; + } + .host-status { + padding: 14px; + gap: 12px; + } + .host-status .device-metrics { + flex-basis: 100%; + justify-content: space-between; + gap: 8px; + } + .app-shell .task-glyph { + display: none; + } + .app-shell .connection { + display: none; + } + .app-shell .run-button, + .app-shell .stop-button { + padding: 8px; + font-size: 12px; + } + .scene-trigger { + font-size: 12px; + padding: 10px; + } +} diff --git a/docs/design/skin-system/ideas.json b/docs/design/skin-system/ideas.json new file mode 100644 index 00000000..eaa887a2 --- /dev/null +++ b/docs/design/skin-system/ideas.json @@ -0,0 +1,242 @@ +[ + { + "number": 1, + "id": "task-train", + "title": "任务小火车", + "category": "空间", + "concept": "每个任务是一节车厢,正在执行的车厢驶过检票口,完成的车厢亮起车窗。展开车厢就能查看真实任务详情;失败车厢停在维修站,重试后重新接入队列。", + "capability": ["队列状态", "语义插槽", "状态动画", "详情动作"], + "level": "L1", + "restraint": "车厢顺序与真实队列一致,文字列表始终可切回。" + }, + { + "number": 2, + "id": "launch-pad", + "title": "火箭发射台", + "category": "叙事", + "concept": "连接设备、检查资源、开始任务分别点亮发射台的检查灯。运行时尾焰随任务状态变化,全部完成才发射一枚小火箭;中途停止则平稳熄火,保留本次成果。", + "capability": ["状态绑定", "完成事件", "多层装饰", "条件显隐"], + "level": "L1", + "restraint": "发射只是完成反馈,不插入倒计时,也不延迟真实启动。" + }, + { + "number": 3, + "id": "ramen-shop", + "title": "深夜拉面铺", + "category": "叙事", + "concept": "任务进入队列时挂上一张订单签,执行时厨师搅动汤锅,完成后把一碗面送到取餐台。暂停时关小火,出错时举起写有原始错误摘要的小牌子。", + "capability": ["队列状态", "角色状态机", "进度映射", "错误摘要"], + "level": "L1", + "restraint": "订单签使用真实任务名称,出错说明保留原文和日志入口。" + }, + { + "number": 4, + "id": "tiny-mechanic", + "title": "住在错误框里的修理工", + "category": "角色", + "concept": "平时只露出工具箱,任务失败时小修理工探出身来,拿手电指向错误信息。用户点击查看日志,他便拉开旁边的检修盖;重试成功后留下一个小小补丁。", + "capability": ["错误事件", "锚点定位", "宿主动作", "状态动画"], + "level": "L1", + "restraint": "修理工不自动重试、不猜测原因,所有修复操作由用户触发。" + }, + { + "number": 5, + "id": "sleepy-gatekeeper", + "title": "打盹的设备门卫", + "category": "角色", + "concept": "设备未连接时,门卫裹着毯子在连接卡片旁打盹;连接成功便起身开门,运行时认真值守。断连时亮出明确的断线牌,并把重新连接入口递到手边。", + "capability": ["连接状态", "角色状态机", "边缘占位", "宿主动作"], + "level": "L1", + "restraint": "文字连接状态和重连按钮一直可见,角色不会挡住点击。" + }, + { + "number": 6, + "id": "magic-library", + "title": "会自己翻页的魔法书", + "category": "叙事", + "concept": "一组任务就是一本书,任务标题排成目录,运行进度映射为书页边缘的光线。完成一个任务,书签移到下一章;展开章节可看日志,失败章节夹上一枚红书签。", + "capability": ["语义插槽", "进度映射", "状态装饰", "日志动作"], + "level": "L1", + "restraint": "翻页只改变装饰,任务列表和键盘焦点不跟着跳走。" + }, + { + "number": 7, + "id": "miniature-city", + "title": "窗边的微缩城市", + "category": "空间", + "concept": "任务卡片变成一排建筑的立面,运行中的楼层亮灯,完成的楼顶升起小旗。城市只占侧栏,点击建筑展开对应任务;任务多时街区自然换行,不强塞进一张地图。", + "capability": ["任务状态", "响应式布局", "语义插槽", "详情动作"], + "level": "L1", + "restraint": "建筑保留可读名称和状态图标,窄屏回到紧凑任务列表。" + }, + { + "number": 8, + "id": "journey-map", + "title": "桌上的旅行地图", + "category": "空间", + "concept": "按顺序执行的任务成为一条旅行路线,当前任务旁停着小旅行车,已完成的站点盖上印章。暂停时车子停靠路边,失败站点出现路障,点击路标能查看详情。", + "capability": ["队列顺序", "状态映射", "SVG 路线", "详情动作"], + "level": "L1", + "restraint": "路线只反映已有队列,不暗示不存在的任务依赖或自动改序。" + }, + { + "number": 9, + "id": "amber-hourglass", + "title": "琥珀沙漏", + "category": "状态", + "concept": "真实进度驱动细沙落下,暂停时沙流立即停止,完成时沙粒凝成一块琥珀。无法得到百分比时,改为少量循环落沙,并直接标注进行中,不伪造剩余时间。", + "capability": ["进度映射", "暂停状态", "不定进度", "静态降级"], + "level": "L1", + "restraint": "百分比仍用数字呈现,低动效模式用静止水位线替代沙粒。" + }, + { + "number": 10, + "id": "weather-window", + "title": "属于任务的天气窗", + "category": "状态", + "concept": "界面角落是一扇小窗:待机时晴朗,运行时云缓缓移动,暂停时夕阳停在窗框上,失败时窗外下雨。成功后云散开,天气只在这扇窗里变化,不覆盖正文。", + "capability": ["状态绑定", "局部动画", "多层装饰", "低动效"], + "level": "L1", + "restraint": "天气不代替成功和错误文字,也不通过全屏明暗变化打扰阅读。" + }, + { + "number": 11, + "id": "living-circuit", + "title": "被点亮的电路板", + "category": "状态", + "concept": "连接、资源、任务三个状态区像电路元件,装饰走线在状态满足时亮起。当前任务有柔和的电流脉冲,错误区域旁的指示灯常亮,完成后整条线路稳定发光。", + "capability": ["状态绑定", "锚点定位", "SVG 走线", "分层绘制"], + "level": "L1", + "restraint": "走线表示状态流转而非真实任务依赖,错误灯不闪烁。" + }, + { + "number": 12, + "id": "print-workshop", + "title": "任务印刷工坊", + "category": "叙事", + "concept": "队列像一摞待印纸张,正在执行的任务经过滚筒,完成后吐出带任务名称的小票。用户展开小票查看结果;失败的纸张停在旁边,保留原始错误和重试入口。", + "capability": ["队列状态", "完成事件", "结果插槽", "宿主动作"], + "level": "L1", + "restraint": "小票数量受上限约束,历史结果仍在正常列表中查询。" + }, + { + "number": 13, + "id": "photo-developing", + "title": "慢慢显影的照片", + "category": "状态", + "concept": "皮肤自带的插画藏在拍立得相纸里,随着任务进度逐渐显影。完成时露出完整画面和任务完成时间;失败时保留当前显影程度,红色状态签明确指出失败。", + "capability": ["进度映射", "CSS 遮罩", "完成事件", "结果标签"], + "level": "L1", + "restraint": "只使用皮肤素材,不自动读取屏幕截图,也不把插画当执行结果。" + }, + { + "number": 14, + "id": "balloon-courier", + "title": "气球快递员", + "category": "角色", + "concept": "小快递员把任务名称绑在气球篮上,等待时气球轻轻晃动,执行时沿固定航线移动。任务完成后投递一枚徽章,取消则慢慢落地,失败时降到修整平台。", + "capability": ["角色状态机", "进度映射", "完成事件", "固定路径"], + "level": "L1", + "restraint": "飞行限制在预留装饰区,任务名称和取消按钮保持静止。" + }, + { + "number": 15, + "id": "mechanical-dials", + "title": "机械仪表驾驶舱", + "category": "空间", + "concept": "任务进度成为机械指针,连接状态是拨杆旁的指示灯,运行按钮嵌进黄铜面板。指针转动和刻度变化对应真实数据;状态切换时,表盘边缘的小铭牌同步更新。", + "capability": ["语义插槽", "进度映射", "SVG 刻度", "状态绑定"], + "level": "L1", + "restraint": "旋钮外观仍使用标准按钮操作,不要求用户精确拖动才能执行。" + }, + { + "number": 16, + "id": "magnetic-workbench", + "title": "磁吸工具台", + "category": "交互", + "concept": "侧栏里的装饰螺帽和工具会被指针轻轻吸引,离开后弹回原位;选中的任务卡片旁吸附一枚小磁铁,帮助辨认当前项。可拖动的只有装饰,不改变任务顺序。", + "capability": ["指针输入", "弹簧动画", "局部物理", "选择状态"], + "level": "L2", + "restraint": "触屏和低动效模式取消磁吸,真实控件的位置和点击区域不变。" + }, + { + "number": 17, + "id": "inspection-lens", + "title": "工程师的观察镜", + "category": "交互", + "concept": "指针靠近任务卡片时,旁边的装饰观察镜转向当前项,并放大展示该项的状态图形。按下固定的详情按钮,观察镜展开为摘要面板,形成一个有趣的阅读焦点。", + "capability": ["指针输入", "选中状态", "摘要绑定", "宿主动作"], + "level": "L2", + "restraint": "镜片不跟随覆盖鼠标,键盘聚焦和点击详情具有同等反馈。" + }, + { + "number": 18, + "id": "sliding-drawers", + "title": "抽屉里的工具箱", + "category": "交互", + "concept": "不常用的设置收进带标签的抽屉,点击拉手才展开正常的设置面板;运行时当前任务的抽屉亮起边灯,日志入口像一卷可拉开的记录纸,收起后保留清晰标签。", + "capability": ["宿主折叠状态", "语义插槽", "布局占位", "CSS 过渡"], + "level": "L1", + "restraint": "停止、错误和核心任务入口永远不藏进抽屉,折叠由宿主管理焦点。" + }, + { + "number": 19, + "id": "darkroom-flashlight", + "title": "暗室里的探照灯", + "category": "交互", + "concept": "深色背景上的装饰星图平时很淡,指针经过时局部显出线条和彩色尘埃。运行中的任务像一盏固定灯塔,暂停时光束收拢;文字和按钮不受这层明暗变化影响。", + "capability": ["指针输入", "CSS 遮罩", "状态绑定", "背景层"], + "level": "L2", + "restraint": "灯光仅作用于装饰,触屏和低动效模式显示静态背景。" + }, + { + "number": 20, + "id": "orbital-window", + "title": "空间站舷窗", + "category": "空间", + "concept": "任务区域像空间站工作台,窗外行星和舱内装饰随指针产生轻微视差。运行时远处推进器亮起,完成后有一颗卫星经过;真正的表格和操作区始终稳稳停在前景。", + "capability": ["指针视差", "多层合成", "状态绑定", "完成事件"], + "level": "L2", + "restraint": "视差限制位移幅度,缩小窗口或开启低动效后立即停止。" + }, + { + "number": 21, + "id": "comic-strip", + "title": "今天的四格漫画", + "category": "叙事", + "concept": "同一套角色插画对应准备、运行、成功和失败四个分镜,状态变化时切换到对应画面。重试成功会解锁一个小彩蛋结尾;漫画旁始终并排显示真实任务状态。", + "capability": ["有限状态机", "条件显隐", "事件绑定", "本次会话状态"], + "level": "L1", + "restraint": "剧情不改变错误含义、不延迟操作,也不需要记录用户的长期历史。" + }, + { + "number": 22, + "id": "detective-board", + "title": "侦探的案件板", + "category": "叙事", + "concept": "任务卡片像夹在软木板上的档案,连接状态贴着设备标签,失败任务留下醒目的线索卡。点击线索直接展开原始日志,重试成功后档案盖上已解决印章。", + "capability": ["语义插槽", "错误事件", "日志动作", "状态装饰"], + "level": "L1", + "restraint": "只呈现宿主允许的摘要,不向皮肤暴露完整日志、路径或敏感配置。" + }, + { + "number": 23, + "id": "letters-and-stamps", + "title": "写给自己的完成信", + "category": "状态", + "concept": "每次任务结束生成一张当前会话的装饰信笺,写明任务名称与结果,成功盖圆邮戳,失败盖待处理章。信封展开动画结束前,用户也能立即查看详情或开始下一次任务。", + "capability": ["完成事件", "结果插槽", "CSS 过渡", "状态标签"], + "level": "L1", + "restraint": "信笺只在本地展示,不自动发信、不保存未经授权的任务信息。" + }, + { + "number": 24, + "id": "pixel-inn", + "title": "任务像素旅馆", + "category": "角色", + "concept": "任务依次入住像素旅馆的房间,执行中的房门亮灯,完成后有小客人拖着箱子离开。空闲时老板擦柜台,失败房间挂上需要帮助的牌子,点击房门查看详情。", + "capability": ["队列状态", "角色状态机", "精灵动画", "详情动作"], + "level": "L1", + "restraint": "房间数量随窗口调整,任务多时使用正常滚动列表而非无限扩张。" + } +] diff --git a/docs/design/skin-system/ideas.md b/docs/design/skin-system/ideas.md new file mode 100644 index 00000000..82ecda82 --- /dev/null +++ b/docs/design/skin-system/ideas.md @@ -0,0 +1,209 @@ + + +# MXU 皮肤创意清单 + +本页收集可供讨论的视觉与交互方向。L1 / L2 表示建议实现层级;部分创意还需要额外公开宿主槽位、只读状态或语义动作,不代表 [v0.1 草案](protocol.md) 已经支持。所有趣味表现均应有静态模式,并保留直接、可理解的任务操作。 + +六个已做成概念 demo 的方向见 [设计入口](README.md)。下列 24 条用于继续扩展,不是已经完成的功能。编号与 [交互 demo](demo/index.html#ideas) 一致,按类别阅读时保留原编号。唯一内容源为 [ideas.json](ideas.json)。 + +## 角色 + +### IDEA 04 · 住在错误框里的修理工 + +平时只露出工具箱,任务失败时小修理工探出身来,拿手电指向错误信息。用户点击查看日志,他便拉开旁边的检修盖;重试成功后留下一个小小补丁。 + +- 建议层级:**L1**。 +- 所需能力:错误事件、锚点定位、宿主动作、状态动画。 +- 操作兜底:修理工不自动重试、不猜测原因,所有修复操作由用户触发。 + +### IDEA 05 · 打盹的设备门卫 + +设备未连接时,门卫裹着毯子在连接卡片旁打盹;连接成功便起身开门,运行时认真值守。断连时亮出明确的断线牌,并把重新连接入口递到手边。 + +- 建议层级:**L1**。 +- 所需能力:连接状态、角色状态机、边缘占位、宿主动作。 +- 操作兜底:文字连接状态和重连按钮一直可见,角色不会挡住点击。 + +### IDEA 14 · 气球快递员 + +小快递员把任务名称绑在气球篮上,等待时气球轻轻晃动,执行时沿固定航线移动。任务完成后投递一枚徽章,取消则慢慢落地,失败时降到修整平台。 + +- 建议层级:**L1**。 +- 所需能力:角色状态机、进度映射、完成事件、固定路径。 +- 操作兜底:飞行限制在预留装饰区,任务名称和取消按钮保持静止。 + +### IDEA 24 · 任务像素旅馆 + +任务依次入住像素旅馆的房间,执行中的房门亮灯,完成后有小客人拖着箱子离开。空闲时老板擦柜台,失败房间挂上需要帮助的牌子,点击房门查看详情。 + +- 建议层级:**L1**。 +- 所需能力:队列状态、角色状态机、精灵动画、详情动作。 +- 操作兜底:房间数量随窗口调整,任务多时使用正常滚动列表而非无限扩张。 + +## 空间 + +### IDEA 01 · 任务小火车 + +每个任务是一节车厢,正在执行的车厢驶过检票口,完成的车厢亮起车窗。展开车厢就能查看真实任务详情;失败车厢停在维修站,重试后重新接入队列。 + +- 建议层级:**L1**。 +- 所需能力:队列状态、语义插槽、状态动画、详情动作。 +- 操作兜底:车厢顺序与真实队列一致,文字列表始终可切回。 + +### IDEA 07 · 窗边的微缩城市 + +任务卡片变成一排建筑的立面,运行中的楼层亮灯,完成的楼顶升起小旗。城市只占侧栏,点击建筑展开对应任务;任务多时街区自然换行,不强塞进一张地图。 + +- 建议层级:**L1**。 +- 所需能力:任务状态、响应式布局、语义插槽、详情动作。 +- 操作兜底:建筑保留可读名称和状态图标,窄屏回到紧凑任务列表。 + +### IDEA 08 · 桌上的旅行地图 + +按顺序执行的任务成为一条旅行路线,当前任务旁停着小旅行车,已完成的站点盖上印章。暂停时车子停靠路边,失败站点出现路障,点击路标能查看详情。 + +- 建议层级:**L1**。 +- 所需能力:队列顺序、状态映射、SVG 路线、详情动作。 +- 操作兜底:路线只反映已有队列,不暗示不存在的任务依赖或自动改序。 + +### IDEA 15 · 机械仪表驾驶舱 + +任务进度成为机械指针,连接状态是拨杆旁的指示灯,运行按钮嵌进黄铜面板。指针转动和刻度变化对应真实数据;状态切换时,表盘边缘的小铭牌同步更新。 + +- 建议层级:**L1**。 +- 所需能力:语义插槽、进度映射、SVG 刻度、状态绑定。 +- 操作兜底:旋钮外观仍使用标准按钮操作,不要求用户精确拖动才能执行。 + +### IDEA 20 · 空间站舷窗 + +任务区域像空间站工作台,窗外行星和舱内装饰随指针产生轻微视差。运行时远处推进器亮起,完成后有一颗卫星经过;真正的表格和操作区始终稳稳停在前景。 + +- 建议层级:**L2**。 +- 所需能力:指针视差、多层合成、状态绑定、完成事件。 +- 操作兜底:视差限制位移幅度,缩小窗口或开启低动效后立即停止。 + +## 状态 + +### IDEA 09 · 琥珀沙漏 + +真实进度驱动细沙落下,暂停时沙流立即停止,完成时沙粒凝成一块琥珀。无法得到百分比时,改为少量循环落沙,并直接标注进行中,不伪造剩余时间。 + +- 建议层级:**L1**。 +- 所需能力:进度映射、暂停状态、不定进度、静态降级。 +- 操作兜底:百分比仍用数字呈现,低动效模式用静止水位线替代沙粒。 + +### IDEA 10 · 属于任务的天气窗 + +界面角落是一扇小窗:待机时晴朗,运行时云缓缓移动,暂停时夕阳停在窗框上,失败时窗外下雨。成功后云散开,天气只在这扇窗里变化,不覆盖正文。 + +- 建议层级:**L1**。 +- 所需能力:状态绑定、局部动画、多层装饰、低动效。 +- 操作兜底:天气不代替成功和错误文字,也不通过全屏明暗变化打扰阅读。 + +### IDEA 11 · 被点亮的电路板 + +连接、资源、任务三个状态区像电路元件,装饰走线在状态满足时亮起。当前任务有柔和的电流脉冲,错误区域旁的指示灯常亮,完成后整条线路稳定发光。 + +- 建议层级:**L1**。 +- 所需能力:状态绑定、锚点定位、SVG 走线、分层绘制。 +- 操作兜底:走线表示状态流转而非真实任务依赖,错误灯不闪烁。 + +### IDEA 13 · 慢慢显影的照片 + +皮肤自带的插画藏在拍立得相纸里,随着任务进度逐渐显影。完成时露出完整画面和任务完成时间;失败时保留当前显影程度,红色状态签明确指出失败。 + +- 建议层级:**L1**。 +- 所需能力:进度映射、CSS 遮罩、完成事件、结果标签。 +- 操作兜底:只使用皮肤素材,不自动读取屏幕截图,也不把插画当执行结果。 + +### IDEA 23 · 写给自己的完成信 + +每次任务结束生成一张当前会话的装饰信笺,写明任务名称与结果,成功盖圆邮戳,失败盖待处理章。信封展开动画结束前,用户也能立即查看详情或开始下一次任务。 + +- 建议层级:**L1**。 +- 所需能力:完成事件、结果插槽、CSS 过渡、状态标签。 +- 操作兜底:信笺只在本地展示,不自动发信、不保存未经授权的任务信息。 + +## 交互 + +### IDEA 16 · 磁吸工具台 + +侧栏里的装饰螺帽和工具会被指针轻轻吸引,离开后弹回原位;选中的任务卡片旁吸附一枚小磁铁,帮助辨认当前项。可拖动的只有装饰,不改变任务顺序。 + +- 建议层级:**L2**。 +- 所需能力:指针输入、弹簧动画、局部物理、选择状态。 +- 操作兜底:触屏和低动效模式取消磁吸,真实控件的位置和点击区域不变。 + +### IDEA 17 · 工程师的观察镜 + +指针靠近任务卡片时,旁边的装饰观察镜转向当前项,并放大展示该项的状态图形。按下固定的详情按钮,观察镜展开为摘要面板,形成一个有趣的阅读焦点。 + +- 建议层级:**L2**。 +- 所需能力:指针输入、选中状态、摘要绑定、宿主动作。 +- 操作兜底:镜片不跟随覆盖鼠标,键盘聚焦和点击详情具有同等反馈。 + +### IDEA 18 · 抽屉里的工具箱 + +不常用的设置收进带标签的抽屉,点击拉手才展开正常的设置面板;运行时当前任务的抽屉亮起边灯,日志入口像一卷可拉开的记录纸,收起后保留清晰标签。 + +- 建议层级:**L1**。 +- 所需能力:宿主折叠状态、语义插槽、布局占位、CSS 过渡。 +- 操作兜底:停止、错误和核心任务入口永远不藏进抽屉,折叠由宿主管理焦点。 + +### IDEA 19 · 暗室里的探照灯 + +深色背景上的装饰星图平时很淡,指针经过时局部显出线条和彩色尘埃。运行中的任务像一盏固定灯塔,暂停时光束收拢;文字和按钮不受这层明暗变化影响。 + +- 建议层级:**L2**。 +- 所需能力:指针输入、CSS 遮罩、状态绑定、背景层。 +- 操作兜底:灯光仅作用于装饰,触屏和低动效模式显示静态背景。 + +## 叙事 + +### IDEA 02 · 火箭发射台 + +连接设备、检查资源、开始任务分别点亮发射台的检查灯。运行时尾焰随任务状态变化,全部完成才发射一枚小火箭;中途停止则平稳熄火,保留本次成果。 + +- 建议层级:**L1**。 +- 所需能力:状态绑定、完成事件、多层装饰、条件显隐。 +- 操作兜底:发射只是完成反馈,不插入倒计时,也不延迟真实启动。 + +### IDEA 03 · 深夜拉面铺 + +任务进入队列时挂上一张订单签,执行时厨师搅动汤锅,完成后把一碗面送到取餐台。暂停时关小火,出错时举起写有原始错误摘要的小牌子。 + +- 建议层级:**L1**。 +- 所需能力:队列状态、角色状态机、进度映射、错误摘要。 +- 操作兜底:订单签使用真实任务名称,出错说明保留原文和日志入口。 + +### IDEA 06 · 会自己翻页的魔法书 + +一组任务就是一本书,任务标题排成目录,运行进度映射为书页边缘的光线。完成一个任务,书签移到下一章;展开章节可看日志,失败章节夹上一枚红书签。 + +- 建议层级:**L1**。 +- 所需能力:语义插槽、进度映射、状态装饰、日志动作。 +- 操作兜底:翻页只改变装饰,任务列表和键盘焦点不跟着跳走。 + +### IDEA 12 · 任务印刷工坊 + +队列像一摞待印纸张,正在执行的任务经过滚筒,完成后吐出带任务名称的小票。用户展开小票查看结果;失败的纸张停在旁边,保留原始错误和重试入口。 + +- 建议层级:**L1**。 +- 所需能力:队列状态、完成事件、结果插槽、宿主动作。 +- 操作兜底:小票数量受上限约束,历史结果仍在正常列表中查询。 + +### IDEA 21 · 今天的四格漫画 + +同一套角色插画对应准备、运行、成功和失败四个分镜,状态变化时切换到对应画面。重试成功会解锁一个小彩蛋结尾;漫画旁始终并排显示真实任务状态。 + +- 建议层级:**L1**。 +- 所需能力:有限状态机、条件显隐、事件绑定、本次会话状态。 +- 操作兜底:剧情不改变错误含义、不延迟操作,也不需要记录用户的长期历史。 + +### IDEA 22 · 侦探的案件板 + +任务卡片像夹在软木板上的档案,连接状态贴着设备标签,失败任务留下醒目的线索卡。点击线索直接展开原始日志,重试成功后档案盖上已解决印章。 + +- 建议层级:**L1**。 +- 所需能力:语义插槽、错误事件、日志动作、状态装饰。 +- 操作兜底:只呈现宿主允许的摘要,不向皮肤暴露完整日志、路径或敏感配置。 diff --git a/docs/design/skin-system/images/01-mascot.png b/docs/design/skin-system/images/01-mascot.png new file mode 100644 index 00000000..1011bc84 Binary files /dev/null and b/docs/design/skin-system/images/01-mascot.png differ diff --git a/docs/design/skin-system/images/01-mascot.svg b/docs/design/skin-system/images/01-mascot.svg new file mode 100644 index 00000000..d5526188 --- /dev/null +++ b/docs/design/skin-system/images/01-mascot.svg @@ -0,0 +1,38 @@ +MXU 皮肤系统 · 捣蛋团子 · 静态设计示意m✳MXU / SKIN LAB01 / 06 DESIGN PROPOSAL捣蛋团子让角色参与布局,而不只是悬浮在按钮上。MAKE SOME ROOM.m✳MXU我的自动化工作台● ● ●▦⌘◷⚙今日任务模拟设备已就绪✓↗启动与连接建立连接 · 检查运行环境等待✓◇日常任务自动执行 · 收集运行结果等待✓▤整理与收尾保存记录 · 完成后待机等待准备就绪,随时出发0%▶ 开始任务停止运行概览↗执行状态待机中已完成0 / 3宿主分配的占位区域 + 挤挤,一只喜欢占座的薄荷小怪兽 + 两只不对称的小角,圆滚滚身体和伸向右边的手,正在把界面按钮挤开。 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +LAYOUT REQUEST+ 132 px角色进入,布局让位。按钮身份与点击区域保留。语义锚点真实占位保留宿主控件角色申请 132 CSS px 的边缘空间(放大示意),宿主分配占位,真实按钮随布局让位。静态设计示意 · 非 MXU 实机截图 \ No newline at end of file diff --git a/docs/design/skin-system/images/02-mecha.png b/docs/design/skin-system/images/02-mecha.png new file mode 100644 index 00000000..d0de3fec Binary files /dev/null and b/docs/design/skin-system/images/02-mecha.png differ diff --git a/docs/design/skin-system/images/02-mecha.svg b/docs/design/skin-system/images/02-mecha.svg new file mode 100644 index 00000000..1882d491 --- /dev/null +++ b/docs/design/skin-system/images/02-mecha.svg @@ -0,0 +1,102 @@ +MXU 皮肤系统 · 边界破坏者 · 静态设计示意m✳MXU / SKIN LAB02 / 06 DESIGN PROPOSAL边界破坏者把装饰画到面板之外,让武器突破方框。BEYOND THE FRAME.m✳MXU我的自动化工作台● ● ●▦⌘◷⚙今日任务模拟设备已就绪✓↗启动与连接建立连接 · 检查运行环境等待✓◇日常任务自动执行 · 收集运行结果等待✓▤整理与收尾保存记录 · 完成后待机等待准备就绪,随时出发0%▶ 开始任务停止运行概览↗执行状态待机中已完成0 / 3 + 白隼,携带青绿色能量长枪的机甲 + 白灰装甲、橘色肩甲与长长的发光武器。武器向右上方突出,适合演示越过面板边框的装饰。 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +面板边界ornament-front根级装饰层允许越框装饰不拦截点击长枪越过的是应用内面板;装饰随面板定位,仍受整个应用视口约束。静态设计示意 · 非 MXU 实机截图 \ No newline at end of file diff --git a/docs/design/skin-system/images/03-garden.png b/docs/design/skin-system/images/03-garden.png new file mode 100644 index 00000000..ce4587d6 Binary files /dev/null and b/docs/design/skin-system/images/03-garden.png differ diff --git a/docs/design/skin-system/images/03-garden.svg b/docs/design/skin-system/images/03-garden.svg new file mode 100644 index 00000000..1f9a577c --- /dev/null +++ b/docs/design/skin-system/images/03-garden.svg @@ -0,0 +1,53 @@ +MXU 皮肤系统 · 微型生态舱 · 静态设计示意m✳MXU / SKIN LAB03 / 06 DESIGN PROPOSAL微型生态舱把一段任务进度,养成会生长的风景。A LITTLE MORE ALIVE.m✳MXU我的自动化工作台● ● ●▦⌘◷⚙今日任务模拟设备已就绪✓↗启动与连接建立连接 · 检查运行环境完成✓◇日常任务自动执行 · 收集运行结果执行中✓▤整理与收尾保存记录 · 完成后待机等待正在模拟执行,请稍候55%Ⅱ 暂停任务停止生态监测↗执行状态执行中已完成1 / 3 + 微光温室 + 玻璃罩中的蓝绿色植物和一颗漂浮的暖橘色小行星。 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +GROWTH / 55%只读任务状态进度映射生长与昼夜进度推进时,生态舱舒展开来;关闭动态效果后,文字状态与进度仍然完整。静态设计示意 · 非 MXU 实机截图 \ No newline at end of file diff --git a/docs/design/skin-system/images/04-vinyl.png b/docs/design/skin-system/images/04-vinyl.png new file mode 100644 index 00000000..3263030f Binary files /dev/null and b/docs/design/skin-system/images/04-vinyl.png differ diff --git a/docs/design/skin-system/images/04-vinyl.svg b/docs/design/skin-system/images/04-vinyl.svg new file mode 100644 index 00000000..97741d40 --- /dev/null +++ b/docs/design/skin-system/images/04-vinyl.svg @@ -0,0 +1 @@ +MXU 皮肤系统 · 黑胶工作台 · 静态设计示意m✳MXU / SKIN LAB04 / 06 DESIGN PROPOSAL黑胶工作台唱针落下,任务进入自己的节奏。GET INTO YOUR GROOVE.m✳MXU我的自动化工作台● ● ●▦⌘◷⚙今日任务模拟设备已就绪✓↗启动与连接建立连接 · 检查运行环境完成✓◇日常任务自动执行 · 收集运行结果执行中✓▤整理与收尾保存记录 · 完成后待机等待正在模拟执行,请稍候55%Ⅱ 暂停任务停止SIDE A / DAILY MIX↗执行状态执行中已完成1 / 3MXU33⅓ / DAILYNEEDLE DOWN · RUNNING运行状态映射唱针与唱片语义控件保留开始时落针、运行时旋转、暂停时停转。声音不是必要条件,演示保持静音。静态设计示意 · 非 MXU 实机截图 \ No newline at end of file diff --git a/docs/design/skin-system/images/05-paper.png b/docs/design/skin-system/images/05-paper.png new file mode 100644 index 00000000..8f9c2a8b Binary files /dev/null and b/docs/design/skin-system/images/05-paper.png differ diff --git a/docs/design/skin-system/images/05-paper.svg b/docs/design/skin-system/images/05-paper.svg new file mode 100644 index 00000000..6769311f --- /dev/null +++ b/docs/design/skin-system/images/05-paper.svg @@ -0,0 +1 @@ +MXU 皮肤系统 · 纸上事务所 · 静态设计示意m✳MXU / SKIN LAB05 / 06 DESIGN PROPOSAL纸上事务所办完一件小事,留下一个完成的印记。LESS WORK. MORE LIFE.m✳MXU我的自动化工作台● ● ●▦⌘◷⚙今日任务模拟设备已就绪✓↗启动与连接建立连接 · 检查运行环境完成✓◇日常任务自动执行 · 收集运行结果完成✓▤整理与收尾保存记录 · 完成后待机完成全部完成,辛苦啦!100%↻ 再运行一次停止DESK NOTES↗执行状态已完成已完成3 / 3TO DO / DAILY✓ 少一点琐事✓ 多一点时间DONE纸张与折角完成事件静态反馈三项任务完成后盖下印章;纸张材质和状态反馈,可以由 CSS 与 SVG 直接表达。静态设计示意 · 非 MXU 实机截图 \ No newline at end of file diff --git a/docs/design/skin-system/images/06-sonar.png b/docs/design/skin-system/images/06-sonar.png new file mode 100644 index 00000000..23795da4 Binary files /dev/null and b/docs/design/skin-system/images/06-sonar.png differ diff --git a/docs/design/skin-system/images/06-sonar.svg b/docs/design/skin-system/images/06-sonar.svg new file mode 100644 index 00000000..b848b1ae --- /dev/null +++ b/docs/design/skin-system/images/06-sonar.svg @@ -0,0 +1 @@ +MXU 皮肤系统 · 深海声呐站 · 静态设计示意m✳MXU / SKIN LAB06 / 06 DESIGN PROPOSAL深海声呐站让每一次运行,都在界面里产生回声。EVERY TASK, A SIGNAL.m✳MXU我的自动化工作台● ● ●▦⌘◷⚙今日任务模拟设备已就绪✓↗启动与连接建立连接 · 检查运行环境完成✓◇日常任务自动执行 · 收集运行结果执行中✓▤整理与收尾保存记录 · 完成后待机等待正在模拟执行,请稍候55%Ⅱ 暂停任务停止SIGNAL / 03↗执行状态执行中已完成1 / 33 SIGNALS · SCANNING任务驱动扫描事件信号减少动态效果任务运行时扫描、暂停时静止。氛围是第二种状态表达,清晰的文字状态始终保留。静态设计示意 · 非 MXU 实机截图 \ No newline at end of file diff --git a/docs/design/skin-system/images/07-deepseek.png b/docs/design/skin-system/images/07-deepseek.png new file mode 100644 index 00000000..026df151 Binary files /dev/null and b/docs/design/skin-system/images/07-deepseek.png differ diff --git a/docs/design/skin-system/images/08-naiwa-preview.mp4 b/docs/design/skin-system/images/08-naiwa-preview.mp4 new file mode 100644 index 00000000..38e05e0a Binary files /dev/null and b/docs/design/skin-system/images/08-naiwa-preview.mp4 differ diff --git a/docs/design/skin-system/images/08-naiwa.png b/docs/design/skin-system/images/08-naiwa.png new file mode 100644 index 00000000..d66ec82e Binary files /dev/null and b/docs/design/skin-system/images/08-naiwa.png differ diff --git a/docs/design/skin-system/images/09-settings-skins.png b/docs/design/skin-system/images/09-settings-skins.png new file mode 100644 index 00000000..2a600de2 Binary files /dev/null and b/docs/design/skin-system/images/09-settings-skins.png differ diff --git a/docs/design/skin-system/images/10-main-naiwa.png b/docs/design/skin-system/images/10-main-naiwa.png new file mode 100644 index 00000000..a7b5924f Binary files /dev/null and b/docs/design/skin-system/images/10-main-naiwa.png differ diff --git a/docs/design/skin-system/meme-skins.md b/docs/design/skin-system/meme-skins.md new file mode 100644 index 00000000..75b8d7da --- /dev/null +++ b/docs/design/skin-system/meme-skins.md @@ -0,0 +1,74 @@ +# 白饭鲸鱼娘与奶蛙 + +这两套皮肤是基于网络热梗制作的**非官方社区二创场景**,已作为可信内置皮肤接入 MXU 的外观设置与任务主视图;本目录同时保留它们在离线概念 demo 中的版本。白饭鲸鱼娘使用现有社区公开视频及同源透明抽帧。奶蛙使用公开可访问的社区大笑视频、海报与原片第 1 秒的闭口静态截帧。最终两套均不包含本次生成或重绘的角色图。素材来源和权利状态见文末。 + +两套只展示公开素材中的动作,不添加角色对白、拟声台词或额外剧情;界面文字只表达宿主任务状态与操作提示。 + +## 主程序体验 + +在 **设置 → 外观 → 内置皮肤** 中选择默认、蓝色大肥鱼吃白饭或奶蛙。原有明暗模式和强调色继续保留,皮肤不会覆盖用户配色。设置页提供预览,角色在任务主视图的工具栏前以普通布局卡片出现;默认选项移除卡片。减少动效同时遵循系统请求与皮肤设置。 + +主程序卡片只消费活动实例已有的运行、整批成功和整批失败事实,其余显示待机;角色互动不调用任务命令。停止后任务状态为 `null` 时不会假装成功或取消,也没有从时间或动画计算的百分比。换肤保持业务任务执行,桌面选择按项目外观配置保存,WebUI 选择保存在浏览器本地且不会覆盖桌面选择。 + +组件与素材随前端发布,资源固定在 `src/assets/skins/`。本次没有通用 Manifest 解释器或第三方 `.mxuskin` 安装入口,也没有新增作者脚本、文件或网络能力。 + +## 离线体验与预设 + +下载或检出文件后,用浏览器打开以下入口即可体验,无需安装依赖或连接设备: + +| 场景 | 离线入口 | 预设文件 | +| --- | --- | --- | +| 蓝色大肥鱼吃白饭 | [demo/index.html#deepseek](demo/index.html#deepseek) | [deepseek.demo.json](demo/examples/deepseek.demo.json) | +| 奶蛙 | [demo/index.html#naiwa](demo/index.html#naiwa) | [naiwa.demo.json](demo/examples/naiwa.demo.json) | + +预设可通过 demo 的“导入预设”按钮加载;调整后也可重新导出,下载名分别为 `mxu-deepseek.demo.json`、`mxu-naiwa.demo.json`。格式为 `mxu-skin-demo/0.1`,仅包含内置场景、主题色、强度与互动开关,不是正式皮肤 Manifest,也不能作为 `.mxuskin` 安装包使用。 + +## 离线 Demo 中的两套设计 + +以下主题色、可调幅度、进度条、暂停与取消均属于独立模拟器。它们不代表主程序任务适配器已经提供相同字段或操作。 + +**蓝色大肥鱼吃白饭**采用既有社区动画中的蓝发鲸鱼娘:鲸鳍、呆毛、大鲸尾、蓝白女仆装与白饭碗。主题色为 `#225fbd`,互动仅播放原有吃白饭动作,不改变任务进度或执行结果。待机、吃饭与错误静态图分别从同源公开视频抽帧,保留透明,没有重绘角色。 + +**奶蛙**以实际大笑原片为准:没有描边的三维黄色梨形身体、头身相连的圆滑轮廓、浅色肚皮、绿色眼睛与黑瞳、黄色有肉的手臂和腿,只有手掌及脚掌偏深褐。公开动作是双手捧腹、仰头张嘴、身体发颤。主题色为 `#667c28`;宿主互动按钮或任务成功反馈可以播放该动作,失败时停止播放,不为角色另添性格或台词。 + +两套均不播放音频,原片音轨不作为本皮肤内容分发。模拟器的文字状态、模拟进度和任务结果始终保留,角色动作不承担唯一的状态提示;系统或页面开启“减少动效”时使用静态姿态并保留客观任务文字。 + +奶蛙随包视频基于该站提供的 720×982、15.4 秒 H.264 播放版本,移除音轨、剪去开头 2.6 秒的安静片段,保留 12.8 秒大笑动作;海报为同一条目的本地 WebP,`naiwa-calm.png` 是未裁剪原视频第 1 秒的闭口静态截帧。无需运行时 seek,进度更新与重复触发不会跳转或重播。视频白底通过装饰列的 CSS 混合融入背景,不依赖远程播放器或在线抠像服务。两套静态姿态都只取自原片,不重绘。 + +| 触发或状态 | 白饭鲸鱼娘 | 奶蛙 | +| --- | --- | --- | +| 待机 | 待机静态帧,显示待机状态 | 中性静态姿态,显示待机状态 | +| 互动动作 | 宿主按钮播放吃白饭动作,约 2.4 秒后复位 | 宿主按钮播放捧腹大笑动作,约 2.4 秒后复位 | +| 运行 | 显示执行中;动作不代表任务进度 | 显示执行中;动作不代表任务进度 | +| 暂停 | 停止互动,显示已暂停 | 停止互动,显示已暂停 | +| 成功 | 显示任务完成 | 播放原有捧腹大笑动作,显示任务完成 | +| 失败 | 同源错误静态帧,显示执行失败 | 停止大笑,显示执行失败 | +| 取消 | 停止互动,显示本次已取消 | 停止互动,显示本次已取消 | + +离线 demo 的角色互动仅在待机和运行时可用,暂停和终态由模拟任务状态控制。失败与取消会清除未结束的互动,不触发成功庆祝;互动超时只复位角色,不修改任务事实。切换场景会重置本 demo 的模拟任务;主程序的内置皮肤切换则保持实际业务任务。 + +## 白饭鲸鱼娘素材与许可 + +来源为 [PC2005-cloud/dsh-pet](https://github.com/PC2005-cloud/dsh-pet),固定提交 `33938a355f422525886c3facd883d7179c58ebf4`。主程序与离线 demo 使用相同来源的本地副本: + +| 本地文件 | 原文件 | 本次处理 | +| --- | --- | --- | +| `deepseek-eat.mp4` | [吃白饭.webm](https://github.com/PC2005-cloud/dsh-pet/blob/33938a355f422525886c3facd883d7179c58ebf4/dsh-pet/assets/webm/%E5%90%83%E7%99%BD%E9%A5%AD.webm) | libvpx 解码 VP9 alpha、合成白底、移除音轨并转为 H.264,保留约 10.04 秒原动作 | +| `deepseek.png` | 同上 | 4 秒处抽帧为透明 PNG | +| `deepseek-calm.png` | [待机呼吸休闲.webm](https://github.com/PC2005-cloud/dsh-pet/blob/33938a355f422525886c3facd883d7179c58ebf4/dsh-pet/assets/webm/%E5%BE%85%E6%9C%BA%E5%91%BC%E5%90%B8%E4%BC%91%E9%97%B2.webm) | 1 秒处抽帧为透明 PNG | +| `deepseek-error.png` | [工作状态-垂头叹气冒汗.webm](https://github.com/PC2005-cloud/dsh-pet/blob/33938a355f422525886c3facd883d7179c58ebf4/dsh-pet/assets/webm/%E5%B7%A5%E4%BD%9C%E7%8A%B6%E6%80%81-%E5%9E%82%E5%A4%B4%E5%8F%B9%E6%B0%94%E5%86%92%E6%B1%97.webm) | 4 秒处抽帧为透明 PNG | + +动画素材署名 **PC2005-cloud**;原型「溟月」署名 **上善无形**;DeepSeek 女仆鲸鱼娘设计署名 **ZipZipPipe**。来源 [README 的许可说明](https://github.com/PC2005-cloud/dsh-pet/blob/33938a355f422525886c3facd883d7179c58ebf4/README.md#许可)将代码 MIT 与素材授权区分,素材按角色的 [CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/deed.zh-hans) 保留署名、非商业与相同方式共享条件;形象许可可核对 [ZipZipPipe 的原声明](https://www.bilibili.com/opus/1247156596114456581)。这些媒体不适用代码许可证。随附 [来源与处理说明](../../../src/assets/skins/README.md) 及 [许可摘要](../../../src/assets/skins/LICENSE.deepseek.txt)保留上述记录。 + +原 VP9 alpha 视频在不支持透明 WebM 的平台可能出现黑底,因此随包视频使用白底 H.264;主程序使用白色肖像框,离线 demo 将白底混入背景。减少动效、播放失败或格式不支持时使用同源静态帧。此处理不构成各平台 WebView 解码兼容性的保证。 + +## 研究来源 + +- [极客公园/凤凰网:DeepSeek,怎么就变成了一条“大肥鱼”](https://i.ifeng.com/c/8xhKxSaK0sd):梳理蓝发鲸鱼娘、女仆装与白饭形象的社区演变;早期识图对话仍缺少可核验原记录。 +- [LINUX DO:一只爱吃白饭的蓝色大肥鱼](https://linux.do/t/topic/2727836):实际社区梗图与端碗干饭的使用场景。 +- [梗鲸:DeepSeek娘角色百科](https://aigengtu.com/zh/whale-chan):鲸鳍、长发、鲸尾等视觉要素及社区形象谱系。 +- [Naiwa Hub:奶蛙的故事](https://naiwahub.com/zh/about):奶蛙外形、捧腹大笑动作与起源考据视频线索。这里的奶蛙是黄色网络迷因形象,与普通绿色青蛙、真实牛奶蛙不同。 +- [Naiwa Hub:奶蛙连续大笑原片](https://naiwahub.com/zh/memes/ghn-naiwa-wallpaper-1c05a2a8af20):本次动态素材的收录页,标注原片为 1080×1474、30fps、15.4 秒;同时注明创作者与素材权利尚未核实。 +- [Naiwa Hub:仰头捧腹大笑](https://naiwahub.com/zh/memes/dot-naiwa-0056):交叉核对典型三维形态,避免把奶蛙画成眼柄外凸、四肢如细杆的普通卡通青蛙。 + +上述网站用于研究梗义和视觉特征,不代表其为相关品牌或原作的官方网站。奶蛙动态素材来自 [Naiwa Hub 公开视频](https://naiwahub.com/media/naiwa/ghn-naiwa-wallpaper-1c05a2a8af20-playback.mp4),其来源记录指向 [Nailong-Studio/NaiWa-Wallpaper 的社区收录文件](https://github.com/Nailong-Studio/NaiWa-Wallpaper/blob/036afc4f4930c90e78c0a8e26d4a2da6606c95e2/videos/%E5%A5%B6%E8%9B%99-%E5%A4%A7%E7%AC%91.mp4)。公开可访问不等于已获得开源授权:收录页标注的创作者和素材权利尚未核实,既有视频、海报及角色形象仍归相应权利人所有;本项目不宣称这些素材适用开源许可。 diff --git a/docs/design/skin-system/package-lock.json b/docs/design/skin-system/package-lock.json new file mode 100644 index 00000000..d04840b4 --- /dev/null +++ b/docs/design/skin-system/package-lock.json @@ -0,0 +1,610 @@ +{ + "name": "mxu-skin-system-design", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "mxu-skin-system-design", + "version": "0.1.0", + "devDependencies": { + "jsdom": "26.1.0", + "marked": "17.0.1", + "playwright": "1.62.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/marked": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.1.tgz", + "integrity": "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==", + "dev": true, + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nwsapi": { + "version": "2.2.28", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.28.tgz", + "integrity": "sha512-IlVB7OS7qrOsVYlpnFIkETjMwT9jwvmocJmmM+GZU/PAB3uGi9Ezd7vcWhWBUnSc0ya4ppmQITOyP1ez9gg8cg==", + "dev": true, + "license": "MIT" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ws": { + "version": "8.22.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.1.tgz", + "integrity": "sha512-oyL6OPYme/CmiFvNuGzeoxCJ4nOrZE2O1FwM9MWM1AsGri6nLXB2pg+lNA8aLeXMSav0J6eIVS1j6vUPzUtK9g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/docs/design/skin-system/package.json b/docs/design/skin-system/package.json new file mode 100644 index 00000000..ffb83174 --- /dev/null +++ b/docs/design/skin-system/package.json @@ -0,0 +1,21 @@ +{ + "name": "mxu-skin-system-design", + "private": true, + "version": "0.1.0", + "type": "module", + "engines": { + "node": ">=20" + }, + "scripts": { + "generate": "node scripts/generate.mjs", + "check:generated": "node scripts/generate.mjs --check", + "check": "npm run check:generated && node tests/check.mjs", + "check:browser": "node tests/browser.mjs", + "check:integration": "node tests/integration-browser.mjs" + }, + "devDependencies": { + "jsdom": "26.1.0", + "marked": "17.0.1", + "playwright": "1.62.1" + } +} diff --git a/docs/design/skin-system/protocol.md b/docs/design/skin-system/protocol.md new file mode 100644 index 00000000..16cdbfa0 --- /dev/null +++ b/docs/design/skin-system/protocol.md @@ -0,0 +1,458 @@ +# MXU Skin Protocol 0.1 草案 + +> 状态:通用协议设计讨论稿,字段命名、精确 schema 和兼容性承诺尚未冻结。本 PR 另将白饭鲸鱼娘与奶蛙作为可信内置卡片接入主程序设置与任务视图(见 §9.3),不代表完整 L0/L1 运行时已经实现。配套 [离线 demo](demo/index.html) 用于展示八类视觉与交互方向,不是通用皮肤运行时。 + +## 1. 技术选择与职责 + +MXU 已使用 React 19、TypeScript、Vite、Tailwind、Zustand,以及 Tauri 2 / Axum。建议继续使用浏览器的渲染能力:Grid/Flex 处理布局,CSS 变量处理主题,SVG、CSS animation 和 Web Animations API 处理角色与特效。第一版不需要自研绘图引擎、物理碰撞系统或另一套通用布局语言。 + +皮肤描述外观、装饰位置和视觉响应;宿主维护真实控件的语义、可用状态与业务行为。启动、停止、设备选择等继续使用 MXU 的真实 React 控件,保留键盘操作、无障碍语义与既有业务判断。皮肤依赖公开契约,不依赖内部组件的 CSS 类名或 DOM 结构。 + +```text +作者提供 MXU 宿主 Web 渲染能力 +skin.json 包校验、稳定锚点 真实业务控件 +HTML / CSS / SVG / assets → 布局占位、图层合成 → 隔离装饰层 + 受控状态与事件 Grid / Flex / CSS / SVG +``` + +这里的 HTML/CSS 自定义主要指装饰场景和公开的样式变量。任意 HTML 接管应用、修改私有业务 DOM,不属于第一版皮肤能力;更高权限的应用插件应有独立契约。 + +## 2. 能力分级与开放节奏 + +| 层级 | 作者提供 | 表达能力 | 建议顺序 | +| ----------- | --------------------------------------- | ------------------------------------------ | ------------------------------------------------------------------------------- | +| L0 · Tokens | 通过宿主注册表校验的 JSON 值 | 明暗模式、强调色,以及逐步开放的外观参数 | 先实现统一注入与兼容,再提供内置 token-only 皮肤 | +| L1 · Scene | 受限 HTML/CSS/SVG、本地资源、声明式绑定 | 角色动画、装饰越框、布局占位、任务状态联动 | 先以随 MXU 发布且经过审阅的内置场景验证;第三方来源等隔离与安装门槛完成后再开放 | +| L2 · Script | 独立授权的隔离脚本 | 程序化角色、复杂粒子与可选 WebGL | 仅预留;关闭 L2 不代表 L1 已安全 | + +协议仍描述完整目标能力,但不再把“L1 的设计存在”等同于“第一阶段可以安全导入第三方场景”。建议按 L0 与宿主契约 → 内置 L1 → 第三方导入推进;具体版本号和发布时间待维护者决定。WebUI 浏览器模式默认不加载第三方场景,须单独通过其部署模式的验证。 + +### 2.1 来源与项目打包 + +这是皮肤协议,不是某一套具体皮肤。支持三种来源的目标不变:MXU 内置、各项目随项目包附带、用户自行导入。项目可以在自己的资源包中放置皮肤清单与资源,并提出默认皮肤建议;发现目录与 ProjectInterface 的对接字段尚待讨论,不在本 PR 中私自扩展 PI 协议。 + +来源不扩大权限:项目附带的 HTML/CSS/SVG 也属于第三方内容,必须经过相同的校验和开放门槛。内置包同样遵循清单与资源校验,但早期只用经审阅且随程序发布的内置场景验证运行时。 + +身份按 `(来源, 项目标识或用户命名空间, skin.id)` 隔离;只用包内 `id` 不能覆盖其他项目或内置皮肤。项目更新不能静默替换用户已选的另一个来源,也不能通过“内置于项目包”绕过版本锁定。 + +### 2.2 Token 准入与优先级 + +现有 `applyTheme` 使用 `document.documentElement.style.setProperty` 写入变量。L0 必须通过宿主统一的解析、合成、注入通道应用;不能让皮肤插入 `:root` 样式与内联变量竞争,更不能要求作者使用 `!important`。皮肤文档内 CSS 只影响装饰自身。 + +| 类别 | 准入与类型 | 优先级和边界 | +| -------------------------------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------- | +| 背景、正文、边框、强调色 | 注册的语义变量;首版可只接受 `#RRGGBB`,拒绝 CSS 表达式、URL、任意属性名 | 宿主默认 → 皮肤明暗默认 → 用户明确覆盖;宿主最后检查文字/控件对比度 | +| 圆角、间距等后续外观项 | 每项单独注册数值范围和单位;未注册则拒绝 | 不改变控件最小点击尺寸或布局保护区;不能把“沿用变量”解释成开放任意 CSS | +| success / info / warning / error | 宿主拥有的状态语义,不开放任意重映射 | 当前 success/info 跟随 accent 是既有配色策略,不是状态混淆;适配时必须保留文字/图标与可辨识性,高对比策略优先 | +| 背景资源及透明度 | 经包资源索引解析;透明度为有界数值 | 用户现有背景设置继续优先;装饰与背景合成后仍需验证对比度,不能拼接原始文件 URL | +| 明暗模式 | 宿主解析应用设置与系统偏好后下发 `data-mxu-theme` | 皮肤不得另行读取 `prefers-color-scheme` 决定模式,避免应用设为浅色而皮肤随系统变深 | +| 高对比、减少动效 | 宿主强制策略;减少动效取系统请求与应用设置的并集 | 覆盖皮肤默认与普通用户外观覆盖,不由皮肤关闭 | + +现有少数 `dark:` 样式尚可能使用 Tailwind 默认的系统媒体查询;接入前需统一应用主题属性/变体,不能声称这一点已修复。新增宿主文案须覆盖项目现有五个语言包,不能只验证中文短标签。 + +## 3. 皮肤包与 Manifest + +拟议的 `.mxuskin` 是 ZIP 容器,根目录必须有 `skin.json`。文件扩展名只用于识别容器;清单决定格式版本。最小皮肤可以只有清单,复杂皮肤再附带场景和资源。 + +```text +little-trouble.mxuskin +├── skin.json +├── scene.html +├── scene.css +├── preview.webp +└── assets/ + └── mascot.svg +``` + +以下清单也保存为 [mascot.skin.json](demo/examples/mascot.skin.json)。它是协议示例,不是可安装成品;其中的语义锚点、槽位、场景状态及配套资源仍需宿主和皮肤作者实现。 + +```json +{ + "schemaVersion": "0.1", + "id": "studio.example.little-trouble", + "name": { + "zh-CN": "捣蛋小怪兽", + "en-US": "Little Trouble" + }, + "version": "1.0.0", + "mxu": { + "skinApi": "0.1" + }, + "capabilities": { + "required": ["tokens.v1"], + "optional": ["scene.l1", "reservation.edge.v1", "bleed.stage.v1"] + }, + "fallback": { + "mode": "tokens-only" + }, + "tokens": { + "light": { + "--color-accent": "#7357d8" + }, + "dark": { + "--color-accent": "#b6a4ff" + } + }, + "scene": { + "entry": "scene.html", + "initialInteractionState": "rest", + "interactionStates": ["rest", "mischief"] + }, + "placements": [ + { + "id": "mascot", + "element": "mascot", + "anchor": "toolbar.primary", + "box": "inline-start", + "nudge": { + "inline": -8, + "block": 0 + }, + "layer": "ornament-front", + "bleed": { + "blockStart": 48, + "inlineEnd": 16, + "blockEnd": 12, + "inlineStart": 24 + }, + "hitTest": "none" + } + ], + "reservations": [ + { + "id": "mascot-space", + "slot": "toolbar.actions", + "edge": "inline-start", + "size": 112, + "maxFraction": 0.28, + "when": { + "interaction.state": "mischief" + } + } + ], + "bindings": [ + { + "event": "host.hotspot.activate", + "target": "mascot-poke", + "setInteractionState": "mischief" + }, + { + "event": "interaction.timeout", + "state": "mischief", + "afterMs": 2400, + "setInteractionState": "rest" + } + ], + "hotspots": [ + { + "id": "mascot-poke", + "placement": "mascot", + "label": { + "zh-CN": "让团子挤一下", + "en-US": "Make room for the mascot" + }, + "minTargetSize": 44 + } + ] +} +``` + +| 字段 | 拟议语义 | +| ------------------------------------ | ----------------------------------------------------------------------------------------------- | +| `schemaVersion` / `mxu.skinApi` | 清单格式版本与宿主皮肤接口版本;与皮肤自身的 SemVer `version` 分离。0.x 不假设跨 minor 兼容。 | +| `id` / `name` | 稳定的反向域名式标识与本地化名称。同 ID 更新须显式选择,不能静默覆盖另一来源。 | +| `capabilities.required` | 缺少任一必需能力则拒绝启用。 | +| `capabilities.optional` / `fallback` | 缺少可选能力则移除对应表现并按清单降级;本例可保留 tokens。各能力的依赖关系须由宿主注册表定义。 | +| `tokens` | 仅接受宿主注册的变量及对应类型;色值不能替换为任意 CSS。用户的明暗模式偏好继续有效。 | +| `scene` | 场景入口与有限交互态列表,入口只能引用包内资源;任务态不在这里定义。 | +| `placements` | 场景元素绑定到宿主扩展盒,声明小幅 nudge、图层和最大越框量;由宿主执行定位。 | +| `reservations` | 向宿主边缘槽位申请布局空间。`size` 是请求值,`maxFraction` 是作者上限;宿主还要施加自身上限。 | +| `when` | 只对公开字段做相等匹配,不执行 JavaScript 表达式;本例用在占位请求上。 | +| `bindings` | 仅将白名单事件映射到瞬时交互态;定时器由宿主管理,不能写任务态。 | + +`hotspots` 是宿主生成的真实可聚焦按钮清单,包含稳定 ID、本地化名称、目标装饰和最小目标尺寸;不是允许场景元素自行处理业务点击。44 CSS px 是建议起点,实际还受宿主布局和无障碍策略约束。空间不足或会覆盖保护区时不生成 hotspot,并在宿主皮肤控制区提供等价按钮。 + +每个 placement 使用清单内唯一的 `id`;`hotspots[].placement` 引用该 ID,`element` 则引用场景中的装饰元素,两者不能混用。清单必须验证引用的元素、交互态、placement 与 hotspot 是否存在。缺少任一依赖能力时整组相关装饰/占位/hotspot 降级,不能留下孤立按钮或占位。下面的样式只是表现示例,省略资源引用: + +```html + +``` + +```css +/* 均由宿主的可信渲染层投影;皮肤文件本身不运行脚本。 */ +[data-task-state='running'] .mascot-motion { + animation: breathe 2.8s ease-in-out infinite; +} +[data-interaction-state='mischief'] .mascot { + transform: rotate(-8deg); +} +[data-task-state='error'] .mascot-motion, +[data-task-state='cancelled'] .mascot-motion, +[data-reduced-motion='true'] .mascot-motion { + animation: none; +} +@keyframes breathe { + 50% { + transform: translateY(-6px); + } +} +``` + +任务态与交互态分别作用于内外包裹层。示例不再用 `running → cheer → timeout → idle` 共用一个状态变量:2.4 秒结束后只恢复 `interaction=rest`,持续任务态仍是 `running`。错误、取消和恢复提示优先于趣味交互。 + +## 4. 真实占位与装饰越框 + +### 4.1 Anchor:宿主给出可排布的扩展盒 + +宿主发布语义锚点实例,至少包含 `anchorId`、`instanceId`、皮肤 `generation`、布局 `revision`、锚点矩形、允许的扩展盒与保护区。只注册当前视图中可见且有效的实例;隐藏 Toolbar、卸载节点和旧视图不能成为可选锚点。CSS 类名与 DOM 查询不是协议。 + +| 字段 | 首版候选值 | 执行方与限制 | +| ------------------------------ | ----------------------------------------------------------------------- | ----------------------------------------------------------- | +| `box` | `inline-start` / `inline-end` / `block-start` / `block-end` / `overlay` | 宿主为特定锚点发布其中的允许子集;作者在盒内用普通 CSS 排布 | +| `reservations.edge` | `inline-start` / `inline-end` / `block-start` / `block-end` | 仅限槽位支持的边;不做任意二维碰撞 | +| `placements.layer` | `background` / `ornament-front` | 仅是宿主开放的装饰层;popover/modal/recovery 层不能选择 | +| `placements.hitTest` | `none` | 皮肤装饰不抢事件;交互由独立 `hotspots` 清单交给宿主 | +| `nudge.inline` / `nudge.block` | CSS px 数值,建议各轴限制在 -16 到 16 | 只允许小幅视觉校正;不授予逃离扩展盒/保护区的能力 | +| `bleed` | 四个非负逻辑边:`blockStart` / `inlineEnd` / `blockEnd` / `inlineStart` | 是最多可以伸出的距离,不是任意 z-index、占位请求或输入权限 | + +矩形使用宿主视口的 CSS px,不使用设备像素;逻辑边由宿主按方向/书写模式解释。宿主把 `getBoundingClientRect()` 得到的坐标转换到对应装饰层坐标,处理页面滚动、容器偏移及缩放;作者不能自行混用屏幕坐标。布局与画面都使用同一 revision 的已授予盒,旧数据丢弃。含变换容器的转换方案及几何断言需由实现验证。 + +上述是待评审的最小枚举,不是已经冻结的兼容契约。 + +### 4.2 Reservation:角色占空间,按钮真的移动 + +角色进入捣蛋状态时,宿主在 `toolbar.actions` 的逻辑起始边插入占位轨道,让 Grid/Flex 中的真实按钮重新排布;退出后释放空间。控件的 DOM 身份、焦点与业务事件处理器保持不变。 + +只给角色或按钮添加 `transform` 不会改变兄弟元素布局,因此不能替代真实占位。 + +v0.1 建议只支持边缘槽位和面板 inset,不支持任意多边形障碍、二维碰撞或物理推挤。可用宽度不足时先缩小占位;仍不足时返回零占位,让角色移到备用锚点或隐藏。多个请求按稳定优先级分配,合计不得侵占宿主控件最小宽度。 + +宿主返回实际分配的 `acceptedSize` 与原因,场景按实际结果摆位,不能按“申请 112 px”绘制而按钮只让出 40 px。用户正按住按钮、拖拽或进行键盘操作时,应延后布局变化,避免控件从手下移走。 + +### 4.3 Bleed:越过面板边框,仍在应用视口内 + +机甲由宿主装饰合成层绘制,跟随被锚定面板的位置,绕过该卡片及其滚动容器的裁切。此能力依赖先完成统一壳层与 portal/层叠上下文治理,不能只在现有 App 根节点插入一个高 z-index 元素。`bleed` 声明最大越框范围;最终允许区域取皮肤请求、宿主安全区和视口边界的交集。装饰越框不参与布局,也不会自动推开其他面板。 + +`overflow: visible` 无法穿过 iframe、WebView 或原生窗口边界。隔离装饰 renderer 应覆盖宿主允许的 stage,不能把大角色塞进一张小卡片 iframe 后期待它自行越框。 + +本提案中的“破框”指应用内面板。伸到操作系统桌面需要额外透明窗口与系统级命中处理,不属于 v0.1。 + +### 4.4 图层、保护区与命中 + +以下是需要宿主实现的目标合成顺序,不代表现有代码已有这些层: + +```text +background → content → ornament-front → host-popover → host-modal → emergency-controls +``` + +皮肤只能选择开放的层,不能提交任意全局 `z-index`。前景装饰默认 `pointer-events: none`;开始、停止、错误处理、恢复默认皮肤等保护区,以及键盘焦点环,不能被装饰遮挡。 + +保护区还包括原生窗口拖动/关闭区域、右栏 resizer、Schedule/权限弹窗和被打开的菜单。宿主设备统计和任务结果不能因为皮肤构图被隐藏;窄窗口需要另有固定文本摘要。恢复入口应位于不依赖皮肤成功渲染的通路。 + +“戳一下角色”由宿主创建带本地化名称、焦点环及 Enter/Space 行为的真实按钮,并验证其区域不覆盖关键控件。透明区域不应拦截指针,场景文档不得接管全屏鼠标。只有已验证的 host hotspot 事件进入 bindings,任何点击不能绕过启动/停止的既有业务校验。 + +## 5. 状态快照与瞬时交互分层 + +皮肤仅接收裁剪后的公开视图模型,不读取完整 Zustand store、任务密码、原始日志、文件路径或设备凭据。建议由宿主适配层持续投影只读快照: + +```text +{ generation, revision, runId, + task: { state, progress }, + preferences: { theme, reducedMotion }, + layoutRevision } +``` + +`revision` 在当前 generation 内单调递增;宿主丢弃旧快照和旧 runId 的迟到回调,创建新实例时先发完整快照。相同 revision 的重复消息不重放动画。`progress` 为 `[0,1]` 或 `null`(未知),不能从时间动画伪造进度或剩余时间。 + +### 5.1 只暴露有事实来源的任务状态 + +当前 `TaskRunStatus` 是 `idle / pending / running / succeeded / failed`,实例 `TaskStatus` 与它也不是同一层级。冻结前须建立实例/任务范围的映射表,不能把当前任务成功误当成整个队列成功。 + +| 皮肤视图状态 | 适配要求 | +| ------------------------------ | ---------------------------------------------------------------------------------------------- | +| `idle` / `pending` / `running` | 从当前运行实例、排队/启动事实派生,不能只看按钮文字 | +| `success` / `error` | 对同一 runId 的完成结果映射 `succeeded` / `failed`,核对其是单任务还是批次结果 | +| `cancelled` | 仅在适配层记录显式停止意图并确认该次运行终止后开放;当前停止清成 `null` 不能直接当作取消或成功 | +| `paused` | 仅有真正暂停/恢复语义及事实来源时开放;当前生产适配器不能因为 demo 有暂停按钮就宣布支持 | + +`cancelled` / `paused` 是目标能力,需能力声明或后续宿主状态机支持,不列为现有 MXU 必有状态。能力缺失时不展示相关皮肤状态;尚无法区分终止原因时显示中性的“已结束”,不能伪报成功。 + +### 5.2 任务态不被互动计时器改写 + +bindings 只改 `interaction.state`,不能写任务状态。最终显示由宿主任务快照与临时交互态共同决定;错误/取消/恢复提示优先,趣味动作可被抑制或清除。 + +处理规则建议:快照按 revision 接收;激活 hotspot 进入 `mischief` 并由宿主启动 2400 ms 的交互定时器;重复激活重置同一个定时器;超时只回到 `rest`。进入终止态时同时取消旧定时器、恢复 `interaction=rest` 并释放该交互态申请的占位;切换 generation 或卸载时也清除交互态和占位。即使超时事件与任务快照同帧到达,也不影响任务事实。 + +业务操作仍由真实宿主控件触发。未来如开放 `requestAction("task.start")`,必须校验实例、动作白名单、业务状态与真实用户手势,并继续经过既有启动流程;不开放通用 invoke、任意 HTTP、文件读写或 store setter。 + +L1 作者不编写消息 SDK:可信宿主读取声明并管理布局/热点,把状态与有限属性单向投影到无作者脚本的渲染树。具体跨文档投影机制须满足第 7 节隔离门槛;不能为了接收状态就默认给不可信内容帧开放 `allow-scripts`。跨源、无脚本的 iframe 不能自行通过 postMessage 更新 CSS,宿主也不能直接访问其 DOM;可信控制域与内容树如何投影状态、重建是否重置动画,都需要隔离原型证明,不能把“双帧”当作现成 SDK。 + +### 5.3 趣味反馈的组合与优先级 + +新增的白饭鲸鱼娘与奶蛙场景不需要增加任务状态、执行作者脚本或把角色动作写回业务。它们分别把端碗干饭与捧腹大笑作为表现层,仍消费同一份宿主快照。推荐组合顺序如下: + +本轮两套皮肤只复用公开素材中的动作,不为角色添加对白、拟声台词或额外剧情。任务状态和互动按钮是宿主界面信息,不借角色口吻表达。 + +下表包含目标协议状态和离线 demo 能力;主程序本轮内置接入仅映射已有事实支持的 `idle` / `running` / `success` / `error`,不因 demo 存在相应按钮就新增生产 `paused`、`cancelled` 或百分比进度。 + +| 宿主事实 | 角色表现与互动规则 | +| -------- | ------------------ | +| `idle` | 可以预览吃饭或大笑;标明这是角色互动,不会启动任务。 | +| `running` | 允许叠加短暂互动,但任务文字、进度和停止入口持续保留;互动结束后回到当前运行表现。 | +| `paused` | 停止工作循环与持续表演,保留明确的暂停文字;不能靠改动画速度模拟业务暂停。 | +| `success` | 只对已确认完成的同一 `runId` 显示庆祝;单任务成功不能提前触发整批完成。 | +| `error` / `cancelled` | 清理旧互动并优先显示失败或取消;停止捧腹狂笑、吃饭庆祝等容易误读的反馈。 | +| 恢复模式或场景不可用 | 宿主恢复入口优先,隐藏角色并释放占位,直接使用默认外观。 | + +进度表现只读取宿主的已知进度。`progress: null` 时应显示不确定状态,不能用吃饭次数、大笑次数或动画时长推算百分比、任务数量和剩余时间;也不把这些计数写成角色设定。 + +交互结束、重复激活、切肤和卸载应分别测试。超时只清理其所属场景实例的 `interaction`,不清空任务、不重新启动任务计时器;新场景先消费当前任务快照。系统减少动效时保留静态角色、明确状态与等价热点按钮,停止摇晃、弹跳和动态视频。音频不是这两套场景的必需能力;若以后加入,须另行定义用户手势、静音偏好、停止和卸载清理,不能让主题切换触发自动播放。 + +本地视频同样由宿主管理播放生命周期,不能因嵌入媒体而要求开放作者脚本或外部网络。减少动效、页面隐藏、场景卸载与终止表演时暂停视频;恢复可见时仅在当前状态仍需要表演的情况下继续。视频加载、格式或播放失败时保留本地静态图与任务文字,不阻断业务操作。媒体资源的 MIME、解码预算和来源记录仍须纳入包校验。 + +## 6. 宿主的五项职责 + +| 模块 | 职责 | +| ------------------- | ----------------------------------------------------------------------------------- | +| `SkinLoader` | 解包、schema 校验、版本协商、资源索引与可回滚启用。安装完成不等于立即替换当前皮肤。 | +| `AnchorRegistry` | 发布稳定语义锚点及矩形,用 ResizeObserver、滚动监听与合并后的帧更新同步位置。 | +| `LayoutCoordinator` | 分配边缘占位、保护控件最小尺寸、返回实际分配结果,响应缩放、语言和布局变化。 | +| `SkinStage` | 管理装饰图层、越框裁切、保护区、hotspot、动效生命周期与受信任恢复入口。 | +| `SkinBridge` | 从 Zustand 派生只读状态,验证消息、调度声明式事件并清理卸载资源。 | + +卸载皮肤时必须清除定时器、观察器、资源 URL、占位与事件订阅。启用与健康标记应由原生侧持久化。下一次启动在任何第三方皮肤求值之前判断安全模式,回退到内置主题;窗口显示和恢复入口不能依赖皮肤初始化完成,同时保留现有自启动隐藏窗口的用户偏好。正常换肤不能中断真实任务。 + +## 7. 第三方导入的隔离与安装门槛 + +### 7.1 无作者脚本不等于没有攻击面 + +L1 内容文档不执行作者脚本,可信适配器、业务控件与 hotspot 留在宿主域。HTML/CSS 仍可发起资源请求、导航或造成昂贵渲染,因此只禁用 JS 远远不够。Shadow DOM 仅隔离样式;iframe 标签、CSP nonce/hash 和 `origin === "null"` 均不能代替权限边界。 + +撤回原稿把“同文档可信 bootstrap + allow-scripts + nonce/hash”作为默认隔离方案的写法。nonce/hash 可以限制脚本执行,但不会分离同一文档中的数据与权限,也不能保护其他可达的本地 API。它并非在任何使用方式下都必然可绕过;本提案不依赖这一争论来宣布隔离成立。 + +主窗 `csp: null` 意味着没有可继承的主窗策略;子文档仍可施加更严格策略。第三方内容应由独立资源响应提供明确、可测试的 CSP,而非只靠父文档继承或皮肤提供的 meta。若使用多帧,可信控制外壳与无脚本内容帧必须分离;双帧本身也不证明安全。 + +### 7.2 必须验证的四条权限路径 + +| 路径 | 当前事实与开放前要求 | +| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Tauri 插件和原生 IPC | 主窗有广泛文件/网络能力;ACL 与 window/webview label 和 origin 相关,DOM iframe ID 不是独立 capability 边界。独立 WebView、不同源等都只是候选手段,必须证明其拿不到宿主原生权限。 | +| 应用自定义命令 | 除插件 capability 外,还要核验 `invoke_handler` 中应用命令的授权。不能假设“不给新 WebView 插件权限”就封住全部自定义命令;需显式命令范围/来源校验或对应 AppManifest 配置并实测。 | +| 本地 Axum / WebUI API | 当前 Web 服务路由包含配置写入和任务控制,CORS 允许任意 origin 且该路由栈未见认证层。CORS 不是认证。要避免第三方渲染域访问控制面,需控制 API 的鉴权、授权与请求来源,并切断皮肤网络访问;不能只隐藏 Tauri 全局对象。 | +| 资源/导航出口 | 仅可读取包资源索引;阻止 fetch、WebSocket、图片/CSS/字体外联、表单、重定向、弹窗与顶层导航等路径,覆盖 loopback 地址和其他本地服务。资产源与控制 API 分开,皮肤不得收到控制 API 的随机会话凭据。 | + +这里的 API 风险以应用当前权限为界;“重启为管理员”仍经过操作系统提权流程,不能表述成无条件获得管理员权限。实际 Tauri/Wry 版本与平台对初始化脚本、子帧注入有差异;源码中的 main-frame-only 标志不是跨平台隔离承诺。 + +Windows WebView2、macOS WKWebView、Linux WebKitGTK,以及 WebUI 的目标部署方式,需要分别做最小隔离原型与负向测试。`__TAURI__` / `__TAURI_INTERNALS__` 不可见可作为检查项,但不是充分证明:还要验证原始 IPC、自定义命令和本地 API 实际被拒绝。不存在这些证据时保持第三方场景关闭。 + +权限边界可对照 [Tauri capabilities 文档](https://tauri.app/security/capabilities/)、[Tauri 2.9.5 WebView 源码与平台备注](https://github.com/tauri-apps/tauri/blob/tauri-v2.9.5/crates/tauri/src/webview/mod.rs) 及 [CSP3 安全考虑](https://www.w3.org/TR/CSP3/#security-considerations) 核对;文档说明与静态源码检查都不能替代目标平台的负向实测。 + +### 7.3 专用安装通路 + +| 边界 | 拟议约束 | +| ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 解包器 | 单独实现皮肤 ZIP 导入,不复用应用升级用的通用 `extract_zip`,不接收 tar.gz。拒绝绝对路径、`..`、符号链接、重复/大小写冲突、跨平台保留名/特殊文件和超限条目;校验解码后的路径及实际写入目标。 | +| 预算 | 初始建议压缩 20 MiB、实际解压 60 MiB、256 文件、单文件 16 MiB;流式计算实际写入字节,不能信任 ZIP 头中的长度。限制节点、像素解码量与昂贵 CSS 效果。 | +| 内容 | 校验实际 MIME;SVG 移除脚本、事件、外链和 foreignObject;用成熟解析器及 allowlist 处理 HTML/CSS,遍历嵌套规则、URL、字体和 `@import`,不用正则代替消毒器。 | +| 内容身份 | 计算实际包/版本内容摘要,完整性校验覆盖保存与读取;摘要只有和可信来源记录绑定才提供来源保证,不把作者自报哈希当签名。 | +| 安装/启用 | 暂存校验 → 不执行未批准内容的预览 → 写入不可变版本目录 → 原子更新启用记录。使用独立皮肤目录和版本清单,不用符号链接表达 current。失败保留上一个可用版本。 | +| 回滚与清理 | 建议至少保留最近两个已知可用版本;回滚记录与健康标记不放到现有启动清理的临时更新目录。清理必须理解引用关系,不能删除当前/回滚版本。 | +| 更新 | 首版不做自动或静默皮肤更新;新版本重新校验、显式启用,用户选择锁定来源和版本,不能借更新增加权限。 | + +当前应用更新工具支持的归档类型与校验预算不等同于皮肤安装要求;本 PR 不顺带改造应用更新系统。内置和项目附带的资源仍走对应校验,来源不豁免内容约束。 + +### 7.4 开放第三方场景的验收门槛 + +以下全部完成后再讨论默认启用:隔离域三平台负向实测;原生 IPC/自定义命令/控制 API 权限隔离及网络出口限制;专用解包与资源校验;不依赖皮肤的原生启动恢复;锁定版本、显式更新与回滚。WebUI 另做独立验收。L2 需要重新评审,不是给 L1 随手添加一个 `allow-scripts` 开关。 + +## 8. 兼容、降级与动效预算 + +宿主先检查 schema 与必需能力,再检查锚点。当前视图暂时没有某个锚点时,隐藏对应装饰并释放占位。缺少可选场景能力可以降为 L0;未知必需能力必须拒绝。新增字段需在 schema 中明确标为可选,不能静默容忍字段拼写错误。 + +基础方案优先使用 Grid/Flex、CSS 变量、SVG 与经典关键帧。CSS anchor positioning、复杂滤镜、WebGL、Lottie/Rive 等可在有实际需求时作为独立能力与适配器添加,不把协议绑定到某个动画库。 + +- **可操作性**:保留文字、键盘焦点、足够对比度与直接操作入口。纯装饰对读屏隐藏;hotspot 具有名称和键盘行为。 +- **减少动效**:尊重系统 `prefers-reduced-motion` 与 MXU 手动设置。关闭循环弹跳、强闪光、大幅视差;静态模式仍表达任务状态。 +- **布局预算**:只在状态变化时调整占位,避免每帧重排模拟物理。指针交互期间延后移动关键控件。 +- **动画预算**:初始建议最多 4 个持续动画角色;状态与锚点更新合并到每帧一次。离屏、隐藏窗口与后台暂停表演。 +- **退化路径**:持续超预算时关闭昂贵滤镜/粒子、改静态图,最终卸载场景保留 tokens。JS watchdog 不能中断任意 CSS 渲染卡顿,导入前还需限制节点与昂贵效果。 + +这些数值是实现起点,不是测得的性能承诺。验证需覆盖五种语言与长标签、850×525 最小窗口、125%/150% 缩放、面板折叠、移动 WebUI、减少动效与运行中换肤。需要真实浏览器/WebView 的静态布局 harness 验证扩展盒、碰撞与焦点/点击保护区;jsdom 无法证明这些几何性质。字体、字重与颜色组合须以可读性和 WCAG 对比度检查验收,不能把示意图的小字号当产品规范。 + +## 9. 接入现有 MXU 的前提 + +下表以本设计 PR 的基线 `a05a112` 说明通用皮肤运行时的接入前提。§9.3 单独列出本轮已实现的两套可信内置卡片;不能将该有限接入当作表中全部改造已经完成。 + +| 现有代码 | 应先完成的工作 | +| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [`src/themes/index.ts`](../../../src/themes/index.ts)、[`src/index.css`](../../../src/index.css) | 保留已有主题行为,以 token registry 统一入口和优先级;核对自定义强调色、背景透明度、主题变体与无障碍策略。 | +| [`src/App.tsx`](../../../src/App.tsx) | 设置/加载/主页面有互斥布局路径,裁切容器与 stacking context 多处存在。先设计统一 AppShell 和 portal 层策略,明确每个视图支持哪些装饰层;不是加一个根级 Stage 就完成。 | +| [`Toolbar.tsx`](../../../src/components/Toolbar.tsx) | 三处 JSX 使用位置并非运行时同时挂载三份,但设置页存在隐藏实例。注册锚点必须按可见实例和 ref/id 管理,清理隐藏实例的皮肤订阅,不借用业务全局快捷键监听作桥接。 | +| 弹窗、菜单、resizer 与标题栏 | 统一宿主层级,保留权限/Schedule 弹窗、拖动区域、停止操作与恢复入口的保护区;现有 z-50/z-[100]/z-[9999] 和 portal 需逐一映射。 | +| [`src/stores/appStore.ts`](../../../src/stores/appStore.ts)、[`src/stores/types.ts`](../../../src/stores/types.ts) | 为任务/批次建立只读 VM 适配表,明确状态来源、runId、revision 和终止原因;不能直接枚举尚无事实来源的 paused/cancelled。 | +| [`src-tauri/src/lib.rs`](../../../src-tauri/src/lib.rs)、[`windowUtils.ts`](../../../src/utils/windowUtils.ts) | 当前窗口起初隐藏,正常显示依赖前端初始化。原生侧需在皮肤求值前处理启用健康标记/安全模式,并提供不等皮肤成功的显示与恢复通路;当前并不存在一个可直接挂钩的 Rust setup `show()`。 | +| [`web_server.rs`](../../../src-tauri/src/web_server.rs)、[`tauri.conf.json`](../../../src-tauri/tauri.conf.json)、[`capabilities/default.json`](../../../src-tauri/capabilities/default.json) | 分别验证本地控制 API、资源 origin、CSP、插件与自定义命令权限,不能互相替代。 | + +### 9.1 偏好与版本的存储域 + +桌面现有外观配置是按项目保存,WebUI 有独立的 origin-local 外观偏好且不会直接回写覆盖桌面。这些行为应保留兼容,不能新增一个全局 activeSkinId 后默默改变用户的项目偏好。 + +建议明确区分桌面本机默认、用户对特定项目的显式覆盖,以及项目包的推荐值。初次启用项目皮肤需要用户选择“跟随项目推荐”;没有选择时不因项目更新强制换肤。解析优先级为用户项目覆盖 → 用户本机默认 → 已授权跟随的项目推荐 → MXU 内置回退。记录来源与版本,项目身份需稳定且可迁移。 + +WebUI 的选择仍是该浏览器客户端的偏好,不广播到桌面或其他浏览器。已有后端配置 revision/CAS 可复用到其实际负责的设置域,不需要另造一套全局写入机制;皮肤公开状态的 generation/revision 是运行时消息时序,与持久配置 revision 不混用。切项目与多客户端并发策略需在实现前固定。 + +### 9.2 建议的实施顺序 + +1. 先补 token 注册/优先级、统一壳层与图层计划、VM 事实映射和恢复设计;内置 L0 可先落地。 +2. 实现稳定锚点、扩展盒与占位、宿主 hotspot,在经审阅的内置 L1 上验证各类表现。 +3. 提供浏览器/WebView 布局 harness,测试最小窗口、缩放、语言、滚动、折叠、热点与运行中换肤。 +4. 完成第 7 节全部门槛后,才开放项目附带/用户导入的第三方场景;WebUI 单独放行。 +5. 有不可由 L1 表达的实际需求时,再设计 L2。 + +具体版本节奏待讨论,本稿没有将上述步骤变成发行承诺。所有新增宿主界面文案使用既有 i18n 体系。 + +### 9.3 本轮可信内置皮肤接入 + +主程序设置的外观分区增加默认、蓝色大肥鱼吃白饭、奶蛙三项选择,以及皮肤减少动效偏好。原有明暗模式、强调色、自定义强调色和背景图继续独立生效;内置皮肤选择不通过改写用户强调色实现。 + +`skinId` 仅接受 `default` / `deepseek` / `naiwa`,未知或旧配置中缺失的值回退为默认;`skinReducedMotion` 与系统减少动效请求共同约束角色表现。桌面沿用现有项目外观配置,WebUI 沿用本地外观存储及后端值缓存,避免浏览器本地选择回写覆盖桌面偏好。这不是第 9.1 节完整多来源、项目推荐和版本管理方案。 + +`BuiltinSkinCompanion` 由宿主 React 代码和前端随包资源实现,在主视图两个可见工具栏位置之前使用普通文档流卡片,设置页提供预览。默认皮肤不渲染角色。它不依赖隐藏 Toolbar 实例,不接管业务控件,也不创建任意高 z-index 覆盖层;图片、视频仅负责装饰,互动使用宿主真实按钮。换肤只卸载或更新表现,不改任务 store、启动流程或停止流程。 + +生产任务状态读取活动实例:`isRunning` 优先映射运行中,再将整批 `Succeeded` / `Failed` 映射成功 / 失败,其余中性显示为待机。停止流程清成 `null` 不被解释成成功或取消;不从单任务状态、动画时间、饭量或笑声计算百分比。角色临时互动只更新组件本地状态,不能覆盖当前任务事实。 + +资源固定来自 `src/assets/skins/`,不支持作者提供 HTML/脚本、不解释 `.mxuskin`、不提供第三方安装入口,也不增加原生或网络权限。媒体来源说明见 [两套皮肤说明](meme-skins.md)。本轮验证可信内置场景在现有宿主中的选择、持久化和真实布局,通用 token registry、Manifest 绑定、稳定锚点、保护区裁切与隔离门槛继续保留。 + +鲸鱼娘采用固定版本社区吃饭动画,原 VP9 alpha 经解码合成白底、移除音轨并转为 H.264,静态姿态从同源视频抽帧保留透明,本次不重绘角色。格式不受支持或播放失败时保留静态表现;不能以 Chromium 成功播放推断所有原生 WebView 都已通过验证。资产的 CC BY-NC-SA 4.0、署名与修改记录独立于代码许可。 + +## 10. 离线 Demo 能证明什么 + +八个离线场景分别展示角色占位、机甲越框、生态舱生长、黑胶旋转、完成印章、声呐扫描,以及白饭鲸鱼娘与奶蛙的趣味互动;另提供成功/失败/取消/重试的任务模拟、稳定的宿主状态摘要、进度条语义、宿主按钮热点、布局轮廓、减少动效、有限预设导入导出和 24 条扩展创意。它们用于讨论表现力、协议范围与操作手感;主程序本轮只接入 §9.3 的两套内置皮肤。 + +需要明确区分: + +- demo 是可信内置场景与本地模拟任务,未连接 MaaFramework,没有执行真实设备操作。demo 自己的暂停/取消/失败状态不代表现有生产 store 已提供同样事实。 +- 角色交互使用演示器拥有的真实 button,可由键盘激活;它展示宿主拥有交互语义,但尚未证明动态定位、保护区命中或跨隔离域消息安全。 +- `mxu-skin-demo/0.1` 仅包含名称、八种内置场景选择、受限十六进制色值、数值参数和效果开关;它不执行导入 HTML/CSS/JS,不是 `.mxuskin` 安装器。 +- 原六场景使用手动预览开关;其中团子的 Flex 占位证明真实重排,但未执行 Manifest 示例中的 2400 ms 自动复位。新两套白饭鲸鱼娘与奶蛙由演示宿主管理 2400 ms 互动计时器,重复触发只重置同一计时器,超时不改任务状态或进度,暂停与终态清理互动。它们尚不是正式 Manifest 绑定解释器;完整分配结果回传、交互期间延后移动、保护区裁切和隔离导入仍需宿主实现。 +- 奶蛙大笑使用包内无音轨视频,始终设为静音、循环和内联播放;仅角色互动或成功反馈需要表演时播放。减少动效或页面隐藏时暂停,`pagehide` 清理互动并停止、复位视频,`pageshow` 仅按当前状态恢复。播放失败时退回静态姿态与文字;闭口静态姿态直接取自原片第 1 秒。原片、海报及静态截帧的来源记录见 [两套皮肤说明](meme-skins.md)。 +- 鲸鱼娘吃饭使用既有社区视频转成的白底无声 H.264,并以同源透明抽帧作为静态姿态;它与奶蛙都没有角色对白。媒体来源、许可、作者署名和实际处理单独记录,不以代码许可替代资产许可。 +- demo 切换场景会重置模拟任务;生产换肤必须保持业务任务继续执行。 +- 概念切片与 Manifest 示例不是完整规范对象或可安装成品。 +- 静态设计图不是 MXU 实机截图。已完成的 DOM/事件检查不等于浏览器视觉、CSS 几何、跨平台或生产隔离验证。 + +## 11. 待讨论与冻结事项 + +正式实现前仍需共同决定(本文候选字段仍需 schema 与原型验证): + +1. L1 是否只承担装饰和主题,还是需要受控的业务卡片模板?完整布局替换是否另设能力? +2. 锚点、槽位、公开状态和能力注册表的最小集合,以及缺失能力的依赖降级顺序。 +3. 本文新增的最小候选枚举、分层状态与 hotspot 字段是否合适;仍需正式 JSON Schema、完整错误诊断与兼容性测试。 +4. 各平台隔离 renderer 的具体实现和原生权限验证方法。 +5. 多占位请求冲突、长标签与窄窗口下的布局分配规则。 +6. 作者预览、调试工具、静态打包与资源预算如何做得足够简单。 +7. 第一批内置皮肤优先验证哪些玩法,哪些效果值得先做成官方可复用组件。 + +欢迎在设计 PR 中补充想法、替代方案、示意图或具体皮肤需求;本稿的目标是把讨论变得具体,不是提前冻结实现。 diff --git a/docs/design/skin-system/review-notes.md b/docs/design/skin-system/review-notes.md new file mode 100644 index 00000000..0842c16a --- /dev/null +++ b/docs/design/skin-system/review-notes.md @@ -0,0 +1,43 @@ +# PR #431 Review 核对与本轮处理 + +核对对象为 [Windsland52 的评审](https://github.com/MaaXYZ/MXU/pull/431#pullrequestreview-5800475634)(2026-11-06)。首次核对时 PR 头为 `6e5f366`,本地生产代码为 `c321532`。该评审针对更早的 `a05a112`;PR 后续设计稿已吸收大部分意见。本轮在该设计基础上增加两套可信内置皮肤的设置与主视图接入。本文件区分已调整的设计、离线 demo 的证明范围、实际内置接入,以及尚未实现的通用生产能力。 + +| Review 问题 | 最新设计稿与本轮处理 | 尚需完成的工作 | +| ----------- | -------------------- | -------------- | +| **P0:iframe、nonce 不构成权限隔离** | 协议 §7 已撤回“同文档 bootstrap + allow-scripts + nonce”默认方案;明确无作者脚本、原生 IPC、自定义命令、控制 API、资源与导航出口分别验证。双帧和隐藏 Tauri 全局对象均不被当成充分证明。 | 主程序 `csp: null`、广泛主窗 capability 与本地控制 API 的 CORS/鉴权风险仍存在。本轮没有开放第三方皮肤或实现生产隔离。 | +| **P1:L0 与内联主题变量冲突** | §2.2 已给出类型准入、宿主统一合成入口、用户覆盖、语义色、高对比和减少动效优先级;明暗模式由宿主属性下发。本轮内置皮肤选择独立于现有主题、强调色与背景,不通过覆写用户强调色实现。 | 通用 token registry、统一主题变体,以及背景合成、五种语言与长标签验证仍需完成。现有 success/info 取 accent 是源码事实,不能仅凭同色认定业务状态混淆。 | +| **P1:任务态被互动 timeout 覆盖** | §5 已拆分任务快照和临时互动;只有 bindings 的 `setInteractionState` 可写。新增 §5.3 约束状态组合、终止态优先、未知进度和无动效降级;新两套场景实际使用宿主 2400 ms 互动计时器,重复激活重置同一定时器,超时不改任务,暂停/终态/切肤/pagehide 清理旧互动。原六场景仍保留手动预览。 | 生产适配器仍需 `runId`、generation/revision、迟到回调与实例/整批结果映射;demo 的本地任务状态不是现有 store 已支持暂停/取消的证明。 | +| **P1:锚点、坐标系、占位与热点不明确** | §4 已列最小枚举、CSS px 坐标、逻辑边、有限 nudge、实际授予尺寸和保护区;热点由宿主创建真实键盘按钮,装饰不接收事件。 | 正式能力协商、实际盒回传、保护区裁切、缩放和焦点/点击几何仍需宿主实现及真实浏览器/WebView 验证。 | +| **P1:壳层、图层、存储、启动恢复缺少接入计划** | §9 已列互斥顶层布局、隐藏 Toolbar、portal 与层叠上下文、项目/本机/WebUI 偏好优先级,以及独立于皮肤初始化的原生恢复要求。两套内置皮肤以普通布局卡片放在可见主视图 Toolbar 前;`skinId` / `skinReducedMotion` 沿用桌面项目配置和 WebUI 本地外观隔离存储。 | 本轮没有构建通用越框合成层或完整偏好来源解析器。不能把有限的内置卡片当作统一壳层;原生恢复和启动健康标记仍未实现。 | +| **P1:安装、更新与供应链** | §7.3 已明确专用 ZIP 解包器、实际字节预算、拒绝链接/冲突路径、不可变版本目录、原子启用、回滚与显式更新;第三方开放需 §7.4 全部门槛通过。 | 当前更新工具仍不是皮肤安装器。本轮不改动应用更新链路,不把演示预设 JSON 当作 `.mxuskin` 包。 | +| **文档和创意数据重复维护** | 原 PR 最新头已有 `protocol.md` → `demo/protocol.html`、`ideas.json` → `ideas.md` / `demo/ideas.js` 的生成器及一致性检查。本轮继续从源文件生成。 | 派生文件变更仍须通过 `npm run check:generated`。 | +| **Demo 可读性、隐藏统计、缺少失败/取消和语义** | 最新头已保留宿主状态摘要和机甲场景统计,增加进度条语义、阶段播报、宿主角色按钮、失败/取消/重试及系统/手动减少动效。两套新场景沿用这些直接操作入口。 | jsdom 无法证明字体、对比度和 CSS 几何;真实浏览器检查结果应与状态/DOM 检查分开记录,跨平台 WebView 仍待验证。 | + +本轮还把奶蛙表演改为本地三维大笑视频与静态姿态,由宿主管理播放与停止。视频已移除音轨,运行时保持静音;减少动效、页面隐藏、离页与切肤都会暂停,播放失败退回静态表现。形象、原片与海报来源记录见 [两套皮肤说明](meme-skins.md)。这验证的是可信内置 demo 的媒体生命周期,不代表已向第三方开放视频或脚本权限。 + +鲸鱼娘素材现改为 PC2005-cloud/dsh-pet 固定版本的公开视频:吃饭原片解码合成白底并转为无声 H.264,静态帧保留透明。按来源分别保留动画作者、原型与女仆版设计者的署名,记录 CC BY-NC-SA 4.0 和实际处理;源码许可证不被当作媒体许可证。奶蛙闭口静态姿态也改为其未裁剪原片第 1 秒的截帧。最终两套不包含本次生成或重绘的角色图,并移除虚构对白、拟声台词和额外剧情,只保留公开动作、客观任务状态与操作提示。 + +## 本轮主程序内置接入 + +用户要求可从设置切换这两套皮肤,因此本次不再仅修改设计目录:新增主程序内置选择、设置预览、任务视图卡片与随包本地素材。默认皮肤移除角色,明暗和强调色保留现有行为;换肤不改业务任务。 + +同时修复首次 WebUI 外观编辑的默认值来源:无本地记录时以已加载的后端外观作为起点,避免第一次换肤后刷新重置明暗、语言与强调色。空实例配置也通过统一导入恢复外观和全局设置,随后仍沿原流程创建初始实例。 + +任务表现优先取活动实例 `isRunning`,随后取整批 `Succeeded` / `Failed`,其余中性显示待机。不会把停止后的 `null` 当成成功、从 demo 搬入无事实来源的暂停/取消、或计算虚假百分比。角色互动只改组件本地表现;主程序的状态适配与离线模拟状态表分别说明在协议 §9.3 和 §10。 + +此接入由宿主 React 代码渲染固定资产,未开放第三方包、作者 HTML/脚本、原生命令或网络入口。第 7 节的安装、隔离与恢复门槛保持不变。 + +## 两处需要避免照搬的表述 + +- `App.tsx` 中有三处 Toolbar JSX 使用位置,但设置页、主视图和响应式分支并非同时可见的三份工具栏。应修的是可见实例注册和订阅生命周期,不能把源码出现次数当成运行时重复挂载证据。 +- 当前 `extract_zip` 已有 `ensure_update_path` 目标路径检查;这比原 review 基线有所进展,但仍保留 `tar::Archive::unpack`,也没有满足皮肤专用的实际解压预算与所有条目约束。不能宣称“更新工具完全没有路径保护”,也不能据此复用成皮肤安装器。 + +## 验证边界 + +协议、离线 demo 与研究素材位于 `docs/design/skin-system/`,主程序内置组件、设置、外观持久化和素材位于 `src/`。设计目录的独立开发检查用 `npm ci`、`npm run generate`、`npm run check` 复现;主程序检查使用仓库根目录的测试和构建流程。浏览器布局、交互与截图检查只说明已测试的浏览器与视口;不会替代 Windows WebView2、macOS WKWebView、Linux WebKitGTK 的权限负向测试。 + +离线 demo 已通过 37 组文档/DOM/事件检查,`npm run check:browser` 在 Chromium 155 中 18 项通过,包括新两套的五种视口布局、原六套在 850×525 下的任务操作、实际 H.264 解码播放和系统减少动效。两份原动作视频的请求、速度、清理与跨角色隔离有独立回归。680×420、566×350 是 850×525 在 125%/150% 缩放下的有效 CSS 空间模拟,不是原生窗口实测。受管理浏览器策略禁止直接 `file://` 导航,该项标为跳过;全部运行素材经本地 HTTP 加载验证。这些数字不作为本轮主程序内置接入的测试结果。 + +本轮 `pnpm build` 通过;根测试为 127 项通过、1 项既有平台跳过、0 失败;`npm run check:integration` 的真实 App/设置 Chromium 检查 11 项通过,新增社区原片吃饭动作静音播放与减少动效验证,并确认角色没有对白段落。后端为受控测试替身,未操作设备。原生 WebView 与通用隔离验收仍未完成。 + +上述通用生产安全与接入项继续作为第三方导入的阻断条件。新增可信内置皮肤不扩大来源信任,不增加原生、文件或网络权限。 diff --git a/docs/design/skin-system/scripts/generate.mjs b/docs/design/skin-system/scripts/generate.mjs new file mode 100644 index 00000000..f99026a1 --- /dev/null +++ b/docs/design/skin-system/scripts/generate.mjs @@ -0,0 +1,141 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { Marked, Renderer } from 'marked'; + +const root = fileURLToPath(new URL('../', import.meta.url)); +const check = process.argv.includes('--check'); +if (process.argv.slice(2).some((argument) => argument !== '--check')) { + throw new Error('Usage: node scripts/generate.mjs [--check]'); +} +const read = (relative) => fs.readFile(path.join(root, relative), 'utf8'); +const escapeHtml = (value) => + value.replace( + /[&<>"']/g, + (character) => + ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + })[character], + ); + +// Array order is the demo order. Keep existing numbers stable when adding ideas. +const ideas = JSON.parse(await read('ideas.json')); +if (!Array.isArray(ideas) || ideas.some((idea, index) => idea.number !== index + 1)) { + throw new Error('ideas.json must be an ordered array with stable, consecutive numbers.'); +} +const categories = ['角色', '空间', '状态', '交互', '叙事']; +if (ideas.some((idea) => !categories.includes(idea.category))) { + throw new Error('ideas.json contains an unknown category.'); +} +const ideasJavaScript = + '// Generated from ../ideas.json. Run npm run generate; do not edit this file.\n' + + `window.MXU_IDEAS = ${JSON.stringify(ideas, null, 2)};\n`; +const ideasMarkdown = [ + '', + '', + '# MXU 皮肤创意清单', + '', + '本页收集可供讨论的视觉与交互方向。L1 / L2 表示建议实现层级;部分创意还需要额外公开宿主槽位、只读状态或语义动作,不代表 [v0.1 草案](protocol.md) 已经支持。所有趣味表现均应有静态模式,并保留直接、可理解的任务操作。', + '', + '六个已做成概念 demo 的方向见 [设计入口](README.md)。下列 24 条用于继续扩展,不是已经完成的功能。编号与 [交互 demo](demo/index.html#ideas) 一致,按类别阅读时保留原编号。唯一内容源为 [ideas.json](ideas.json)。', + '', + ...categories.flatMap((category) => [ + `## ${category}`, + '', + ...ideas + .filter((idea) => idea.category === category) + .flatMap((idea) => [ + `### IDEA ${String(idea.number).padStart(2, '0')} · ${idea.title}`, + '', + idea.concept, + '', + `- 建议层级:**${idea.level}**。`, + `- 所需能力:${idea.capability.join('、')}。`, + `- 操作兜底:${idea.restraint}`, + '', + ]), + ]), +].join('\n'); + +// Markdown links are relative to skin-system/, whereas generated HTML is in demo/. +// Preserve external URLs, absolute URLs and same-document fragments verbatim. +function fromDemo(href) { + return /^(?:[a-z][a-z\d+.-]*:|\/|#)/i.test(href) ? href : `../${href}`; +} +const headings = []; +const seenIds = new Map(); +const renderer = new Renderer(); +renderer.heading = function (token) { + const label = token.text.replace(/[`*_]/g, ''); + const base = + label + .normalize('NFKC') + .toLowerCase() + .replace(/[^\p{L}\p{N}\s-]/gu, '') + .trim() + .replace(/\s+/g, '-') || 'section'; + const count = (seenIds.get(base) || 0) + 1; + seenIds.set(base, count); + const id = count === 1 ? base : `${base}-${count}`; + headings.push({ depth: token.depth, id, label }); + return `${this.parser.parseInline(token.tokens)}\n`; +}; +renderer.link = function (token) { + return Renderer.prototype.link.call(this, { ...token, href: fromDemo(token.href) }); +}; +renderer.image = function (token) { + return Renderer.prototype.image.call(this, { ...token, href: fromDemo(token.href) }); +}; +renderer.table = function (token) { + return `
\n${Renderer.prototype.table.call(this, token)}
\n`; +}; +const markdown = await read('protocol.md'); +const content = new Marked({ renderer, gfm: true }).parse(markdown); +const title = headings.find((heading) => heading.depth === 1)?.label; +if (!title) throw new Error('protocol.md must contain a level-one title.'); +const toc = headings + .filter((heading) => [2, 3].includes(heading.depth)) + .map( + (heading) => + ` ${escapeHtml(heading.label)}`, + ) + .join('\n'); +const replacements = { + title: escapeHtml(title), + styles: (await read('scripts/protocol.css')).trimEnd(), + toc, + content: content.trimEnd(), +}; +const protocolHtml = (await read('scripts/protocol.template.html')).replace( + /\{\{(title|styles|toc|content)\}\}/g, + (_, name) => replacements[name], +); +const outputs = new Map([ + ['demo/ideas.js', ideasJavaScript], + ['ideas.md', ideasMarkdown], + ['demo/protocol.html', protocolHtml], +]); +const stale = []; +for (const [relative, value] of outputs) { + const expected = value.trimEnd() + '\n'; + if (check) { + const actual = await read(relative).catch((error) => { + if (error.code === 'ENOENT') return null; + throw error; + }); + if (actual !== expected) stale.push(relative); + } else { + await fs.writeFile(path.join(root, relative), expected); + console.log(`Generated ${relative}`); + } +} +if (stale.length) { + console.error(`Generated files are stale: ${stale.join(', ')}. Run npm run generate.`); + process.exitCode = 1; +} else if (check) { + console.log(`Generated files are current (${outputs.size} files).`); +} diff --git a/docs/design/skin-system/scripts/protocol.css b/docs/design/skin-system/scripts/protocol.css new file mode 100644 index 00000000..c6147ee4 --- /dev/null +++ b/docs/design/skin-system/scripts/protocol.css @@ -0,0 +1,282 @@ +:root { + --ink: #20212b; + --muted: #626370; + --line: #e7e7ef; + --accent: #6454d8; + --paper: #fff; + --wash: #f6f6fb; +} +* { + box-sizing: border-box; +} +html { + scroll-behavior: smooth; + scroll-padding-top: 32px; +} +body { + margin: 0; + background: var(--paper); + color: var(--ink); + font: + 15px/1.85 -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + 'PingFang SC', + 'Microsoft YaHei', + sans-serif; +} +a { + color: var(--accent); + text-underline-offset: 4px; +} +a:hover { + color: #40349f; +} +a:focus-visible { + outline: 3px solid #b3a9ff; + outline-offset: 4px; +} +.skip-link { + position: absolute; + top: -60px; + left: 20px; + background: white; + padding: 8px 14px; +} +.skip-link:focus { + top: 8px; +} +header { + padding: 26px 0; + border-bottom: 1px solid var(--line); +} +.wrap { + width: min(1180px, calc(100% - 64px)); + margin: auto; +} +.nav { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; +} +.brand { + font-size: 18px; + font-weight: 800; + color: var(--ink); + text-decoration: none; + white-space: nowrap; +} +.brand span { + font-size: 11px; + letter-spacing: 0.12em; + margin-left: 8px; + color: var(--muted); +} +.nav-links { + display: flex; + gap: 18px; + flex-wrap: wrap; + font-size: 13px; +} +.layout { + display: grid; + grid-template-columns: 210px minmax(0, 1fr); + gap: 50px; + padding: 54px 0 80px; + align-items: start; +} +aside { + position: sticky; + top: 24px; + max-height: calc(100vh - 48px); + overflow-y: auto; + font-size: 13px; +} +.eyebrow { + color: var(--accent); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.12em; + margin: 0 0 16px; +} +aside a { + display: block; + text-decoration: none; + line-height: 1.65; + padding: 5px 0; + color: var(--muted); +} +aside a[data-depth='3'] { + padding-left: 12px; + font-size: 12px; +} +aside a:hover { + color: var(--accent); +} +article { + min-width: 0; +} +h1 { + font-size: clamp(30px, 4.5vw, 46px); + line-height: 1.3; + letter-spacing: -0.035em; + margin: 0 0 28px; +} +h2 { + font-size: 25px; + line-height: 1.4; + margin: 48px 0 20px; + padding-top: 28px; + border-top: 1px solid var(--line); +} +h3 { + font-size: 18px; + margin: 30px 0 12px; +} +h4 { + font-size: 16px; + margin: 26px 0 10px; +} +p { + margin: 13px 0; +} +ul, +ol { + padding-left: 25px; + margin: 14px 0; +} +li { + margin: 7px 0; +} +blockquote { + margin: 22px 0; + padding: 8px 20px; + border-left: 3px solid #aca1f4; + background: var(--wash); + color: #51525f; + border-radius: 0 8px 8px 0; +} +code { + font: + 12.5px/1.65 ui-monospace, + SFMono-Regular, + Consolas, + monospace; + background: #f1f0f8; + color: #6452ba; + border-radius: 4px; + padding: 2px 5px; + overflow-wrap: anywhere; +} +pre { + margin: 20px 0; + padding: 25px; + overflow: auto; + border-radius: 12px; + background: #191c29; + color: #e6e5f3; +} +pre code { + padding: 0; + color: inherit; + background: none; + overflow-wrap: normal; +} +.table-wrap { + overflow-x: auto; + margin: 20px 0; +} +table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} +th, +td { + border-bottom: 1px solid var(--line); + text-align: left; + vertical-align: top; + padding: 12px; + min-width: 110px; +} +th { + background: var(--wash); + font-weight: 600; +} +td:first-child { + min-width: 145px; +} +img { + max-width: 100%; + height: auto; +} +footer { + padding: 28px 0 38px; + border-top: 1px solid var(--line); + color: var(--muted); + font-size: 12px; +} +@media (max-width: 850px) { + .wrap { + width: calc(100% - 36px); + } + .nav { + align-items: flex-start; + flex-direction: column; + gap: 12px; + } + .layout { + display: block; + padding-top: 30px; + } + aside { + position: static; + max-height: none; + border-bottom: 1px solid var(--line); + padding-bottom: 24px; + margin-bottom: 35px; + } + aside nav { + columns: 2; + column-gap: 20px; + } + aside a { + break-inside: avoid; + } + pre { + padding: 17px; + } +} +@media (max-width: 480px) { + aside nav { + columns: 1; + } +} +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } +} +@media print { + header, + aside, + footer, + .skip-link { + display: none; + } + .wrap { + width: 100%; + } + .layout { + display: block; + padding: 0; + } + pre { + white-space: pre-wrap; + overflow-wrap: anywhere; + } + .table-wrap { + overflow: visible; + } +} diff --git a/docs/design/skin-system/scripts/protocol.template.html b/docs/design/skin-system/scripts/protocol.template.html new file mode 100644 index 00000000..3bfe0249 --- /dev/null +++ b/docs/design/skin-system/scripts/protocol.template.html @@ -0,0 +1,39 @@ + + + + + + + + {{title}} + + + + +
+ +
+
+ +
{{content}}
+
+ + + diff --git a/docs/design/skin-system/tests/browser.mjs b/docs/design/skin-system/tests/browser.mjs new file mode 100644 index 00000000..cda39b68 --- /dev/null +++ b/docs/design/skin-system/tests/browser.mjs @@ -0,0 +1,200 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import http from 'node:http'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { chromium } from 'playwright'; + +const root = fileURLToPath(new URL('../', import.meta.url)); +const artifacts = process.env.SKIN_BROWSER_OUTPUT; +if (artifacts) fs.mkdirSync(artifacts, { recursive: true }); +const types = { + '.html': 'text/html', + '.js': 'text/javascript', + '.css': 'text/css', + '.png': 'image/png', + '.webp': 'image/webp', + '.svg': 'image/svg+xml', + '.mp4': 'video/mp4', +}; +const server = http.createServer((request, response) => { + const filename = path.resolve( + root, + '.' + decodeURIComponent(new URL(request.url, 'http://local').pathname), + ); + if (!filename.startsWith(root) || !fs.existsSync(filename) || !fs.statSync(filename).isFile()) { + response.writeHead(404).end(); + return; + } + response.writeHead(200, { + 'Content-Type': types[path.extname(filename)] || 'application/octet-stream', + 'Content-Length': fs.statSync(filename).size, + }); + fs.createReadStream(filename).pipe(response); +}); +await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); +const base = `http://127.0.0.1:${server.address().port}/demo/index.html`; +let browser; +const checks = []; +const skipped = []; +try { + const executablePath = + process.env.CHROMIUM_PATH || + (fs.existsSync('/usr/bin/chromium') ? '/usr/bin/chromium' : undefined); + browser = await chromium.launch({ headless: true, executablePath }); + const errors = []; + const page = await browser.newPage(); + page.on('pageerror', (error) => errors.push(error.message)); + await page.clock.install(); + // Effective CSS viewports of an 850x525 window at 100%, 125%, and 150% page zoom. + const viewports = [ + [1440, 1100], + [850, 525], + [680, 420], + [566, 350], + [390, 844], + ]; + for (const [width, height] of viewports) { + await page.setViewportSize({ width, height }); + for (const scene of ['deepseek', 'naiwa']) { + await page.goto(`${base}#${scene}`); + await page.locator('#run-button').scrollIntoViewIfNeeded(); + const geometry = await page.evaluate(() => { + const stage = document.querySelector('#stage').getBoundingClientRect(); + const controlIds = ['run-button', 'stop-button', 'fail-button']; + const boxes = controlIds.map((id) => { + const element = document.getElementById(id), + r = element.getBoundingClientRect(); + return { + id, + left: r.left, + right: r.right, + top: r.top, + bottom: r.bottom, + width: r.width, + height: r.height, + }; + }); + const portrait = document.querySelector('.meme-portrait').getBoundingClientRect(); + const overlap = (a, b) => + Math.min(a.right, b.right) - Math.max(a.left, b.left) > 1 && + Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top) > 1; + return { + overflow: document.documentElement.scrollWidth > innerWidth + 1, + clipped: boxes.filter( + (r) => + r.left < stage.left || + r.right > stage.right || + r.top < stage.top || + r.bottom > stage.bottom, + ), + overlaps: boxes.filter((r, i) => boxes.slice(i + 1).some((other) => overlap(r, other))), + decorationOverlap: boxes.filter((r) => overlap(r, portrait)), + invisible: boxes.filter((r) => r.width < 1 || r.height < 1), + }; + }); + assert.deepEqual( + geometry, + { overflow: false, clipped: [], overlaps: [], decorationOverlap: [], invisible: [] }, + `${scene} at ${width}x${height}`, + ); + await page.locator('#run-button').click(); + await page.clock.runFor(1300); + await page.locator('#scene-trigger').click(); + await page.clock.runFor(2500); + assert.equal(await page.locator('#stage').getAttribute('data-run-state'), 'running'); + await page.locator('#run-button').click(); + const progress = await page.locator('#progress-value').textContent(); + await page.clock.runFor(1300); + assert.equal(await page.locator('#progress-value').textContent(), progress); + await page.locator('#fail-button').click(); + assert.equal(await page.locator('#stage').getAttribute('data-run-state'), 'error'); + await page.locator('#run-button').click(); + await page.clock.runFor(13000); + assert.equal(await page.locator('#status-label').textContent(), '已完成'); + checks.push( + `${scene}: ${width}x${height}, controls and decoration separated; timeout, pause, error, retry, success`, + ); + } + } + for (const scene of ['mascot', 'mecha', 'garden', 'vinyl', 'paper', 'sonar']) { + await page.setViewportSize({ width: 850, height: 525 }); + await page.goto(`${base}#${scene}`); + await page.locator('#run-button').click(); + await page.clock.runFor(650); + await page.locator('#stop-button').click(); + assert.equal(await page.locator('#status-label').textContent(), '已取消'); + checks.push(`${scene}: original controls still work at 850x525`); + } + // Use real time for native video; a fake DOM/media mock cannot establish decoding or playback. + const mediaPage = await browser.newPage({ viewport: { width: 1440, height: 1100 } }); + mediaPage.on('pageerror', (error) => errors.push(error.message)); + await mediaPage.goto(`${base}#naiwa`); + await mediaPage.locator('#scene-trigger').click(); + await mediaPage.waitForFunction(() => document.querySelector('#naiwa-motion').currentTime > 0.25); + const video = await mediaPage.locator('#naiwa-motion').evaluate((element) => ({ + muted: element.muted, + paused: element.paused, + ready: element.readyState, + })); + assert(video.muted && !video.paused && video.ready >= 2); + if (artifacts) + await mediaPage.locator('#stage').screenshot({ path: path.join(artifacts, '08-naiwa.png') }); + await mediaPage.locator('#reduce-motion').check(); + assert(await mediaPage.locator('#naiwa-motion').evaluate((element) => element.paused)); + await mediaPage.locator('#reduce-motion').uncheck(); + await mediaPage.locator('#scene-trigger').click(); + await mediaPage.locator('#run-button').click(); + await mediaPage.locator('#fail-button').click(); + await mediaPage.waitForFunction(() => { + const video = document.querySelector('#naiwa-motion'); + return video.paused && video.currentTime === 0; + }); + assert(await mediaPage.locator('.naiwa-actor .meme-calm-art').isVisible()); + checks.push( + 'native H.264 video decodes, advances silently, stops under reduced motion and error, and shows calm fallback', + ); + await mediaPage.emulateMedia({ reducedMotion: 'reduce' }); + await mediaPage.goto(`${base}#naiwa`); + await mediaPage.reload(); + await mediaPage.locator('#scene-trigger').click(); + assert(await mediaPage.locator('#reduce-motion').isDisabled()); + assert(await mediaPage.locator('#naiwa-motion').evaluate((element) => element.paused)); + assert(await mediaPage.locator('.naiwa-actor .meme-laugh-still').isVisible()); + checks.push('system reduced motion enforces static pose while retaining interaction text'); + await mediaPage.emulateMedia({ reducedMotion: 'no-preference' }); + const offlinePage = await browser.newPage(); + try { + await offlinePage.goto(pathToFileURL(path.join(root, 'demo/index.html')).href + '#naiwa'); + await offlinePage.locator('#scene-trigger').click(); + await offlinePage.waitForFunction( + () => document.querySelector('#naiwa-motion').currentTime > 0.25, + ); + checks.push('offline file:// launch also plays local video'); + } catch (error) { + if (!error.message.includes('ERR_BLOCKED_BY_ADMINISTRATOR')) throw error; + skipped.push('file:// navigation blocked by managed Chromium policy; localhost HTTP tested'); + } finally { + await offlinePage.close(); + } + if (artifacts) { + await mediaPage.goto(`${base}#deepseek`); + await mediaPage.locator('#stage').screenshot({ path: path.join(artifacts, '07-deepseek.png') }); + } + assert.deepEqual(errors, [], 'no browser runtime errors'); + console.log( + JSON.stringify( + { + checks, + skipped, + browser: browser.version(), + boundary: 'Chromium only; no production isolation or native WebView claim.', + }, + null, + 2, + ), + ); +} finally { + await browser?.close(); + await new Promise((resolve) => server.close(resolve)); +} diff --git a/docs/design/skin-system/tests/check.mjs b/docs/design/skin-system/tests/check.mjs new file mode 100644 index 00000000..4c34e916 --- /dev/null +++ b/docs/design/skin-system/tests/check.mjs @@ -0,0 +1,1033 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import vm from 'node:vm'; +import { JSDOM, VirtualConsole } from 'jsdom'; +import { marked } from 'marked'; + +const root = fileURLToPath(new URL('../', import.meta.url)); +const read = (relative) => fs.readFileSync(path.join(root, relative), 'utf8'); +const results = []; +const pass = (label) => results.push(label); +const plain = (value) => JSON.parse(JSON.stringify(value)); + +// These are document/data checks, not a production skin-package schema validator. +const ideas = JSON.parse(read('ideas.json')); +assert.equal(ideas.length, 24); +assert.equal(new Set(ideas.map((idea) => idea.id)).size, ideas.length); +const categories = new Set(['角色', '空间', '状态', '交互', '叙事']); +for (const [index, idea] of ideas.entries()) { + assert.equal(idea.number, index + 1); + for (const key of ['id', 'title', 'concept', 'restraint']) { + assert.equal(typeof idea[key], 'string', `idea ${index + 1}: ${key}`); + assert(idea[key].trim().length > 0); + } + assert(categories.has(idea.category)); + assert(['L1', 'L2'].includes(idea.level)); + assert(Array.isArray(idea.capability)); + assert(idea.capability.length >= 3 && idea.capability.length <= 4); + assert(idea.capability.every((tag) => typeof tag === 'string' && tag.trim())); +} +pass('ideas.json: 24 unique ideas, stable numbering and typed fields'); + +const manifest = JSON.parse(read('demo/examples/mascot.skin.json')); +const protocolTokens = marked.lexer(read('protocol.md')); +const jsonExamples = []; +marked.walkTokens(protocolTokens, (token) => { + if (token.type === 'code' && token.lang === 'json') { + jsonExamples.push(JSON.parse(token.text)); + } +}); +const documentedManifest = jsonExamples.find((example) => example.id === manifest.id); +assert(documentedManifest, 'protocol.md must include the downloadable manifest example'); +assert.deepEqual(documentedManifest, manifest); +assert.equal(typeof manifest.schemaVersion, 'string'); +assert.equal(typeof manifest.version, 'string'); +assert.equal(typeof manifest.mxu.skinApi, 'string'); +assert(manifest.name && typeof manifest.name === 'object' && !Array.isArray(manifest.name)); +assert(Object.values(manifest.name).every((name) => typeof name === 'string' && name.trim())); +for (const field of ['required', 'optional']) { + assert(Array.isArray(manifest.capabilities[field])); + assert(manifest.capabilities[field].every((value) => typeof value === 'string')); +} +pass('protocol JSON examples parse; manifest example exactly matches its downloadable JSON'); + +// Check references and declared write targets rather than inventing a skin runtime +// to execute a design-only manifest. Neither binding may write the host task state. +const interactionStates = new Set(manifest.scene.interactionStates); +assert(interactionStates.has(manifest.scene.initialInteractionState)); +const placements = new Set(manifest.placements.map((placement) => placement.id)); +const hotspots = new Set(manifest.hotspots.map((hotspot) => hotspot.id)); +assert.equal(placements.size, manifest.placements.length); +assert( + manifest.placements.every( + (placement) => + typeof placement.id === 'string' && + placement.id && + typeof placement.element === 'string' && + placement.element, + ), +); +assert.equal(hotspots.size, manifest.hotspots.length); +for (const hotspot of manifest.hotspots) assert(placements.has(hotspot.placement)); +for (const binding of manifest.bindings) { + assert(interactionStates.has(binding.setInteractionState)); + if (binding.event === 'host.hotspot.activate') { + assert.deepEqual(Object.keys(binding).sort(), ['event', 'setInteractionState', 'target']); + assert(hotspots.has(binding.target)); + } else { + assert.equal(binding.event, 'interaction.timeout'); + assert.deepEqual(Object.keys(binding).sort(), [ + 'afterMs', + 'event', + 'setInteractionState', + 'state', + ]); + assert(interactionStates.has(binding.state)); + assert(Number.isFinite(binding.afterMs) && binding.afterMs > 0); + } +} +for (const reservation of manifest.reservations) { + assert(interactionStates.has(reservation.when['interaction.state'])); +} +pass( + 'manifest hotspots, placements and interaction references resolve; bindings only write interaction state', +); + +const htmlDocuments = new Map(); +function htmlDocument(file) { + if (!htmlDocuments.has(file)) { + const document = new JSDOM(fs.readFileSync(file, 'utf8')).window.document; + const ids = [...document.querySelectorAll('[id]')].map((element) => element.id); + assert.equal( + new Set(ids).size, + ids.length, + `duplicate HTML id in ${path.relative(root, file)}`, + ); + htmlDocuments.set(file, document); + } + return htmlDocuments.get(file); +} +let checkedLinks = 0; +function checkLink(source, href) { + if (!href || /^(?:[a-z][a-z\d+.-]*:|\/\/)/i.test(href)) return; + const url = new URL(href, pathToFileURL(source)); + const target = fileURLToPath(url); + assert(fs.existsSync(target), `${path.relative(root, source)}: missing local link ${href}`); + assert( + fs.statSync(target).isFile(), + `${path.relative(root, source)}: expected a file for ${href}`, + ); + if (url.hash && path.extname(target) === '.html') { + assert( + htmlDocument(target).getElementById(decodeURIComponent(url.hash.slice(1))), + `${path.relative(root, source)}: missing HTML fragment ${href}`, + ); + } + checkedLinks++; +} +for (const relative of [ + 'README.md', + 'protocol.md', + 'ideas.md', + 'meme-skins.md', + 'review-notes.md', +]) { + const source = path.join(root, relative); + marked.walkTokens(marked.lexer(read(relative)), (token) => { + if (token.type === 'link' || token.type === 'image') checkLink(source, token.href); + }); +} +for (const relative of ['demo/index.html', 'demo/protocol.html']) { + const source = path.join(root, relative); + for (const element of htmlDocument(source).querySelectorAll('[href], [src], [poster]')) { + for (const attribute of ['href', 'src', 'poster']) { + if (element.hasAttribute(attribute)) checkLink(source, element.getAttribute(attribute)); + } + } +} +for (const document of htmlDocuments.values()) document.defaultView.close(); +pass(`local documentation links, images and HTML fragments resolve (${checkedLinks} references)`); + +const css = read('demo/style.css'); +for (const script of ['demo/app.js', 'demo/ideas.js']) { + new vm.Script(read(script), { filename: script }); +} +const errors = []; +const virtualConsole = new VirtualConsole(); +virtualConsole.on('jsdomError', (error) => errors.push(error.message)); +const dom = new JSDOM(read('demo/index.html'), { + runScripts: 'outside-only', + url: 'https://test.invalid/', + virtualConsole, +}); +const w = dom.window; +const d = w.document; +const visibility = { hidden: false }; +Object.defineProperty(d, 'hidden', { configurable: true, get: () => visibility.hidden }); +const playback = { plays: 0, pauses: 0, mode: 'resolve', pending: [] }; +const pausedMedia = new WeakMap(); +Object.defineProperty(w.HTMLMediaElement.prototype, 'paused', { + configurable: true, + get() { + return pausedMedia.get(this) ?? true; + }, +}); +w.HTMLMediaElement.prototype.play = function () { + playback.plays++; + if (playback.mode === 'throw') throw new Error('Playback is unavailable'); + if (playback.mode === 'reject') return Promise.reject(new Error('Playback was denied')); + if (playback.mode === 'pending') { + return new Promise((resolve) => + playback.pending.push(() => { + pausedMedia.set(this, false); + resolve(); + }), + ); + } + pausedMedia.set(this, false); + return Promise.resolve(); +}; +w.HTMLMediaElement.prototype.pause = function () { + playback.pauses++; + pausedMedia.set(this, true); +}; +const $ = (id) => { + const element = d.getElementById(id); + assert(element, `missing demo element #${id}`); + return element; +}; +const style = d.createElement('style'); +style.textContent = css; +d.head.append(style); +const timers = new Map(); +const timeouts = new Map(); +let timerSequence = 0; +w.setInterval = (callback, delay) => { + const id = ++timerSequence; + timers.set(id, { callback, delay }); + return id; +}; +w.clearInterval = (id) => timers.delete(id); +w.setTimeout = (callback, delay) => { + const id = ++timerSequence; + timeouts.set(id, { callback, delay }); + return id; +}; +w.clearTimeout = (id) => timeouts.delete(id); +const media = { + matches: false, + listener: null, + addEventListener(type, listener) { + assert.equal(type, 'change'); + this.listener = listener; + }, +}; +w.matchMedia = () => media; +const tick = (count = 1) => { + for (let i = 0; i < count; i++) { + for (const timer of [...timers.values()]) timer.callback(); + } +}; +const expireInteractions = () => { + const pending = [...timeouts.values()]; + timeouts.clear(); + for (const timer of pending) timer.callback(); +}; +const change = (id, checked) => { + const element = $(id); + element.checked = checked; + element.dispatchEvent(new w.Event('change')); +}; +const skin = (name) => d.querySelector(`[data-skin="${name}"]`).click(); +const state = (name, text) => { + assert.equal($('stage').dataset.runState, name); + assert.equal($('status-label').textContent, text); +}; +w.eval(read('demo/ideas.js')); +w.eval(read('demo/app.js')); +assert.deepEqual(plain(w.MXU_IDEAS), ideas); +assert.equal(d.querySelectorAll('.idea-card').length, 24); +assert.equal(timers.size, 0); +assert.equal(timeouts.size, 0); +pass('demo mounts without timers and renders the canonical 24 ideas'); + +const configurations = { + mascot: [132, 'mischief'], + mecha: [100, 'armed'], + garden: [75, 'night'], + vinyl: [50, 'audition'], + paper: [5, 'stamped'], + sonar: [50, 'pinging'], + deepseek: [100, 'feeding'], + naiwa: [100, 'laughing'], +}; +assert.equal(d.querySelectorAll('[data-skin]').length, 8); +const memeScenes = ['deepseek', 'naiwa']; +const tasks = [...d.querySelectorAll('[data-task]')]; +for (const [name, [intensity, effect]] of Object.entries(configurations)) { + skin(name); + state('idle', '待机中'); + assert($('stage').classList.contains(`theme-${name}`)); + assert.equal(d.querySelectorAll('[data-skin][aria-pressed="true"]').length, 1); + assert.equal(Number($('intensity').value), intensity); + $('scene-trigger').click(); + assert($('stage').classList.contains(effect)); + assert.equal($('scene-trigger').getAttribute('aria-pressed'), 'true'); + $('scene-trigger').click(); + if (memeScenes.includes(name)) { + assert($('stage').classList.contains(effect)); + assert.equal(timeouts.size, 1, 'repeated interaction replaces its timeout'); + assert.equal([...timeouts.values()][0].delay, 2400); + expireInteractions(); + } + assert(!$('stage').classList.contains(effect)); + const preset = w.MXUSkinDemo.makePreset(); + assert.equal(preset.scene, name); + w.MXUSkinDemo.validatePreset(preset); + + $('run-button').click(); + state('running', '执行中'); + assert.equal(timers.size, 1); + assert(tasks.every((input) => input.disabled)); + const runningAnnouncement = $('task-announcement').textContent; + tick(3); + assert.equal($('progress-value').textContent, '15%'); + assert.equal($('task-progress').getAttribute('aria-valuenow'), '15'); + assert.equal($('task-announcement').textContent, runningAnnouncement); + $('run-button').click(); + state('paused', '已暂停'); + assert.equal(timers.size, 0); + assert.equal(timeouts.size, 0); + assert.equal($('run-label').textContent, '继续任务'); + tick(); + assert.equal($('progress-value').textContent, '15%'); + $('run-button').click(); + assert.equal(timers.size, 1); + tick(); + assert.equal($('progress-value').textContent, '20%'); + + $('stop-button').click(); + state('cancelled', '已取消'); + assert.equal(timers.size, 0); + assert($('stop-button').disabled); + assert($('fail-button').disabled); + assert(tasks.every((input) => !input.disabled)); + assert.equal($('run-label').textContent, '重新开始'); + assert(!$('stage').classList.contains('complete')); + $('run-button').click(); + assert.equal($('progress-value').textContent, '0%'); + tick(2); + $('fail-button').click(); + state('error', '执行失败'); + assert.equal(timers.size, 0); + assert.equal($('run-label').textContent, '重试任务'); + assert.equal($('progress-value').textContent, '10%'); + assert(tasks.every((input) => !input.disabled)); + assert(!$('stage').classList.contains('complete')); + assert($('fail-button').disabled); + tick(20); + assert.equal($('progress-value').textContent, '10%'); + $('run-button').click(); + assert.equal($('progress-value').textContent, '0%'); + tick(20); + state('success', '已完成'); + assert.equal(timers.size, 0); + assert.equal($('completed-count').textContent, '3'); + assert.equal(d.querySelectorAll('.task-row.done').length, 3); + assert($('stage').classList.contains('complete')); + assert.equal($('task-progress').getAttribute('aria-valuenow'), '100'); + $('run-button').click(); + assert.equal(timers.size, 1); + skin(name === 'mascot' ? 'mecha' : 'mascot'); + assert.equal(timers.size, 0); + assert.equal($('progress-value').textContent, '0%'); + pass( + `${name}: effects, export, start, pause, resume, cancel, error, retry, success and timer cleanup`, + ); +} + +for (const name of memeScenes) { + const effect = configurations[name][1]; + skin(name); + const title = name === 'deepseek' ? '蓝色大肥鱼 · 吃白饭' : '奶蛙 · 大笑'; + assert.equal($('scene-title').textContent, title); + assert.equal($('inspector-title').textContent, title); + assert($('scene-trigger').textContent.startsWith(name === 'deepseek' ? '投喂白饭' : '播放大笑')); + assert.equal($('scene-trigger').dataset.hostHotspot, name); + assert.equal($('scene-trigger').getAttribute('aria-controls'), 'meme-state'); + $('run-button').click(); + tick(2); + const progress = $('progress-value').textContent; + const renderedProgress = $('rice-fill').style.getPropertyValue('--task-progress'); + assert.equal(renderedProgress, '0.1'); + assert.equal($('meme-progress-text').textContent, '任务进度 10%'); + assert.equal($('meme-state').textContent, '执行中'); + assert.equal(d.getElementById('meme-dialogue'), null); + assert.equal(d.querySelector('.laugh-caption'), null); + $('scene-trigger').click(); + assert( + $('scene-trigger').textContent.startsWith( + name === 'deepseek' ? '再次投喂白饭' : '再次播放大笑', + ), + ); + const firstTimeout = [...timeouts.keys()][0]; + const firstCallback = timeouts.get(firstTimeout).callback; + assert.equal(timeouts.size, 1); + $('scene-trigger').click(); + assert.equal(timeouts.size, 1); + assert(!timeouts.has(firstTimeout), 'repeated activation cancels its previous timeout'); + firstCallback(); + assert($('stage').classList.contains(effect), 'a stale timeout cannot clear a newer interaction'); + assert.equal(timeouts.size, 1); + assert.equal($('progress-value').textContent, progress, 'interaction never advances a task'); + assert.equal($('rice-fill').style.getPropertyValue('--task-progress'), renderedProgress); + assert.equal($('meme-progress-text').textContent, '任务进度 10%'); + assert.equal( + $('meme-state').textContent, + `执行中 · ${name === 'deepseek' ? '投喂中' : '播放大笑'}`, + ); + expireInteractions(); + state('running', '执行中'); + assert.equal(timers.size, 1, 'interaction expiry preserves the task clock'); + assert.equal($('progress-value').textContent, progress); + assert(!$('stage').classList.contains(effect)); + assert.equal($('scene-trigger').getAttribute('aria-pressed'), 'false'); + assert($('meme-state').textContent.trim()); + + $('scene-trigger').click(); + $('run-button').click(); + state('paused', '已暂停'); + assert.equal(timeouts.size, 0); + assert.equal(timers.size, 0); + assert(!$('stage').classList.contains(effect)); + assert(!$('stage').classList.contains('running')); + assert($('scene-trigger').disabled); + tick(2); + expireInteractions(); + assert.equal($('progress-value').textContent, progress); + assert.match($('meme-state').textContent, /暂停|继续/); + $('run-button').click(); + $('scene-trigger').click(); + $('stop-button').click(); + state('cancelled', '已取消'); + assert.equal(timeouts.size, 0); + assert(!$('stage').classList.contains(effect)); + assert.match($('meme-state').textContent, /取消/); + assert($('scene-trigger').disabled); + expireInteractions(); + state('cancelled', '已取消'); + + $('run-button').click(); + tick(2); + $('scene-trigger').click(); + $('fail-button').click(); + state('error', '执行失败'); + assert.equal(timeouts.size, 0); + assert(!$('stage').classList.contains(effect)); + assert.match($('meme-state').textContent, /失败/); + assert($('scene-trigger').disabled); + expireInteractions(); + state('error', '执行失败'); + + $('run-button').click(); + tick(19); + $('scene-trigger').click(); + tick(); + state('success', '已完成'); + assert.equal(timeouts.size, 0); + assert.equal(timers.size, 0); + assert(!$('stage').classList.contains(effect)); + assert.match($('meme-state').textContent, /完成/); + assert($('scene-trigger').disabled); + expireInteractions(); + state('success', '已完成'); + pass( + `${name}: repeated interaction and expiry preserve task progress; pause and terminal states take priority`, + ); + + $('reset').click(); + $('scene-trigger').click(); + assert.equal(timeouts.size, 1); + $('reset').click(); + assert.equal(timeouts.size, 0); + assert(!$('stage').classList.contains(effect)); + $('scene-trigger').click(); + const previousSceneCallback = [...timeouts.values()][0].callback; + skin(name === 'deepseek' ? 'naiwa' : 'deepseek'); + assert.equal(timeouts.size, 0); + assert(!$('stage').classList.contains(effect)); + assert.equal($('progress-value').textContent, '0%'); + $('scene-trigger').click(); + previousSceneCallback(); + assert.equal(timeouts.size, 1, 'an unloaded scene cannot clear the new scene interaction'); + assert( + $('stage').classList.contains(configurations[name === 'deepseek' ? 'naiwa' : 'deepseek'][1]), + ); + skin(name); + $('run-button').click(); + tick(2); + $('scene-trigger').click(); + w.dispatchEvent(new w.Event('pagehide')); + state('paused', '已暂停'); + assert.equal(timeouts.size, 0); + assert.equal(timers.size, 0); + assert(!$('stage').classList.contains(effect)); + assert.equal($('progress-value').textContent, '10%'); + $('reset').click(); + $('scene-trigger').click(); + w.dispatchEvent(new w.Event('pagehide')); + state('idle', '待机中'); + assert.equal(timeouts.size, 0); + assert.equal($('scene-trigger').getAttribute('aria-pressed'), 'false'); + pass(`${name}: reset, scene change and pagehide cancel interactions without stale task feedback`); +} + +tasks.forEach((input) => { + input.checked = false; + input.dispatchEvent(new w.Event('change')); +}); +assert.equal($('selected-count').textContent, '0'); +assert($('run-button').disabled); +$('run-button').click(); +assert.equal(timers.size, 0); +tasks[1].checked = true; +tasks[1].dispatchEvent(new w.Event('change')); +assert.equal($('selected-count').textContent, '1'); +$('run-button').click(); +tick(20); +assert.equal($('completed-count').textContent, '1'); +assert.equal(d.querySelectorAll('.task-row.done').length, 1); +assert.equal(tasks[0].closest('.task-row').querySelector('.task-state').textContent, '跳过'); +pass('selection: no tasks disables start; selected subset completes independently'); + +$('reset').click(); +assert.equal($('selected-count').textContent, '3'); +assert.equal(timers.size, 0); +change('show-bounds', true); +assert($('stage').classList.contains('show-bounds')); +skin('mecha'); +assert($('stage').classList.contains('show-bounds')); +change('reduce-motion', true); +assert($('stage').classList.contains('reduce-motion')); +skin('garden'); +assert($('stage').classList.contains('reduce-motion')); +change('reduce-motion', false); +assert(!$('stage').classList.contains('reduce-motion')); +media.matches = true; +media.listener(); +assert($('reduce-motion').disabled); +assert($('stage').classList.contains('reduce-motion')); +pass('reset, persistent debug/motion controls and enforced system motion reduction'); +for (const name of memeScenes) { + skin(name); + assert($('stage').classList.contains('reduce-motion')); + assert($('reduce-motion').disabled); + $('run-button').click(); + tick(20); + state('success', '已完成'); + assert.match($('meme-state').textContent, /完成/); + assert.equal($('meme-progress-text').textContent, '任务进度 100%'); + assert.equal($('task-progress').getAttribute('aria-valuenow'), '100'); + assert.equal($('rice-fill').style.getPropertyValue('--task-progress'), '1'); + assert.equal(timeouts.size, 0); +} +pass('both meme scenes retain readable task results with system motion reduction enforced'); + +const valid = { + format: 'mxu-skin-demo/0.1', + name: 'My preset', + scene: 'mascot', + accent: '#aadea6', + intensity: 132, + effect: true, +}; +assert.doesNotThrow(() => w.MXUSkinDemo.validatePreset(valid)); +const invalid = [ + null, + [], + {}, + { ...valid, script: 'alert(1)' }, + { ...valid, format: '0.1' }, + { ...valid, scene: '__proto__' }, + { ...valid, scene: 'unknown' }, + { ...valid, scene: ['mascot'] }, + { ...valid, name: '' }, + { ...valid, name: 'a'.repeat(41) }, + { ...valid, accent: 'url(https://bad.invalid)' }, + { ...valid, accent: '#ff00ff;display:none' }, + { ...valid, intensity: 1000 }, + { ...valid, intensity: '132' }, + { ...valid, intensity: Infinity }, + { ...valid, effect: 1 }, + JSON.parse('{"__proto__":{},"format":"mxu-skin-demo/0.1"}'), +]; +for (const value of invalid) assert.throws(() => w.MXUSkinDemo.validatePreset(value)); +pass(`preset validator rejects ${invalid.length} invalid structures, fields, values and types`); + +for (const button of d.querySelectorAll('[data-filter]')) { + button.click(); + const category = button.dataset.filter; + const expected = ideas.filter((idea) => category === '全部' || idea.category === category); + const cards = [...d.querySelectorAll('.idea-card')]; + assert.equal(cards.length, expected.length); + assert.equal(d.querySelectorAll('[data-filter][aria-pressed="true"]').length, 1); + for (const [index, card] of cards.entries()) { + assert.equal(card.querySelector('h4').textContent, expected[index].title); + assert( + card + .querySelector('.idea-top') + .textContent.includes(`IDEA ${String(expected[index].number).padStart(2, '0')}`), + ); + } +} +pass('all category filters retain the canonical idea titles and stable numbers'); + +const input = $('preset-file'); +async function importFile(data, size = 100) { + Object.defineProperty(input, 'files', { + configurable: true, + value: [{ size, text: async () => (typeof data === 'string' ? data : JSON.stringify(data)) }], + }); + input.dispatchEvent(new w.Event('change')); + await Promise.resolve(); + await Promise.resolve(); +} +await importFile({ ...valid, name: '', accent: '#123456' }); +assert.equal(w.MXUSkinDemo.makePreset().accent, '#123456'); +assert.equal($('import-result').querySelector('img'), null); +assert($('stage').classList.contains('mischief')); +const importedPreset = plain(w.MXUSkinDemo.makePreset()); +await importFile(valid, 17000); +assert($('import-result').textContent.includes('16 KB')); +assert.deepEqual(plain(w.MXUSkinDemo.makePreset()), importedPreset); +await importFile('{broken'); +assert($('import-result').textContent.includes('JSON 格式有误')); +assert.deepEqual(plain(w.MXUSkinDemo.makePreset()), importedPreset); +pass('import handler applies data as text and preserves the current preset on invalid files'); +for (const name of memeScenes) { + const example = JSON.parse(read(`demo/examples/${name}.demo.json`)); + assert.equal(example.scene, name); + assert.doesNotThrow(() => w.MXUSkinDemo.validatePreset(example)); + await importFile(example); + assert.deepEqual(plain(w.MXUSkinDemo.makePreset()), example); + assert($('stage').classList.contains(`theme-${name}`)); + for (const intensity of [name === 'deepseek' ? 59 : 49, name === 'deepseek' ? 141 : 151]) + assert.throws(() => w.MXUSkinDemo.validatePreset({ ...example, intensity })); +} +pass('downloadable meme presets import and export unchanged and enforce scene-specific ranges'); + +$('run-button').click(); +assert.equal(timers.size, 1); +w.dispatchEvent(new w.Event('pagehide')); +assert.equal(timers.size, 0); +state('paused', '已暂停'); +$('run-button').click(); +assert.equal(timers.size, 1); +tick(); +assert.equal($('progress-value').textContent, '5%'); +$('stop-button').click(); +pass('pagehide clears the interval and leaves a resumable paused task'); + +skin('deepseek'); +$('run-button').click(); +tick(2); +$('scene-trigger').click(); +for (const anchor of ['playground', 'ideas', 'architecture']) { + w.history.replaceState(null, '', `#${anchor}`); + w.dispatchEvent(new w.HashChangeEvent('hashchange')); + assert($('stage').classList.contains('theme-deepseek')); + state('running', '执行中'); + assert.equal($('progress-value').textContent, '10%'); + assert.equal(timers.size, 1); + assert.equal(timeouts.size, 1); +} +for (const anchor of ['unknown', '__proto__']) { + w.history.replaceState(null, '', `#${anchor}`); + w.dispatchEvent(new w.HashChangeEvent('hashchange')); + assert($('stage').classList.contains('theme-mascot')); + state('idle', '待机中'); + assert.equal(timers.size, 0); + assert.equal(timeouts.size, 0); +} +pass('page section hashes preserve ongoing tasks; unknown scene hashes fall back and clean up'); +for (const name of memeScenes) { + const direct = new JSDOM(read('demo/index.html'), { + runScripts: 'outside-only', + url: `https://test.invalid/#${name}`, + }); + direct.window.matchMedia = () => ({ matches: false, addEventListener() {} }); + direct.window.eval(read('demo/ideas.js')); + direct.window.eval(read('demo/app.js')); + assert(direct.window.document.getElementById('stage').classList.contains(`theme-${name}`)); + assert.equal(direct.window.MXUSkinDemo.makePreset().scene, name); + direct.window.close(); +} +pass('direct links mount each meme scene without an intermediate scene selection'); + +for (const name of ['vinyl', 'sonar', 'paper']) { + skin(name); + $('scene-trigger').click(); + assert($('stage').classList.contains(configurations[name][1])); + $('run-button').click(); + assert(!$('stage').classList.contains(configurations[name][1])); + assert($('scene-trigger').disabled); + assert.equal($('scene-trigger').getAttribute('aria-pressed'), 'false'); + $('run-button').click(); + state('paused', '已暂停'); + assert(!$('stage').classList.contains(configurations[name][1])); + assert($('scene-trigger').disabled); + $('fail-button').click(); + state('error', '执行失败'); + assert.equal(timers.size, 0); + assert(!$('stage').classList.contains('complete')); + assert($('scene-trigger').disabled); +} +pass('independent scene previews do not override task states; paused failures clear timers'); + +skin('mascot'); +assert.equal($('scene-trigger').tagName, 'BUTTON'); +assert.equal($('scene-trigger').dataset.hostHotspot, 'mascot'); +assert.equal($('scene-trigger').getAttribute('aria-controls'), 'reservation'); +assert.equal($('scene-trigger').getAttribute('aria-describedby'), 'hotspot-note'); +$('scene-trigger').focus(); +assert.equal(d.activeElement, $('scene-trigger')); +$('run-button').click(); +$('scene-trigger').click(); +state('running', '执行中'); +tick(2); +$('scene-trigger').click(); +state('running', '执行中'); +assert.equal($('progress-value').textContent, '10%'); +assert.equal(timers.size, 1); +$('stop-button').click(); +skin('mascot'); +$('scene-trigger').click(); +assert.equal(d.querySelector('.mascot-actor').parentElement, $('reservation')); +$('scene-trigger').click(); +assert.equal(d.querySelector('.mascot-actor').parentElement, d.querySelector('.app-wrap')); +$('scene-trigger').click(); +skin('mecha'); +assert.equal(d.querySelector('.mascot-actor').parentElement, d.querySelector('.app-wrap')); +assert.equal($('scene-trigger').getAttribute('data-host-hotspot'), null); +assert(!$('host-status').closest('.device-panel')); +assert($('host-status').contains($('completed-count'))); +assert.equal($('task-progress').getAttribute('role'), 'progressbar'); +assert.equal($('task-progress').getAttribute('aria-valuemin'), '0'); +assert.equal($('task-progress').getAttribute('aria-valuemax'), '100'); +assert.equal($('task-announcement').getAttribute('aria-live'), 'polite'); +pass( + 'host hotspot remains focusable; actor reservation, progress semantics and status placement are preserved', +); + +// Browser media methods are mocked here; decoding, frame output and geometry need a real browser. +const flushPlayback = () => new Promise((resolve) => setImmediate(resolve)); +await flushPlayback(); +media.matches = false; +media.listener(); +change('reduce-motion', false); +w.dispatchEvent(new w.Event('pageshow')); +skin('naiwa'); +const video = $('naiwa-motion'); +assert(video.muted && video.defaultMuted && video.loop && video.playsInline); +assert( + video.hasAttribute('muted') && video.hasAttribute('loop') && video.hasAttribute('playsinline'), +); +assert(!video.hasAttribute('autoplay')); +assert.equal(video.querySelector('source').getAttribute('type'), 'video/mp4'); +assert.equal(video.paused, true); +const initialPlays = playback.plays; +$('scene-trigger').click(); +assert.equal(playback.plays, initialPlays + 1); +assert.equal(video.paused, false); +assert($('stage').classList.contains('naiwa-performing')); +video.currentTime = 1.25; +assert.equal(video.playbackRate, 1, 'Naiwa starts at its original playback speed'); +for (const [intensity, rate] of [ + [50, 0.5], + [150, 1.5], +]) { + $('intensity').value = String(intensity); + $('intensity').dispatchEvent(new w.Event('input')); + assert.equal(video.playbackRate, rate); + assert.equal(playback.plays, initialPlays + 1, 'speed changes never restart a playing clip'); + assert.equal(video.currentTime, 1.25, 'speed changes preserve the current playback position'); +} +$('run-button').click(); +tick(3); +assert.equal(playback.plays, initialPlays + 1, 'task progress never restarts a playing clip'); +assert.equal(video.currentTime, 1.25); +$('scene-trigger').click(); +assert.equal(playback.plays, initialPlays + 1, 'repeated pokes keep a playing clip continuous'); +expireInteractions(); +assert.equal(video.paused, true); +assert(!$('stage').classList.contains('naiwa-performing')); +$('scene-trigger').click(); +const continuingPlays = playback.plays; +tick(17); +state('success', '已完成'); +assert.equal(video.paused, false); +assert.equal(playback.plays, continuingPlays, 'success continues an already playing celebration'); +pass( + 'Naiwa uses a muted inline clip only for interaction or success and never seeks on progress ticks', +); + +video.currentTime = 2.5; +visibility.hidden = true; +d.dispatchEvent(new w.Event('visibilitychange')); +assert.equal(video.paused, true); +assert.equal(video.currentTime, 2.5, 'background pause preserves the current frame'); +assert(!$('stage').classList.contains('naiwa-performing')); +visibility.hidden = false; +d.dispatchEvent(new w.Event('visibilitychange')); +assert.equal(video.paused, false); +assert.equal(video.currentTime, 2.5); +change('reduce-motion', true); +assert.equal(video.paused, true); +assert(!$('stage').classList.contains('naiwa-performing')); +change('reduce-motion', false); +assert.equal(video.paused, false); +media.matches = true; +media.listener(); +assert.equal(video.paused, true); +assert($('reduce-motion').disabled); +media.matches = false; +media.listener(); +change('reduce-motion', false); +assert.equal(video.paused, false); +w.dispatchEvent(new w.Event('pagehide')); +assert.equal(video.paused, true); +assert.equal(video.currentTime, 0); +assert(!$('stage').classList.contains('naiwa-performing')); +await flushPlayback(); +assert.equal(video.paused, true, 'success rendering cannot restart playback after pagehide'); +w.dispatchEvent(new w.Event('pageshow')); +assert.equal(video.paused, false); +state('success', '已完成'); +pass( + 'Naiwa media follows visibility and both motion preferences; pagehide cannot restart success playback', +); + +for (const action of ['fail-button', 'stop-button', 'reset']) { + $('reset').click(); + $('run-button').click(); + $('scene-trigger').click(); + assert.equal(video.paused, false); + video.currentTime = 1.5; + $(action).click(); + assert.equal(video.paused, true); + assert.equal(video.currentTime, 0); + assert(!$('stage').classList.contains('naiwa-performing')); +} +$('scene-trigger').click(); +video.currentTime = 1.5; +skin('deepseek'); +assert.equal(video.paused, true); +assert.equal(video.currentTime, 0); +pass('Naiwa failure, cancellation, reset and scene changes stop and rewind the clip'); + +skin('naiwa'); +await flushPlayback(); +playback.mode = 'reject'; +$('scene-trigger').click(); +await flushPlayback(); +assert.equal(video.paused, true); +assert(!$('stage').classList.contains('naiwa-performing')); +assert($('meme-state').textContent.trim(), 'playback rejection retains readable static feedback'); +$('reset').click(); +playback.mode = 'throw'; +$('scene-trigger').click(); +assert.equal(video.paused, true); +assert(!$('stage').classList.contains('naiwa-performing')); +$('reset').click(); +playback.mode = 'pending'; +const beforePending = playback.plays; +$('scene-trigger').click(); +$('run-button').click(); +tick(3); +assert.equal( + playback.plays, + beforePending + 1, + 'an unresolved play promise is not retried each tick', +); +assert.equal(playback.pending.length, 1); +w.dispatchEvent(new w.Event('pagehide')); +playback.pending.shift()(); +await flushPlayback(); +assert.equal( + video.paused, + true, + 'late playback completion is stopped after the page becomes inactive', +); +assert(!$('stage').classList.contains('naiwa-performing')); +assert.equal(timers.size, 0); +assert.equal(timeouts.size, 0); +playback.mode = 'resolve'; +w.dispatchEvent(new w.Event('pageshow')); +pass( + 'Naiwa media failures keep a static fallback; pending and stale playback promises cannot replay an inactive page', +); + +skin('deepseek'); +const deepseekVideo = $('deepseek-motion'); +assert( + deepseekVideo.muted && + deepseekVideo.defaultMuted && + deepseekVideo.loop && + deepseekVideo.playsInline, +); +assert(!deepseekVideo.hasAttribute('autoplay')); +assert.equal(deepseekVideo.querySelector('source').getAttribute('src'), 'assets/deepseek-eat.mp4'); +assert.equal(deepseekVideo.querySelector('source').getAttribute('type'), 'video/mp4'); +assert.equal(deepseekVideo.paused, true); +assert( + !css.includes('@keyframes rice-munch'), + 'the original eating clip replaces simulated CSS movement', +); +assert.equal(w.getComputedStyle(deepseekVideo).objectFit, 'cover'); +const deepseekInitialPlays = playback.plays; +$('scene-trigger').click(); +assert.equal(playback.plays, deepseekInitialPlays + 1); +assert.equal(deepseekVideo.paused, false); +assert($('stage').classList.contains('deepseek-performing')); +assert.equal(video.paused, true, 'playing DeepSeek never starts Naiwa'); +deepseekVideo.currentTime = 1.25; +for (const [intensity, rate] of [ + [60, 0.6], + [140, 1.4], +]) { + $('intensity').value = String(intensity); + $('intensity').dispatchEvent(new w.Event('input')); + assert.equal(deepseekVideo.playbackRate, rate); + assert.equal(playback.plays, deepseekInitialPlays + 1); + assert.equal(deepseekVideo.currentTime, 1.25); +} +$('run-button').click(); +tick(3); +expireInteractions(); +assert.equal(deepseekVideo.paused, false, 'DeepSeek keeps eating while the task is running'); +assert.equal( + playback.plays, + deepseekInitialPlays + 1, + 'progress and interaction expiry never restart the clip', +); +assert.equal(deepseekVideo.currentTime, 1.25); +$('run-button').click(); +state('paused', '已暂停'); +assert.equal(deepseekVideo.paused, true); +assert(!$('stage').classList.contains('deepseek-performing')); +$('run-button').click(); +assert.equal(deepseekVideo.paused, false); +change('reduce-motion', true); +assert.equal(deepseekVideo.paused, true); +assert(!$('stage').classList.contains('deepseek-performing')); +change('reduce-motion', false); +assert.equal(deepseekVideo.paused, false); +pass( + 'DeepSeek uses the original muted eating clip, supports playback speed and preserves frames across task ticks', +); + +for (const action of ['fail-button', 'stop-button', 'reset']) { + $('reset').click(); + $('run-button').click(); + deepseekVideo.currentTime = 1.5; + $(action).click(); + assert.equal(deepseekVideo.paused, true); + assert.equal(deepseekVideo.currentTime, 0); + assert(!$('stage').classList.contains('deepseek-performing')); +} +$('run-button').click(); +tick(20); +state('success', '已完成'); +assert.equal(deepseekVideo.paused, false); +deepseekVideo.currentTime = 2.5; +visibility.hidden = true; +d.dispatchEvent(new w.Event('visibilitychange')); +assert.equal(deepseekVideo.paused, true); +visibility.hidden = false; +d.dispatchEvent(new w.Event('visibilitychange')); +assert.equal(deepseekVideo.paused, false); +w.dispatchEvent(new w.Event('pagehide')); +assert.equal(deepseekVideo.paused, true); +assert.equal(deepseekVideo.currentTime, 0); +await flushPlayback(); +assert.equal(deepseekVideo.paused, true); +w.dispatchEvent(new w.Event('pageshow')); +assert.equal(deepseekVideo.paused, false); +pass( + 'DeepSeek error, stop, reset, visibility and pagehide enforce static fallback without changing task results', +); + +$('reset').click(); +await flushPlayback(); +playback.mode = 'reject'; +$('scene-trigger').click(); +await flushPlayback(); +assert.equal(deepseekVideo.paused, true); +assert(!$('stage').classList.contains('deepseek-performing')); +assert.equal(d.getElementById('meme-dialogue'), null); +$('reset').click(); +playback.mode = 'throw'; +$('scene-trigger').click(); +assert.equal(deepseekVideo.paused, true); +assert(!$('stage').classList.contains('deepseek-performing')); +$('reset').click(); +playback.mode = 'pending'; +const beforeIndependentPlayback = playback.plays; +$('scene-trigger').click(); +$('run-button').click(); +tick(3); +assert.equal(playback.plays, beforeIndependentPlayback + 1); +assert.equal(playback.pending.length, 1); +skin('naiwa'); +$('scene-trigger').click(); +assert.equal(playback.pending.length, 2, 'each character owns an independent playback request'); +playback.pending.shift()(); +await flushPlayback(); +assert.equal( + deepseekVideo.paused, + true, + 'a stale DeepSeek completion cannot restart an inactive character', +); +assert.equal(video.paused, true, "a stale completion cannot replace Naiwa's pending request"); +assert.equal( + playback.plays, + beforeIndependentPlayback + 2, + 'a stale completion never duplicates the other request', +); +playback.pending.shift()(); +await flushPlayback(); +assert.equal(video.paused, false); +assert.equal(deepseekVideo.paused, true); +w.dispatchEvent(new w.Event('pagehide')); +assert.equal(video.paused, true); +assert.equal(deepseekVideo.paused, true); +playback.mode = 'resolve'; +w.dispatchEvent(new w.Event('pageshow')); +pass( + 'both character clips retain static fallbacks on playback failures and isolate pending requests across skin changes', +); + +skin('vinyl'); +assert.equal( + Number.parseFloat(d.querySelector('.record').style.animationDuration), + 60 / Number($('intensity').value), +); +assert.equal(timers.size, 0); +assert.equal(timeouts.size, 0); +assert.deepEqual(errors, []); +pass('vinyl rpm matches the declared duration; no pending timers or jsdom runtime errors'); + +dom.window.close(); +console.log( + JSON.stringify( + { + checks: results, + boundary: + 'Document/data/DOM/event checks only. No browser rendering, CSS geometry, native API, or production security verification.', + }, + null, + 2, + ), +); diff --git a/docs/design/skin-system/tests/integration-browser.mjs b/docs/design/skin-system/tests/integration-browser.mjs new file mode 100644 index 00000000..9953029f --- /dev/null +++ b/docs/design/skin-system/tests/integration-browser.mjs @@ -0,0 +1,278 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createServer } from 'vite'; +import { chromium } from 'playwright'; + +// Exercise the actual App/settings, with a simulated Maa backend and no device commands. +const root = fileURLToPath(new URL('../../../../', import.meta.url)); +const output = process.env.SKIN_INTEGRATION_OUTPUT; +if (output) fs.mkdirSync(output, { recursive: true }); +const server = await createServer({ + root, + server: { host: '127.0.0.1', port: 0, strictPort: false }, +}); +let browser; +const checks = []; +try { + await server.listen(); + browser = await chromium.launch({ + headless: true, + executablePath: + process.env.CHROMIUM_PATH || + (fs.existsSync('/usr/bin/chromium') ? '/usr/bin/chromium' : undefined), + }); + const page = await browser.newPage({ viewport: { width: 1280, height: 1000 } }); + const errors = []; + page.on('pageerror', (error) => errors.push(error.message)); + await page.routeWebSocket('**/api/ws', () => {}); + await page.route('**/api/**', async (route) => { + const url = new URL(route.request().url()); + let body = {}; + if (url.pathname === '/api/interface') { + body = { + interface: { + interface_version: 2, + name: 'MXU Skin Preview', + version: '0.0.0-dev', + task: [], + resource: [], + controller: [], + }, + translations: {}, + basePath: '', + dataPath: '', + backendOS: 'linux', + }; + } else if (url.pathname === '/api/config/snapshot') { + body = { + revision: 'fixture-1', + config: { + version: '1.0', + instances: [], + settings: { + theme: 'dark', + accentColor: 'deepsea', + language: 'zh-CN', + skinId: 'deepseek', + skinReducedMotion: false, + onboardingCompleted: true, + }, + }, + }; + } else if (url.pathname === '/api/config' && route.request().method() === 'PUT') { + body = { revision: 'fixture-2', config: route.request().postDataJSON().config }; + } else if (url.pathname === '/api/maa/state') { + body = { + instances: {}, + cached_adb_devices: [], + cached_win32_windows: [], + cached_wlroots_sockets: [], + cached_gamescope_instances: [], + }; + } else if (url.pathname === '/api/maa/initialized') { + body = { initialized: true, version: '5.0.0' }; + } else if (url.pathname === '/api/focus/pending') body = []; + await route.fulfill({ json: body }); + }); + const base = server.resolvedUrls.local[0]; + await page.goto(base); + await page.locator('[data-builtin-skin="deepseek"]').waitFor(); + assert(await page.locator('html').evaluate((element) => element.classList.contains('dark'))); + checks.push('startup restores skin and theme even when the saved configuration has no instances'); + assert.equal( + await page.locator('[data-builtin-skin] p').count(), + 0, + 'companions do not invent dialogue', + ); + await page.getByRole('button', { name: '投喂白饭', exact: true }).click(); + await page.waitForFunction( + () => document.querySelector('[data-builtin-skin="deepseek"] video')?.currentTime > 0.2, + ); + assert( + await page + .locator('[data-builtin-skin="deepseek"] video') + .evaluate((element) => element.muted && !element.paused), + ); + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.waitForFunction( + () => document.querySelector('[data-builtin-skin="deepseek"] video').paused, + ); + await page.emulateMedia({ reducedMotion: 'no-preference' }); + checks.push( + 'community rice-eating footage plays silently and respects reduced motion without character dialogue', + ); + await page.getByTitle('设置', { exact: true }).click(); + await page.getByRole('button', { name: '奶蛙', exact: true }).click(); + assert.equal( + await page.getByRole('button', { name: '奶蛙', exact: true }).getAttribute('aria-pressed'), + 'true', + ); + await page.getByRole('button', { name: '播放大笑', exact: true }).click(); + const video = page.locator('[data-builtin-skin] video'); + await page.waitForFunction( + () => document.querySelector('[data-builtin-skin] video')?.currentTime > 0.2, + ); + assert(await video.evaluate((element) => element.muted && !element.paused)); + await page.getByRole('checkbox', { name: '减少皮肤动效', exact: true }).check(); + await page.waitForFunction(() => document.querySelector('[data-builtin-skin] video').paused); + await page.getByRole('checkbox', { name: '减少皮肤动效', exact: true }).uncheck(); + if (output) { + await page.locator('#settings-skin-label').scrollIntoViewIfNeeded(); + await page.screenshot({ path: path.join(output, 'settings-skins.png') }); + } + checks.push( + 'settings selects each bundled skin and previews real silent video with a motion toggle', + ); + await page.reload(); + await page.locator('[data-builtin-skin="naiwa"]').waitFor(); + assert(await page.locator('html').evaluate((element) => element.classList.contains('dark'))); + assert.deepEqual( + await page.evaluate(() => { + const appearance = JSON.parse(localStorage.getItem('mxu-webui-appearance')); + return { + theme: appearance.theme, + language: appearance.language, + accent: appearance.accentColor, + }; + }), + { theme: 'dark', language: 'zh-CN', accent: 'deepsea' }, + ); + assert.equal( + await page.evaluate(() => JSON.parse(localStorage.getItem('mxu-webui-appearance')).skinId), + 'naiwa', + ); + checks.push('WebUI selection persists across reload without overwriting the backend appearance'); + + const setTaskState = async (mode) => + page.evaluate(async (state) => { + const { useAppStore } = await import('/src/stores/appStore.ts'); + const store = useAppStore.getState(); + store.updateInstance(store.activeInstanceId, { isRunning: state === 'running' }); + store.setInstanceTaskStatus( + store.activeInstanceId, + state === 'success' + ? 'Succeeded' + : state === 'error' + ? 'Failed' + : state === 'running' + ? 'Running' + : null, + ); + }, mode); + await setTaskState('running'); + await page.getByTitle('设置', { exact: true }).click(); + const retained = await page.evaluate(async () => { + const { useAppStore } = await import('/src/stores/appStore.ts'); + const before = useAppStore.getState(); + const instances = before.instances, + statuses = before.instanceTaskStatus; + before.setSkinId('deepseek'); + const after = useAppStore.getState(); + return ( + instances === after.instances && + statuses === after.instanceTaskStatus && + before.theme === after.theme && + before.accentColor === after.accentColor + ); + }); + assert(retained); + assert.equal( + await page.locator('[data-builtin-skin]').getAttribute('data-task-state'), + 'idle', + 'settings preview must not impersonate the running task', + ); + await page.getByRole('button', { name: '默认', exact: true }).click(); + assert.equal(await page.locator('[data-builtin-skin]').count(), 0); + await page.getByRole('button', { name: '奶蛙', exact: true }).click(); + await page.evaluate(async () => + (await import('/src/stores/appStore.ts')).useAppStore.getState().setCurrentPage('main'), + ); + assert.equal( + await page.locator('[data-builtin-skin]').getAttribute('data-task-state'), + 'running', + ); + checks.push( + 'switching/default removal leaves the running task and existing theme intact; preview remains independent', + ); + await setTaskState('success'); + await page.waitForFunction( + () => document.querySelector('[data-builtin-skin] video').currentTime > 0.2, + ); + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.waitForFunction(() => document.querySelector('[data-builtin-skin] video').paused); + assert.equal( + await page.locator('[data-builtin-skin]').getAttribute('data-task-state'), + 'success', + ); + await page.emulateMedia({ reducedMotion: 'no-preference' }); + await setTaskState('error'); + assert.equal(await page.locator('[data-builtin-skin]').getAttribute('data-task-state'), 'error'); + assert(await page.locator('[data-builtin-skin] video').evaluate((element) => element.paused)); + await setTaskState('idle'); + assert.equal(await page.locator('[data-builtin-skin]').getAttribute('data-task-state'), 'idle'); + checks.push( + 'real host results drive celebration/error; stop stays neutral and system motion reduction pauses video', + ); + + for (const [width, height] of [ + [1280, 900], + [850, 525], + [680, 420], + [566, 350], + [390, 844], + ]) { + await page.setViewportSize({ width, height }); + for (const skinId of ['deepseek', 'naiwa']) { + await page.evaluate( + async (id) => + (await import('/src/stores/appStore.ts')).useAppStore.getState().setSkinId(id), + skinId, + ); + const companion = page.locator('[data-builtin-skin]'); + await companion.scrollIntoViewIfNeeded(); + const geometry = await page.evaluate(() => { + const card = document.querySelector('[data-builtin-skin]').getBoundingClientRect(); + const action = [...document.querySelectorAll('[data-role="start-stop-button"]')] + .find((element) => element.getBoundingClientRect().height > 0) + .getBoundingClientRect(); + return { + overflow: document.documentElement.scrollWidth > innerWidth + 1, + overlap: + Math.min(card.right, action.right) > Math.max(card.left, action.left) && + Math.min(card.bottom, action.bottom) > Math.max(card.top, action.top), + clipped: card.left < 0 || card.right > innerWidth + 1, + }; + }); + assert.deepEqual( + geometry, + { overflow: false, overlap: false, clipped: false }, + `${skinId} ${width}x${height}`, + ); + } + checks.push(`real task toolbar and companion remain separated at ${width}x${height}`); + } + if (output) { + await page.setViewportSize({ width: 1280, height: 900 }); + await setTaskState('success'); + await page.waitForTimeout(350); + await page.screenshot({ path: path.join(output, 'main-naiwa.png') }); + } + assert.deepEqual(errors, [], 'no browser runtime errors'); + console.log( + JSON.stringify( + { + checks, + browser: browser.version(), + boundary: + 'Actual React App and settings in Chromium with a simulated backend; no native Tauri or real devices.', + }, + null, + 2, + ), + ); +} finally { + await browser?.close(); + await server.close(); +} diff --git a/src/App.tsx b/src/App.tsx index b5ab908f..a559e793 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,6 +18,7 @@ import { ConnectionPanel, } from '@/components'; import { BackgroundOverlay } from '@/components/BackgroundOverlay'; +import { BuiltinSkinCompanion } from '@/components/skins/BuiltinSkinCompanion'; import { isAllowedSettingsHotkey } from '@/components/FormControls'; import type { BadPathType } from '@/components'; import { @@ -105,12 +106,7 @@ import { WebUIBetaBanner } from './components/app/WebUIBetaBanner'; import { startGlobalCallbackListener } from './components/connection/callbackCache'; import { useIsMobile } from '@/hooks/useIsMobile'; import { ScrollText } from 'lucide-react'; -import { - defaultConfig, - defaultScheduledUpdateCheckTime, - defaultWindowSize, - isValidMxuConfig, -} from '@/types/config'; +import { defaultConfig, defaultWindowSize, isValidMxuConfig } from '@/types/config'; const log = loggers.app; @@ -762,17 +758,8 @@ function App() { toast.error(t('config.recoveryFailed')); } - // 应用配置 - if (config.instances.length > 0) { - importConfig(config); - } else { - // 无实例时单独恢复定时检查偏好 - const state = useAppStore.getState(); - state.setScheduledUpdateCheckEnabled(config.settings.scheduledUpdateCheckEnabled === true); - state.setScheduledUpdateCheckTime( - config.settings.scheduledUpdateCheckTime ?? defaultScheduledUpdateCheckTime, - ); - } + // 外观与全局设置也适用于尚无实例的配置;下方仍会按既有流程创建初始实例。 + importConfig(config); // 初始化匿名遥测(仅当 interface 声明了 telemetry.sentry.dsn 且非调试 / 开发版本) // 即便用户当前关闭,也传入配置以便后端缓存,用户在设置中开启时无需重启 @@ -1213,6 +1200,8 @@ function App() { localAppearance.customAccents?.forEach((a) => registerCustomAccent(a)); useAppStore.setState({ theme: localAppearance.theme, + skinId: localAppearance.skinId, + skinReducedMotion: localAppearance.skinReducedMotion, accentColor: localAppearance.accentColor, language: localAppearance.language, backgroundImage: localAppearance.backgroundImage, @@ -2194,6 +2183,7 @@ function App() { {/* 工具栏(任务列表下方) */} + setShowAddTaskPanel(!showAddTaskPanel)} @@ -2240,6 +2230,7 @@ function App() { {/* 底部工具栏 */} + setShowAddTaskPanel(!showAddTaskPanel)} diff --git a/src/assets/skins/LICENSE.deepseek.txt b/src/assets/skins/LICENSE.deepseek.txt new file mode 100644 index 00000000..3661f565 --- /dev/null +++ b/src/assets/skins/LICENSE.deepseek.txt @@ -0,0 +1,33 @@ +白饭鲸鱼娘素材:来源、署名与许可摘要 + +素材与动画提供:PC2005-cloud / dsh-pet +来源仓库:https://github.com/PC2005-cloud/dsh-pet +固定提交:33938a355f422525886c3facd883d7179c58ebf4 +许可说明:https://github.com/PC2005-cloud/dsh-pet/blob/33938a355f422525886c3facd883d7179c58ebf4/README.md#许可 + +原型「溟月」:上善无形 +作者主页:https://space.bilibili.com/4456176 +DeepSeek 女仆鲸鱼娘设计:ZipZipPipe +作者主页:https://space.bilibili.com/4168597 +形象许可原声明:https://www.bilibili.com/opus/1247156596114456581 + +依来源说明,动画素材与角色形象按 CC BY-NC-SA 4.0 使用: +署名(BY)、非商业(NC)、相同方式共享(SA)。 +许可摘要:https://creativecommons.org/licenses/by-nc-sa/4.0/deed.zh-hans +完整许可文本:https://creativecommons.org/licenses/by-nc-sa/4.0/legalcode +本文件是来源与处理记录;正式条款以完整许可为准。 + +本地文件与处理: +- deepseek-eat.mp4:从上述固定提交 dsh-pet/assets/webm/吃白饭.webm + 解码 VP9 alpha、合成白底、移除音轨并转为 H.264,保留约 10.04 秒原动作。 +- deepseek.png:从同一吃白饭视频的 4 秒处导出透明 PNG 静态帧。 +- deepseek-calm.png:从同一提交 dsh-pet/assets/webm/待机呼吸休闲.webm + 的 1 秒处导出透明 PNG 静态帧。 +- deepseek-error.png:从同一提交 dsh-pet/assets/webm/工作状态-垂头叹气冒汗.webm + 的 4 秒处导出透明 PNG 静态帧。 + +本次修改仅为视频解码、白底合成、转码与静态帧导出,静态 PNG 保留透明, +不生成或重绘角色,不添加对白或额外剧情。主程序与离线 Demo 中的对应副本 +保留相同来源。这批媒体不适用来源仓库的代码 MIT 许可,也不因 MXU 的代码 +许可而改授许可。非 DeepSeek 官方形象;不暗示上述作者或 DeepSeek 对 MXU +的认可或代言。 diff --git a/src/assets/skins/README.md b/src/assets/skins/README.md new file mode 100644 index 00000000..d6755d45 --- /dev/null +++ b/src/assets/skins/README.md @@ -0,0 +1,26 @@ +# 内置角色素材 + +## 白饭鲸鱼娘 + +直接使用 [PC2005-cloud/dsh-pet](https://github.com/PC2005-cloud/dsh-pet) 在提交 `33938a355f422525886c3facd883d7179c58ebf4` 已公开的社区视频。本次不生成或重绘鲸鱼娘;透明 PNG 仅从同源视频抽帧。 + +| 本地文件 | 固定来源 | 处理 | +| --- | --- | --- | +| `deepseek-eat.mp4` | [吃白饭.webm](https://github.com/PC2005-cloud/dsh-pet/blob/33938a355f422525886c3facd883d7179c58ebf4/dsh-pet/assets/webm/%E5%90%83%E7%99%BD%E9%A5%AD.webm) | VP9 alpha 解码、白底合成、移除音轨并转为 H.264,保留约 10.04 秒原动作 | +| `deepseek.png` | 同上 | 取 4 秒静态帧,保留透明 | +| `deepseek-calm.png` | [待机呼吸休闲.webm](https://github.com/PC2005-cloud/dsh-pet/blob/33938a355f422525886c3facd883d7179c58ebf4/dsh-pet/assets/webm/%E5%BE%85%E6%9C%BA%E5%91%BC%E5%90%B8%E4%BC%91%E9%97%B2.webm) | 取 1 秒静态帧,保留透明 | +| `deepseek-error.png` | [工作状态-垂头叹气冒汗.webm](https://github.com/PC2005-cloud/dsh-pet/blob/33938a355f422525886c3facd883d7179c58ebf4/dsh-pet/assets/webm/%E5%B7%A5%E4%BD%9C%E7%8A%B6%E6%80%81-%E5%9E%82%E5%A4%B4%E5%8F%B9%E6%B0%94%E5%86%92%E6%B1%97.webm) | 取 4 秒静态帧,保留透明 | + +动画素材:**PC2005-cloud**;原型「溟月」:**上善无形**;DeepSeek 女仆鲸鱼娘设计:**ZipZipPipe**。按来源 [README 许可说明](https://github.com/PC2005-cloud/dsh-pet/blob/33938a355f422525886c3facd883d7179c58ebf4/README.md#许可) 使用 CC BY-NC-SA 4.0,保留署名、非商业、相同方式共享条件。来源仓库的代码 MIT 许可不替代素材许可。随附来源与许可摘要见 [LICENSE.deepseek.txt](LICENSE.deepseek.txt)。 + +原视频为 VP9 alpha。为避免透明 WebM 不受支持时出现黑底,随包动态素材转为白底 H.264;播放失败、格式不支持或减少动效时使用同源静态帧。不承诺所有原生 WebView 都已通过验证。 + +## 奶蛙 + +- `naiwa-calm.png`:从同一 Naiwa Hub 未裁剪原视频第 1 秒导出的闭口静态 PNG,未重绘角色。 +- `naiwa-still.webp`:[Naiwa Hub 奶蛙大笑条目](https://naiwahub.com/zh/memes/ghn-naiwa-wallpaper-1c05a2a8af20)提供的海报。 +- `naiwa-laugh.mp4`:同一条目的本地视频,移除音轨并剪去开头 2.6 秒,保留 12.8 秒大笑动作。 + +社区原片和海报的收录页注明创作者及素材权利尚未核实;公开访问不代表开源授权,不将这些媒体归入代码许可。完整来源记录见 [皮肤说明](../../../docs/design/skin-system/meme-skins.md)。 + +最终两套不包含本次生成或重绘的角色图,不添加对白或额外剧情。运行时只使用随包本地资源,界面文字仅表达任务事实与操作提示。 diff --git a/src/assets/skins/deepseek-calm.png b/src/assets/skins/deepseek-calm.png new file mode 100644 index 00000000..7c3143f0 Binary files /dev/null and b/src/assets/skins/deepseek-calm.png differ diff --git a/src/assets/skins/deepseek-eat.mp4 b/src/assets/skins/deepseek-eat.mp4 new file mode 100644 index 00000000..37f7401d Binary files /dev/null and b/src/assets/skins/deepseek-eat.mp4 differ diff --git a/src/assets/skins/deepseek-error.png b/src/assets/skins/deepseek-error.png new file mode 100644 index 00000000..93ba13f3 Binary files /dev/null and b/src/assets/skins/deepseek-error.png differ diff --git a/src/assets/skins/deepseek.png b/src/assets/skins/deepseek.png new file mode 100644 index 00000000..d8203351 Binary files /dev/null and b/src/assets/skins/deepseek.png differ diff --git a/src/assets/skins/naiwa-calm.png b/src/assets/skins/naiwa-calm.png new file mode 100644 index 00000000..acd10430 Binary files /dev/null and b/src/assets/skins/naiwa-calm.png differ diff --git a/src/assets/skins/naiwa-laugh.mp4 b/src/assets/skins/naiwa-laugh.mp4 new file mode 100644 index 00000000..d8ea1656 Binary files /dev/null and b/src/assets/skins/naiwa-laugh.mp4 differ diff --git a/src/assets/skins/naiwa-still.webp b/src/assets/skins/naiwa-still.webp new file mode 100644 index 00000000..cb7395a7 Binary files /dev/null and b/src/assets/skins/naiwa-still.webp differ diff --git a/src/components/settings/AppearanceSection.tsx b/src/components/settings/AppearanceSection.tsx index 79de815f..1ab97ed8 100644 --- a/src/components/settings/AppearanceSection.tsx +++ b/src/components/settings/AppearanceSection.tsx @@ -16,6 +16,10 @@ import { useAppStore } from '@/stores/appStore'; import { getAccentInfoList, type AccentColor, type CustomAccent } from '@/themes'; import { SortableAccentTile } from './SortableAccentTile'; import { isTauri } from '@/utils/windowUtils'; +import { BuiltinSkinCompanion } from '@/components/skins/BuiltinSkinCompanion'; +import deepseekThumbnail from '@/assets/skins/deepseek.png'; +import naiwaThumbnail from '@/assets/skins/naiwa-calm.png'; +import deepseekLicense from '@/assets/skins/LICENSE.deepseek.txt?raw'; interface AppearanceSectionProps { onOpenCreateAccentModal: () => void; @@ -32,6 +36,10 @@ export function AppearanceSection({ const { theme, setTheme, + skinId, + setSkinId, + skinReducedMotion, + setSkinReducedMotion, accentColor, setAccentColor, customAccents, @@ -137,6 +145,95 @@ export function AppearanceSection({ + {/* 内置皮肤 */} +
+
+
+

+ {t('settings.skinHint')} +

+
+ {(['default', 'deepseek', 'naiwa'] as const).map((id) => ( + + ))} +
+ {skinId === 'deepseek' && ( +
+ {t('settings.skinAssetLicense')} +
+              {deepseekLicense}
+            
+
+ )} +
+ +

+ {t('settings.skinReducedMotionHint')} +

+
+ {skinId !== 'default' && ( +
+

+ {t('settings.skinPreview')} +

+ +
+ )} +
+ {/* 强调色 */}
diff --git a/src/components/skins/BuiltinSkinCompanion.tsx b/src/components/skins/BuiltinSkinCompanion.tsx new file mode 100644 index 00000000..a5a975c0 --- /dev/null +++ b/src/components/skins/BuiltinSkinCompanion.tsx @@ -0,0 +1,205 @@ +import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useShallow } from 'zustand/react/shallow'; +import { useAppStore } from '@/stores/appStore'; +import type { BuiltinSkinId } from '@/skins/types'; +import deepseekArt from '@/assets/skins/deepseek.png'; +import deepseekCalm from '@/assets/skins/deepseek-calm.png'; +import deepseekError from '@/assets/skins/deepseek-error.png'; +import deepseekClip from '@/assets/skins/deepseek-eat.mp4'; +import naiwaCalm from '@/assets/skins/naiwa-calm.png'; +import naiwaStill from '@/assets/skins/naiwa-still.webp'; +import naiwaClip from '@/assets/skins/naiwa-laugh.mp4'; +import { getSkinTaskState } from './state'; +import './skins.css'; + +const INTERACTION_MS = 2400; + +function useSkinMotion() { + const [systemReduced, setSystemReduced] = useState( + () => + typeof window !== 'undefined' && + !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches, + ); + const [pageVisible, setPageVisible] = useState( + () => typeof document === 'undefined' || !document.hidden, + ); + useEffect(() => { + const media = window.matchMedia('(prefers-reduced-motion: reduce)'); + const updateMotion = () => setSystemReduced(media.matches); + const updateVisibility = () => setPageVisible(!document.hidden); + const hide = () => setPageVisible(false); + updateMotion(); + media.addEventListener('change', updateMotion); + document.addEventListener('visibilitychange', updateVisibility); + window.addEventListener('pagehide', hide); + window.addEventListener('pageshow', updateVisibility); + return () => { + media.removeEventListener('change', updateMotion); + document.removeEventListener('visibilitychange', updateVisibility); + window.removeEventListener('pagehide', hide); + window.removeEventListener('pageshow', updateVisibility); + }; + }, []); + return { systemReduced, pageVisible }; +} + +/** Trusted, bundled companions only. This is not a third-party skin renderer. */ +export function BuiltinSkinCompanion({ preview = false }: { preview?: boolean }) { + const skinId = useAppStore((state) => state.skinId); + const instanceId = useAppStore((state) => state.activeInstanceId); + if (skinId === 'default') return null; + return ( + + ); +} + +function SkinScene({ + skinId, + preview, +}: { + skinId: Exclude; + preview: boolean; +}) { + const { t } = useTranslation(); + const { activeInstanceId, isRunning, status, userReduced } = useAppStore( + useShallow((state) => ({ + activeInstanceId: state.activeInstanceId, + isRunning: !!state.instances.find((instance) => instance.id === state.activeInstanceId) + ?.isRunning, + status: state.activeInstanceId ? state.instanceTaskStatus[state.activeInstanceId] : null, + userReduced: state.skinReducedMotion, + })), + ); + const taskState = preview ? 'idle' : getSkinTaskState(isRunning, status); + const { systemReduced, pageVisible } = useSkinMotion(); + const reduced = userReduced || systemReduced; + const [interacting, setInteracting] = useState(false); + const [videoFailed, setVideoFailed] = useState(false); + const timer = useRef | null>(null); + const interactionRevision = useRef(0); + const videoRef = useRef(null); + const playbackRevision = useRef(0); + const wantsPlayback = useRef(false); + const interactionAllowed = taskState === 'idle' || taskState === 'running'; + const hasInteraction = interacting && interactionAllowed; + const laughing = skinId === 'naiwa' && (hasInteraction || taskState === 'success'); + const eating = + skinId === 'deepseek' && (hasInteraction || taskState === 'running' || taskState === 'success'); + const performing = (laughing || eating) && !reduced && pageVisible && !videoFailed; + wantsPlayback.current = performing; + + // Instance changes and terminal results retire the old interaction without changing any task. + useEffect(() => { + setInteracting(false); + interactionRevision.current++; + if (timer.current !== null) clearTimeout(timer.current); + timer.current = null; + return () => { + interactionRevision.current++; + if (timer.current !== null) clearTimeout(timer.current); + timer.current = null; + }; + }, [activeInstanceId, taskState, pageVisible]); + + useEffect(() => { + const video = videoRef.current; + if (!video) return; + const revision = ++playbackRevision.current; + video.muted = true; + video.defaultMuted = true; + if (performing) { + try { + const result = video.play(); + result?.then( + () => { + if ( + !video.isConnected || + (revision !== playbackRevision.current && !wantsPlayback.current) + ) + video.pause(); + }, + () => { + if (revision === playbackRevision.current) setVideoFailed(true); + }, + ); + } catch { + setVideoFailed(true); + } + } else { + video.pause(); + if (taskState === 'idle' || taskState === 'error' || !pageVisible) video.currentTime = 0; + } + return () => { + playbackRevision.current++; + video.pause(); + }; + }, [performing, activeInstanceId, taskState, pageVisible]); + + function interact() { + if (!interactionAllowed) return; + if (timer.current !== null) clearTimeout(timer.current); + const revision = ++interactionRevision.current; + setInteracting(true); + timer.current = setTimeout(() => { + if (revision !== interactionRevision.current) return; + timer.current = null; + setInteracting(false); + }, INTERACTION_MS); + } + + const deepseekPose = taskState === 'error' ? deepseekError : eating ? deepseekArt : deepseekCalm; + + return ( +
+ +
+
+ + {t(skinId === 'deepseek' ? 'settings.skinDeepseek' : 'settings.skinNaiwa')} + + + {t(`skin.state.${taskState}`)} + +
+ {reduced && {t('skin.reducedMotion')}} +
+ +
+ ); +} diff --git a/src/components/skins/skins.css b/src/components/skins/skins.css new file mode 100644 index 00000000..22720acc --- /dev/null +++ b/src/components/skins/skins.css @@ -0,0 +1,144 @@ +.builtin-skin { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: 14px; + margin: 8px 12px; + padding: 8px 14px; + min-width: 0; + color: var(--color-text-primary); + background: var(--color-bg-secondary); + border: 1px solid var(--color-border); + border-radius: 14px; + isolation: isolate; +} +.builtin-skin--deepseek { + background-image: linear-gradient(115deg, #3b82f612, transparent 80%); +} +.builtin-skin--naiwa { + background-image: linear-gradient(115deg, #eab30812, transparent 80%); +} +.builtin-skin--preview { + margin: 0; +} +.builtin-skin__art { + width: 84px; + height: 96px; + position: relative; + flex: 0 0 auto; + pointer-events: none; + background: #fff; + border-radius: 10px; + overflow: hidden; +} +.builtin-skin__art--naiwa { + background: #fff; + border-radius: 10px; + overflow: hidden; +} +.builtin-skin__art img, +.builtin-skin__art video { + width: 100%; + height: 100%; + display: block; + object-fit: contain; +} +.builtin-skin__art--deepseek img, +.builtin-skin__art--deepseek video { + object-fit: cover; +} +.builtin-skin__art video { + position: absolute; + inset: 0; +} +.builtin-skin__video { + visibility: hidden; +} +.builtin-skin__video.is-playing { + visibility: visible; +} +.builtin-skin__copy { + flex: 1; + min-width: 0; +} +.builtin-skin__heading { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px 12px; + font-size: 13px; +} +.builtin-skin__heading span { + color: var(--color-text-secondary); + font-size: 12px; +} +.builtin-skin__copy small { + display: block; + font-size: 11px; + color: var(--color-text-secondary); + margin-top: 4px; +} +.builtin-skin__interact { + flex: 0 0 auto; + border: 1px solid var(--color-border-strong); + border-radius: 8px; + background: var(--color-bg-primary); + color: var(--color-text-primary); + padding: 8px 10px; + font-size: 12px; + line-height: 1.5; + cursor: pointer; +} +.builtin-skin__interact:hover:not(:disabled) { + background: var(--color-bg-hover); +} +.builtin-skin__interact:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 3px; +} +.builtin-skin__interact:disabled { + opacity: 0.55; + cursor: default; +} +.builtin-skin[data-reduced-motion='true'] * { + animation: none; + transition: none; +} +@media (max-width: 600px) { + .builtin-skin { + gap: 10px; + margin: 6px 8px; + padding: 8px 10px; + flex-wrap: wrap; + } + .builtin-skin--preview { + margin: 0; + } + .builtin-skin__art { + width: 66px; + height: 76px; + } + .builtin-skin__copy { + flex-basis: calc(100% - 76px); + } + .builtin-skin__interact { + margin-left: 76px; + } +} +@media (max-height: 600px) and (min-width: 601px) { + .builtin-skin { + margin: 4px 8px; + padding: 6px 10px; + gap: 10px; + } + .builtin-skin__art { + width: 56px; + height: 64px; + } +} +@media (prefers-reduced-motion: reduce) { + .builtin-skin * { + animation: none; + transition: none; + } +} diff --git a/src/components/skins/state.ts b/src/components/skins/state.ts new file mode 100644 index 00000000..f905f905 --- /dev/null +++ b/src/components/skins/state.ts @@ -0,0 +1,11 @@ +import type { TaskStatus } from '@/types/maa'; + +export type SkinTaskState = 'idle' | 'running' | 'success' | 'error'; + +/** Only map facts the host currently supplies. A stopped batch has no cancellation reason. */ +export function getSkinTaskState(isRunning: boolean, status?: TaskStatus | null): SkinTaskState { + if (isRunning) return 'running'; + if (status === 'Succeeded') return 'success'; + if (status === 'Failed') return 'error'; + return 'idle'; +} diff --git a/src/i18n/locales/en-US.ts b/src/i18n/locales/en-US.ts index fc7419ed..0b2f30c8 100644 --- a/src/i18n/locales/en-US.ts +++ b/src/i18n/locales/en-US.ts @@ -56,6 +56,19 @@ export default { }, // Settings + skin: { + regionLabel: 'Skin companion', + state: { + idle: 'Idle', + running: 'Running', + success: 'Completed', + error: 'Failed', + }, + feed: 'Feed rice', + poke: 'Play laughter', + reducedMotion: 'Motion reduced', + }, + settings: { title: 'Settings', appearance: 'Appearance', @@ -76,6 +89,16 @@ export default { themeDark: 'Dark', accentColor: 'Accent Color', themeSystem: 'System', + skin: 'Built-in Skins', + skinHint: 'Choose a character skin for the task view. Default hides the character.', + skinAssetLicense: 'Asset source and license', + skinDefault: 'Default', + skinDeepseek: 'Blue Fat Fish', + skinNaiwa: 'Naiwa', + skinReducedMotion: 'Reduce skin motion', + skinReducedMotionHint: + 'Reduce continuous animation. Your system’s reduced motion preference also applies.', + skinPreview: 'Skin preview', showOptionPreview: 'Show Option Preview', showOptionPreviewHint: 'Display quick preview of options in the task list', openLogDir: 'Open Log Directory', diff --git a/src/i18n/locales/ja-JP.ts b/src/i18n/locales/ja-JP.ts index 2b4bda01..930a6062 100644 --- a/src/i18n/locales/ja-JP.ts +++ b/src/i18n/locales/ja-JP.ts @@ -51,6 +51,19 @@ export default { }, // 設定 + skin: { + regionLabel: 'スキンの仲間', + state: { + idle: '待機中', + running: '実行中', + success: '完了', + error: '実行失敗', + }, + feed: '白ごはんをあげる', + poke: '大笑いを再生', + reducedMotion: '動きを減らしています', + }, + settings: { title: '設定', appearance: '外観', @@ -71,6 +84,15 @@ export default { themeDark: 'ダーク', accentColor: 'アクセントカラー', themeSystem: 'システムに従う', + skin: '内蔵スキン', + skinHint: 'タスク画面のキャラクタースキンを選択します。「デフォルト」で非表示になります。', + skinAssetLicense: '素材の出典とライセンス', + skinDefault: 'デフォルト', + skinDeepseek: '青い太っちょクジラ', + skinNaiwa: 'ナイワ', + skinReducedMotion: 'スキンの動きを減らす', + skinReducedMotionHint: '連続する動きを減らします。システムの視覚効果を減らす設定にも従います。', + skinPreview: 'スキンのプレビュー', showOptionPreview: 'オプションプレビューを表示', showOptionPreviewHint: 'タスクリストにオプションのクイックプレビューを表示します', openLogDir: 'ログフォルダを開く', diff --git a/src/i18n/locales/ko-KR.ts b/src/i18n/locales/ko-KR.ts index 5c8f1872..85292a4d 100644 --- a/src/i18n/locales/ko-KR.ts +++ b/src/i18n/locales/ko-KR.ts @@ -51,6 +51,19 @@ export default { }, // 설정 + skin: { + regionLabel: '스킨 친구', + state: { + idle: '대기 중', + running: '실행 중', + success: '완료', + error: '실행 실패', + }, + feed: '흰밥 주기', + poke: '웃음 재생', + reducedMotion: '움직임을 줄였습니다', + }, + settings: { title: '설정', appearance: '외관', @@ -71,6 +84,15 @@ export default { themeDark: '다크', accentColor: '강조 색상', themeSystem: '시스템 설정', + skin: '기본 제공 스킨', + skinHint: '작업 화면의 캐릭터 스킨을 선택합니다. 기본값을 선택하면 캐릭터가 숨겨집니다.', + skinAssetLicense: '소재 출처 및 라이선스', + skinDefault: '기본값', + skinDeepseek: '파란 뚱보 고래', + skinNaiwa: '나이와', + skinReducedMotion: '스킨 움직임 줄이기', + skinReducedMotionHint: '반복되는 움직임을 줄입니다. 시스템의 동작 줄이기 설정도 적용됩니다.', + skinPreview: '스킨 미리보기', showOptionPreview: '옵션 미리보기 표시', showOptionPreviewHint: '작업 목록에 옵션의 빠른 미리보기를 표시합니다', openLogDir: '로그 폴더 열기', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 87d21c24..eca184b6 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -50,6 +50,19 @@ export default { close: '关闭', }, + skin: { + regionLabel: '皮肤伙伴', + state: { + idle: '待机中', + running: '执行中', + success: '已完成', + error: '执行失败', + }, + feed: '投喂白饭', + poke: '播放大笑', + reducedMotion: '已减少动态效果', + }, + // 设置 settings: { title: '设置', @@ -70,6 +83,15 @@ export default { themeLight: '浅色', themeDark: '深色', themeSystem: '系统', + skin: '内置皮肤', + skinHint: '选择任务界面的角色皮肤。选择默认可隐藏角色。', + skinAssetLicense: '素材来源与许可', + skinDefault: '默认', + skinDeepseek: '蓝色大肥鱼', + skinNaiwa: '奶蛙', + skinReducedMotion: '减少皮肤动效', + skinReducedMotionHint: '减少连续动作,同时遵循系统的减少动态效果设置。', + skinPreview: '皮肤预览', accentColor: '强调色', showOptionPreview: '显示选项预览', showOptionPreviewHint: '在任务列表中显示选项的快捷预览信息', diff --git a/src/i18n/locales/zh-TW.ts b/src/i18n/locales/zh-TW.ts index 6b8b99da..4fb46d80 100644 --- a/src/i18n/locales/zh-TW.ts +++ b/src/i18n/locales/zh-TW.ts @@ -50,6 +50,19 @@ export default { }, // 設定 + skin: { + regionLabel: '造型夥伴', + state: { + idle: '待命中', + running: '執行中', + success: '已完成', + error: '執行失敗', + }, + feed: '餵白飯', + poke: '播放大笑', + reducedMotion: '已減少動態效果', + }, + settings: { title: '設定', appearance: '外觀', @@ -70,6 +83,15 @@ export default { themeDark: '深色', accentColor: '強調色', themeSystem: '系統', + skin: '內建造型', + skinHint: '選擇任務畫面的角色外觀。選擇預設可隱藏角色。', + skinAssetLicense: '素材來源與授權', + skinDefault: '預設', + skinDeepseek: '藍色大肥魚', + skinNaiwa: '奶蛙', + skinReducedMotion: '減少造型動態效果', + skinReducedMotionHint: '減少連續動作,同時遵循系統的減少動態效果設定。', + skinPreview: '造型預覽', showOptionPreview: '顯示選項預覽', showOptionPreviewHint: '在任務列表中顯示選項的快捷預覽資訊', openLogDir: '開啟日誌目錄', diff --git a/src/services/appearanceStorage.ts b/src/services/appearanceStorage.ts index 1a1335be..2c77cef4 100644 --- a/src/services/appearanceStorage.ts +++ b/src/services/appearanceStorage.ts @@ -8,6 +8,7 @@ import type { AccentColor, CustomAccent } from '@/themes/types'; import type { Theme, Language } from '@/stores/types'; import type { WindowSize, WindowPosition, ScreenshotFrameRate } from '@/types/config'; +import { normalizeBuiltinSkinId, type BuiltinSkinId } from '@/skins/types'; import { loggers } from '@/utils/logger'; const log = loggers.config; @@ -19,6 +20,8 @@ const APPEARANCE_KEY = 'mxu-webui-appearance'; export interface WebUIAppearance { theme: Theme; accentColor: AccentColor; + skinId: BuiltinSkinId; + skinReducedMotion: boolean; language: Language; backgroundImage?: string; backgroundOpacity: number; @@ -28,6 +31,8 @@ export interface WebUIAppearance { const defaultAppearance: WebUIAppearance = { theme: 'system', accentColor: 'emerald', + skinId: 'default', + skinReducedMotion: false, language: 'system', backgroundOpacity: 50, customAccents: [], @@ -37,7 +42,15 @@ export function loadWebUIAppearance(): WebUIAppearance | null { try { const raw = localStorage.getItem(APPEARANCE_KEY); if (!raw) return null; - return { ...defaultAppearance, ...JSON.parse(raw) }; + const parsed: unknown = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null; + const appearance = parsed as Partial; + return { + ...defaultAppearance, + ...appearance, + skinId: normalizeBuiltinSkinId(appearance.skinId), + skinReducedMotion: appearance.skinReducedMotion === true, + }; } catch { log.warn('WebUI 外观设置加载失败,将使用默认值'); return null; @@ -46,14 +59,28 @@ export function loadWebUIAppearance(): WebUIAppearance | null { export function saveWebUIAppearance(appearance: WebUIAppearance): void { try { - localStorage.setItem(APPEARANCE_KEY, JSON.stringify(appearance)); + localStorage.setItem( + APPEARANCE_KEY, + JSON.stringify({ + ...appearance, + skinId: normalizeBuiltinSkinId(appearance.skinId), + skinReducedMotion: appearance.skinReducedMotion === true, + }), + ); } catch { log.warn('WebUI 外观设置保存失败'); } } export function patchWebUIAppearance(patch: Partial): void { - const current = loadWebUIAppearance() || defaultAppearance; + // The first local preference starts from the appearance already loaded from the backend. + // Missing fields in older configs must not replace the local defaults with undefined. + const current = loadWebUIAppearance() || { + ...defaultAppearance, + ...Object.fromEntries( + Object.entries(getBackendAppearance()).filter(([, value]) => value !== undefined), + ), + }; saveWebUIAppearance({ ...current, ...patch }); } @@ -115,13 +142,18 @@ export function patchWebUILayout(patch: Partial): void { * 缓存后端 config 中的外观 & 布局设置。 * generateConfig 序列化时使用此缓存,避免 WebUI 本地偏好回写覆盖桌面端设置。 */ -let _backendAppearance: Partial = {}; +let _backendAppearance: Partial = { + skinId: 'default', + skinReducedMotion: false, +}; let _backendLayout: Partial = {}; export function cacheBackendAppearance( settings: { theme?: Theme; accentColor?: AccentColor; + skinId?: unknown; + skinReducedMotion?: unknown; language?: Language; backgroundImage?: string; backgroundOpacity?: number; @@ -131,6 +163,8 @@ export function cacheBackendAppearance( _backendAppearance = { theme: settings.theme as Theme, accentColor: settings.accentColor as AccentColor, + skinId: normalizeBuiltinSkinId(settings.skinId), + skinReducedMotion: settings.skinReducedMotion === true, language: settings.language as Language, backgroundImage: settings.backgroundImage, backgroundOpacity: settings.backgroundOpacity, diff --git a/src/skins/types.ts b/src/skins/types.ts new file mode 100644 index 00000000..8b4428ac --- /dev/null +++ b/src/skins/types.ts @@ -0,0 +1,7 @@ +/** Reviewed skins bundled with MXU; configuration cannot name external content. */ +export type BuiltinSkinId = 'default' | 'deepseek' | 'naiwa'; + +/** Old configurations and unrecognized skin identifiers use the standard interface. */ +export function normalizeBuiltinSkinId(value: unknown): BuiltinSkinId { + return value === 'deepseek' || value === 'naiwa' ? value : 'default'; +} diff --git a/src/stores/appStore.ts b/src/stores/appStore.ts index 9990dac0..c6a1ba86 100644 --- a/src/stores/appStore.ts +++ b/src/stores/appStore.ts @@ -71,6 +71,7 @@ import { patchWebUILayout, } from '@/services/appearanceStorage'; import { isTauri } from '@/utils/paths'; +import { normalizeBuiltinSkinId } from '@/skins/types'; import { convertPresetOptionValue, generateId, @@ -237,6 +238,8 @@ export const useAppStore = create()( // 主题和语言 theme: 'light', accentColor: 'emerald', + skinId: 'default', + skinReducedMotion: false, language: 'system', backgroundImage: undefined, backgroundOpacity: 50, @@ -258,6 +261,16 @@ export const useAppStore = create()( applyTheme(mode, accent); if (!isTauri()) patchWebUIAppearance({ accentColor: accent }); }, + setSkinId: (skinId) => { + const normalized = normalizeBuiltinSkinId(skinId); + set({ skinId: normalized }); + if (!isTauri()) patchWebUIAppearance({ skinId: normalized }); + }, + setSkinReducedMotion: (enabled) => { + const normalized = enabled === true; + set({ skinReducedMotion: normalized }); + if (!isTauri()) patchWebUIAppearance({ skinReducedMotion: normalized }); + }, setLanguage: (lang) => { set({ language: lang }); setI18nLanguage(lang); @@ -1497,6 +1510,13 @@ export const useAppStore = create()( const localLayout = isWebUI ? loadWebUILayout() : null; const effectiveTheme = localAppearance?.theme ?? config.settings.theme; const effectiveAccentColor = localAppearance?.accentColor ?? configAccentColor; + const effectiveSkinId = normalizeBuiltinSkinId( + localAppearance ? localAppearance.skinId : config.settings.skinId, + ); + const effectiveSkinReducedMotion = + (localAppearance + ? localAppearance.skinReducedMotion + : config.settings.skinReducedMotion) === true; const effectiveLanguage = localAppearance?.language ?? config.settings.language; const effectiveBgImage = localAppearance ? localAppearance.backgroundImage @@ -1526,6 +1546,8 @@ export const useAppStore = create()( activeInstanceId, theme: effectiveTheme, accentColor: effectiveAccentColor, + skinId: effectiveSkinId, + skinReducedMotion: effectiveSkinReducedMotion, language: effectiveLanguage, backgroundImage: effectiveBgImage, backgroundOpacity: effectiveBgOpacity, @@ -2549,6 +2571,8 @@ function generateConfig(): MxuConfig { settings: { theme: ba?.theme ?? state.theme, accentColor: ba?.accentColor ?? state.accentColor, + skinId: normalizeBuiltinSkinId(_isWebUI ? ba?.skinId : state.skinId), + skinReducedMotion: (_isWebUI ? ba?.skinReducedMotion : state.skinReducedMotion) === true, language: ba?.language ?? state.language, backgroundImage: ba?.backgroundImage ?? state.backgroundImage, backgroundOpacity: ba?.backgroundOpacity ?? state.backgroundOpacity, @@ -2635,6 +2659,8 @@ useAppStore.subscribe( ...(!_isWebUI && { theme: state.theme, accentColor: state.accentColor, + skinId: state.skinId, + skinReducedMotion: state.skinReducedMotion, language: state.language, customAccents: state.customAccents, windowSize: state.windowSize, diff --git a/src/stores/types.ts b/src/stores/types.ts index 7e565201..a6c874eb 100644 --- a/src/stores/types.ts +++ b/src/stores/types.ts @@ -24,6 +24,7 @@ import type { GamescopeInstance, } from '@/types/maa'; import type { AccentColor, CustomAccent } from '@/themes'; +import type { BuiltinSkinId } from '@/skins/types'; /** 单个任务的运行状态 */ export type TaskRunStatus = 'idle' | 'pending' | 'running' | 'succeeded' | 'failed'; @@ -107,6 +108,8 @@ export interface AppState { // 主题和语言 theme: Theme; accentColor: AccentColor; + skinId: BuiltinSkinId; + skinReducedMotion: boolean; language: Language; backgroundImage?: string; backgroundOpacity: number; @@ -120,6 +123,8 @@ export interface AppState { customAccents: CustomAccent[]; setTheme: (theme: Theme) => void; setAccentColor: (accent: AccentColor) => void; + setSkinId: (skinId: BuiltinSkinId) => void; + setSkinReducedMotion: (enabled: boolean) => void; setLanguage: (lang: Language) => void; setBackgroundImage: (path?: string) => void; setBackgroundOpacity: (opacity: number) => void; diff --git a/src/types/config.ts b/src/types/config.ts index 1524c031..5993c26d 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -2,6 +2,7 @@ import type { ActionConfig, OptionValue } from './interface'; import type { AccentColor, CustomAccent } from '@/themes/types'; +import type { BuiltinSkinId } from '@/skins/types'; export const DEFAULT_MAX_LOGS_PER_INSTANCE = 500; @@ -136,6 +137,10 @@ export interface HotkeySettings { export interface AppSettings { theme: 'light' | 'dark' | 'system'; accentColor?: AccentColor; // 强调色 + /** 内置皮肤;旧配置或未知标识按 default 处理。 */ + skinId?: BuiltinSkinId; + /** 手动减少皮肤动效;系统的减少动效设置始终优先。 */ + skinReducedMotion?: boolean; language: 'system' | 'zh-CN' | 'zh-TW' | 'en-US' | 'ja-JP' | 'ko-KR'; backgroundImage?: string; // 背景图片路径 backgroundOpacity?: number; // 背景图片不透明度 (0-100) @@ -251,6 +256,8 @@ export const defaultConfig: MxuConfig = { settings: { theme: 'system', accentColor: defaultAccentColor, + skinId: 'default', + skinReducedMotion: false, language: 'system', confirmBeforeDelete: false, maxLogsPerInstance: DEFAULT_MAX_LOGS_PER_INSTANCE, diff --git a/tests/builtinSkins.test.ts b/tests/builtinSkins.test.ts new file mode 100644 index 00000000..d1959276 --- /dev/null +++ b/tests/builtinSkins.test.ts @@ -0,0 +1,86 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { normalizeBuiltinSkinId } from '../src/skins/types.ts'; +import { getSkinTaskState } from '../src/components/skins/state.ts'; +import type { TaskStatus } from '../src/types/maa.ts'; +import enUS from '../src/i18n/locales/en-US.ts'; +import zhCN from '../src/i18n/locales/zh-CN.ts'; +import zhTW from '../src/i18n/locales/zh-TW.ts'; +import jaJP from '../src/i18n/locales/ja-JP.ts'; +import koKR from '../src/i18n/locales/ko-KR.ts'; + +test('skin IDs accept only bundled choices and old or unrecognized values use the default interface', () => { + for (const id of ['default', 'deepseek', 'naiwa'] as const) + assert.equal(normalizeBuiltinSkinId(id), id); + for (const value of [ + undefined, + null, + '', + false, + 1, + ['naiwa'], + { id: 'deepseek' }, + 'DeepSeek', + '__proto__', + 'third-party-skin', + 'https://example.invalid/skin.html', + ]) { + assert.equal(normalizeBuiltinSkinId(value), 'default'); + } +}); + +test('skin task feedback honors active runs before old outcomes and never invents pause or cancellation facts', () => { + const statuses: Array = [ + undefined, + null, + 'Pending', + 'Running', + 'Succeeded', + 'Failed', + ]; + for (const status of statuses) assert.equal(getSkinTaskState(true, status), 'running'); + assert.equal(getSkinTaskState(false, 'Succeeded'), 'success'); + assert.equal(getSkinTaskState(false, 'Failed'), 'error'); + for (const status of [undefined, null, 'Pending', 'Running'] as const) + assert.equal(getSkinTaskState(false, status), 'idle'); + // Stopping currently clears the host fact; it does not distinguish pause from cancellation. + for (const unsupported of ['Paused', 'Cancelled']) + assert.equal(getSkinTaskState(false, unsupported as TaskStatus), 'idle'); +}); + +function flattenStrings(value: Record, prefix = ''): Record { + return Object.fromEntries( + Object.entries(value).flatMap(([key, item]) => { + const path = prefix ? `${prefix}.${key}` : key; + if (item && typeof item === 'object') + return Object.entries(flattenStrings(item as Record, path)); + assert.equal(typeof item, 'string', `${path} must be translated text`); + assert((item as string).trim(), `${path} must not be empty`); + return [[path, item as string]]; + }), + ); +} + +test('skin controls and factual task labels are translated without invented character dialogue in all five languages', () => { + const resources = { enUS, zhCN, zhTW, jaJP, koKR }; + const expected = flattenStrings(enUS.skin); + const settingKeys = Object.keys(enUS.settings) + .filter((key) => key.startsWith('skin')) + .sort(); + for (const [language, translation] of Object.entries(resources)) { + const messages = flattenStrings(translation.skin); + assert.deepEqual(Object.keys(messages).sort(), Object.keys(expected).sort(), language); + assert.equal(Object.hasOwn(translation.skin, 'deepseek'), false, language); + assert.equal(Object.hasOwn(translation.skin, 'naiwa'), false, language); + const settings = Object.fromEntries( + Object.entries(translation.settings).filter(([key]) => key.startsWith('skin')), + ); + assert.deepEqual(Object.keys(settings).sort(), settingKeys, language); + flattenStrings(settings); + for (const state of ['idle', 'running', 'success', 'error']) { + assert.ok(messages[`state.${state}`], `${language} needs an accessible ${state} label`); + } + for (const key of ['feed', 'poke', 'regionLabel', 'reducedMotion']) + assert.ok(messages[key], `${language} needs ${key}`); + } +}); diff --git a/tests/skinAppearance.test.ts b/tests/skinAppearance.test.ts new file mode 100644 index 00000000..f34d7074 --- /dev/null +++ b/tests/skinAppearance.test.ts @@ -0,0 +1,316 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { fileURLToPath } from 'node:url'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { createServer } from 'vite'; +import type { AppSettings, MxuConfig } from '../src/types/config.ts'; + +const appearanceKey = 'mxu-webui-appearance'; +const project = { + interface_version: 2 as const, + name: 'skin-test', + controller: [], + resource: [], + task: [{ name: 'Run', entry: 'Entry' }], +}; + +const config = (settings: Partial = {}): MxuConfig => ({ + version: '1.0', + instances: [{ id: 'one', name: 'One', tasks: [] }], + settings: { + theme: 'light', + accentColor: 'deepsea', + language: 'en-US', + ...settings, + }, +}); + +async function harness(desktop: boolean) { + const data = new Map(); + const storage = { + getItem: (key: string) => data.get(key) ?? null, + setItem: (key: string, value: string) => data.set(key, value), + removeItem: (key: string) => data.delete(key), + }; + const window = Object.assign(new EventTarget(), { + location: { protocol: 'http:', hostname: 'localhost', host: 'localhost' }, + localStorage: storage, + matchMedia: () => ({ matches: false }), + }); + const document = { + documentElement: { + classList: { toggle() {} }, + style: { setProperty() {} }, + }, + }; + for (const [key, value] of Object.entries({ window, document, localStorage: storage })) { + Object.defineProperty(globalThis, key, { configurable: true, value }); + } + const root = fileURLToPath(new URL('..', import.meta.url)); + const server = await createServer({ + root, + configFile: false, + cacheDir: join(tmpdir(), `mxu-skin-tests-${process.pid}-${desktop ? 'desktop' : 'web'}`), + optimizeDeps: { noDiscovery: true, include: [] }, + server: { middlewareMode: true, hmr: false }, + resolve: { alias: { '@': join(root, 'src') } }, + }); + // Avoid starting the unrelated native file logger in this transport-independent store test. + await server.ssrLoadModule('/src/utils/logger.ts'); + if (desktop) Object.assign(window, { __TAURI__: {} }); + const store: typeof import('../src/stores/appStore.ts') = + await server.ssrLoadModule('/src/stores/appStore.ts'); + const appearance: typeof import('../src/services/appearanceStorage.ts') = + await server.ssrLoadModule('/src/services/appearanceStorage.ts'); + store.useAppStore.setState({ projectInterface: project, dataPath: '.' }); + return { + data, + store, + appearance, + async close() { + store.useAppStore.getState().setConfigPersistenceReady(false); + store.flushSaveConfig(); + await server.close(); + }, + }; +} + +test('desktop skin preferences survive config round trips and old configurations use defaults', async () => { + const h = await harness(true); + try { + h.data.set(appearanceKey, JSON.stringify({ skinId: 'deepseek', skinReducedMotion: false })); + const untouchedBrowserPreferences = h.data.get(appearanceKey); + h.store.useAppStore + .getState() + .importConfig(config({ skinId: 'naiwa', skinReducedMotion: true })); + assert.equal(h.store.useAppStore.getState().skinId, 'naiwa'); + assert.equal(h.store.useAppStore.getState().skinReducedMotion, true); + h.store.useAppStore.getState().setConfigPersistenceReady(true); + const saved = h.store.flushConfig(); + assert.ok(saved); + assert.equal(saved.settings.skinId, 'naiwa'); + assert.equal(saved.settings.skinReducedMotion, true); + h.store.useAppStore.getState().setSkinId('default'); + h.store.useAppStore.getState().setSkinReducedMotion(false); + h.store.useAppStore.getState().importConfig(saved); + assert.equal(h.store.useAppStore.getState().skinId, 'naiwa'); + assert.equal(h.store.useAppStore.getState().skinReducedMotion, true); + assert.equal(h.data.get(appearanceKey), untouchedBrowserPreferences); + + h.store.useAppStore.getState().importConfig(config()); + assert.equal(h.store.useAppStore.getState().skinId, 'default'); + assert.equal(h.store.useAppStore.getState().skinReducedMotion, false); + const invalid = config(); + Object.assign(invalid.settings, { skinId: 'external-skin', skinReducedMotion: 'true' }); + h.store.useAppStore.getState().importConfig(invalid); + assert.equal(h.store.useAppStore.getState().skinId, 'default'); + assert.equal(h.store.useAppStore.getState().skinReducedMotion, false); + } finally { + await h.close(); + } +}); + +test('switching a built-in skin leaves running tasks, runtime state and theme preferences intact', async () => { + const h = await harness(false); + try { + const selectedTasks = [ + { id: 'task', taskName: 'Run', enabled: true, expanded: true, optionValues: {} }, + ]; + const instances = [{ id: 'one', name: 'One', isRunning: true, selectedTasks }]; + const statuses = { one: 'Running' as const }; + const currentTasks = { one: 'task' }; + const runStatuses = { one: { task: 'running' as const } }; + h.store.useAppStore.setState({ + instances, + activeInstanceId: 'one', + instanceTaskStatus: statuses, + instanceCurrentTaskId: currentTasks, + instanceTaskRunStatus: runStatuses, + theme: 'dark', + accentColor: 'emerald', + }); + for (const skinId of ['deepseek', 'naiwa', 'default'] as const) { + h.store.useAppStore.getState().setSkinId(skinId); + h.store.useAppStore.getState().setSkinReducedMotion(true); + const state = h.store.useAppStore.getState(); + assert.equal(state.skinId, skinId); + assert.equal(state.skinReducedMotion, true); + assert.equal(state.instances, instances); + assert.equal(state.instances[0].selectedTasks, selectedTasks); + assert.equal(state.instances[0].isRunning, true); + assert.equal(state.instanceTaskStatus, statuses); + assert.equal(state.instanceCurrentTaskId, currentTasks); + assert.equal(state.instanceTaskRunStatus, runStatuses); + assert.equal(state.activeInstanceId, 'one'); + assert.equal(state.theme, 'dark'); + assert.equal(state.accentColor, 'emerald'); + assert.equal(h.appearance.loadWebUIAppearance()?.skinId, skinId); + assert.equal(h.appearance.loadWebUIAppearance()?.skinReducedMotion, true); + } + h.store.useAppStore.getState().setSkinReducedMotion(false); + assert.equal(h.appearance.loadWebUIAppearance()?.skinReducedMotion, false); + } finally { + await h.close(); + } +}); + +test('WebUI skin preferences remain local when saving unrelated backend configuration', async () => { + const h = await harness(false); + try { + h.appearance.patchWebUIAppearance({ + theme: 'dark', + skinId: 'naiwa', + skinReducedMotion: true, + }); + h.store.useAppStore + .getState() + .importConfig(config({ skinId: 'deepseek', skinReducedMotion: false })); + assert.equal(h.store.useAppStore.getState().skinId, 'naiwa'); + assert.equal(h.store.useAppStore.getState().skinReducedMotion, true); + assert.equal(h.store.useAppStore.getState().theme, 'dark'); + h.store.useAppStore.getState().setSkinId('default'); + h.store.useAppStore.getState().setConfirmBeforeDelete(true); + const saved = h.store.captureConfigForDraft(); + assert.ok(saved); + assert.equal(saved.settings.skinId, 'deepseek'); + assert.equal(saved.settings.skinReducedMotion, false); + assert.equal(saved.settings.theme, 'light'); + assert.equal(saved.settings.confirmBeforeDelete, true); + assert.equal(h.appearance.loadWebUIAppearance()?.skinId, 'default'); + assert.equal(h.appearance.loadWebUIAppearance()?.skinReducedMotion, true); + + h.appearance.patchWebUIAppearance({ skinId: 'naiwa', skinReducedMotion: true }); + h.store.useAppStore.getState().importConfig(config()); + assert.equal(h.store.useAppStore.getState().skinId, 'naiwa'); + assert.equal(h.store.useAppStore.getState().skinReducedMotion, true); + const legacySaved = h.store.captureConfigForDraft(); + assert.ok(legacySaved); + assert.equal(legacySaved.settings.skinId, 'default'); + assert.equal(legacySaved.settings.skinReducedMotion, false); + assert.equal(h.appearance.getBackendAppearance().skinId, 'default'); + assert.equal(h.appearance.getBackendAppearance().skinReducedMotion, false); + } finally { + await h.close(); + } +}); + +test('WebUI appearance storage restores skin choices and normalizes old or invalid local records', async () => { + const h = await harness(false); + try { + h.appearance.patchWebUIAppearance({ skinId: 'deepseek', skinReducedMotion: true }); + const saved = h.appearance.loadWebUIAppearance(); + assert.equal(saved?.skinId, 'deepseek'); + assert.equal(saved?.skinReducedMotion, true); + h.appearance.patchWebUIAppearance({ skinId: 'naiwa' }); + assert.equal(h.appearance.loadWebUIAppearance()?.skinId, 'naiwa'); + assert.equal(h.appearance.loadWebUIAppearance()?.skinReducedMotion, true); + + h.data.set(appearanceKey, JSON.stringify({ theme: 'dark' })); + assert.equal(h.appearance.loadWebUIAppearance()?.skinId, 'default'); + assert.equal(h.appearance.loadWebUIAppearance()?.skinReducedMotion, false); + h.store.useAppStore + .getState() + .importConfig(config({ skinId: 'deepseek', skinReducedMotion: true })); + assert.equal(h.store.useAppStore.getState().skinId, 'default'); + assert.equal(h.store.useAppStore.getState().skinReducedMotion, false); + + h.data.set( + appearanceKey, + JSON.stringify({ skinId: 'https://example.invalid', skinReducedMotion: 1 }), + ); + assert.equal(h.appearance.loadWebUIAppearance()?.skinId, 'default'); + assert.equal(h.appearance.loadWebUIAppearance()?.skinReducedMotion, false); + h.data.delete(appearanceKey); + h.store.useAppStore + .getState() + .importConfig(config({ skinId: 'deepseek', skinReducedMotion: true })); + assert.equal(h.store.useAppStore.getState().skinId, 'deepseek'); + assert.equal(h.store.useAppStore.getState().skinReducedMotion, true); + } finally { + await h.close(); + } +}); + +test('the first local skin edit preserves inherited appearance on reload, including an empty instance configuration', async () => { + const h = await harness(false); + try { + const backend = config({ + theme: 'dark', + language: 'zh-CN', + accentColor: 'deepsea', + skinId: 'default', + skinReducedMotion: false, + backgroundImage: 'wallpaper.png', + backgroundOpacity: 35, + }); + backend.instances = []; + backend.customAccents = [ + { + id: 'custom', + name: 'custom-color', + label: { 'zh-CN': '自定义', 'en-US': 'Custom' }, + colors: { + default: '#123456', + hover: '#234567', + light: '#ddeeff', + lightDark: '#112233', + }, + }, + ]; + for (const setting of ['skinId', 'skinReducedMotion'] as const) { + h.data.delete(appearanceKey); + h.store.useAppStore.getState().importConfig(backend); + assert.equal(h.appearance.loadWebUIAppearance(), null); + if (setting === 'skinId') h.store.useAppStore.getState().setSkinId('naiwa'); + else h.store.useAppStore.getState().setSkinReducedMotion(true); + + const savedLocal = h.appearance.loadWebUIAppearance(); + assert.ok(savedLocal); + assert.equal(savedLocal.theme, 'dark'); + assert.equal(savedLocal.language, 'zh-CN'); + assert.equal(savedLocal.accentColor, 'deepsea'); + assert.equal(savedLocal.backgroundImage, 'wallpaper.png'); + assert.equal(savedLocal.backgroundOpacity, 35); + assert.deepEqual(savedLocal.customAccents, backend.customAccents); + assert.equal(savedLocal.skinId, setting === 'skinId' ? 'naiwa' : 'default'); + assert.equal(savedLocal.skinReducedMotion, setting === 'skinReducedMotion'); + + // A fresh configuration import reads the local record just as a browser reload would. + h.store.useAppStore.getState().importConfig(backend); + const restored = h.store.useAppStore.getState(); + assert.equal(restored.instances.length, 0); + assert.equal(restored.activeInstanceId, null); + assert.equal(restored.theme, 'dark'); + assert.equal(restored.language, 'zh-CN'); + assert.equal(restored.accentColor, 'deepsea'); + assert.equal(restored.backgroundImage, 'wallpaper.png'); + assert.equal(restored.backgroundOpacity, 35); + assert.deepEqual(restored.customAccents, backend.customAccents); + assert.equal(restored.skinId, savedLocal.skinId); + assert.equal(restored.skinReducedMotion, savedLocal.skinReducedMotion); + + const savedBackend = h.store.captureConfigForDraft(); + assert.ok(savedBackend); + assert.equal(savedBackend.settings.skinId, 'default'); + assert.equal(savedBackend.settings.skinReducedMotion, false); + assert.equal(savedBackend.settings.theme, 'dark'); + assert.equal(savedBackend.settings.language, 'zh-CN'); + assert.equal(savedBackend.settings.accentColor, 'deepsea'); + } + + h.data.delete(appearanceKey); + h.appearance.cacheBackendAppearance({ theme: undefined, accentColor: undefined }); + h.appearance.patchWebUIAppearance({ skinId: 'naiwa' }); + const oldBackendDefaults = h.appearance.loadWebUIAppearance(); + assert.ok(oldBackendDefaults); + assert.equal(oldBackendDefaults.theme, 'system'); + assert.equal(oldBackendDefaults.language, 'system'); + assert.equal(oldBackendDefaults.accentColor, 'emerald'); + assert.equal(oldBackendDefaults.backgroundOpacity, 50); + assert.equal(oldBackendDefaults.skinId, 'naiwa'); + assert.equal(oldBackendDefaults.skinReducedMotion, false); + } finally { + await h.close(); + } +});